Generator Zip Python dengan yang ke-2 lebih pendek: cara mengambil elemen yang dikonsumsi secara diam-diam

50

Saya ingin menguraikan 2 generator dengan (berpotensi) berbeda panjang dengan zip:

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

Namun, jika gen2memiliki lebih sedikit elemen, satu elemen tambahan gen1adalah "dikonsumsi".

Sebagai contoh,

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

Rupanya, nilai hilang ( 8dalam contoh saya sebelumnya) karena gen1dibaca (sehingga menghasilkan nilai 8) sebelum disadari gen2tidak memiliki elemen lagi. Tetapi nilai ini menghilang di alam semesta. Ketika gen2"lebih lama", tidak ada "masalah" seperti itu.

PERTANYAAN : Apakah ada cara untuk mengambil nilai yang hilang ini (yaitu 8dalam contoh saya sebelumnya)? ... idealnya dengan sejumlah variabel argumen (seperti ziphalnya).

CATATAN : Saat ini saya telah menerapkan cara lain dengan menggunakan itertools.zip_longesttetapi saya benar-benar bertanya-tanya bagaimana cara mendapatkan nilai yang hilang ini menggunakan zipatau setara.

CATATAN 2 : Saya telah membuat beberapa pengujian dari implementasi yang berbeda dalam REPL ini jika Anda ingin mengirim dan mencoba implementasi baru :) https://repl.it/@jfthuong/MadPhysicistChester

Jean-Francois T.
sumber
19
Dokumen memang mencatat bahwa "zip () hanya boleh digunakan dengan input panjang yang tidak sama ketika Anda tidak peduli tentang trailing, nilai yang tidak cocok dari iterables yang lebih panjang. Jika nilai-nilai itu penting, gunakan itertools.zip_longest () sebagai gantinya.".
Carcigenicate
2
@ Ch3steR. Tetapi pertanyaannya tidak ada hubungannya dengan "mengapa". Itu berbunyi "Apakah ada cara untuk mengambil nilai yang hilang ini ...?" Tampaknya semua jawaban tetapi jawaban saya mudah lupa membaca bagian itu.
Fisikawan Gila
@MadPhysicist Aneh memang. Saya mengulangi pertanyaannya menjadi lebih jelas pada aspek itu.
Jean-Francois T.
1
Masalah mendasarnya adalah tidak ada cara untuk mengintip atau mendorong kembali ke generator. Jadi sekali zip()sudah membaca 8dari gen1, itu hilang.
Barmar
1
@Barmar pasti, kita semua sepakat tentang itu. Pertanyaannya adalah bagaimana cara menyimpannya di suatu tempat agar dapat menggunakannya.
Jean-Francois T.

Jawaban:

28

Salah satu caranya adalah dengan mengimplementasikan generator yang memungkinkan Anda melakukan cache nilai terakhir:

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

Untuk menggunakan ini, bungkus input ke zip:

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

Penting untuk membuat gen2iterator daripada iterable, jadi Anda bisa tahu mana yang habis. Jika gen2habis, Anda tidak perlu memeriksa gen1.last.

Pendekatan lain adalah mengesampingkan zip untuk menerima urutan iterables yang bisa berubah dari pada iterables yang terpisah. Itu akan memungkinkan Anda untuk mengganti iterables dengan versi berantai yang menyertakan item "mengintip" Anda:

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

Pendekatan ini bermasalah karena berbagai alasan. Tidak hanya itu akan kehilangan iterable asli, tetapi juga akan kehilangan salah satu sifat berguna yang mungkin dimiliki oleh objek asli dengan menggantinya dengan chainobjek.

Fisikawan Gila
sumber
@MadPhysicist. Sukai jawaban Anda cache_last, dan fakta bahwa itu tidak mengubah nextperilaku ... sangat buruk itu tidak simetris (beralih gen1dan gen2di zip akan mengarah ke hasil yang berbeda) .Cheers
Jean-Francois T.
1
@ Jean-Francois. Saya telah memperbarui iterator untuk menanggapi lastpanggilan dengan benar setelah habis. Itu akan membantu dalam mencari tahu apakah Anda membutuhkan nilai terakhir atau tidak. Juga membuatnya lebih banyak produksi-y.
Fisikawan Gila
@MadPhysicist Aku berlari kode dan output dari print(gen1.last) print(next(gen1)) isNone and 9
Ch3steR
@MadPhysicist dengan beberapa dokumen dan semuanya. Bagus;) Saya akan memeriksa nanti ketika saya punya waktu. Terima kasih atas waktu yang dihabiskan
Jean-Francois T.
@ Ch3steR. Terima kasih atas tangkapannya. Saya terlalu bersemangat dan menghapus pernyataan pengembalian dari last.
Fisikawan Gila
17

Ini zipsetara dengan implementasi yang diberikan dalam dokumen

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)

