Générateurs Python zippés dont le 2ème est plus court: comment récupérer un élément consommé en silence

50

Je veux analyser 2 générateurs de longueur (potentiellement) différente avec zip:

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

Cependant, s'il gen2a moins d'éléments, un élément supplémentaire de gen1est "consommé".

Par exemple,

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

Apparemment, une valeur manque ( 8dans mon exemple précédent) car elle gen1est lue (générant ainsi la valeur 8) avant de se rendre compte qu'elle gen2n'a plus d'éléments. Mais cette valeur disparaît dans l'univers. Quand gen2est "plus long", il n'y a pas un tel "problème".

QUESTION : Existe-t-il un moyen de récupérer cette valeur manquante (c'est- 8à- dire dans mon exemple précédent)? ... idéalement avec un nombre variable d'arguments (comme le zipfait).

REMARQUE : J'ai actuellement implémenté d'une autre manière en utilisant itertools.zip_longestmais je me demande vraiment comment obtenir cette valeur manquante en utilisant zipou équivalent.

NOTE 2 : J'ai créé quelques tests des différentes implémentations dans ce REPL au cas où vous voudriez soumettre et essayer une nouvelle implémentation :) https://repl.it/@jfthuong/MadPhysicistChester

Jean-Francois T.
la source
19
Les documents notent que "zip () ne doit être utilisé qu'avec des entrées de longueur inégale lorsque vous ne vous souciez pas des valeurs finales et sans correspondance des itérables plus longues. Si ces valeurs sont importantes, utilisez plutôt itertools.zip_longest ().".
Carcigenicate
2
@ Ch3steR. Mais la question n'a rien à voir avec "pourquoi". Il lit littéralement "Existe-t-il un moyen de récupérer cette valeur manquante ...?" Il semble que toutes les réponses, mais la mienne, ont oublié de lire cette partie.
Mad Physicist
@MadPhysicist Strange en effet. J'ai reformulé la question pour être plus clair sur cet aspect.
Jean-Francois T.
1
Le problème de base est qu'il n'y a aucun moyen de jeter un œil ou de repousser dans un générateur. Donc , une fois zip()a lu à 8partir gen1, il est parti.
Barmar
1
@Barmar définitivement, nous sommes tous d'accord là-dessus. La question était plutôt de savoir comment le stocker quelque part pour pouvoir l'utiliser.
Jean-Francois T.

Réponses:

28

Une façon serait d'implémenter un générateur qui vous permet de mettre en cache la dernière valeur:

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

Pour l'utiliser, encapsulez les entrées pour zip:

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

Il est important de créer gen2un itérateur plutôt qu'un itérable, afin que vous puissiez savoir lequel a été épuisé. Si gen2est épuisé, vous n'avez pas besoin de vérifier gen1.last.

Une autre approche consisterait à remplacer zip pour accepter une séquence mutable d'itérables au lieu d'itérables séparés. Cela vous permettrait de remplacer les itérables par une version chaînée qui inclut votre élément "jeté un œil":

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

Cette approche est problématique pour de nombreuses raisons. Non seulement il perdra l'itérable d'origine, mais il perdra toutes les propriétés utiles que l'objet d'origine aurait pu avoir en le remplaçant par un chainobjet.

Physicien fou
la source
@MadPhysicist. J'adore votre réponse avec cache_last, et le fait qu'elle ne modifie pas le nextcomportement ... tant pis ce n'est pas symétrique (la commutation gen1et gen2dans le zip conduira à des résultats différents) Cheers
Jean-Francois T.
1
@ Jean-Francois. J'ai mis à jour l'itérateur pour répondre correctement aux lastappels une fois qu'il est épuisé. Cela devrait aider à déterminer si vous avez besoin de la dernière valeur ou non. Le rend également plus productif.
Mad Physicist
@MadPhysicist J'ai exécuté le code et la sortie de print(gen1.last) print(next(gen1)) isNone and 9
Ch3steR
@MadPhysicist avec quelques docstrings et tout. Sympa;) Je vérifierai plus tard quand j'aurai le temps. Merci pour le temps passé
Jean-Francois T.
@ Ch3steR. Merci pour la capture. J'étais trop excité et j'avais supprimé la déclaration de retour de last.
Mad Physicist
17

C'est l' zipéquivalent d'implémentation donné dans la documentation

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)

