Gezippte Python-Generatoren, wobei der zweite kürzer ist: So rufen Sie Elemente ab, die stillschweigend verbraucht werden

50

Ich möchte 2 Generatoren (möglicherweise) unterschiedlicher Länge analysieren mit zip:

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

Wenn gen2jedoch weniger Elemente vorhanden sind, wird ein zusätzliches Element von gen1"verbraucht".

Zum Beispiel,

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

Anscheinend fehlt ein Wert ( 8in meinem vorherigen Beispiel), weil er gen1gelesen wird (wodurch der Wert generiert wird 8), bevor er erkennt, dass er gen2keine weiteren Elemente enthält. Aber dieser Wert verschwindet im Universum. Wenn gen2"länger" ist, gibt es kein solches "Problem".

FRAGE : Gibt es eine Möglichkeit, diesen fehlenden Wert abzurufen (dh 8in meinem vorherigen Beispiel)? ... idealerweise mit einer variablen Anzahl von Argumenten (wie es der zipFall ist).

HINWEIS : Ich habe derzeit eine andere Implementierung mithilfe von verwendet, itertools.zip_longestaber ich frage mich wirklich, wie ich diesen fehlenden Wert mithilfe von zipoder einem gleichwertigen Wert erhalten kann .

HINWEIS 2 : Ich habe einige Tests der verschiedenen Implementierungen in dieser REPL erstellt, falls Sie eine neue Implementierung einreichen und ausprobieren möchten :) https://repl.it/@jfthuong/MadPhysicistChester

Jean-Francois T.
quelle
19
In den Dokumenten wird darauf hingewiesen, dass "zip () nur mit Eingaben ungleicher Länge verwendet werden sollte, wenn Sie sich nicht für nachgestellte, nicht übereinstimmende Werte aus den längeren Iterables interessieren. Wenn diese Werte wichtig sind, verwenden Sie stattdessen itertools.zip_longest ()."
Carcigenicate
2
@ Ch3steR. Die Frage hat aber nichts mit dem "Warum" zu tun. Es lautet wörtlich "Gibt es eine Möglichkeit, diesen fehlenden Wert abzurufen ...?" Es scheint, dass alle Antworten außer meinen bequemerweise vergessen haben, diesen Teil zu lesen.
Mad Physicist
@ MadPhysicist Seltsam in der Tat. Ich habe die Frage umformuliert, um diesbezüglich klarer zu sein.
Jean-Francois T.
1
Das Grundproblem ist, dass es keine Möglichkeit gibt, einen Blick in einen Generator zu werfen oder zurückzuschieben. Also einmal zip()gelesen hat , 8aus gen1, es ist weg.
Barmar
1
@Barmar definitiv, wir waren uns alle einig. Die Frage war eher, wie man es irgendwo aufbewahrt, um es verwenden zu können.
Jean-Francois T.

Antworten:

28

Eine Möglichkeit wäre, einen Generator zu implementieren, mit dem Sie den letzten Wert zwischenspeichern können:

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

Um dies zu verwenden, wickeln Sie die Eingaben in zip:

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

Es ist wichtig, gen2einen Iterator anstelle eines iterierbaren zu erstellen, damit Sie wissen, welcher erschöpft war. Wenn Sie gen2erschöpft sind, müssen Sie nicht überprüfen gen1.last.

Ein anderer Ansatz wäre, zip zu überschreiben, um eine veränderbare Folge von Iterables anstelle von separaten Iterables zu akzeptieren. Auf diese Weise können Sie iterables durch eine verkettete Version ersetzen, die Ihr "Peeked" -Element enthält:

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]))

Dieser Ansatz ist aus vielen Gründen problematisch. Es verliert nicht nur die ursprüngliche Iterierbarkeit, sondern auch alle nützlichen Eigenschaften des ursprünglichen Objekts, wenn es durch ein chainObjekt ersetzt wird.

Verrückter Physiker
quelle
@ MadPhysicist. Lieben Sie Ihre Antwort mit cache_lastund die Tatsache, dass es das nextVerhalten nicht verändert ... so schlecht, dass es nicht symmetrisch ist (das Umschalten gen1und gen2im Reißverschluss führt zu unterschiedlichen Ergebnissen) .Cheers
Jean-Francois T.
1
@ Jean-Francois. Ich habe den Iterator aktualisiert, um auf lastAnrufe richtig zu reagieren, nachdem er erschöpft ist. Das sollte helfen, herauszufinden, ob Sie den letzten Wert benötigen oder nicht. Macht es auch produktionsreicher.
Verrückter Physiker
@ MadPhysicist Ich habe den Code ausgeführt und die Ausgabe von print(gen1.last) print(next(gen1)) istNone and 9
Ch3steR
@ MadPhysicist mit einigen Docstrings und allem. Schön;) Ich werde später nachsehen, wenn ich Zeit habe. Vielen Dank für die aufgewendete Zeit
Jean-Francois T.
@ Ch3steR. Danke für den Fang. Ich war zu aufgeregt und hatte die Rückgabeerklärung von gelöscht last.
Verrückter Physiker
17

