Nacházíte se zde: Domů Ponořme se do Pythonu 3

Úroveň obtížnosti: ♦♦♦♦♢

Iterátory pro pokročilé

Great fleas have little fleas upon their backs to bite ’em,
And little fleas have lesser fleas, and so ad infinitum.
(Veliké blechy maj malé své blechy, aby je kousaly do jejich zad,
Hle, malé si nesou své o něco menší; konce to nemá — podivný řád.)
— Augustus De Morgan

 

Ponořme se

Jestliže přirovnáme regulární výrazy ke steroidům pro řetězce, pak modul itertools představuje steroidy pro iterátory. Ale nejdříve si ukážeme jednu klasickou hádanku.

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4

Hádankám tohoto typu se říká algebrogramy (anglicky cryptarithms nebo alphametics). Písmena jsou složena do skutečných slov, ale pokud každé z nich nahradíte číslicí 0–9, pak tvoří aritmetickou rovnici. Úkol spočívá v nalezení dvojic písmeno/číslice. Všechny výskyty stejného písmene se musí dát nahradit stejnou číslicí. Žádná číslice se nesmí opakovat a žádné „slovo“ nesmí začínat číslicí 0.

V této kapitole se ponoříme do neuvěřitelného pythonovského programu, který původně napsal Raymond Hettinger. Program řeší algebrogramy na pouhých 14 řádcích kódu.

[stáhnout alphametics.py]

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution)

Program můžeme spustit z příkazového řádku. Pod Linuxem to bude vypadat nějak takto. (V závislosti na rychlosti vašeho počítače to může zabrat nějaký čas a není zde žádný indikátor průběhu výpočtu. Buďte trpěliví.)

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652

Nalezení všech výskytů vzorku

Program pro řešení algebrogramu v něm ze všeho nejdřív hledá písmena (A–Z).

>>> import re
>>> re.findall('[0-9]+', '16 2-by-4s in rows of 8')  
['16', '2', '4', '8']
>>> re.findall('[A-Z]+', 'SEND + MORE == MONEY')     
['SEND', 'MORE', 'MONEY']
  1. Modul re implementuje v Pythonu regulární výrazy. Najdeme v něm i šikovnou funkci nazvanou findall(), které zadáváme vzorek pro regulární výraz a řetězec. Funkce v zadaném řetězci nalezne všechny výskyty vzorku. V tomto případě vzorek pasuje na posloupnosti číslic. Funkce findall() vrací seznam všech podřetězců, které vzorku vyhovují.
  2. Zde regulární výraz popisuje posloupnosti písmen. Návratovou hodnotou je opět seznam, jehož prvky jsou řetězce, které pasovaly k regulárnímu výrazu.

Následuje další příklad, který vám trochu procvičí mozek.

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"]

Překvapeni? Regulární výraz hledá mezeru, znak s, pak nejkratší možnou posloupnost libovolných znaků (.*?), pak mezeru a další s. Když se tak dívám na vstupní řetězec, vidím pět pasujících podřetězců:

  1. The sixth sick sheikh's sixth sheep's sick.
  2. The sixth sick sheikh's sixth sheep's sick.
  3. The sixth sick sheikh's sixth sheep's sick.
  4. The sixth sick sheikh's sixth sheep's sick.
  5. The sixth sick sheikh's sixth sheep's sick.

Ale funkce re.findall() vrátila jen tři shody. Konkrétně vrátila jen první, třetí a pátou. Proč jen tři? Protože nevrací překrývající se shody se vzorkem. První shoda se překrývá s druhou, takže první se vrací a druhá se přeskakuje. Pak se třetí shoda překrývá se čtvrtou, takže třetí se vrací a čtvrtá se přeskakuje. A nakonec je tu pátá shoda, která se vrací. Najdou se tedy tři výskyty a ne pět.

Tahle poznámka neměla s řešením algebrogramu nic společného. Prostě mi to připadlo zajímavé.

Nalezení jedinečných prvků posloupnosti

Jedinečné hodnoty z posloupnosti můžeme snadno najít pomocí množin (set).

