Spakowane generatory Pythona, przy czym drugi jest krótszy: jak odzyskać element, który jest cicho zużywany

50

Chcę przeanalizować 2 generatory (potencjalnie) różnej długości za pomocą zip:

for el1, el2 in zip(gen1, gen2):
    print(el1, el2)

Jeśli jednak gen2ma mniej elementów, jeden dodatkowy element gen1jest „konsumowany”.

Na przykład,

def my_gen(n:int):
    for i in range(n):
        yield i

gen1 = my_gen(10)
gen2 = my_gen(8)

list(zip(gen1, gen2))  # Last tuple is (7, 7)
print(next(gen1))  # printed value is "9" => 8 is missing

gen1 = my_gen(8)
gen2 = my_gen(10)

list(zip(gen1, gen2))  # Last tuple is (7, 7)
print(next(gen2))  # printed value is "8" => OK

Najwyraźniej brakuje wartości ( 8w moim poprzednim przykładzie), ponieważ gen1jest ona odczytywana (generując wartość 8), zanim się zorientuje, gen2że nie ma już żadnych elementów. Ale ta wartość znika we wszechświecie. Kiedy gen2jest „dłuższy”, nie ma takiego „problemu”.

PYTANIE : Czy istnieje sposób na odzyskanie tej brakującej wartości (tj. 8W moim poprzednim przykładzie)? ... najlepiej ze zmienną liczbą argumentów (podobnie jak ziprobi).

UWAGA : Obecnie zaimplementowałem w inny sposób, używając, itertools.zip_longestale naprawdę zastanawiam się, jak uzyskać tę brakującą wartość przy użyciu ziplub równoważnej.

UWAGA 2 : Stworzyłem niektóre testy różnych implementacji w niniejszej REPL na wypadek, gdybyś chciał przesłać i wypróbować nową implementację :) https://repl.it/@jfthuong/MadPhysicistChester

Jean-Francois T.
źródło
19
Dokumenty zwracają uwagę, że „zip () należy używać tylko z danymi wejściowymi o nierównej długości, gdy nie zależy ci na końcowych, niedopasowanych wartościach z dłuższych iteracji. Jeśli te wartości są ważne, użyj zamiast tego itertools.zip_longest ().”.
Carcigenicate
2
@ Ch3steR. Ale pytanie nie ma nic wspólnego z „dlaczego”. Dosłownie brzmi: „Czy istnieje sposób na odzyskanie tej brakującej wartości ...?” Wydaje się, że wszystkie odpowiedzi oprócz mojej wygodnie zapomniałem przeczytać tę część.
Szalony fizyk
@MadPhysicist Strange rzeczywiście. Przeformułowałem to pytanie, aby było jaśniejsze w tym aspekcie.
Jean-Francois T.
1
Podstawowym problemem jest to, że nie ma sposobu, aby zajrzeć do generatora lub wcisnąć go z powrotem. Więc raz zip()zapoznał się 8z gen1, to nie ma.
Barmar
1
@Barmar zdecydowanie wszyscy się zgodziliśmy. Pytanie dotyczyło raczej tego, jak go gdzieś przechowywać, aby móc z niego korzystać.
Jean-Francois T.

Odpowiedzi:

28

Jednym ze sposobów byłoby zaimplementowanie generatora, który pozwala buforować ostatnią wartość:

