Geradores compactados em Python, sendo o segundo menor: como recuperar o elemento consumido silenciosamente

50

Quero analisar 2 geradores de comprimento (potencialmente) diferente com zip:

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

No entanto, se gen2tiver menos elementos, um elemento extra de gen1é "consumido".

Por exemplo,

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, um valor está ausente ( 8no meu exemplo anterior) porque gen1é lido (gerando o valor 8) antes que ele perceba gen2que não possui mais elementos. Mas esse valor desaparece no universo. Quando gen2é "mais longo", não existe esse "problema".

PERGUNTA : Existe uma maneira de recuperar esse valor ausente (ou seja, 8no meu exemplo anterior)? ... idealmente com um número variável de argumentos (como zipfaz).

NOTA : No momento, eu implementei de outra maneira usando, itertools.zip_longestmas realmente me pergunto como obter esse valor ausente usando zipou equivalente.

NOTA 2 : Criei alguns testes das diferentes implementações neste REPL, caso você queira enviar e tentar uma nova implementação :) https://repl.it/@jfthuong/MadPhysicistChester

Jean-Francois T.
fonte
19
Os documentos observam que "zip () só deve ser usado com entradas de comprimento desigual quando você não se importa com valores incomparáveis ​​e à direita das iteráveis ​​mais longas. Se esses valores forem importantes, use itertools.zip_longest ().".
Carcigenicate
2
@ Ch3steR. Mas a questão não tem nada a ver com "por que". Ele literalmente diz "Existe uma maneira de recuperar esse valor ausente ...?" Parece que todas as respostas, exceto as minhas, esqueceram convenientemente de ler essa parte.
Mad Physicist
@MadPhysicist Strange, de fato. Eu reformulei a pergunta para ficar mais claro sobre esse aspecto.
Jean-Francois T.
11
O problema básico é que não há como espiar ou empurrar de volta para um gerador. Assim uma vez que zip()tenha lido 8a partir gen1, ela se foi.
Barmar 10/04
11
@ Barmar definitivamente, todos concordamos com isso. A questão era mais como armazená-lo em algum lugar para poder usá-lo.
Jean-Francois T.

Respostas:

28

Uma maneira seria implementar um gerador que permita armazenar em cache o ú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 isso, enrole as entradas para zip:

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

É importante criar gen2um iterador em vez de iterável, para que você possa saber qual deles estava esgotado. Se gen2estiver esgotado, você não precisa verificargen1.last .

Outra abordagem seria substituir o zip para aceitar uma sequência mutável de iteráveis ​​em vez de iteráveis ​​separados. Isso permitiria substituir iterables por uma versão encadeada que inclui o item "espiado":

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

Essa abordagem é problemática por vários motivos. Não apenas perderá o iterável original, mas também perderá qualquer uma das propriedades úteis que o objeto original pode ter ao substituí-lo por um chainobjeto.

Físico louco
fonte
@MadPhysicist. Ame sua resposta com cache_last, eo fato de que ele não altera o nextcomportamento ... tão ruim que não é simétrica (comutação gen1e gen2no Zip conduz a resultados diferentes) .Cheers
Jean-François T.
11
@ Jean-François. Atualizei o iterador para responder adequadamente às lastchamadas após o esgotamento. Isso deve ajudar a descobrir se você precisa do último valor ou não. Também o torna mais produtivo-y.
Mad Physicist
@MadPhysicist Executei o código e a saída de print(gen1.last) print(next(gen1)) isNone and 9
Ch3steR
@ MadPhysicist com alguns documentos e tudo. Bom;) Vou verificar mais tarde quando tiver tempo. Obrigado pelo tempo gasto
Jean-Francois T.
@ Ch3steR. Obrigado pela captura. Eu fiquei muito animado e excluí a declaração de retorno de last.
Mad Physicist
17

Isso é zipequivalente à implementação, fornecido nos 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)