>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick']
>>> set(a_list)                      
{'sixth', 'The', "sheep's", 'sick', "sheik's"}
>>> a_string = 'EAST IS EAST'
>>> set(a_string)                    
{'A', ' ', 'E', 'I', 'S', 'T'}
>>> words = ['SEND', 'MORE', 'MONEY']
>>> ''.join(words)                   
'SENDMOREMONEY'
>>> set(''.join(words))              
{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
  1. Pokud máme seznam s několika řetězci, pak nám z něj funkce set() vytvoří množinu jedinečných řetězců. Dá se to snadno pochopit, když si to představíte jako cyklus for. Vezmeme první položku ze seznamu a vložíme ji do množiny. Pak druhou. A třetí. Čtvrtou. Pátou... Počkat! Ta už v množině je, takže se bude vypisovat jen jednou, protože množiny v Pythonu neumožňují existenci duplicit. A šestou. Sedmou — a znovu duplicita, takže se pak objeví jen jednou. A jaký je konečný výsledek? Z původního seznamu zbyly jen jedinečné položky bez duplicit. Původní seznam ani nemusíme předem seřadit.
  2. Stejná technika funguje i pro řetězce, protože řetězce jsou posloupnostmi znaků.
  3. Pokud máme seznam řetězců, pak ''.join(a_list) spojí všechny řetězce do jednoho.
  4. Takže pokud máme seznam řetězců, tento řádek kódu vrátí jedinečné znaky nacházející se ve všech řetězcích. Bez duplicit.

Program pro řešení algebrogramů tuto techniku používá pro vytvoření množiny všech jedinečných znaků v zadání.

unique_characters = set(''.join(words))

Program postupně prochází všemi možnými řešeními a tuto množinu používá pro přiřazení číslic jednotlivým znakům.

Činíme předpoklady

V Pythonu, stejně jako v mnoha jiných programovacích jazycích, najdeme příkaz assert. Funguje následovně.

>>> assert 1 + 1 == 2                                     
>>> assert 1 + 1 == 3                                     
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> assert 2 + 2 == 5, "Only for very large values of 2"  
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2
  1. Za příkaz assert uvedeme libovolný platný pythonovský výraz. V tomto případě se výraz 1 + 1 == 2 vyhodnotí jako True, takže příkaz assert nedělá nic.
  2. Pokud se ale pythonovský výraz vyhodnotí jako False, vyvolá příkaz assert výjimku AssertionError.
  3. Za výraz můžeme uvést také lidsky čitelnou zprávu, která se v případě vyvolání výjimky AssertionError zobrazí.

Takže následující řádek kódu:

assert len(unique_characters) <= 10, 'Too many letters'

… je ekvivalentem zápisu:

if len(unique_characters) > 10:
    raise AssertionError('Too many letters')

Program řešící algebrogram používá přesně takový příkaz assert k předčasnému ukončení činnosti v případě, kdy hádanka obsahuje víc než deset jedinečných znaků. Protože každému písmenu přiřazujeme jedinečnou číslici a číslic máme jen deset, hádanka s více než deseti jedinečnými znaky nemůže mít řešení.

Generátorové výrazy

Generátorový výraz se podobá generátorové funkci, ale funkce to není.

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
>>> gen = (ord(c) for c in unique_characters)  
>>> gen                                        
<generator object <genexpr> at 0x00BADC10>
>>> next(gen)                                  
69
>>> next(gen)
68
>>> tuple(ord(c) for c in unique_characters)   
(69, 68, 77, 79, 78, 83, 82, 89)
  1. Generátorový výraz se chová jako anonymní funkce, která produkuje hodnoty. Výraz samotný se podobá generátorové notaci seznamu (list comprehension), ale místo do hranatých závorek je uzavřen v kulatých závorkách.
  2. Generátorový výraz vrací… iterátor.
  3. Při volání next(gen) se nám vrací další hodnota iterátoru.
  4. Pokud chcete, můžete iterovat přes všechny hodnoty a vrátit n-tici, seznam nebo množinu tím, že generátorový výraz použijete v roli argumentu tuple(), list() nebo set(). V takovém případě nemusíte používat sadu kulatých závorek navíc. Funkci tuple() stačí předat „holý“ výraz ord(c) for c in unique_characters a Python už pozná, že jde o generátorový výraz.

Když místo generátorové notace seznamu použijete generátorový výraz, ušetříte jak CPU, tak RAM. Pokud konstruujete seznam jen proto, abyste ho zase zahodili (tj. když ho například chcete předat do tuple() nebo set()), použijte raději generátorový výraz!

Následující ukázka dosahuje stejného efektu s použitím generátorové funkce:

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters)

