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


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.


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

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.


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

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

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

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))
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.