class cache_last(collections.abc.Iterator):
    """
    Wraps an iterable in an iterator that can retrieve the last value.

    .. attribute:: obj

       A reference to the wrapped iterable. Provided for convenience
       of one-line initializations.
    """
    def __init__(self, iterable):
        self.obj = iterable
        self._iter = iter(iterable)
        self._sentinel = object()

    @property
    def last(self):
        """
        The last object yielded by the wrapped iterator.

        Uninitialized iterators raise a `ValueError`. Exhausted
        iterators raise a `StopIteration`.
        """
        if self.exhausted:
            raise StopIteration
        return self._last

    @property
    def exhausted(self):
        """
        `True` if there are no more elements in the iterator.
        Violates EAFP, but convenient way to check if `last` is valid.
        Raise a `ValueError` if the iterator is not yet started.
        """
        if not hasattr(self, '_last'):
            raise ValueError('Not started!')
        return self._last is self._sentinel

    def __next__(self):
        """
        Retrieve, record, and return the next value of the iteration.
        """
        try:
            self._last = next(self._iter)
        except StopIteration:
            self._last = self._sentinel
            raise
        # An alternative that has fewer lines of code, but checks
        # for the return value one extra time, and loses the underlying
        # StopIteration:
        #self._last = next(self._iter, self._sentinel)
        #if self._last is self._sentinel:
        #    raise StopIteration
        return self._last

    def __iter__(self):
        """
        This object is already an iterator.
        """
        return self

Aby tego użyć, zawiń dane wejściowe do zip:

gen1 = cache_last(range(10))
gen2 = iter(range(8))
list(zip(gen1, gen2))
print(gen1.last)
print(next(gen1)) 

Ważne jest, aby utworzyć gen2iterator zamiast iterowalnego, abyś mógł wiedzieć, który z nich został wyczerpany. Jeśli gen2jest wyczerpany, nie musisz sprawdzać gen1.last.

Innym podejściem byłoby zastąpienie zip, aby zaakceptować zmienną sekwencję iteracji zamiast osobnych iteracji. To pozwoli ci zamienić iterowalne na łańcuchową wersję, która zawiera twój „zerknięty” element:

def myzip(iterables):
    iterators = [iter(it) for it in iterables]
    while True:
        items = []
        for it in iterators:
            try:
                items.append(next(it))
            except StopIteration:
                for i, peeked in enumerate(items):
                    iterables[i] = itertools.chain([peeked], iterators[i])
                return
            else:
                yield tuple(items)

gens = [range(10), range(8)]
list(myzip(gens))
print(next(gens[0]))

Takie podejście jest problematyczne z wielu powodów. Utraci nie tylko pierwotną iterowalność, ale także utraci wszelkie użyteczne właściwości, które pierwotny obiekt mógł mieć, zastępując go chainprzedmiotem.

Szalony fizyk
źródło
@MadPhysicist. Uwielbiam twoją odpowiedź cache_lasti fakt, że nie zmienia to nextzachowania ... tak źle, że nie jest symetryczne (zmiana gen1i gen2w zipie prowadzi do różnych wyników). Cheers
Jean-Francois T.
1
@ Jean-Francois. Zaktualizowałem iterator, aby poprawnie reagował na lastpołączenia po jego wyczerpaniu. To powinno pomóc w ustaleniu, czy potrzebujesz ostatniej wartości, czy nie. Sprawia również, że jest bardziej produktywny.
Szalony fizyk
@MadPhysicist Uruchomiłem kod, a dane wyjściowe print(gen1.last) print(next(gen1)) toNone and 9
Ch3steR
@MadPhysicist z kilkoma dokumentami i wszystkim. Fajnie;) Sprawdzę później, kiedy będę miał czas. Dzięki za poświęcony czas
Jean-Francois T.
@ Ch3steR. Dzięki za haczyk. Byłem zbyt podekscytowany i usunąłem oświadczenie zwrotne last.
Szalony fizyk
17

Jest to zipodpowiednik implementacji podany w dokumentach

def zip(*iterables):
    # zip('ABCD', 'xy') --> Ax By
    sentinel = object()
    iterators = [iter(it) for it in iterables]
    while iterators:
        result = []
        for it in iterators:
            elem = next(it, sentinel)
            if elem is sentinel:
                return
            result.append(elem)
        yield tuple(result)