Generátorový výraz je kompaktnější, ale funguje stejně.

Výpočet permutací (pro lenochy)

Ze všeho nejdříve se podívejme, co to vlastně jsou permutace? Permutace jsou matematický koncept. (Ve skutečnosti existuje několik definicí v závislosti na tom, jakým druhem matematiky se zabýváte. Zde se dotkneme kombinatoriky. Ale pokud vám to nic neříká, nedělejte si s tím starosti. Tak jako vždy, vaším kamarádem je Wikipedie.)

Základní myšlenka spočívá v tom, že vezmeme seznam věcí (mohou to být čísla, písmenka nebo tancující medvídci) a najdeme všechny možné způsoby, jak z něj udělat menší seznamy. (Poznámka překladatele: V našich školách se pro označení tohoto úkonu používá pojem variace k-té třídy z n prvků bez opakování. Pojem permutace bez opakování se u nás používá jen pro speciální případ, kdy k je rovno n. V dalším textu zůstanu u chápání pojmu z originální publikace také z důvodu pojmenování příslušné funkce.) Všechny menší seznamy mají mít stejnou velikost, která může být od 1 až po celkový počet prvků. A nic se nesmí opakovat. Matematici by řekli „najděme permutace dvojic z tří různých prvků“ (u nás „najděte variace druhé třídy z tří prvků bez opakování“). To znamená, že máme posloupnost tří prvků a chceme nalézt všechny možné uspořádané dvojice.

>>> import itertools                              
>>> perms = itertools.permutations([1, 2, 3], 2)  
>>> next(perms)                                   
(1, 2)
>>> next(perms)
(1, 3)
>>> next(perms)
(2, 1)                                            
>>> next(perms)
(2, 3)
>>> next(perms)
(3, 1)
>>> next(perms)
(3, 2)
>>> next(perms)                                   
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
  1. Modul itertools obsahuje celou řadu zábavných věcí, včetně funkce permutations(), která nás při hledání permutací zbaví veškeré námahy.
  2. Funkce permutations() přebírá posloupnost (zde jde o seznam tří čísel) a požadovaný počet prvků v menších skupinách. Funkce vrací iterátor, který můžeme použít v cyklu for nebo na jakémkoliv starém známém místě, ve kterém se iteruje (tj. prochází všemi prvky). Zde budeme provádět kroky iterátoru ručně, abychom si všechny hodnoty ukázali.
  3. První permutací ze seznamu [1, 2, 3] je dvojice (1, 2).
  4. Poznamenejme, že permutace jsou uspořádané: (2, 1) je něco jiného než (1, 2).
  5. Tak to jsou ony! Tohle jsou permutace všech dvojic z [1, 2, 3]. Dvojice jako (1, 1) nebo (2, 2) zde nikdy neuvidíte, protože obsahují opakující se prvky. Takže nejde o platné permutace. Pokud už více permutací neexistuje, iterátor vyvolá výjimku StopIteration.

Funkci permutations() nemusíme předávat jen seznam. Může přebírat jakoukoliv posloupnost, dokonce i řetězec.

