Generatorji

V oglate oklepaje zapiramo (izpeljane ali naštevne) sezname, v zavite zapiramo množice in slovarje. V okrogle pa ... terke? Lahko naredimo "izpeljano terko"? Ne, tega ne bi potrebovali velikokrat. Pač pa nas čaka - in bo pogosto v okroglih oklepajih - še nekaj bolj nenavadnega in imenitnejšega: generatorji.

g = (x ** 2 for x in range(4))

Tale g, kot se hitro prepričamo, ni terka, temveč nekaj drugega.

g
<generator object <genexpr> at 0x1067263b0>

Kaj je generator, čemu služi in kako ga uporabljamo? Na prvi dve vprašanji lahko odgovorimo naenkrat: generator je nekaj, kar generira objekte. Vsakič, ko bomo od njega zahtevali nov objekt, bo vrnil novo število - najprej 0, potem 1, potem 4, potem 9. pač kvadrate naravnih števil od 0 do (vključno) 3.

Kako pa "zahtevamo" nov objekt. Tega ne počnemo s funkcijo next.

next(g)
0
next(g)
1
next(g)
4
next(g)
9
next(g)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/var/folders/f9/lgq5ysmn24j3lkss6zkgmtr00000gn/T/ipykernel_96026/4253931490.py in <module>
----> 1 next(g)

StopIteration: 

Na koncu, ko smo zahtevali že peti objekt - g pa je zmožen generirati le štiri, se je pač pritožil, javil napako.

Sem se zgoraj zatipkal, ko sem napisal, da generatorjev ne uporabljamo s funkcijo next, potem pa točno to počel? Ne, nisem se zatipkal. V resnici ne bomo. Funkcijo next sem jo pokazal samo zato, da bi razumeli, kaj počnejo generatorji. V praksi pa klicanje next-a prepustimo for-u.

g = (x ** 2 for x in range(4))

for i in g:
    print(i)
0
1
4
9

Če to poskusimo ponoviti, ne gre: generator je že "iztrošen". Kar je imel zgenerirati, je zgeneriral.

for i in g:
    print(i)

Lahko zavrtimo generator nazaj? Obstaja prev? Ali rewind, restart. Ne, to bi bila prehuda zahteva, kot bomo videli v nadaljevanju, ko bomo spoznavali, kako so narejeni in česa vsega so v resnici zmožni. Pa tudi uporabno in smiselno ne bi bilo.

Generatorji kot argumenti

Generator je torej videti kot izpeljani seznam, le da namesto oglatih oklepajev uporabimo okrogle. Kadar ga pošljemo kot edini argument funkciji, smemo oklepaje celo izpustiti. Napišimo funkcijo, ki kot argument dobi seznam števil in vrne prvo število, ki je večje od 50.

def nad50(s):
    for e in s:
        if e > 50:
            return e

Preskusimo jo, trikrat:

nad50([5, 1, 40, 1, 82, 12, 6])
82
nad50([x ** 2 for x in range(10)])
64
# Dodatni presledki so neumni in so tu samo zaradi pregledanosti primera
nad50(    (x ** 2 for x in range(10))    )
64

V prvem primeru je dobila najobičajnejši seznam. Tako smo si predstavljali, ko smo jo pisali. V drugem primeru smo ji dali seznam kvadratov števil do 10 in vrnila je prvi kvadrat, ki je večji od 50. V tretjem klicu pa ji sploh nismo dali seznama, temveč generator, ki je dajal kvadrate - natančno isto reč kot gornji g. Funkcija je čez generator pognala zanko for natančno tako, kot jo sicer poganja čez sezname.

