Generatori Python con zip con il secondo più corto: come recuperare un elemento che viene consumato silenziosamente


50

Voglio analizzare 2 generatori di (potenzialmente) diversa lunghezza con zip:

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

Tuttavia, se gen2ha meno elementi, un elemento aggiuntivo di gen1viene "consumato".

Per esempio,

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

Apparentemente, manca un valore ( 8nel mio esempio precedente) perché gen1viene letto (generando così il valore 8) prima che si realizzi gen2non ha più elementi. Ma questo valore scompare nell'universo. Quando gen2è "più lungo", non esiste tale "problema".

DOMANDA : C'è un modo per recuperare questo valore mancante (cioè 8nel mio esempio precedente)? ... idealmente con un numero variabile di argomenti (come zipfa).

NOTA : attualmente ho implementato in un altro modo utilizzando, itertools.zip_longestma mi chiedo davvero come ottenere questo valore mancante utilizzando zipo equivalente.

NOTA 2 : ho creato alcuni test delle diverse implementazioni in questo REPL nel caso in cui si desideri inviare e provare una nuova implementazione :) https://repl.it/@jfthuong/MadPhysicistChester


19
I documenti notano che "zip () dovrebbe essere usato con input di lunghezza diversa quando non ti interessa trascinare valori ineguagliati dagli iterabili più lunghi. Se questi valori sono importanti, usa invece itertools.zip_longest ().".
Carcigenicato

2
@ Ch3steR. Ma la domanda non ha nulla a che fare con il "perché". Legge letteralmente "C'è un modo per recuperare questo valore mancante ...?" Sembra che tutte le risposte, tranne la mia, abbiano dimenticato di leggere quella parte.
Fisico pazzo,

@MadPhysicist Davvero strano. Ho riformulato la domanda per essere più chiaro su questo aspetto.
Jean-Francois T.

1
Il problema di base è che non c'è modo di sbirciare o respingere un generatore. Quindi, una volta zip()ha letto 8da gen1, non c'è più.
Barmar il

1
@Barmar sicuramente, siamo tutti d'accordo su questo. La domanda era più come conservarlo da qualche parte per poterlo usare.
Jean-Francois T.

Risposte:


28

Un modo sarebbe implementare un generatore che ti consenta di memorizzare nella cache l'ultimo valore:

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

Per usarlo, avvolgi gli input per zip:

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

È importante creare gen2un iteratore piuttosto che un iterabile, in modo da poter sapere quale è stato esaurito. Se gen2è esaurito, non è necessario controllare gen1.last.

Un altro approccio sarebbe quello di ignorare zip per accettare una sequenza mutabile di iterabili invece di iterabili separati. Ciò ti consentirebbe di sostituire gli iterabili con una versione concatenata che include l'elemento "sbirciato":

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

Questo approccio è problematico per molte ragioni. Non solo perderà l'iterabile originale, ma perderà tutte le proprietà utili che l'oggetto originale potrebbe avere avuto sostituendolo con un chainoggetto.


@MadPhysicist. Adoro la tua risposta cache_laste il fatto che non altera il nextcomportamento ... così male che non è simmetrico (passare gen1e gen2nella zip porterà a risultati diversi). Saluti
Jean-Francois T.

1
@ Jean-Francois. Ho aggiornato l'iteratore per rispondere correttamente alle lastchiamate dopo che è esaurito. Ciò dovrebbe aiutare a capire se hai bisogno dell'ultimo valore o meno. Inoltre lo rende più produttivo-y.
Fisico pazzo,

@MadPhysicist Ho eseguito il codice e l'output di print(gen1.last) print(next(gen1)) èNone and 9
Ch3steR

@MadPhysicist con alcune dotstring e tutto il resto. Bello;) controllerò quando avrò tempo. Grazie per il tempo trascorso
Jean-Francois T.

@ Ch3steR. Grazie per la cattura. Mi ero eccitato troppo e avevo eliminato la dichiarazione di reso last.
Fisico pazzo,

17

Questo è l' zipequivalente di implementazione indicato nei documenti

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)