>>> import itertools
>>> perms = itertools.permutations('ABC', 3)  
>>> next(perms)
('A', 'B', 'C')                               
>>> next(perms)
('A', 'C', 'B')
>>> next(perms)
('B', 'A', 'C')
>>> next(perms)
('B', 'C', 'A')
>>> next(perms)
('C', 'A', 'B')
>>> next(perms)
('C', 'B', 'A')
>>> next(perms)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> list(itertools.permutations('ABC', 3))    
[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')]
  1. Řetězec je jen posloupností znaků. Takže pro účely hledání permutací je řetězec 'ABC' ekvivalentem k seznamu ['A', 'B', 'C'].
  2. První permutací trojic z tří prvků ['A', 'B', 'C'] je ('A', 'B', 'C'). Pro stejné znaky existuje pět dalších myslitelných uspořádání, tedy permutací.
  3. Funkce permutations() vrací vždy iterátor. Snadný způsob zviditelnění všech permutací při ladění spočívá ve vytvoření jejich seznamu předáním iterátoru do zabudované funkce list().

Další legrácky v modulu itertools

>>> import itertools
>>> list(itertools.product('ABC', '123'))   
[('A', '1'), ('A', '2'), ('A', '3'),
 ('B', '1'), ('B', '2'), ('B', '3'),
 ('C', '1'), ('C', '2'), ('C', '3')]
>>> list(itertools.combinations('ABC', 2))  
[('A', 'B'), ('A', 'C'), ('B', 'C')]
  1. Funkce itertools.product() vrací iterátor, který vytváří kartézský součin dvou posloupností.
  2. Funkce itertools.combinations() vrací iterátor, který vytváří všechny možné kombinace dané délky z dané posloupnosti. Podobá se funkci itertools.permutations() s tou výjimkou, že kombinace nezahrnují výsledky, které vzniknou pouhou změnou uspořádání položek jiného výsledku. Takže itertools.permutations('ABC', 2) vrátí jak ('A', 'B'), tak ('B', 'A') (mimo jiné), ale itertools.combinations('ABC', 2) nevrátí ('B', 'A'), protože jde o duplicitu vytvořenou změnou pořadí položek ('A', 'B').

[stáhnout favorite-people.txt]

>>> names = list(open('examples/favorite-people.txt', encoding='utf-8'))  
>>> names
['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']
>>> names = [name.rstrip() for name in names]                             
>>> names
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']
>>> names = sorted(names)                                                 
>>> names
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']
>>> names = sorted(names, key=len)                                        
>>> names
['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley']
  1. Tento obrat vrací seznam všech řádků v textovém souboru.
  2. Naneštěstí (pro tento příklad) obrat list(open(filename)) vrací na konci každého řádku i znak konce řádku. V této generátorové notaci seznamu použijeme metodu řetězce rstrip(), která z konce každého řádku odstraní koncové bílé znaky. (Řetězce definují též metodu lstrip(), která odstraňuje úvodní bílé znaky, a metodu strip(), která odstraňuje bílé znaky z obou konců.)
  3. Funkce sorted() přebírá seznam a vrací nový, seřazený. Neřekneme-li jinak, řadí se podle abecedy.
  4. Ale funkci sorted() můžeme parametrem key předat funkci a pak se provede řazení podle jejích výsledků. V tomto případě byla předána funkce len(), takže řazení probíhá podle výsledků funkce len(položka). Nejkratší jména se dostanou na začátek, pak budou následovat delší a delší.

A co to má společného s modulem itertools? To jsem rád, že se ptáte.

…pokračování v předchozí práci s interaktivním shellem…
>>> import itertools
>>> groups = itertools.groupby(names, len)  
>>> groups
<itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, <itertools._grouper object at 0x00BA8BF0>),
 (5, <itertools._grouper object at 0x00BB4050>),
 (6, <itertools._grouper object at 0x00BB4030>)]