W twoim pierwszym przykładzie gen1 = my_gen(10)i gen2 = my_gen(8). Po zużyciu obu generatorów do siódmej iteracji. Teraz w 8. iteracji gen1wywołania, elem = next(it, sentinel)które zwracają 8, ale gdy gen2wywołania elem = next(it, sentinel), zwracają sentinel(ponieważ w tym momencie gen2są wyczerpane) i if elem is sentinelsą spełnione, a funkcja wykonuje return i zatrzymuje. Teraz next(gen1)zwraca 9.

W twoim drugim przykładzie gen1 = gen(8)i gen2 = gen(10). Po zużyciu obu generatorów do siódmej iteracji. Teraz w ósmej iteracji gen1wywołuje, elem = next(it, sentinel)która zwraca sentinel(ponieważ w tym momencie gen1jest wyczerpana) i if elem is sentineljest spełniona, a funkcja wykonuje return i zatrzymuje się. Teraz next(gen2)zwraca 8.

Zainspirowany odpowiedzią Szalonego Fizyka możesz użyć tego Genopakowania, aby temu przeciwdziałać:

Edycja : Aby obsłużyć sprawy wskazane przez Jean-Francois T.

Gdy wartość zostanie zużyta z iteratora, na zawsze zniknie z iteratora i nie ma żadnej metody mutacji w miejscu dla iteratorów, aby dodać ją z powrotem do iteratora. Jednym z obejść jest przechowywanie ostatnio wykorzystanej wartości.

class Gen:
    def __init__(self,iterable):
        self.d = iter(iterable)
        self.sentinal = object()
        self.prev = self.sentinal
    def __iter__(self):
        return self
    @property
    def last_val_consumed(self):
        if self.prev is None:
            raise StopIteration
        if self.prev == self.sentinal:
            raise ValueError('Nothing has been consumed')
        return self.prev
    def __next__(self):
        self.prev = next(self.d,None)
        if self.prev is None:
            raise StopIteration
        return self.prev

Przykłady:

# When `gen1` is larger than `gen2`
gen1 = Gen(range(10))
gen2 = Gen(range(8))
list(zip(gen1,gen2))
# [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7)]
gen1.last_val_consumed
# 8 #as it was the last values consumed
next(gen1)
# 9
gen1.last_val_consumed
# 9

# 2. When `gen1` or `gen2` is empty
gen1 = Gen(range(0))
gen2 = Gen(range(5))
list(zip(gen1,gen2))
gen1.last_val_consumed
# StopIteration error is raised
gen2.last_val_consumed
# ValueError is raised saying `ValueError: Nothing has been consumed`
Ch3steR
źródło
Dziękuję @ Ch3steR za czas poświęcony na ten problem. Twoja modyfikacja rozwiązania MadPhysicist ma kilka ograniczeń: # 1. Jeśli, gen1 = cache_last(range(0))a gen2 = cache_last(range(2))następnie po wykonaniu list(zip(gen1, gen2), wezwanie do next(gen2)podniesie AttributeError: 'cache_last' object has no attribute 'prev'. # 2. Jeśli gen1 jest dłuższy niż gen2, po zużyciu wszystkich elementów next(gen2)będzie nadal zwracał ostatnią wartość zamiast StopIteration. Oznaczę odpowiedź MadPhysicist i THE. Dzięki!
Jean-Francois T.
@ Jean-FrancoisT. Tak zgodził się. Powinieneś zaznaczyć jego odpowiedź jako odpowiedź. To ma ograniczenia. Spróbuję poprawić tę odpowiedź, aby przeciwdziałać wszystkim przypadkom. ;)
Ch3steR
@ Ch3steR Mogę ci pomóc, jeśli chcesz. Jestem profesjonalistą w dziedzinie sprawdzania poprawności oprogramowania :)
Jean-Francois T.
@ Jean-FrancoisT. Bardzo chciałabym. To wiele by znaczyło. Jestem studentem trzeciego roku.
Ch3steR
2
Dobra robota, przechodzi wszystkie testy, które tu napisałem: repl.it/@jfthuong/MadPhysicistChester Możesz je uruchomić online, całkiem wygodne :)
Jean-Francois T.
6

