4. Dictionaries#
In de huidige tekstversie van het Alchemy spel hebben we lijsten gebruikt voor de elementen, recepten en ontdekkingen. Daaraan kleven echter een aantal nadelen. De recepten en ontdekkingen verwijzen naar de indices van de elementen in elements. Stel dat om de een of andere reden de volgorde van de elementen in elements verandert (en dus de indices), dan klopt er niets meer van de recepten en de ontdekkingen. Een ander nadeel is dat de tweedimensionale lijst van recepten al snel erg groot wordt. In een spel met 100 elementen, heb je een receptenlijst van 100 ✕ 100 = 10.000 items nodig, waarvan het merendeel de waarde None heeft. Dat is niet erg efficiënt. Daarom gaan we elements en recipes vervangen door dictionaries. Een dictionary is een soort lijst, maar in plaats van dat je de items opvraagt met een index (een getal), doe je dat met een sleutel (meestal een string). De sleutel kan van alles zijn, zolang het maar uniek is. Het voordeel daarvan is dat je de volgorde van de items in de dictionary niet meer hoeft te onthouden. Je kunt ze altijd opvragen met hun unieke sleutel. Dat maakt het ook makkelijker om nieuwe elementen toe te voegen of bestaande elementen te verwijderen. De volgorde doet er dan niet meer toe. Dictionaries zijn dus veel flexibeler dan lijsten.
Key-value pairs#
Een dictionary wordt meestal omschreven als een verzameling van key-value pairs (sleutel-waarde paren). In Python maak je een dictionary aan met accolades ({ en }). De key en de value worden gescheiden door een dubbele punt (:). De verschillende key-value paren worden gescheiden door een komma (,). Een voorbeeld:
capitals = {
'Nederland': 'Amsterdam',
'België': 'Brussel',
'Duitsland': 'Berlijn',
'Frankrijk': 'Parijs'
}
Het opvragen en wijzigen van items gaat op dezelfde manier als bij lijsten, maar in plaats van een index gebruik je de sleutel. Om de hoofdstad van Nederland op te vragen, doe je:
print(capitals['Nederland'])
Het toevoegen van een nieuw key-value paar gaat ook op deze manier:
capitals['Luxemburg'] = 'Luxemburg'
print(capitals)
De uitvoer is:
{'Nederland': 'Amsterdam', 'België': 'Brussel', 'Duitsland': 'Berlijn', 'Frankrijk': 'Parijs', 'Luxemburg': 'Luxemburg'}
Voor keys in een dictionary worden meestal strings of integers gebruikt. De values kunnen van alles zijn: strings, integers, floats, lijsten, dictionaries, enzovoort.
Alchemy met dictionaries#
We gaan de code in alchemytxt.py van boven naar beneden herschrijven om de lijsten te vervangen door dictionaries, te beginnen met de volgende regels:
1################
2# ALCHEMY GAME #
3# Text version #
4################
5
6# DICTIONARIES AND LISTS
7
8elements = {
9 'fire': 'vuur',
10 'water': 'water',
11 'wind': 'wind',
12 'earth': 'aarde',
13 'steam': 'stoom',
14 'wave': 'golf',
15 'plant': 'plant',
16 'smoke': 'rook',
17 'lava': 'lava',
18 'dust': 'stof'
19}
20
21elements_inverse = {v: k for k, v in elements.items()}
22
23recipes = {}
24
25discoveries = []
De elements lijst is een dictionary geworden waarvan de keys de Engelse namen van de elementen zijn en de values de Nederlandse vertalingen.
In regel 21 zie je een dictionary comprehension, die de inverse (omgekeerde) maakt van elements. Dat is nodig omdat de speler straks commando’s gaat typen met de Nederlandse namen van de elementen. Voor de grafische versie van het spel zullen we die inverse straks niet meer nodig hebben, maar voor de tekstversie wel.
Zoals je ziet is discoveries gewoon een lijst gebleven, en geen dictionary geworden. Wellicht kun je zelf bedenken waarom?
Voor de recepten gaan we later een tekstbestand gebruiken, maar nu stoppen we ze alvast in een multi-line string. Ter herinnering: in Python maak je een multi-line string met drie aanhalingstekens.
27recipes_txt = '''water+fire=steam
28water+wind=wave
29water+earth=plant
30fire+wind=smoke
31fire+earth=lava
32wind+earth=dust'''
Nu we lijsten door dictionaries hebben vervangen, moeten we ook de helper functies aanpassen. Dat is de volgende stap.
Helper functies#
De functie add_recipe() gaan we vervangen door een functie build_recipes(). Deze nieuwe functie heeft als taak de recepten in de string recipes_txt op te slaan in de dictionary recipes. Die dictionary zal er uiteindelijk zo uit gaan zien:
recipes = {
'fire': {
'water': 'steam',
'wind': 'smoke'
},
'water': {
'wind': 'wave'
},
'earth': {
'water': 'plant',
'fire': 'lava',
'wind': 'dust'}
}
Toen recipes nog een tweedimensionale lijst was, konden we de symmetrie van bijvoorbeeld water+fire en fire+water eenvoudig verwerken door beide mogelijkheden in de lijst op te nemen. Nu gaan we het anders aanpakken: elke combinatie van twee elementen sorteren we eerst op alfabetische volgorde. Dus als in een recept water+fire staat, maken we daar eerst fire+water van, alvorens het recept in de dictionary op te slaan. Later zullen we hetzelfde doen met de commando’s die de speler typt. Je ziet in bovenstaande dictionary dat we bijvoorbeeld het recept fire+earth (zoals in regel 31 staat) in de dictionary kunnen terugvinden onder de key 'earth' en vervolgens de key 'fire'.
Verwijder de functie add_recipe() en voeg in plaats daarvan onderstaande code toe:
34# HELPER FUNCTIONS
35
36def build_recipes():
37 lines = recipes_txt.split('\n')
38 for line in lines:
39 left, right = line.split('=')
40 ingredients = left.split('+')
41 if (len(ingredients) != 2):
42 raise Exception('Recipe error: number of ingredients must be exactly 2.')
43 if ingredients[0] not in elements or ingredients[1] not in elements:
44 raise Exception('Recipe error: unknown ingredients.')
45 ingredients.sort()
46 if ingredients[0] not in recipes:
47 recipes[ingredients[0]] = {ingredients[1]: right}
48 else:
49 recipes[ingredients[0]][ingredients[1]] = right
In regel 37 splitsen we recipes_txt op in regels, door het newline karakter 'n' als separator te kiezen. lines is dus een list die er zo uitziet:
['water+fire=steam',
'water+wind=wave',
'water+earth=plant',
'fire+wind=smoke',
'fire+earth=lava',
'wind+earth=dust']
Met de for loop in regel 38 bekijken we elke regel en doen daarmee het volgende:
Regel 39: We splitsen de regel op het = teken. Het gedeelte links van het = teken slaan we op in left en het gedeelte rechts in right.
Regel 40: Het linkerdeel splitsen we nogmaals op het + teken en het resultaat stoppen we in ingredients.
Regels 41-42: We checken of ingredients twee elementen bevat. Zo niet, dan veroorzaken we een foutmelding.
Regels 43-44: We checken of de twee ingrediënten bestaan in de elements dictionary. Zo niet, dan veroorzaken we een foutmelding.
Regel 45: We sorteren de twee ingrediënten op alfabetische volgorde met de listfunctie sort().
Regels 46-49: We moeten checken of het eerste ingrediënt al een key is in de recipes dictionary. Als dat niet het geval is, maken we hem aan en geven de value {ingredients[1]: right} mee. Als de key wel al bestaat, voegen we het tweede ingrediënt toe aan de bestaande value.
De get_recipe() functie moeten we aanpassen om met de recipes dictionary te kunnen werken. Dat doen we als volgt:
51def get_recipe(ingredient1, ingredient2):
52 ingredients = sorted([ingredient1, ingredient2])
53 if ingredients[0] in recipes:
54 if ingredients[1] in recipes[ingredients[0]]:
55 return recipes[ingredients[0]][ingredients[1]]
56 return None
In de nieuwe get_recipe() functie sorteren we eerst de twee ingrediënten op alfabetische volgorde. Vervolgens checken we of het eerste ingrediënt als key in recipes voorkomt en als dat het geval is of het tweede ingrediënt als key voorkomt in de value bij de eerste key. Als dat zo is, bestaat het recept en retourneren we het resultaat. In het andere geval retourneren we None.
Omdat we nu niet meer met indices te maken hebben, worden de functies add_discovery() en is_discovered() een stuk eenvoudiger:
58def add_discovery(element):
59 if element not in discoveries:
60 discoveries.append(element)
61
62def is_discovered(element):
63 return element in discoveries
Main program#
In het hoofdprogramma roepen we eerst build_recipes() aan en vervolgens voegen we de vier discoveries toe, zoals in de vorige versie ook het geval was:
65# MAIN PROGRAM
66
67build_recipes()
68add_discovery('water')
69add_discovery('fire')
70add_discovery('wind')
71add_discovery('earth')
De game loop is door het gebruik van dictionaries iets ingewikkelder geworden, omdat de gebruiker de Nederlandse namen van de elementen typt, die we moeten opzoeken in elements_inverse. Maar voor het overige lijkt hij sterk op de vorige versie. Merk op dat we alle teksten nu ook het Nederlands afdrukken.
73print('Mix twee ingrediënten met een + teken, bijv. water+vuur')
74print('d om ontdekkingen te zien, x om te sluiten')
75while True:
76 command = input('> ')
77 if command == 'x':
78 print('SPEL BEËINDIGD')
79 break
80 elif command == 'd':
81 print('-' * 20)
82 print('ONTDEKKINGEN:')
83 for d in discoveries: print(elements[d])
84 print('-' * 20)
85 else:
86 ingredients = command.split('+')
87 if len(ingredients) != 2:
88 print('Dit commando ken ik niet.')
89 continue
90 ing_val_0 = ingredients[0]
91 ing_val_1 = ingredients[1]
92 if ing_val_0 not in elements_inverse or ing_val_1 not in elements_inverse:
93 print('Dit commando ken ik niet.')
94 continue
95 ing_key_0 = elements_inverse[ing_val_0]
96 ing_key_1 = elements_inverse[ing_val_1]
97 if not (is_discovered(ing_key_0) and is_discovered(ing_key_1)):
98 print('Dit commando ken ik niet.')
99 continue
100 r = get_recipe(ing_key_0, ing_key_1)
101 if r == None:
102 print(f'Helaas, geen recept beschikbaar voor {ing_val_0} en {ing_val_1}.')
103 elif r in discoveries:
104 print(f'Je had {elements[r]} al ontdekt.')
105 else:
106 print(f'Je hebt {elements[r]} ontdekt!')
107 add_discovery(r)
In regels 90-91 maken we de variabelen ing_val_0 en ing_val_1 die de values bevatten die in elements zouden moeten voorkomen. Deze values zijn de keys in elements_inverse. In regels 95-96 vullen we ing_key_0 en ing_key_1 met de keys die bij de values horen. De code is weinig elegant, maar als we straks de grafische versie gaan maken, is deze omslachtigheid niet meer nodig; voor nu accepteren we het.
Run de code om te zien of het spel naar behoren werkt. Probeer ook gerust zelf wat dingen uit. Wellicht kun je ervoor zorgen dat de foutmelding in regel 42 of 44 wordt geactiveerd?
Opdracht 01
In dit deel hebben we ervoor gezorgd dat de recepten uit de string recipes_txt worden gelezen en in de dictionary recipes worden opgeslagen. Dat gebeurt in de helper functie build_recipes(). In deze opdracht ga je build_recipes() nog iets geavanceerder maken.
Wijzig de string recipes_txt als volgt:
27recipes_txt = '''water
28fire
29wind
30earth
31-
32water+fire=steam
33water+wind=wave
34water+earth=plant
35fire+wind=smoke
36fire+earth=lava
37wind+earth=dust'''
De string bestaat nu uit twee delen: de eerste vier regels bevatten de elementen die bij aanvang van het spel beschikbaar zijn voor de speler, daarna volgt een scheidingsteken -, en het tweede deel bevat de recepten.
Breid build_recipes() uit met de volgende functionaliteit:
De string
recipes_txtwordt opgesplitst in de twee delenfirst_partensecond_part. Maak hiervoor gebruik van het scheidingsteken-, maar je zult merken dat je hier nog iets aan moet toevoegen om de splitsing goed te krijgen.De string
first_part, die de vier basiselementen bevat, wordt opgesplitst in regels, en het resultaat opgeslagen in de variabeleprimes.De string
second_part, die de recepten bevat, wordt opgesplitst in regels, en het resultaat opgeslagen in de variabelecombinations.Met een for loop wordt voor elke
primeinprimeseerst gecheckt of die als key voorkomt inelements. Als dat niet zo is, wordt een foutmelding gegenereerd. In het andere geval wordt deprimetoegevoegd aandiscoveriesmet behulp van de functieadd_discovery().De rest van de code in
build_recipes()blijft hetzelfde, behalve dat de stringlineswordt vervangen doorcombinations.
Als je dit hebt gedaan, kun je de vier aanroepen van add_discovery() in het hoofdprogramma verwijderen. De vier elementen worden nu automatisch toegevoegd aan discoveries in de helper functie build_recipes(). Run het programma en kijk of alles nog steeds werkt.
Oplossing
41def build_recipes():
42 first_part, second_part = recipes_txt.split('\n-\n')
43 primes = first_part.split('\n')
44 combinations = second_part.split('\n')
45 for prime in primes:
46 if prime not in elements:
47 raise Exception('Recipe error: unknown prime element.')
48 add_discovery(prime)
49 for combination in combinations:
50 left, right = combination.split('=')
51 ingredients = left.split('+')
52 if (len(ingredients) != 2):
53 raise Exception('Recipe error: number of ingredients must be exactly 2.')
54 if ingredients[0] not in elements or ingredients[1] not in elements:
55 raise Exception('Recipe error: unknown ingredients.')
56 ingredients.sort()
57 if ingredients[0] not in recipes:
58 recipes[ingredients[0]] = {ingredients[1]: right}
59 else:
60 recipes[ingredients[0]][ingredients[1]] = right