>>> groups = itertools.groupby(names, len)   
>>> for name_length, name_iter in groups:    
...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley
  1. Fukce itertools.groupby() přebírá posloupnost a funkci klíče. Vrací iterátor, který vytváří dvojice. Každá dvojice obsahuje jednak výsledek funkce_klic(každá položka) a jednak další iterátor, který prochází všemi položkami se stejným výsledkem funkce klíče.
  2. Voláním funkce list() jsme iterátor „vyčerpali“. To znamená, že jsme při vytváření seznamu vygenerovali každou položku iterátoru. Iterátor nemá žádné tlačítko „reset“. Jakmile jsme posloupnost jednou vyčerpali, nemůžeme začít znovu. Pokud chceme hodnoty projít znovu (dejme tomu v dalším cyklu for), musíme znovu zavolat itertools.groupby() a vytvořit nový iterátor.
  3. Za předpokladu, že už máme seznam jmen seřazený podle jejich délek, přidělí itertools.groupby(names, len) všem jménům délky 4 jeden iterátor, všem jménům délky 5 druhý iterátor atd. Funkce groupby() je zcela obecná. Řetězce můžeme seskupit podle prvního písmene, čísla podle počtu jejich prvočinitelů nebo podle jakékoliv myslitelné funkce klíče.

Funkce itertools.groupby() funguje jen v případě, kdy je vstupní posloupnost již seřazená podle sdružovací funkce. Ve výše uvedeném příkladu jsme seznam jmen seskupili podle funkce len(). Fungovalo to jen díky tomu, že byl vstupní seznam již seřazen podle délky položek.

Díváte se pozorně?

>>> list(range(0, 3))
[0, 1, 2]
>>> list(range(10, 13))
[10, 11, 12]
>>> list(itertools.chain(range(0, 3), range(10, 13)))        
[0, 1, 2, 10, 11, 12]
>>> list(zip(range(0, 3), range(10, 13)))                    
[(0, 10), (1, 11), (2, 12)]
>>> list(zip(range(0, 3), range(10, 14)))                    
[(0, 10), (1, 11), (2, 12)]
>>> list(itertools.zip_longest(range(0, 3), range(10, 14)))  
[(0, 10), (1, 11), (2, 12), (None, 13)]
  1. Funkce itertools.chain() přebírá dva iterátory a vrací iterátor, který vytváří posloupnost všech položek nejdříve z prvního iterátoru a pak všech položek z druhého iterátoru. (Ve skutečnosti můžeme předat libovolný počet iterátorů a tato funkce zřetězí všechny jejich hodnoty v pořadí, v jakém jsme je funkci předali.)
  2. Funkce zip() dělá něco docela obyčejného, ale ukazuje se, že je velmi užitečná. Přebírá libovolný počet posloupností a vrací iterátor, který vytváří n-tice z prvních položek každé posloupnosti, pak z druhých položek, pak z třetích atd.
  3. Funkce zip() zastaví na konci nejkratší posloupnosti. Funkce range(10, 14) produkuje 4 položky (10, 11, 12 a 13), ale range(0, 3) jen 3. Takže funkce zip() vrátí iterátor produkující 3 položky.
  4. Naopak funkce itertools.zip_longest() zastaví až na konci nejdelší posloupnosti. Místo chybějících položek kratších posloupností doplní hodnoty None.