Widzę, że już znalazłeś tę odpowiedź i została ona poruszona w komentarzach, ale pomyślałem, że dam odpowiedź. Chcesz użyć itertools.zip_longest(), który zastąpi puste wartości krótszego generatora None:

import itertools

def my_gen(n:int):
    for i in range(n):
        yield i

gen1 = my_gen(10)
gen2 = my_gen(8)

for i, j in itertools.zip_longest(gen1, gen2):
    print(i, j)

Wydruki:

0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 None
9 None

Możesz również podać fillvalueargument podczas wywoływania w zip_longestcelu zastąpienia Nonewartości domyślnej, ale w zasadzie dla twojego rozwiązania, gdy trafisz None(w ilub jw pętli for), inna zmienna będzie miała twoją 8.

TerryA
źródło
Dzięki. Naprawdę już to wymyśliłem zip_longesti tak właśnie było w moim pytaniu. :)
Jean-Francois T.
6

Zainspirowani wyjaśnieniem @ GrandPhuba zip, stwórzmy „bezpieczny” wariant (testowany tutaj tutaj ):

def safe_zip(*args):
    """
    Safe zip that restores last consumed element in eachgenerator
    if not able to consume an element in all of them

    Returns:
        * generators in tuple
        * generator for zipped generators
    """
  continue_ = True
  n = len(args)
  result = (_ for _ in [])
  while continue_:
    addend = []
    for i, gen in enumerate(args):
      try:
        value = next(gen)
        addend.append(value)
      except StopIteration:
        genlist = list(args)
        args = tuple([chain([v], g) for v, g in zip(addend, genlist[:i])]+genlist[i:])
        continue_ = False
        break
    if len(addend)==n: result = chain(result, [tuple(addend)])
  return args, result

Oto podstawowy test:

    g1, g2 = (i for i in range(10)), (i for i in range(4))
    # Create (g1, g2), g3 first, then loop over g3 as one would with zip
    (g1, g2), g3 = safe_zip(g1, g2)
    for a, b in g3:
        print(a, b)#(0, 0) to (3, 3)
    for x in g1:
        print(x)#4 to 9
JG
źródło
4

możesz użyć itertools.tee i itertools.islice :

from itertools import islice, tee

def zipped(gen1, gen2, pred=list):
    g11, g12 = tee(gen1)
    z = pred(zip(g11, gen2))

    return (islice(g12, len(z), None), gen2), z

gen1 = iter(range(10))
gen2 = iter(range(5))

(gen1, gen2), output = zipped(gen1, gen2)

print(output)
print(next(gen1))
# [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
# 5
kederrac
źródło
3

Jeśli chcesz ponownie użyć kodu, najłatwiejszym rozwiązaniem jest:

from more_itertools import peekable

a = peekable(a)
b = peekable(b)

while True:
    try:
        a.peek()
        b.peek()
    except StopIteration:
        break
    x = next(a)
    y = next(b)
    print(x, y)


print(list(a), list(b))  # Misses nothing.

Możesz przetestować ten kod, używając swojej konfiguracji:

def my_gen(n: int):
    yield from range(n)

a = my_gen(10)
b = my_gen(8)

Wydrukuje:

0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
[8, 9] []
Neil G.
źródło
2

nie sądzę, że można odzyskać upuszczoną wartość za pomocą podstawowej pętli for, ponieważ wyczerpany iterator jest pobierany po zip(..., ...).__iter__upuszczeniu po wyczerpaniu i nie można uzyskać do niego dostępu.

Powinieneś mutować zip, a następnie możesz uzyskać pozycję upuszczonego przedmiotu z jakimś hackym kodem)

z = zip(range(10), range(8))
for _ in iter(z.__next__, None):
    ...
_, (one, other) = z.__reduce__()
_, (i_one,), p_one = one.__reduce__() # p_one == current pos, 1 based
import itertools
val = next(itertools.islice(iter(i_one), p_one - 1, p_one))
Максим Степанов
źródło