Dalam contoh pertama Anda gen1 = my_gen(10) dan gen2 = my_gen(8). Setelah kedua generator dikonsumsi hingga iterasi ke-7. Sekarang dalam 8 gen1panggilan iterasi elem = next(it, sentinel)yang mengembalikan 8 tetapi ketika gen2panggilan elem = next(it, sentinel)itu kembali sentinel(karena saat ini gen2sudah habis) dan if elem is sentinelpuas dan fungsi menjalankan kembali dan berhenti. Sekarang next(gen1)mengembalikan 9.

Dalam contoh 2 Anda gen1 = gen(8)dan gen2 = gen(10). Setelah kedua generator dikonsumsi hingga iterasi ke-7. Sekarang dalam gen1panggilan iterasi ke-8 elem = next(it, sentinel)yang kembali sentinel(karena pada titik gen1ini habis) dan if elem is sentinelpuas dan fungsi mengeksekusi kembali dan berhenti. Sekarang next(gen2)mengembalikan 8.

Terinspirasi oleh jawaban Fisikawan Gila , Anda bisa menggunakan Genpembungkus ini untuk mengatasinya:

Sunting : Untuk menangani kasus yang ditunjukkan oleh Jean-Francois T.

Setelah nilai dikonsumsi dari iterator, ia akan hilang selamanya dari iterator dan tidak ada metode mutasi di tempat bagi iterator untuk menambahkannya kembali ke iterator. Salah satu penyelesaiannya adalah menyimpan nilai yang dikonsumsi terakhir.

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

Contoh:

# 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
sumber
Terima kasih @ Ch3steR atas waktu yang dihabiskan untuk masalah ini. Modifikasi solusi MadPhysicist Anda memiliki beberapa keterbatasan: # 1. Jika gen1 = cache_last(range(0))dan gen2 = cache_last(range(2))kemudian setelah melakukan list(zip(gen1, gen2), panggilan ke next(gen2)akan menaikkan AttributeError: 'cache_last' object has no attribute 'prev'. # 2. Jika gen1 lebih panjang dari gen2, setelah mengkonsumsi semua elemen, next(gen2)akan terus mengembalikan nilai terakhir sebagai gantinya StopIteration. Saya akan menandai jawaban MadPhysicist dan jawaban THE. Terima kasih!
Jean-Francois T.
@ Jean-FrancoisT. Ya setuju. Anda harus menandai jawabannya sebagai jawabannya. Ini memiliki keterbatasan. Saya akan mencoba meningkatkan jawaban ini untuk melawan semua kasus. ;)
Ch3steR
@ Ch3steR Saya dapat membantu Anda mengocoknya jika Anda mau. Saya seorang profesional di bidang Validasi Perangkat Lunak :)
Jean-Francois T.
@ Jean-FrancoisT. Aku sangat ingin. Itu akan sangat berarti. Saya seorang mahasiswa tahun ke-3.
Ch3steR
2
Kerja bagus, ia lulus semua tes yang saya tulis di sini: repl.it/@jfthuong/MadPhysicistChester Anda dapat menjalankannya secara online, cukup nyaman :)
Jean-Francois T.
6

Saya bisa melihat Anda sudah menemukan jawaban ini dan muncul di komentar tapi saya pikir saya akan membuat jawabannya. Anda ingin menggunakan itertools.zip_longest(), yang akan menggantikan nilai kosong dari generator yang lebih pendek dengan 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)

Cetakan:

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

Anda juga dapat memberikan fillvalueargumen saat memanggil zip_longestuntuk mengganti Nonedengan nilai default, tetapi pada dasarnya untuk solusi Anda setelah Anda menekan None(salah satu iatau j) di for loop, variabel lain akan memiliki Anda 8.

TerryA
sumber
Terima kasih. Saya memang sudah menemukan zip_longestdan itu sebenarnya adalah pertanyaan saya. :)
Jean-Francois T.
6

Terinspirasi oleh penjelasan @ GrandPhuba tentang zip, mari kita buat varian "aman" (unit-diuji di sini ):

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

Berikut ini adalah tes dasar:

    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
J G
sumber
4

Anda bisa menggunakan itertools.tee dan 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
sumber
3

Jika Anda ingin menggunakan kembali kode, solusi termudah adalah:

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.

Anda dapat menguji kode ini menggunakan pengaturan Anda:

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

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

Itu akan mencetak:

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

saya tidak berpikir Anda dapat mengambil nilai yang dijatuhkan dengan dasar untuk loop, karena iterator habis, diambil dari zip(..., ...).__iter__ yang dijatuhkan sekali habis dan Anda tidak dapat mengaksesnya.

Anda harus bermutasi pos Anda, maka Anda bisa mendapatkan posisi item yang dijatuhkan dengan beberapa kode 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))
Максим Степанов
sumber