No dobrá, tohle všechno je sice velmi zajímavé, ale jak se to vztahuje k programu na řešení algebrogramů? Takto:

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y')
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7')
>>> tuple(zip(characters, guess))  
(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))
>>> dict(zip(characters, guess))   
{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'}
  1. Máme-li dán seznam písmen a seznam číslic (každá z nich je v něm reprezentována jako jednoznakový řetězec), pak nám funkce zip spáruje písmena a číslice v uvedeném pořadí.
  2. A proč by to mělo být nějak zvlášť výhodné? Protože shodou okolností je taková datová struktura přesně tou správnou datovou strukturou, kterou můžeme předat funkci dict(), aby vytvořila slovník, který používá písmena jako klíče a k nim přidružené číslice jako hodnoty. (Není to, samozřejmě, jediný způsob, jak toho můžeme dosáhnout. Slovník bychom mohli vytvořit přímo, pomocí generátorové notace.) Ačkoliv textová reprezentace obsahu slovníku zobrazuje dvojice v jiném pořadí (slovníky samy o sobě nedefinují „pořadí“), vidíme, že každé písmeno má k sobě číslici přidruženou na základě původních posloupností characters a guess.

Program pro řešení algebrogramů tuto techniku používá pro vytvoření slovníku, který převádí písmena z hádanky na čísla v řešení — pro každé možné řešení.

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
    equation = puzzle.translate(dict(zip(characters, guess)))

Ale co je za metodu ta translate()? Teď se dostáváme k opravdu zábavné části.

Nový způsob úpravy řetězce

Pythonovské řetězce definují mnoho metod. O některých z nich jsme se učili v kapitole Řetězce: lower(), count() a format(). Teď si představíme mocnou, ale málo známou techniku pro manipulaci s řetězcem. Jde o metodu translate().

>>> translation_table = {ord('A'): ord('O')}  
>>> translation_table                         
{65: 79}
>>> 'MARK'.translate(translation_table)       
'MORK'
  1. Překlad řetězce začíná naplněním překladové tabulky, což je prostě slovník, který zobrazuje jeden znak na jiný. Pojem „znak“ je zde vlastně uveden chybně. Překladová tabulka ve skutečnosti zobrazuje bajty na jiné bajty.
  2. Připomeňme si, že bajty jsou v Pythonu 3 celá čísla. Funkce ord() vrací ASCII hodnotu daného znaku. V případě znaků A–Z to budou vždy bajty od 65 do 90.
  3. Metoda řetězcového objektu translate() přebírá překladovou tabulku a obsah řetězce přes ni propasíruje. To znamená, že nahradí všechny výskyty klíčů z překladové tabulky odpovídajícími hodnotami. V tomto případě se MARK „přeloží“ na MORK.

Ale co to má společného s řešením algebrogramů? Jak se ukáže za chvíli, všechno.

>>> characters = tuple(ord(c) for c in 'SMEDONRY')       
>>> characters
(83, 77, 69, 68, 79, 78, 82, 89)
>>> guess = tuple(ord(c) for c in '91570682')            
>>> guess
(57, 49, 53, 55, 48, 54, 56, 50)
>>> translation_table = dict(zip(characters, guess))     
>>> translation_table
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}
>>> 'SEND + MORE == MONEY'.translate(translation_table)  
'9567 + 1085 == 10652'
  1. Prostřednictvím generátorového výrazu pro každý znak řetězce rychle vypočteme hodnotu odpovídajícího bajtu. Obsah proměnné characters je příkladem obsahu proměnné sorted_characters z funkce alphametics.solve().
  2. Pomocí dalšího generátorového výrazu rychle vypočítáme hodnoty bajtů reprezentujících každou číslici řetězce. Výsledek v proměnné guess (tj. odhad) má podobu vrácenou funkcí itertools.permutations() — viz funkce alphametics.solve()
  3. Překladová tabulka se generuje zipováním posloupností characters a guess dohromady a použitím výsledné posloupnosti dvojic pro vybudování slovníku. Přesně tohle dělá funkce alphametics.solve() uvnitř cyklu for.
  4. Nakonec překladovou tabulku předáme metodě translate() původního řetězce hádanky. Tím se každý znak řetězce přeloží na odpovídající číslici (podle písmen v characters a číslic v guess). Výsledkem je platný pythonovský výraz v řetězcové podobě.

To je docela efektní. Ale co můžeme dělat s řetězcem, který shodou okolností zachycuje platný pythonovský výraz?

Vyhodnocování libovolných řetězců zachycujících pythonovské výrazy

Tohle je poslední kousek skládanky (nebo spíše poslední kousek programu pro řešení hádanky). Po všech těch efektních manipulacích s řetězci jsme skončili u řetězce, jako je '9567 + 1085 == 10652'. Ale je to jen řetězec. A k čemu je nám řetězec dobrý? Seznamte se s eval(), s univerzálním pythonovským vyhodnocovacím nástrojem.

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True

Ale počkejte! Je toho ještě víc! Funkce eval() se neomezuje jen na booleovské výrazy. Zvládne libovolný pythonovský výraz a vrací libovolný datový typ.

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*']

