Generadores Python comprimidos con el segundo más corto: cómo recuperar el elemento que se consume silenciosamente


50

Quiero analizar 2 generadores de (potencialmente) diferente longitud con zip:

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

Sin embargo, si gen2tiene menos elementos, gen1se "consume" un elemento adicional .

Por ejemplo,

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

Aparentemente, falta un valor ( 8en mi ejemplo anterior) porque gen1se lee (generando así el valor 8) antes de darse gen2cuenta de que no tiene más elementos. Pero este valor desaparece en el universo. Cuando gen2es "más largo", no existe tal "problema".

PREGUNTA : ¿Hay alguna forma de recuperar este valor faltante (es decir, 8en mi ejemplo anterior)? ... idealmente con un número variable de argumentos (como lo ziphace).

NOTA : Actualmente lo he implementado de otra manera usando, itertools.zip_longestpero realmente me pregunto cómo obtener este valor faltante usando zipo equivalente.

NOTA 2 : He creado algunas pruebas de las diferentes implementaciones en este REPL en caso de que desee enviar y probar una nueva implementación :) https://repl.it/@jfthuong/MadPhysicistChester


19
Los documentos tienen en cuenta que "zip () solo debe usarse con entradas de longitud desigual cuando no le interesan los valores finales coincidentes de los iterables más largos. Si esos valores son importantes, use itertools.zip_longest () en su lugar".
Carcigenicate

2
@ Ch3steR. Pero la pregunta no tiene nada que ver con "por qué". Literalmente dice "¿Hay alguna manera de recuperar este valor perdido ...?" Parece que todas las respuestas, pero la mía, convenientemente olvidaron leer esa parte.
Físico loco

@MadPhysicist Extraño de hecho. Reformé la pregunta para ser más clara en ese aspecto.
Jean-Francois T.

1
El problema básico es que no hay forma de mirar o retroceder en un generador. Así que una vez que zip()haya leído 8a partir gen1, se ha ido.
Barmar

1
@Barmar definitivamente, todos estuvimos de acuerdo en eso. La pregunta era más cómo almacenarlo en algún lugar para poder usarlo.
Jean-Francois T.

Respuestas:


28

Una forma sería implementar un generador que le permita almacenar en caché el último valor:

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

Para usar esto, ajuste las entradas a zip:

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

Es importante hacer gen2un iterador en lugar de un iterable, para que pueda saber cuál estaba agotado. Si gen2está agotado, no necesita verificar gen1.last.

Otro enfoque sería anular zip para aceptar una secuencia mutable de iterables en lugar de iterables separados. Eso le permitiría reemplazar iterables con una versión encadenada que incluye su elemento "echado un vistazo":

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

Este enfoque es problemático por muchas razones. No solo perderá el iterable original, sino que perderá cualquiera de las propiedades útiles que pueda haber tenido el objeto original al reemplazarlo con un chainobjeto.


@MadPhysicist. Me encanta tu respuesta cache_lasty el hecho de que no altera el nextcomportamiento ... tan malo que no es simétrico (cambiar gen1y gen2en el zip dará lugar a resultados diferentes) .Felicidades
Jean-Francois T.

1
@ Jean-Francois. He actualizado el iterador para responder correctamente a las lastllamadas después de que se haya agotado. Eso debería ayudar a determinar si necesita el último valor o no. También lo hace más productivo.
Físico loco

@MadPhysicist Ejecuté el código y la salida de print(gen1.last) print(next(gen1)) esNone and 9
Ch3steR

@MadPhysicist con algunas cadenas de documentos y todo. Nice;) Lo comprobaré más tarde cuando tenga tiempo. Gracias por el tiempo dedicado
Jean-Francois T.

@ Ch3steR. Gracias por la captura. Me emocioné demasiado y eliminé la declaración de devolución de last.
Físico loco

17

Esto es zipequivalente a la implementación dada en documentos

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)