Dans votre 1er exemple gen1 = my_gen(10)et gen2 = my_gen(8). Après les deux générateurs sont consommés jusqu'à la 7ème itération. Maintenant, à la 8e itération, les gen1appels elem = next(it, sentinel)renvoient 8, mais lorsque les gen2appels elem = next(it, sentinel)reviennent sentinel(car à ce moment, ils gen2sont épuisés) et if elem is sentinelsont satisfaits et la fonction exécute le retour et s'arrête. Renvoie maintenant next(gen1)9.

Dans votre 2ème exemple gen1 = gen(8)et gen2 = gen(10). Après les deux générateurs sont consommés jusqu'à la 7ème itération. Maintenant, à la 8e itération, l' gen1appel elem = next(it, sentinel)revient sentinel(car à ce stade gen1est épuisé) et if elem is sentinelest satisfait et la fonction exécute le retour et s'arrête. Renvoie maintenant next(gen2)8.

Inspiré par la réponse de Mad Physicist , vous pouvez utiliser ce Genwrapper pour le contrer:

Edit : Pour gérer les cas pointés par Jean-François T.

Une fois qu'une valeur est consommée par l'itérateur, elle disparaît à jamais de l'itérateur et il n'y a pas de méthode de mutation sur place pour que les itérateurs la rajoutent à l'itérateur. Une solution consiste à stocker la dernière valeur consommée.

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

Exemples:

# 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
la source
Merci @ Ch3steR pour le temps passé sur ce problème. Votre modification de la solution MadPhysicist a plusieurs limites: # 1. Si gen1 = cache_last(range(0))et gen2 = cache_last(range(2))ensuite après avoir fait list(zip(gen1, gen2), un appel à next(gen2)fera monter un AttributeError: 'cache_last' object has no attribute 'prev'. # 2. Si gen1 est plus long que gen2, après avoir consommé tous les éléments, next(gen2)continuera de renvoyer la dernière valeur au lieu de StopIteration. Je marquerai la réponse de MadPhysicist et LA réponse. Merci!
Jean-Francois T.
@ Jean-FrancoisT. Oui d'accord. Vous devez marquer sa réponse comme réponse. Cela a des limites. Je vais essayer d'améliorer cette réponse pour contrer tous les cas. ;)
Ch3steR
@ Ch3steR Je peux vous aider à le secouer si vous le souhaitez. Je suis un professionnel de la validation logicielle :)
Jean-Francois T.
@ Jean-FrancoisT. J'aimerais bien. Cela signifierait beaucoup. Je suis un étudiant de 3e année de premier cycle.
Ch3steR
2
Bon travail, il passe tous les tests que j'ai écrits ici: repl.it/@jfthuong/MadPhysicistChester Vous pouvez les exécuter en ligne, assez pratique :)
Jean-Francois T.
6

Je peux voir que vous avez déjà trouvé cette réponse et elle a été évoquée dans les commentaires, mais j'ai pensé que j'en ferais une réponse. Vous souhaitez utiliser itertools.zip_longest(), qui remplacera les valeurs vides du générateur le plus court par 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)

Tirages:

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

Vous pouvez également fournir un fillvalueargument lors de l'appel zip_longestpour remplacer le Nonepar une valeur par défaut, mais fondamentalement pour votre solution une fois que vous avez frappé un None(ou iou j) dans la boucle for, l'autre variable aura votre 8.

TerryA
la source
Merci. J'ai en effet déjà trouvé zip_longestet c'était en fait ma question. :)
Jean-Francois T.
6

Inspiré par l'élucidation de @ GrandPhuba zip, créons une variante "sûre" (testée ici ):

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

Voici un test de 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
JG
la source
4

vous pouvez utiliser itertools.tee et 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
la source
3

Si vous souhaitez réutiliser du code, la solution la plus simple est:

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.

Vous pouvez tester ce code à l'aide de votre configuration:

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

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

Il imprimera:

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

Je ne pense pas que vous puissiez récupérer la valeur perdue avec la boucle for de base, car l'itérateur épuisé, tiré de zip(..., ...).__iter__ suppression une fois épuisé et vous ne pouvez pas y accéder.

Vous devez muter votre zip, puis vous pouvez obtenir la position de l'élément déposé avec du code 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))
Максим Степанов
la source