Ale počkejte, to ještě není vše!

>>> x = 5
>>> eval("x * 5")         
25
>>> eval("pow(x, 2)")     
25
>>> import math
>>> eval("math.sqrt(x)")  
2.2360679774997898
  1. Výraz předávaný funkci eval() se může odkazovat na globální proměnné definované vně eval(). A pokud se volá uvnitř funkce, může se odkazovat i na lokální proměnné.
  2. A funkce.
  3. A moduly.

Hej, zastav na minutku…

>>> import subprocess
>>> eval("subprocess.getoutput('ls ~')")                  
'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites'
>>> eval("subprocess.getoutput('rm /some/random/file')")  
  1. Modul subprocess vám dovolí spustit libovolný shellovský příkaz a získat výsledek v podobě pythonovského řetězce.
  2. Jenže libovolný shellovský příkaz může vést k trvalým následkům.

A je to dokonce ještě horší, protože existuje globální funkce __import__(), která přebírá jméno modulu v řetězcové podobě, importuje ho a vrací na něj odkaz. Když to zkombinujeme se silou funkce eval(), můžeme vytvořit výraz, který smaže všechny vaše soubory:

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')")  
  1. A teď si představte výstup příkazu 'rm -rf ~'. Ve skutečnosti žádný výstup neuvidíte. Ale neuvidíte už ani své soubory.

eval() is EVIL

(tj. eval() je zlý, špatný, zlověstný). Tou zlou stránkou je vyhodnocování libovolných výrazů pocházejících z nedůvěryhodných zdrojů. Funkci eval() byste měli používat výhradně pro vstup z důvěryhodných zdrojů. Problém je v tom, jak určit, co je „důvěryhodný“ zdroj. Ale něco vím určitě. Určitě byste NEMĚLI vzít tento program pro řešení algebrogramů a zveřejnit jej na internetu v podobě malé webovské služby. A nemyslete si: „Vždyť ta funkce dělá tolik řetězcových operací, než se vůbec dostane k vyhodnocení. Nedovedu si představit, jak by toho někdo mohl zneužít.“ Někdo přijde na to, jak propašovat nějaký nebezpečný kód všemi těmi řetězcovými manipulacemi (už se staly divnější věci). A pak už můžete svému serveru poslat jen polibek na rozloučenou.

Ale existuje vůbec nějaký způsob, jak výrazy vyhodnotit bezpečně? Lze nějak eval() umístit na pískoviště, odkud nemá přístup k okolnímu světu a nemůže mu škodit? Hmm, ano i ne.

>>> x = 5
>>> eval("x * 5", {}, {})               
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'x' is not defined
>>> eval("x * 5", {"x": x}, {})         
25
>>> import math
>>> eval("math.sqrt(x)", {"x": x}, {})  
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'math' is not defined
  1. Druhý a třetí parametr předávaný funkci eval() se chovají jako globální a lokální prostor jmen. Tyto prostory se používají při vyhodnocování výrazu. V tomto případě jsou oba prázdné. To znamená, že při vyhodnocování řetězce "x * 5" neexistuje žádný odkaz na x ani v globálním ani v lokálním prostoru jmen. Takže eval() vyvolá výjimku.
  2. Do globálního prostoru jmen můžeme vložit výběr určitých hodnot tím, že je jednotlivě vyjmenujeme. Během vyhodnocování pak budou k dispozici tyto a jen tyto proměnné.
  3. Ačkoliv jste zrovna importovali modul math, nevložili jsme jej do prostoru jmen, který předáváme funkci eval(). V důsledku toho vyhodnocení selhalo.

Tý jo. Tak to bylo jednoduché. Teď si udělám webovskou službu pro řešení algebrogramů!