En su primer ejemplo gen1 = my_gen(10)y gen2 = my_gen(8). Después de que ambos generadores se consumen hasta la 7ª iteración. Ahora, en la octava iteración, las gen1llamadas elem = next(it, sentinel)devuelven 8 pero cuando las gen2llamadas elem = next(it, sentinel)regresan sentinel(porque en este momento gen2están agotadas) y if elem is sentinelestán satisfechas y la función ejecuta return y se detiene. Ahora next(gen1)devuelve 9.

En su segundo ejemplo gen1 = gen(8)y gen2 = gen(10). Después de que ambos generadores se consuman hasta la 7ª iteración. Ahora, en la octava iteración gen1, se elem = next(it, sentinel)devuelve sentinel(porque en este punto gen1está agotado) y if elem is sentinelse satisface y la función ejecuta return y se detiene. Ahora next(gen2)devuelve 8.

Inspirado por la respuesta de Mad Physicist , puedes usar este Genenvoltorio para contrarrestarlo:

Editar : Para manejar los casos señalados por Jean-Francois T.

Una vez que el iterador consume un valor, este desaparece para siempre y no hay un método de mutación en el lugar para que los iteradores lo agreguen nuevamente al iterador. Una solución es almacenar el último valor consumido.

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

Ejemplos:

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

Gracias @ Ch3steR por el tiempo dedicado a este problema. Su modificación de la solución MadPhysicist tiene varias limitaciones: # 1. Si gen1 = cache_last(range(0))y gen2 = cache_last(range(2))luego de hacerlo list(zip(gen1, gen2), una llamada a next(gen2)levantará un AttributeError: 'cache_last' object has no attribute 'prev'. # 2 Si gen1 es más largo que gen2, después de consumir todos los elementos, next(gen2)seguirá devolviendo el último valor en lugar de StopIteration. Marcaré la respuesta MadPhysicist y LA respuesta. ¡Gracias!
Jean-Francois T.

@ Jean-FrancoisT. Sí, estoy de acuerdo. Debes marcar su respuesta como la respuesta. Esto tiene limitaciones. Intentaré mejorar esta respuesta para contrarrestar todos los casos. ;)
Ch3steR

@ Ch3steR Puedo ayudarte a sacudirlo si quieres. Soy un profesional en el campo de la validación de software :)
Jean-Francois T.

@ Jean-FrancoisT. Me encantaría. Significaría mucho. Soy un estudiante de tercer año de pregrado.
Ch3steR

2
Buen trabajo, pasa todas las pruebas que he escrito aquí: repl.it/@jfthuong/MadPhysicistChester Puedes ejecutarlas en línea, muy conveniente :)
Jean-Francois T.

6

Puedo ver que ya has encontrado esta respuesta y apareció en los comentarios, pero pensé que la respondería. Desea usar itertools.zip_longest(), que reemplazará los valores vacíos del generador más corto 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)

Huellas dactilares:

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

También puede proporcionar un fillvalueargumento al llamar zip_longestpara reemplazar el Nonecon un valor predeterminado, pero básicamente para su solución una vez que presione un None(o io j) en el bucle for, la otra variable tendrá su 8.


Gracias. De hecho, ya se me ocurrió zip_longesty realmente estaba en mi pregunta. :)
Jean-Francois T.

6

Inspirado por la aclaración de @ GrandPhuba, creemos zipuna variante "segura" (probada por unidad aquí ):

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

Aquí hay una prueba básica:

    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

podría usar 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

Si desea reutilizar el código, la solución más fácil es:

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.

Puede probar este código usando su configuración:

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

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

Imprimirá:

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

2

no creo que pueda recuperar el valor eliminado con el bucle básico for, porque el iterador agotado, tomado de zip(..., ...).__iter__ser eliminado una vez agotado y no puede acceder a él.

Debe mutar su código postal, luego puede obtener la posición del artículo que se cayó con algún código 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))
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.