Dies ist zipdas in den Dokumenten angegebene Implementierungsäquivalent

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)

In Ihrem 1. Beispiel gen1 = my_gen(10)und gen2 = my_gen(8). Nachdem beide Generatoren bis zur 7. Iteration verbraucht sind. Jetzt in der 8. Iteration werden gen1Aufrufe zurückgegeben, elem = next(it, sentinel)die 8 zurückgeben, aber wenn gen2Aufrufe elem = next(it, sentinel)zurückgegeben werden sentinel(weil dies gen2erschöpft ist) und if elem is sentinelerfüllt sind und die Funktion return ausführt und stoppt. Gibt jetzt next(gen1)9 zurück.

In Ihrem 2. Beispiel gen1 = gen(8)und gen2 = gen(10). Nachdem beide Generatoren bis zur 7. Iteration verbraucht sind. Jetzt in der 8. Iteration gen1Aufrufe, elem = next(it, sentinel)die zurückkehren sentinel(weil zu diesem Zeitpunkt gen1erschöpft ist) und if elem is sentinelerfüllt sind und die Funktion return ausführt und stoppt. Gibt jetzt next(gen2)8 zurück.

Inspiriert von der Antwort von Mad Physicist , könnten Sie diesen GenWrapper verwenden, um dem entgegenzuwirken:

Bearbeiten : Um die Fälle von Jean-Francois T. zu behandeln.

Sobald ein Wert vom Iterator verbraucht ist, ist er vom Iterator für immer verschwunden, und es gibt keine direkte Mutationsmethode für Iteratoren, um ihn wieder zum Iterator hinzuzufügen. Eine Lösung besteht darin, den zuletzt verbrauchten Wert zu speichern.

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

Beispiele:

# 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
quelle
Vielen Dank an Ch3steR für die Zeit, die Sie für dieses Problem aufgewendet haben. Ihre Modifikation der MadPhysicist-Lösung weist mehrere Einschränkungen auf: # 1. Wenn gen1 = cache_last(range(0))und gen2 = cache_last(range(2))dann nach, list(zip(gen1, gen2)wird ein Anruf an next(gen2)eine auslösen AttributeError: 'cache_last' object has no attribute 'prev'. # 2. Wenn gen1 länger als gen2 ist, wird nach dem Verzehr aller Elemente next(gen2)weiterhin der letzte Wert anstelle von zurückgegeben StopIteration. Ich werde MadPhysicist Antwort und DIE Antwort markieren. Vielen Dank!
Jean-Francois T.
@ Jean-FrancoisT. Ja angenommen. Sie sollten seine Antwort als Antwort markieren. Dies hat Einschränkungen. Ich werde versuchen, diese Antwort zu verbessern, um allen Fällen entgegenzuwirken. ;)
Ch3steR
@ Ch3steR Ich kann dir helfen, es zu schütteln, wenn du willst. Ich bin ein Profi auf dem Gebiet der Software-Validierung :)
Jean-Francois T.
@ Jean-FrancoisT. Ich würde gern. Es würde viel bedeuten. Ich bin ein Student im 3. Jahr.
Ch3steR
2
Gute Arbeit, es besteht alle Tests, die ich hier geschrieben habe: repl.it/@jfthuong/MadPhysicistChester Sie können sie online ausführen, ziemlich praktisch :)
Jean-Francois T.
6

Ich kann sehen, dass Sie diese Antwort bereits gefunden haben und sie in den Kommentaren erwähnt wurde, aber ich dachte, ich werde eine Antwort daraus machen. Sie möchten verwenden itertools.zip_longest(), wodurch die leeren Werte des kürzeren Generators ersetzt werden durch 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)

Drucke:

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

Sie können auch ein fillvalueArgument angeben zip_longest, wenn Sie aufrufen , um das Nonedurch einen Standardwert zu ersetzen. Grundsätzlich gilt jedoch für Ihre Lösung, sobald Sie ein None(entweder ioder j) in der for-Schleife drücken, die andere Variable mit Ihrem 8.

TerryA
quelle
Vielen Dank. Ich habe es mir ja schon ausgedacht zip_longestund es war eigentlich in meiner Frage. :)
Jean-Francois T.
6

Inspiriert von @ GrandPhuba der Aufklärung zip, lassen Sie sich eine „sichere“ Variante an (Einheit geprüft hier ):

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

Hier ist ein grundlegender 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
quelle
4

Sie könnten itertools.tee und itertools.islice verwenden :

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
quelle
3

Wenn Sie Code wiederverwenden möchten, ist die einfachste Lösung:

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.

Sie können diesen Code mit Ihrem Setup testen:

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

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

Es wird gedruckt:

0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
[8, 9] []
Neil G.
quelle
2

Ich glaube nicht, dass Sie mit der Basis-for-Schleife einen abgelegten Wert abrufen können, da der Iterator zip(..., ...).__iter__erschöpft ist und nicht mehr gelöscht werden kann, wenn er erschöpft ist.

Sie sollten Ihren Reißverschluss mutieren, dann können Sie die Position des abgelegten Gegenstands mit einem Hacky-Code ermitteln.

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))
Максим Степанов
quelle