Nel tuo primo esempio gen1 = my_gen(10)e gen2 = my_gen(8). Dopo che entrambi i generatori vengono consumati fino alla 7a iterazione. Ora in 8a iterazione gen1chiamate elem = next(it, sentinel)che restituiscono 8 ma quando gen2chiamate elem = next(it, sentinel)restituisce sentinel(perché a questo punto gen2è esaurita) ed if elem is sentinelè soddisfatta e la funzione esegue il ritorno e si arresta. Ora next(gen1)restituisce 9.

Nel tuo secondo esempio gen1 = gen(8)e gen2 = gen(10). Dopo che entrambi i generatori vengono consumati fino alla 7a iterazione. Ora in 8a iterazione gen1chiama elem = next(it, sentinel)che ritorna sentinel(perché a questo punto gen1è esaurito) ed if elem is sentinelè soddisfatto e la funzione esegue il ritorno e si arresta. Ora next(gen2)restituisce 8.

Ispirato dalla risposta di Phys Physistist , potresti usare questo Genwrapper per contrastarlo:

Modifica : per gestire i casi indicati da Jean-Francois T.

Una volta che un valore viene consumato dall'iteratore, è passato per sempre dall'iteratore e non esiste un metodo di mutazione sul posto per gli iteratori per aggiungerlo nuovamente all'iteratore. Una soluzione è di memorizzare l'ultimo valore consumato.

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

Esempi:

# 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`

Grazie @ Ch3steR per il tempo dedicato a questo problema. La modifica della soluzione MadPhysicist presenta diversi limiti: # 1. Se gen1 = cache_last(range(0))e gen2 = cache_last(range(2))dopo aver fatto list(zip(gen1, gen2), una chiamata a next(gen2)genererà un AttributeError: 'cache_last' object has no attribute 'prev'. # 2. Se gen1 è più lungo di gen2, dopo aver consumato tutti gli elementi, next(gen2)continuerà a restituire l'ultimo valore invece di StopIteration. Segnerò la risposta di MadPhysicist e la risposta. Grazie!
Jean-Francois T.

@ Jean-franchista. Sì d'accordo. Dovresti contrassegnare la sua risposta come risposta. Questo ha dei limiti. Proverò a migliorare questa risposta per contrastare tutti i casi. ;)
Ch3steR

@ Ch3steR Posso aiutarti a scuoterlo se vuoi. Sono un professionista nel campo della convalida del software :)
Jean-Francois T.

@ Jean-franchista. Mi piacerebbe molto. Significherebbe molto. Sono uno studente universitario del terzo anno.
Ch3steR

2
Ottimo lavoro, supera tutti i test che ho scritto qui: repl.it/@jfthuong/MadPhysicistChester Puoi eseguirli online, abbastanza comodo :)
Jean-Francois T.

6

Vedo che hai già trovato questa risposta e che è stata sollevata nei commenti, ma ho pensato che ne avrei ricavato una risposta. Si desidera utilizzare itertools.zip_longest(), che sostituirà i valori vuoti del generatore più breve con 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)

stampe:

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

Puoi anche fornire un fillvalueargomento quando chiami zip_longestper sostituire il Nonecon un valore predefinito, ma fondamentalmente per la tua soluzione una volta che premi un None(o io j) nel ciclo for, l'altra variabile avrà il tuo 8.


Grazie. In effetti, mi sono già inventato zip_longested era effettivamente nella mia domanda. :)
Jean-Francois T.

6

Ispirato al chiarimento di @ GrandPhuba zip, creiamo una variante "sicura" (testato qui ):

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

Ecco un test di base:

    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

4

potresti usare itertools.tee e 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

3

Se si desidera riutilizzare il codice, la soluzione più semplice è:

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.

Puoi testare questo codice usando la tua configurazione:

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

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

Stampa:

0 0
1 1
2 2
3 3
4 4
5 5
6 6
7 7
[8, 9] []

2

non penso che tu possa recuperare il valore caduto con basic for loop, perché iteratore esaurito, preso da zip(..., ...).__iter__ dall'essere rilasciato una volta esaurito e non puoi accedervi.

Dovresti mutare la tua zip, quindi puoi ottenere la posizione dell'oggetto rilasciato con un po 'di codice hacky)

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))
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.