V čem je prednost generatorjev pred seznami? V tem, da ne sestavijo seznama. Če želimo izračunati vsoto kvadratov prvih stotih števil in za to uporabimo generator, na ta način ne sestavimo seznama teh števil (kot bi ga, če bi rekli sum([x**2 for x in range(100)]), temveč števila, namreč kvadrate, generiramo sproti (tako, da pokličemo sum((x**2 for x in range(100))). Hm, pa to v resnici deluje? No, seveda. Funkcija sum bi lahko bila napisana takole

def sum(s):
    vsota = 0
    for e in s:
        vsota += e
    return vsota

Zanka for lahko gre prek generatorja, torej ona reč deluje.

Že, že, poreče pozornejši študent: kaj pa range(100)? Mar ta ne sestavi seznama stotih števil? Smo res kaj dosti pridobili - namesto seznama kvadratov števil do 100 imamo pač števila do 100 - je to res tak napredek? Tu lahko študenta pohvalimo za budnost, vendar se moti.

range(100)
range(0, 100)

V resnici range ne sestavi seznama, temveč generator. Ampak temu se bomo posvetili malo kasneje, ko bomo o generatorjih vedeli še malo več.

Tu si raje oglejmo še en lušten detalj. Spomnimo se, da okrog elementov terke ni potrebno pisati oklepajev; to smo uporabili, recimo, pri vračanju rezultatov z return x, y ali pri menjavi vrednosti spremenljivk a, b = b, a. Podobno tudi okrog generatorjev ni potrebno pisati oklepajev, kadar so edini argument funkcije. Zadnji klic funkcije nad_50, ko smo podali generator, je imel precej oklepajev - da bi se znašli med njimi, smo dodali celo nepotrebne presledke.

nad50(   (x ** 2 for x in range(10))   )

Ker je tale generator edini argument funkcije, smemo oklepaje izpustiti in pisati kar

nad50(x ** 2 for x in range(10))
64

Vsoto kvadratov števil od 0 do 9 lahko izračunamo tako.

sum(x ** 2 for x in range(10))
285

Tako bodemo počeli poslej.

Funkcije, ki generirajo

Še nekoliko naprednejša snov: Bi znali napisati generator Fibonaccijevih števil, tako kot smo napisali generator kvadratov? Da, vendar bo preprostejša nekoliko drugačna pot. Napisali bomo funkcijo, ki ne vrača le enega rezultata temveč "generira" rezultate. Torej, nekaj takšnega:

def fibonacci(n):
    # Tole ne deluje!
    a = b = 1
    for i in range(n):
        return a
        a, b = b, a+b

Tale funkcija ne deluje, napisali smo jo le, da ilustriramo idejo: radi bi naredili zanko. Ko funkcijo prvič pokličemo, bi return vrnil prvo Fibonaccijevo število. Ko jo pokličemo naslednjič, se funkcija ne bi izvajala od začetka, temveč od tam, kjer smo nazadnje vrnili rezultat, se pravi z vrstico, ki sledi returnu. No, v resnici naredimo natanko tako, le namesto returna moramo uporabiti yield:

def fibonacci(n):
    a = b = 1
    for i in range(n):
        yield a
        a, b = b, a + b

Preskusimo.

f = fibonacci(10)
f
<generator object fibonacci at 0x1068bf840>

Rezultat je torej - generator! Generatorje lahko napišemo v takšni obliki, kot smo jih spoznali sprva, lahko pa v obliki "funkcije". Mehanika je v resnici takšna, da klic te funkcije vrne generator - kot smo poklicali fibonacci(10), smo kot rezultat dobili generator.

next(f)
1
next(f)
1
next(f)
2
next(f)
3
next(f)
5

Kako to deluje, bomo najlažje videli, če v funkcijo nasujemo nekaj print-ov.

def fibonacci(n):
    print("Spet bo treba računati")
    a = b = 1
    for i in range(n):
        print("Zdaj bom vrnil člen", a)
        yield a
        print("In potem bomo nadaljevali")
        a, b = b, a + b

Pokličimo funkcijo, shranimo generator.

f = fibonacci(10)

Se je kaj izpisalo? Nič!!! "Funkcija" se sploh še ni začela izvajati! Funkcija steče vsakič, ko pokličemo next in "zamrzne" ob yield-u.

next(f)
Spet bo treba računati
Zdaj bom vrnil člen 1
1

Poglejte print-e in izpis, pa boste točno videli, kaj se je izvedlo.

In potem glejte, kako teče funkcija naprej vsakič, ko pokličemo next.

next(f)
In potem bomo nadaljevali
Zdaj bom vrnil člen 1
1
next(f)
In potem bomo nadaljevali
Zdaj bom vrnil člen 2
2
next(f)
In potem bomo nadaljevali
Zdaj bom vrnil člen 3
3

Funkcija vsakič nadaljuje od tam, kjer se je prejšnjič ustavila, se pravi od yield-a.

Seveda tudi tega generatorja ne bomo klicali z next, temveč s for.

for x in fibonacci(10):
    print(x)

Napišemo lahko celo funkcijo, ki vrne (no, generira) vsa Fibonaccijeva števila.

def fibonacci():
    a = b = 1
    while True:
        yield a
        a, b = b, a+b

Neskončno zanko, while True, smo že videli, vendar je bil v njej vedno break, ki jo je nekoč prekinil. Kdo pa prekine to zanko? Če nismo previdni, nihče.

for i in fibonacci():
    print(i)

se vidimo onstran večnosti. Pač pa lahko poiščemo, recimo, prvo Fibonaccijevo število, ki je večje od 50.

for i in fibonacci():
    if i > 50:
        print(i)
        break
55

Ah, saj imamo že funkcijo za to reč, nad50. Naj kar ta pove, katero je prvo Fibonaccijevo število večje od 50!

nad50(fibonacci())
55

Primer: Generator deliteljev

Še en zanimiv primer je generator, ki vrne vse delitelje podanega števila.

def delitelji(n):
    for i in range(1, n + 1):
        if n % i == 0:
            yield i
            
list(delitelji(42))
[1, 2, 3, 6, 7, 14, 21, 42]

Opazimo lahko, da z enim deliteljem dobimo dva: če je i delitelj n-ja, je tudi n // i delitelj n-ja. Če je tako, zadošča, da gremo do korena iz n in vrnemo po dva delitelja.

from math import sqrt

def delitelji(n):
    for i in range(1, int(sqrt(n) + 1)):
        if n % i == 0:
            yield i
            yield n // i
            
list(delitelji(24))
[1, 24, 2, 12, 3, 8, 4, 6]

Koren iz n moramo spremeniti v celo število, ker range ne mara necelih.

Pazite, tole sta dva yielda. Funkcija se izvaja tako, da vrne najprej eno število, in ko zahtevamo naslednje, se izvede naslednji yield.

Funkcija je zdaj bistveno hitrejša (pri velikih številih bi se to utegnilo kar poznati - namesto, da gre do milijona, bo šla le do 1000. Vendar žal ne dela pravilno. Če je n, recimo, 25, bo funkcija dvakrat vrnila 5. A tega se znamo hitro znebiti.

def delitelji(n):
    for i in range(1, int(sqrt(n) + 1)):
        if n % i == 0:
            yield i
            if i ** 2 != n:
                yield n // i

Zdaj nas morda moti le še to, da števila ne prihajajo v pravem vrstnem redu. Kot delitelje 42 bi namesto 1, 2, 3, 6, 7, 14, 21, 42 dobili 1, 42, 2, 21, 3, 14, 6, 7. To lahko popravimo tako, da n // i ne vračamo sproti, temveč jih le shranjujemo in jih vračamo kasneje.

def delitelji(n):
    ostali = []
    for i in range(1, int(sqrt(n) + 1)):
        if n % i == 0:
            yield i
            if i ** 2 != n:
                ostali.append(n // i)
    for e in ostali:
        yield e

Nismo še čisto zmagali. Zdaj imamo 1, 2, 3, 6, 41, 21, 14, 7 - prva polovica je iz prvega yielda, druga (od 41 naprej) iz drugega. Te, druge, vrača v enakem vrstnem redu, v katerem jih je vstavljal v seznam, saj append pač vstavlja na konec.

Pa vstavljajmo raje na začetek! Uporabimo insert.

def delitelji(n):
    ostali = []
    for i in range(1, int(sqrt(n) + 1)):
        if n % i == 0:
            yield i
            if i ** 2 != n:
                ostali.insert(0, n // i)
    for e in ostali:
        yield e

Žal se je insertu modro izogibati. Za to, da vstavi element na prvo mesto, mora enega za drugim premakniti vse ostale. V teoriji (ki se jo boste učili drugo leto) je ta program enako počasen kot bi bil, če bi prvo zanko spustili prek range(1, n + 1). S tem, ko smo zamenjali append z insert, smo, vsaj v teoriji, zapravili ves prihranek.

To je preprosto urediti. Vstavljali bomo na konec, z append. Pač pa bomo seznam nato prehodili v obratnem vrstnem redu. To se da narediti z indeksiranjem (for i in range(-1, -n - n, -1): yield ostali[i], vendar si bomo raje pomagali s priročno funkcijo reversed, ki obrne seznam.

def delitelji(n):
    ostali = []
    for i in range(1, int(sqrt(n) + 1)):
        if n % i == 0:
            yield i
            if i ** 2 != n:
                ostali.append(n // i)
    for e in reversed(ostali):
        yield e

Če nam je pomnilnika žal bolj kot časa, pa lahko naredimo drugače: gremo do korena in vračamo delitelji, nato pa od korena nazaj dol in vračamo njihove pare. Z drugimi besedami: to kar v gornjem programu shranjujemo, v spodnjem ponovno zgeneriramo.

from math import sqrt, ceil

def delitelji(n):
    ostali = []
    for i in range(1, int(ceil(sqrt(n)))):
        if n % i == 0:
            yield i
    for i in range(int(sqrt(n)), 0, -1):
        if n % i == 0:
            yield n // i

Prepričajmo se, da deluje tudi v zoprnih robnih primerih.

list(delitelji(42))
[1, 2, 3, 6, 7, 14, 21, 42]
list(delitelji(25))
[1, 5, 25]
list(delitelji(4))
[1, 2, 4]
list(delitelji(3))
[1, 3]
list(delitelji(2))
[1, 2]
list(delitelji(1))
[1]

Iteratorji

Generatorji so samo posebna zvrst iteratorjev. Kaj pa so iteratorji?

Uvedimo jih tako, da najprej posplošimo nekaj, kar že poznamo.

Ob slovarjih smo izvedeli, da kot ključ ne moremo uporabiti ravno poljubnega objekta, temveč le objekte, ki so nespremenljivi immutable. To je samo približno res. Uporabiti smemo objekte, ki so hashable. Objekti morajo imeti določeno (bolj interno) metodo __hash__, ki jo slovarji (in množice) potrebujejo zato, da se bodo odločili, kam shraniti določen ključ (oz. element). Če podatkovni tip to metodo ima, je hashable; če ne, ne in ne more služiti kot ključ slovarja (in biti elemnet množice). Metode __hash__ nikoli ne kličemo neposredno, temveč pokličemo funkcijo hash(obj), ki pokliče obj.__hash__.

hash("Berta")
-3551166701062947909
"Berta".__hash__()  # tega ne počnemo, temveč kličemo `hash`
-3551166701062947909

(Ta ovinek je pogost. Tudi funkcija len(obj) v resnici pokliče obj.__len__ in vrne njen rezultat. Metode __len__ nikoli ne kličemo neposredno. Kot tudi pišemo s[i] in ne s.__getitem__(i). Ker nismo neumni.)

Zdaj pa se vrnimo h generatorjem. Tudi gornja metoda next je samo ovinek: next pokliče metodo __next__. Podatkovni tipi, ki imajo metodo __next__ so iterable. Konkretno, takim objektom rečemo iteratorji.

Generatorji so vrsta iteratorjev. Niso pa generatorji edini iteratorji. V Pythonu je kup priložnostnih iteratorjev. Iterator dobimo s funkcijo iter, ki ji podamo objekt, prek katerega bi radi iterirali. Če imamo seznam s in pokličemo iter(s), bomo dobili iterator prek tega seznama.

s = ["Ana", "Berta", "Cilka"]
t = iter(s)
t
<list_iterator at 0x1068569e0>
next(t)
'Ana'
next(t)
'Berta'
next(t)
'Cilka'

Funkcija iter je sicer enaka prevara kot hash, len ali iter: seznami imajo metodo __iter__. Funkcija iter torej le pokliče __iter__ in ta vrne iterator, torej objekt, ki ima __next__ in ta vrača zaporedne elemente seznama.

Prav tako imajo metodo __iter__ tudi niz (vrne iterator, ki vrača zaporedne znake niza), množica in terka (vračata zaporedne elemente) in slovar (vrača ključe v nekem kakršnemkoliže vrstnem redu).

d = {"Ana": 12, "Berta": 20, "Cilka": 15}
t = iter(d)
t
<dict_keyiterator at 0x1068a6520>
next(t)
'Ana'
next(t)
'Berta'
next(t)
'Cilka'

Kaj torej počne zanka for?

Z zanko for lahko gremo čez tiste in natančno tiste reči, ki so iterabilne - torej, ki imajo metodo iter.

Zanka for je zelo preprosta reč. Če imamo zanko

for spremenljivka in nekaj:

bo poklicala g = iter(nekaj), potem pa izvajala spremenljivka = next(g), dokler next(g) ne vrne napake StopIteration. Napako bo seveda skrila, saj v resnici ne gre za napako, temveč le za signal, da je delo opravljeno. Pokažimo kar primer. Vzemimo

s = ["Ana", "Berta", "Cilka"]

Najprej ga prehodimo s for.

for x in s:
    print(x)
Ana
Berta
Cilka

Kar se v resnici dogaja zgoraj, je to:

g = iter(s)
while True:
    x = next(g)
    print(x)
Ana
Berta
Cilka
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/var/folders/f9/lgq5ysmn24j3lkss6zkgmtr00000gn/T/ipykernel_96026/2251081137.py in <module>
      1 g = iter(s)
      2 while True:
----> 3     x = next(g)
      4     print(x)

StopIteration: 

No, skoraj to. Naš while se konča z napako, for pa to napako prestreže. Tega še ne znamo, a pokažimo, saj bodo mnogi itak razumeli.

g = iter(s)
while True:
    try:
        x = next(g)
        print(x)
    except StopIteration:
        break
Ana
Berta
Cilka

To je to. To v resnici dela zanka for.

Na doslejšnjih predavanjih smo govorili stvari kot "z zanko for lahko gremo prek seznamov", "zanka for zna iti tudi prek množic" ... Figo. Zanka for ne zna ničesar. Vse delo opravijo iteratorji. Zanka for od objekta zahteva iterator in ga kliče.

Generatorji so tudi iteratorji; tudi iteratorji so iteratorji

Naslov zveni malo bedasto. Gre le zato, da imajo iteratorji metodo iter in če jo pokličemo, iteratorji vrnejo kar samega sebe. Zato lahko for-u damo seznam ali generator, in for lahko v vsakem primeru pokliče iter.

t = iter([])
t
<list_iterator at 0x106855450>
iter(t)
<list_iterator at 0x106855450>

Številka v obliki 0x...nekaj, pove, kje v pomnilniku se nahaja ta reč. Na ta način lahko prepoznamo, ali gre za isto stvar ali ne. Tu je številka enaka, torej iter(t) vrne kar sam t. Kot sem rekel.

Kaj so zip, range, enumerate in podobni?

Nekoč smo imeli

imena = ["Ana", "Berta", "Cilka", "Dani", "Ema"]
teze = [72, 65, 75, 68, 63]

in ko sem povedal za zip(imena, teze), sem rekel, da se vede, kot da bi vrnil seznam parov imen in tež. Vendar nisem napisal

zip(imena, teze)

temveč

list(zip(imena, teze))
[('Ana', 72), ('Berta', 65), ('Cilka', 75), ('Dani', 68), ('Ema', 63)]

in rekel, da spreglejmo tisti list.

zip je v resnici generator. Ko ga podamo list-u, le da z nekakšno zanko for pobere vse njegove elemente in jih zloži v novi seznam. Kasneje smo zip uporabljali le v zanki for, ki ji je vseeno, ali dobi seznam ali generator. Zato sem lahko govoril, da se zip vede, kot da bi vrnil seznam.

Najprej preverimo, da je res, kar govorim.

t = zip(imena, teze)
t
<zip at 0x1068fc380>
next(t)
('Ana', 72)
next(t)
('Berta', 65)
next(t)
('Cilka', 75)

Res je generator. Ker po današnjem predavanju kar pokamo od pameti, pa napišimo svoj zip. Če torej zip-a še ne bi bilo, bi si ga sami sprogramirali približno tako.

def my_zip(s, t):
    for i in range(len(s)):
        yield s[i], t[i]
        
t = my_zip(imena, teze)
print(next(t))
print(next(t))
('Ana', 72)
('Berta', 65)
for ime, teza in my_zip(imena, teze):
    print(ime, teza)
Ana 72
Berta 65
Cilka 75
Dani 68
Ema 63

Takle zip bi delal samo, če mu podamo stvari, ki jih je možno indeksirati (recimo sezname), pri čemer mora biti za prvo stvar (s) možno poklicati len, poleg tega pa prva stvar ne sme biti daljša od druge. In, končno, tale zip sprejme samo dva argumenta, pravi zip pa jih poljubno. A za zdaj bodi. Boljše naredimo kasneje.

Kaj pa enumerate. Isto, seveda. Tudi generator.

t = enumerate(imena)
t
<enumerate at 0x1068ef040>
next(t)
(0, 'Ana')
next(t)
(1, 'Berta')

Tudi enumerate bi si znali narediti sami.

def my_enumerate(s):
    for i in range(len(s)):
        yield i, s[i]
for i, ime in my_enumerate(imena):
    print(i, ime)
0 Ana
1 Berta
2 Cilka
3 Dani
4 Ema

range? Isto. Tule je preprostejša različica range-a, ki zahteva zgornjo in spodnjo mejo, korak pa je vedno 1.

def my_range(start, end):
    i = start
    while i < end:
        yield i
        i += 1

for i in my_range(5, 8):
    print(i)
5
6
7

Od Pythona 3 naprej vse tovrstne funkcije vračajo iteratorje. Tudi metode slovarjev, values, items in, hm, keys, vrnejo iteratorje.

Lepota iteratorjev je, da nikoli ne ustvarijo celotnih seznamov. To je hitrejše in prijaznejše do pomnilnika. Obenem uporaba iteratorjev vodi k malo drugačnemu pogledu na potek programa - predstavljamo si ga lahko kot tok podatkov. V Pythonu je to malo manj izrazito, ker ni ravno idealen jezik za tak način programiranja. A vendar se ga da prikazati tudi z njim.

Najbolj frajerski so iteratorji, ki generirajo neskončna zaporedja. V itertools imamo iterator count(n), ki šteje od n do neskončno. Če argument n izpustimo, bo štel od 0.

from itertools import count

for i in count(5):
    if i == 10:
        break
    print(i)
5
6
7
8
9

Če ne bi imeli enumerate, bi namesto enumerate(s) pisali zip(count(), s). Iterator zip bi šel pač hkrati prek iteratorja, ki ga vrne count in prek s.

Tudi count ni vesoljska znanost, ta pa res ne.

def my_count(n=0):
    while True:
        yield n
        n += 1
        
for i in my_count(5):
    if i == 10:
        break
    print(i)
5
6
7
8
9

count() izgleda relativno neuporaben. Ni. Sam ga imam zelo rad in ga pogosto uporabim. V določenih situacijah (katerih opis pa tule izpustimo) ga dejansko uporabljam namesto enumerate. Prav pa pride tudi vedno, kadar nekaj štejemo, iščemo ... in ne vemo, do kod bo potrebno šteti.

Katero je prvo število, katerega kvadrat presega 1000?

for i in count():
    if i ** 2 > 1000:
        break
print(i)
32

Seveda lahko sestavimo tudi kvadrate vseh naravnih števil.

kvadrati = (x ** 2 for x in count())

In potem spustimo zanko čeznje.

for x in kvadrati:
    if x > 1000:
        break
print(i)
32

Katero pa je prvo popolno število? Se pravi, prvo število, ki je enako vsoti svojih deliteljev?

for n in count(1):  # šteti začnemo pri 1, sicer bi dobili 0
    if n == sum(x for x in range(1, n) if n % x == 0):
        break
print(n)
6

Še bolj imenitno: s count si lahko pripravimo generator vseh popolnih števil!

popolna = (n for n in count(1) if n == sum(x for x in range(1, n) if n % x == 0))
next(popolna)
6
next(popolna)
28
next(popolna)
496
next(popolna)
8128

for n in count(1) poskrbi, da bo šel n od 1 do neskončno, if pa izloča vsa nepopolna števila. Ko pokličemo next, bo zanka gnala n toliko časa, da bo if zadovoljen in generator bo "izgeneriral" n. Ko naslednjič pokličemo next, teče zanka naprej. In tako vsakič, ko zahtevamo naslednje število. Prva tri je našel hitro, na 8128 pa je bilo potrebno že malo počakati.

Če razumete tale, zadnji generator, vam je jasno vse.

Resnični zip, range, enumerate

Zgornje funkcije so bile malo poenostavljene. Resnične so sprogramirane v C-ju. Če bi jih hoteli narediti v Pythonu, pa tudi ne bi bile bistveno daljše od gornjih približkov, le malo bolj previdno se jih moramo lotiti.

def my_zip(*args):
    args = [iter(arg) for arg in args]
    try:
        while True:
            yield tuple([next(arg) for arg in args])
    except StopIteration:
        pass

Funkcija dobi poljubno število argumentov, zato *args.

V prvi vrstici takoj pokličemo iter za vsak argument in to zložimo kar nazaj v args. Tako zagotovimo, da imamo same iteratorjev, se pravi, da nam je vseeno, ali je nek argument (že) iterator ali pa (še) seznam ali kaj podobnega.

Nato bomo v neskončnost ponavljali tuple([next(arg) for arg in args]). Z [next(arg) for arg in args] sestavimo seznam vsega, kar vračajo posamični iteratorji, in to pretvorimo v terko, ker zip pač vrača terke. (Zakaj ne kar tuple(next(arg) for arg in args)? Daljša zgodba. PEP-479.)

try-except prestrežeta StopIteration in končata delo.

Preverimo, da res deluje: pokličimo ga s tremi argumenti, pri čemer je en (neskončen) generator, ostala dva pa sta tudi različno dolga.

for i, ime, teza in my_zip(count(), imena, teze[:-2]):
    print(i, ime, teza)
0 Ana 72
1 Berta 65
2 Cilka 75

Kaj pa range?

def my_range(start, end=None, step=1):
    if end == None:
        end = start
        start = 0
    while start < end if step > 0 else start > end:
        yield start
        start += step
list(my_range(5))
[0, 1, 2, 3, 4]
list(my_range(5, 10))
[5, 6, 7, 8, 9]
list(my_range(5, 15, 2))
[5, 7, 9, 11, 13]
list(my_range(15, 5, -2))
[15, 13, 11, 9, 7]

Pravi range zna še marsikaj, ampak "generatorski" del smo podelali.

enumerate je v primerjavi z njima res preprost.

def my_enumerate(s, start=0):
    s = iter(s)
    try:
        while True:
            yield start, next(s)
            start += 1
    except StopIteration:
        pass
list(my_enumerate(imena, start=5))
[(5, 'Ana'), (6, 'Berta'), (7, 'Cilka'), (8, 'Dani'), (9, 'Ema')]

Konec

Enkrat se bo treba ustaviti. V zvezi z vsemi temi rečmi bi bilo mogoče povedati še zelo veliko. Tole je ena najbolj kul tem v Pythonu. Pravzaprav treba priznati, da to niti ni tema iz Pythona. V ozadju tega, kar počnemo tule, je poseben slog programiranja, funkcijsko programiranje. Python ga omogoča in med "normalnimi" jeziki je za takšen slog pravzaprav eden boljših. Obstajajo pa jeziki, ki so posebej narejeni za takšno programiranje. Če je bilo komu tole, kar smo počeli doslej, všeč naj si nujno ogleda kak SML ali Haskell, morda pa ga bo zabaval tudi Racket (dialekt Lispa).

V Pythonu pa si bo ta, ki so mu bile te reči všeč, dobro ogledal module functools, iterools in operator.