>>> eval("pow(5, 2)", {}, {})                   
25
>>> eval("__import__('math').sqrt(5)", {}, {})  
2.2360679774997898
  1. Ačkoliv jste v roli globálního a lokálního prostoru jmen předali prázdné slovníky, během vyhodnocování jsou stále dostupné všechny zabudované pythonovské funkce. Takže pow(5, 2) funguje, protože 5 a 2 jsou literály a pow() je zabudovaná funkce.
  2. Naneštěstí (a pokud netušíte, proč naneštěstí, čtěte dál) je funkce __import__() také zabudovanou funkcí, takže také funguje.

Ano, to znamená, že můžete pořád dělat odporné věci, i když jste při volání eval() pro globální a lokální prostor jmen explicitně nastavili prázdné slovníky:

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {})

A do prčic! To jsem rád, že jsem pro řešení algebrogramů nevytvořil webovou službu. Je zde vůbec nějaký způsob, kterým bychom mohli eval() používat bezpečně? Ano i ne.

>>> eval("__import__('math').sqrt(5)",
...     {"__builtins__":None}, {})          
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__('subprocess').getoutput('rm -rf /')",
...     {"__builtins__":None}, {})          
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
  1. Abyste mohli výrazy z nedůvěryhodných zdrojů vyhodnocovat bezpečně, musíte definovat slovník pro globální prostor jmen, který mapuje "__builtins__" na None, tedy na pythonovskou hodnotu null (nic, nil). „Zabudované“ funkce jsou totiž vnitřně uzavřeny do pseudomodulu nazvaného "__builtins__". Tento pseudomodul (tj. množina zabudovaných funkcí) je vyhodnocovaným výrazům zpřístupněn — pokud jej explicitně nepotlačíte.
  2. Ujistěte se, že předefinováváte __builtins__. Žádné __builtin__, __built-ins__ nebo nějakou podobnou variantu. Ono by to fungovalo bez problémů, ale vystavilo by vás to riziku katastrofy.

Takhle už je eval() bezpečný? Nu, ano i ne.

>>> eval("2 ** 2147483647",
...     {"__builtins__":None}, {})          
  1. I bez přístupu k __builtins__ můžete stále spustit útok typu odmítnutí služby. Pokud se například pokusíte o umocnění 2 na 2147483647, využití procesoru vašeho serveru stoupne na 100 % na pěkně dlouhou dobu. (Pokud to zkoušíte v interaktivním shellu, můžete ho přerušit, když několikrát stisknete Ctrl-C.) Technicky vzato, tento výraz nakonec vrátí nějakou hodnotu, ale do té doby bude server dělat spoustu zbytečné práce.

Takže nakonec je možné bezpečně vyhodnocovat pythonovské výrazy z nedůvěryhodných zdrojů. Vyžaduje to ale určitou definici pojmu „bezpečně“, která v reálném životě není zas tak užitečná. Dobré je, když si hrajete někde poblíž. Dobré taky je, když připustíte jen důvěryhodný vstup. Cokoliv jiného znamená, že si koledujete o malér.

Spojme to všechno dohromady

Rekapitulace: Tento program řeší algebrogramy hrubou silou, tj. vyčerpávajícím hledáním všech možných řešení. Program za tím účelem…

  1. Nalezne v zadání všechna písmena voláním funkce re.findall().
  2. Nalezne všechna jedinečná písmena hádanky s využitím množiny a funkce set().
  3. Zkontroluje příkazem assert, zda se v zadání nevyskytuje více než 10 jedinečných znaků (což by znamenalo, že hádanka je neřešitelná).
  4. Převede znaky na jejich ASCII hodnoty použitím objektu generátoru.
  5. Počítá všechna možná řešení pomocí funkce itertools.permutations().
  6. Převádí každé možné řešení na pythonovský výraz pomocí metody řetězcového objektu translate().
  7. Testuje každé možné řešení vyhodnocením pythonovského výrazu voláním funkce eval().
  8. Vrací první řešení, které se vyhodnotí jako True.

… to vše na pouhých 14 řádcích kódu.

Přečtěte si

Mnohokrát děkuji Raymondu Hettingerovi za souhlas s úpravou licence jeho kódu, abych ho mohl přepsat pro Python 3 a použít jako základ této kapitoly.

© 2001–11 Mark Pilgrim