No seu primeiro exemplo gen1 = my_gen(10)e gen2 = my_gen(8). Depois que os dois geradores são consumidos até a 7ª iteração. Agora, na 8a iteração, as gen1chamadas elem = next(it, sentinel)retornam 8, mas quando as gen2chamadas elem = next(it, sentinel)retornam sentinel(porque isso gen2está esgotado) e if elem is sentinelsão satisfeitas e a função executa o retorno e as paradas. Agoranext(gen1) retorna 9.

No seu segundo exemplo gen1 = gen(8)e gen2 = gen(10). Depois que os dois geradores são consumidos até a 7ª iteração. Agora, na oitava iteração, gen1chama o elem = next(it, sentinel)retorno sentinel(porque neste momento gen1está esgotado) e if elem is sentinelé satisfeito e a função executa o retorno e para. Agoranext(gen2) retorna 8.

Inspirado na resposta do Mad Physicist , você pode usar este Geninvólucro para combatê-lo:

Edit : Para lidar com os casos apontados por Jean-Francois T.

Depois que um valor é consumido do iterador, ele se torna para sempre do iterador e não há um método de mutação no local para que os iteradores o adicionem novamente ao iterador. Uma solução alternativa é armazenar o ú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

Exemplos:

# 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
fonte
Obrigado @ Ch3steR pelo tempo gasto com esse problema. Sua modificação da solução MadPhysicist tem várias limitações: # 1. Se gen1 = cache_last(range(0))e gen2 = cache_last(range(2))depois de fazer list(zip(gen1, gen2), uma chamada para next(gen2)aumentará um AttributeError: 'cache_last' object has no attribute 'prev'. # 2 Se gen1 for maior que gen2, depois de consumir todos os elementos, next(gen2)continuará retornando o último valor em vez de StopIteration. Marcarei a resposta MadPhysicist e A resposta. Obrigado!
Jean-Francois T.
@ Jean-FrancoisT. Sim combinado. Você deve marcar a resposta dele como a resposta. Isso tem limitações. Vou tentar melhorar esta resposta para combater todos os casos. ;)
Ch3steR 10/04
@ Ch3steR Eu posso ajudá-lo a sacudir, se quiser. Eu sou um profissional na área de Validação de Software :)
Jean-Francois T. -
@ Jean-FrancoisT. Eu adoraria. Isso significaria muito. Sou estudante de graduação do terceiro ano.
Ch3steR 10/04
2
Bom trabalho, ele passa em todos os testes que escrevi aqui: repl.it/@jfthuong/MadPhysicistChester Você pode executá-los on-line, muito conveniente :)
Jean-Francois T.
6

Eu posso ver que você já encontrou essa resposta e ela foi mencionada nos comentários, mas achei que seria uma resposta. Você deseja usar itertools.zip_longest(), que substituirá os valores vazios do gerador mais curto por 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)

Impressões:

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

Você também pode fornecer um fillvalueargumento ao chamar zip_longestpara substituir o Nonepor um valor padrão, mas basicamente para a sua solução depois de pressionar um None(ou iou j) no loop for, a outra variável terá o seu 8.

TerryA
fonte
Obrigado. Na verdade, eu já inventei zip_longeste estava na minha pergunta, na verdade. :)
Jean-Francois T.
6

Inspirado na elucidação de @ GrandPhuba zip, vamos criar uma variante "segura" (testada aqui ):

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

Aqui está um teste básico:

    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
fonte
4

você poderia 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
kederrac
fonte
3

Se você deseja reutilizar o código, a solução mais fácil é:

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.

Você pode testar esse código usando sua configuração:

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

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

Irá imprimir:

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

eu não acho que você pode recuperar o valor descartado com o loop for básico, porque o iterador esgotado, retirado de zip(..., ...).__iter__ ser descartado uma vez esgotado, e você não pode acessá-lo.

Você deve alterar seu zip, para obter a posição do item descartado com algum 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))
Максим Степанов
fonte