Laten we een game bouwen.
We hebben boogschutters nodig, ridders, een slagveld, en wat uitrusting die onze held aanzienlijk indrukwekkender laat lijken dan hij eigenlijk is. Niks te ingewikkelds.
In het begin werkt alles prachtig. Dan vraagt iemand om één kleine aanpassing.
Je weet al waar dit heen gaat.
Onderweg ontdekken we drie Python design patterns: Strategy, Observer en Decorator. Niet door definities uit het hoofd te leren, maar door onze game nieuwe requirements te geven en te kijken hoe onze eerste ideeën ongemakkelijk worden.
Dit verhaal is gebaseerd op mijn oorspronkelijke Perzische uitleg en Python-oefeningen in de head-first-design-pattern-leeromgeving. Je hebt een beetje kennis van Python-classes en -methodes nodig; de interessantere termen komen langs zodra we ze echt nodig hebben.
De voorbeelden gebruiken de standaardbibliotheek en zijn geschreven voor Python 3.10 of nieuwer. Behandel elk hoofdstuk als een apart klein scriptje en voer de codeblokken in volgorde uit. Latere versies vervangen eerdere class-definities. We bouwen drie gerichte prototypes, niet stilletjes een complete game-engine.
Broncode: elk voorbeeld hieronder is gebaseerd op sadeghesfahani/head-first-design-pattern op GitHub — clone het, draai het, breek het zelf.
1. Alles ging goed, tot iemand het weer uitvond
Ons eerste personage draagt een boog. Ons tweede draagt een zwaard. Beide hebben een naam en een level, maar ze vallen anders aan en bewegen anders.
Dat klinkt als een volkomen redelijk excuus om wat classes te schrijven.
class Character:
def __init__(self, name: str, level: int) -> None:
self.name = name
self.level = level
class Archer(Character):
def attack(self) -> str:
return f"{self.name} attacks with a bow."
def move(self) -> str:
return f"{self.name} moves slowly."
class Knight(Character):
def attack(self) -> str:
return f"{self.name} attacks with a sword."
def move(self) -> str:
return f"{self.name} moves quickly."

Kijk eens aan. De boogschutter schiet, de ridder zwaait met een zwaard, en beide kunnen bewegen. We zijn praktisch al een gamestudio.
Nu het zo goed gaat, voegen we een koninklijke boogschutter toe. Een indrukwekkendere. Waarschijnlijk beter betaald.
class RoyalArcher(Character):
def attack(self) -> str:
return f"{self.name} attacks with a bow."
def move(self) -> str:
return f"{self.name} moves slowly."Wacht even.
Dit is dezelfde boogschutter met een duurdere functietitel. We hebben de attack- en move-methodes gekopieerd zonder er iets aan te veranderen.
Prima. Wat maakt de koninklijke boogschutter dan echt anders? Misschien schiet een gewone boogschutter over een kortere afstand en mist hij vaker. De koninklijke boogschutter heeft een beter bereik en betere nauwkeurigheid.
Simpel. We voegen attack_range en accuracy toe aan ons basispersonage.
Dan kijken we naar de ridder.
Waarom heeft een man met een zwaard een nauwkeurigheidswaarde voor een boog? Wie heeft hem dat gegeven? Wat gebeurt er als een andere developer aanneemt dat het iets betekent?
Goed, nieuw plan. We scheiden personages op afstand van personages in melee. Boogschutters erven van de ene tak, ridders van de andere. Iedereen krijgt de juiste eigenschappen. De orde is hersteld.
Behoorlijk tevreden met onszelf maken we 48 verschillende boogschutters aan.
Dan begint het te regenen.
"Het is maar een klein weer-feature"
We besluiten dat slecht weer de nauwkeurigheid van een boogschutter moet verlagen. Een volkomen redelijke feature voor onze game.
"Geen probleem," zeggen we. "Ik voeg gewoon een weerfactor toe aan de aanvalsberekening."
Dan herinneren we ons waar we die aanvalsberekening hebben neergezet.
In de personages.
In heel veel personages.
In 48 verschillende boogschutters wier attack-methodes we steeds hebben gekopieerd en aangepast.
Ik wilde een game bouwen. Blijkbaar heb ik een fulltime baan gebouwd waarin ik boogschutters onderhoud.
Het directe probleem is niet dat overerving verboden is. Het is dat, in ons ontwerp, hetzelfde veranderlijke gedrag verspreid staat over meerdere characterclasses. We blijven de code organiseren rond wie het personage is, terwijl wat we eigenlijk moeten veranderen hoe het personage aanvalt is.
Zou een gedeelde methode in een parent class de duplicatie ook weghalen? Ja. Overerving is niet ons kantoor binnengeslopen om die methodes te kopiëren. Dat hebben wij gedaan.
Maar wat gebeurt er als aanval, beweging en personage-identiteit onafhankelijk van elkaar moeten variëren? Een boogschutter zou een paard kunnen berijden. Een ridder zou een boog kunnen oppakken. Voor elke combinatie een familieboom bouwen begint te voelen als een bruiloft organiseren waarbij iedereen steeds van familie wisselt.
Wat gebeurt er als we die twee ideeën scheiden?
Vergeet de boogschutter even. Wat doet een pijl-en-boogaanval eigenlijk?
Een personage heeft een naam en een level. Een aanval heeft zijn eigen regels.
Die regels hoeven niet in elk personage te leven dat ze gebruikt. We kunnen de aanval een eigen object geven en het personage dat laten gebruiken.
Eerst spreken we één ding af: elk aanvalsgedrag moet een attack()-methode bieden. Daarna schrijven we de verschillende gedragingen achter dat gedeelde contract.
from abc import ABC, abstractmethod
class AttackBehavior(ABC):
@abstractmethod
def attack(self) -> str:
pass
class RangedAttack(AttackBehavior):
def __init__(
self,
attack_range: int,
accuracy: float,
weather_factor: float = 1.0,
) -> None:
if attack_range <= 0:
raise ValueError("Attack range must be positive.")
if not 0 <= accuracy <= 1 or not 0 <= weather_factor <= 1:
raise ValueError("Accuracy and weather factor must be between 0 and 1.")
self.attack_range = attack_range
self.accuracy = accuracy
self.weather_factor = weather_factor
def effective_accuracy(self) -> float:
return self.accuracy * self.weather_factor
def attack(self) -> str:
return (
f"Bow attack: range {self.attack_range} m, "
f"accuracy {self.effective_accuracy():.0%}."
)
class MeleeAttack(AttackBehavior):
def __init__(self, damage: int) -> None:
if damage < 0:
raise ValueError("Damage cannot be negative.")
self.damage = damage
def attack(self) -> str:
return f"Sword attack: damage {self.damage}."AttackBehavior is ons contract: "Wat voor aanval je ook bent, bied deze operatie aan." ABC en @abstractmethod voorkomen dat we deze basisclass instantiëren, of een subclass die zijn abstracte methode niet heeft geïmplementeerd. Ze controleren niet of een zwaardaanval ergens zinnig is; dat blijft onze verantwoordelijkheid.
De annotatie -> str beschrijft het verwachte resultaat. Python handhaaft type-annotaties niet automatisch tijdens runtime, daarom controleert de constructor ook de numerieke grenzen waar we om geven.
Ons weermodel is bewust simpel: neem de gebruikelijke nauwkeurigheid en vermenigvuldig die met een factor. Een nauwkeurigheid van 0.9 met een weerfactor van 0.5 wordt 45%. We demonstreren het ontwerp, we solliciteren niet bij het KNMI.
Deze methodes berekenen en beschrijven een aanval. Ze gooien geen dobbelsteen voor een treffer, kiezen geen doelwit en trekken niemands gezondheid af. De berekening is gescheiden van de formulering, zodat we het getal kunnen testen zonder een zin te hoeven inspecteren. weather_factor is hier aangeleverde configuratie, geen live weerdienst.
Wat doet het personage nu nog?
Bijna niets. Wat in dit geval uitstekend nieuws is.
class Character:
def __init__(
self, name: str, level: int, attack_behavior: AttackBehavior
) -> None:
self.name = name
self.level = level
self.attack_behavior = attack_behavior
def attack(self) -> str:
return f"{self.name}: {self.attack_behavior.attack()}"Het personage kent zijn naam, zijn level, en welk aanvalsgedrag het heeft meegekregen. Als we het vragen aan te vallen, geeft het het werk door aan dat gedrag.
Laten we het proberen. In deze versie vervangt Character onze eerdere personagehiërarchie voor de aanvalsdemonstratie.
archer = Character("Sina", 1, RangedAttack(30, 0.9, 0.5))
print(archer.attack())
archer.attack_behavior = MeleeAttack(25)
print(archer.attack())Uitvoer:
Sina: Bow attack: range 30 m, accuracy 45%.
Sina: Sword attack: damage 25.Wacht. Onze boogschutter gebruikt nu een zwaard?
Ja. Zijn boog brak. Of hij had geen pijlen meer. Of de speler drukte op de verkeerde knop. Games zitten vol kansen op slechte beslissingen.
Het belangrijke deel is dat we zijn aanval hebben veranderd zonder een nieuw personage aan te maken of de attack()-methode van het personage te herschrijven.
We hebben zojuist het Strategy-patroon gebruikt.
In plaats van het personage permanent verantwoordelijk te maken voor één aanvalsimplementatie, geven we het een verwisselbaar aanvalsgedrag. Dit is compositie: het personage heeft een aanvalsgedrag en delegeert eraan.
Als we nu de berekening voor de aanval op afstand verbeteren, is er één gedeelde implementatie om bij te werken in plaats van 48 gekopieerde versies om op te sporen. Verschillende boogschutters kunnen nog steeds verschillende waarden voor bereik en nauwkeurigheid hebben.
"Prima. Voeg nu magie toe."
Natuurlijk. We hebben het weer amper opgelost, en iemand wil vuurballen.
Deze keer voegen we nog een implementatie van hetzelfde contract toe:
class MagicAttack(AttackBehavior):
def attack(self) -> str:
return "Cast a fireball. Stand somewhere else."
archer.attack_behavior = MagicAttack()
print(archer.attack())Geen wijziging aan Character.attack(). Geen nieuwe if weapon == "magic"-tak in het personage. We geven het een ander object, en dezelfde aanroep bereikt een andere implementatie. Dat verwisselbare gedrag is het polymorfisme dat we hier gebruiken.
In patroontermen is Character de context, AttackBehavior de strategy interface, en de implementaties voor boog, zwaard en magie zijn concrete strategieën. De namen beschrijven de rollen die we al hebben gebouwd; het zijn geen drie extra dingen om te installeren.
Iemand moet nog steeds de strategie kiezen. Die keuze hoort in onze setup- of wapenkeuzelogica. Strategy verplaatst die beslissing; het laat beslissingen niet verdwijnen.
Voordat de boogschutters feestvieren, checken we de getallen
from math import isclose
assert isclose(RangedAttack(30, 0.9, 0.5).effective_accuracy(), 0.45)
assert isclose(RangedAttack(30, 0.9).effective_accuracy(), 0.9)
sara = Character("Sara", 1, RangedAttack(30, 0.9))
sara.attack_behavior = MeleeAttack(25)
assert sara.attack() == "Sara: Sword attack: damage 25."
assert archer.attack() == "Sina: Cast a fireball. Stand somewhere else."De laatste assertion controleert iets dat makkelijk over het hoofd wordt gezien: het gekozen gedrag van Sara veranderen, verandert dat van Sina niet.
Er is wel een andere valkuil. Geef twee personages hetzelfde muteerbare RangedAttack-object, verander vervolgens de nauwkeurigheid van dat object, en beide personages zien de verandering. Een class delen is niet hetzelfde als één instance delen. Bepaal of de configuratie bij een personage hoort, bij een wapen, of bij de hele wedstrijd.
Zouden we een functie kunnen gebruiken voor een eenvoudigere aanval? Absoluut. Hebben we voor elke kleine instelling een class nodig? Liever niet. Twee boogschutters met verschillende nauwkeurigheidsgetallen kunnen dezelfde strategyclass gebruiken met andere data. Een ander getal is niet automatisch een ander algoritme.
De nuttige grens ligt tussen gedragingen die onafhankelijk veranderen — niet tussen elke twee regels code.
Mooi. Onze personages kunnen vechten.
Helaas staan ze in een nogal eenzame game. Laten we wat mensen uitnodigen.
2. Maryam zegt hallo. Wie hoort haar eigenlijk?
Twintig spelers betreden het slagveld. We verdelen ze in teams, laten ze rondrennen, en ontdekken meteen dat ze met elkaar willen praten.

Natuurlijk. We gaven ze zwaarden, maar ze willen een chatbox.
Teamchat lijkt eenvoudig. Zoek het team van de afzender, loop door de teamgenoten, en bezorg het bericht.
Dan vraagt iemand om chat met spelers in de buurt.
Maryam wil iets zeggen tegen de spelers die naast haar staan. Sara moet het horen. Samira moet het horen. Sina, die naar de andere kant van de map is gedwaald, waarschijnlijk niet.
Nu hebben we een ander probleem: de lijst met spelers in de buurt verandert elke keer dat iemand beweegt.
We zouden al dat werk in Player.move() kunnen stoppen. Positie bijwerken, ieders buren herberekenen, de minimap vernieuwen, een geluidssysteem verversen, een andere interface informeren…
Onze speler wilde alleen drie stappen zetten. We hebben lopen verantwoordelijk gemaakt voor de halve game.
Wat als het slagveld simpelweg aankondigde dat er iets was veranderd, en de geïnteresseerde onderdelen van de game daar zelf op reageerden?
Geef je naam op, en we laten het weten als er iets verandert
Dit is de afspraak: alles wat geïnteresseerd is in bewegingsupdates registreert een functie bij het slagveld. Als een speler beweegt, roept het slagveld die geregistreerde functies aan.
Het hoeft niet te weten hoe je een minimap tekent, of te beslissen of Sara dicht genoeg bij staat om Maryam te horen. Dat is het werk van de luisteraars.
from collections.abc import Callable
from math import dist, isfinite
class Battlefield:
def __init__(self) -> None:
self.players: list["Player"] = []
self._listeners: list[Callable[[], None]] = []
def subscribe(self, listener: Callable[[], None]) -> None:
if listener not in self._listeners:
self._listeners.append(listener)
def unsubscribe(self, listener: Callable[[], None]) -> None:
if listener in self._listeners:
self._listeners.remove(listener)
def notify(self) -> None:
for listener in tuple(self._listeners):
listener()subscribe() betekent "roep deze functie aan zodra je een update hebt." unsubscribe() betekent "stop daarmee." Elk slagveld heeft zijn eigen lijst, dus een nieuwe wedstrijd starten vermengt zijn luisteraars niet met deze.
Die lijsten worden bewust binnen __init__ aangemaakt. Zet een muteerbare lijst op de class zelf, en instances kunnen die lijst opeens delen. Ineens duikt iemand uit de wedstrijd van dinsdag op in die van woensdag. Dat is geen tijdreizen; dat is gedeelde state.
De tuple(...) geeft één notificatie een momentopname van zijn luisteraars. Een callback registreren of verwijderen tijdens de notificatie verandert deze iteratie niet. Een luisteraar die halverwege wordt verwijderd, kan alsnog draaien als hij al in die momentopname zat; toekomstige notificaties gebruiken de bijgewerkte lijst.
Laten we elke speler nu een klein notitieboekje geven, genaamd nearby. Telkens als het slagveld een verandering aankondigt, checkt de speler de huidige posities en vervangt de inhoud van het notitieboekje.
class Player:
def __init__(self, name: str, battlefield: Battlefield) -> None:
self.name = name
self.battlefield = battlefield
self.position = (0.0, 0.0)
self.nearby: list["Player"] = []
self._active = True
battlefield.players.append(self)
battlefield.subscribe(self.refresh_nearby)
battlefield.notify()
def move(self, x: float, y: float) -> None:
if not self._active:
raise RuntimeError("This player has left the battlefield.")
if not isfinite(x) or not isfinite(y):
raise ValueError("Coordinates must be finite numbers.")
self.position = (x, y)
self.battlefield.notify()
def refresh_nearby(self) -> None:
if not self._active:
return
self.nearby = [
player
for player in self.battlefield.players
if player is not self
and dist(self.position, player.position) < 5
]
def send_nearby_message(self, message: str) -> None:
if not self._active:
raise RuntimeError("This player has left the battlefield.")
for recipient in [self, *self.nearby]:
print(f"{recipient.name}'s chat | {self.name}: {message}")
def leave(self) -> None:
if not self._active:
return
self._active = False
self.battlefield.unsubscribe(self.refresh_nearby)
self.battlefield.players.remove(self)
self.nearby.clear()
self.battlefield.notify()Wanneer een speler zich aansluit, registreert die refresh_nearby als zijn reactie op een update. Als iemand beweegt, meldt het slagveld dit aan zijn luisteraars, en herberekent elke speler wie er dichtbij is.
Let op subscribe(self.refresh_nearby), zonder haakjes achter de methodenaam. We geven iets door om later aan te roepen. self.refresh_nearby() schrijven zou het meteen aanroepen en in plaats daarvan het resultaat doorgeven.
Let op dat de bewegende speler de update ook ontvangt. Maryams eigen beeld van haar buren moet veranderen als zij wegloopt — niet alleen ieders beeld van Maryam.
We gebruiken een straal van minder dan vijf game-eenheden. De speler wordt uitgesloten van zijn eigen nearby-lijst, maar we tonen zijn uitgaande bericht apart in zijn eigen chatbox.
Anders typt Maryam "hallo", ziet niets, en dient meteen onze eerste bugmelding in.
Laten we vier mensen op de kaart zetten
battlefield = Battlefield()
sina = Player("Sina", battlefield)
sara = Player("Sara", battlefield)
samira = Player("Samira", battlefield)
maryam = Player("Maryam", battlefield)
sina.move(100, 100)
sara.move(5, 5)
samira.move(7, 7)
maryam.move(8, 8)
maryam.send_nearby_message("Hello to everyone nearby!")Uitvoer:
Maryam's chat | Maryam: Hello to everyone nearby!
Sara's chat | Maryam: Hello to everyone nearby!
Samira's chat | Maryam: Hello to everyone nearby!Geen bericht voor Sina. Hij staat te ver weg.
Niets persoonlijks, Sina. Kom terug naar de groep.
Nu kan Maryam ergens anders naartoe bewegen, en de volgende notificatie bouwt de nearby-lijsten opnieuw op vanuit de nieuwe posities. We voegen niet steeds namen toe aan een oude lijst in de hoop dat niemand de duplicaten opmerkt.
Dat is het Observer-patroon in actie: een subject laat observers zich abonneren en informeert ze zodra een relevante verandering plaatsvindt.
Het slagveld is de bron van de notificatie. De geregistreerde callbacks zijn de reacties van de observers. Hier zegt de aankondiging alleen dat er iets is veranderd; elke speler leest de huidige posities om te bepalen wat dat voor hem of haar betekent.

Een minimap zou zijn eigen refresh-functie kunnen registreren zonder het slagveld te leren hoe je moet tekenen. Als dat scherm sluit, kan zijn luisteraar zich afmelden zonder een speler uit de wedstrijd te verwijderen. Dat zijn verschillende verantwoordelijkheden.
Maryam loopt weg. Samira logt uit.
Laten we controleren dat "nearby" echt dichtbij betekent, en niet "iemand die ik eerder tegenkwam en nooit heb verwijderd."
assert {player.name for player in maryam.nearby} == {"Sara", "Samira"}
maryam.move(50, 50)
assert maryam.nearby == []
assert maryam not in sara.nearby
maryam.move(8, 8)
samira.leave()
assert {player.name for player in maryam.nearby} == {"Sara"}Die leave()-methode heeft twee taken: de speler uit de wedstrijd verwijderen en zijn abonnement opzeggen. Daarna kondigt het de verandering aan zodat iedereen zijn nearby-lijst bijwerkt.
Alleen afmelden, en Samira blijft in de spelerslijst staan. Alleen haar uit de spelerslijst halen, en het slagveld houdt haar callback nog steeds vast. We moeten beide relaties opruimen. leave() twee keer aanroepen is in deze versie onschadelijk; bewegen of berichten sturen na het verlaten geeft een foutmelding. Samira is uitgelogd, niet begiftigd met bovennatuurlijke krachten.
Twintig spelers gaat prima. En twintigduizend?
Dit is de rekening voor onze eenvoudige implementatie: één beweging vraagt elke speler om de hele spelerslijst te doorzoeken. Met n spelers is dat ruwweg n² afstandsberekeningen per beweging. Observer organiseerde de communicatie; het maakte het werk binnen elke callback niet goedkoper.
Voor een grotere versie zou ik uitzoeken hoe je alleen getroffen buren bijhoudt, of posities groepeert in ruimtelijke cellen. Die machinerie zou ik niet aan deze les toevoegen voordat de daadwerkelijke behoefte is gemeten.
We hebben ook niet stiekem een multiplayerserver gebouwd. Dit prototype voert callbacks direct na elkaar uit. Een trage callback vertraagt de volgende; een exception stopt deze notificatielus. Er zit geen berichtenwachtrij, persistentie, retrybeleid of thread-safety verstopt in notify().
Voor het verversen van een UI is het misschien acceptabel om een mislukte luisteraar over te slaan en de fout te rapporteren. Voor een verplichte game-state-update kan stilletjes overslaan de wereld inconsistent achterlaten. Dat is een beslissing die je expliciet neemt, niet iets wat het patroon voor je kiest.
Onze notificatie zegt alleen "er is iets veranderd", en de spelers lezen de huidige state. Een alternatief zou een bewegingsevent doorgeven met de speler en de oude en nieuwe posities. Dat zou onnodig werk kunnen verminderen, maar dan zouden de luisteraars afhankelijk worden van de structuur van dat event. We hebben verantwoordelijkheden gescheiden, niet elke afhankelijkheid geëlimineerd.
Gebruik Observer voor reacties die op zichzelf kunnen staan. Als stappen in een vereiste volgorde moeten gebeuren, maak die workflow dan expliciet in plaats van te vertrouwen op een gelukkige abonneervolgorde.
Goed. Onze spelers kunnen vechten, bewegen en praten.
Er is nog maar één gênant probleem over.
3. Een prins kan niet zo ten strijde trekken
Onze held heet LovelyPrince.
Hij begint met 100 gezondheid, 10 kracht en absoluut geen harnas. Ik bewonder zijn zelfvertrouwen. Ik ben minder onder de indruk van zijn voorbereiding.

class Hero:
def __init__(self, name: str) -> None:
self.name = name
def get_health(self) -> int:
return 100
def get_power(self) -> int:
return 10
def get_armor(self) -> int:
return 0
def get_equipments(self) -> str:
return "No equipment"Terwijl hij door de game vordert, vindt hij betere uitrusting. Laten we beginnen met een Prince Hat en wat Prince Gloves.
De hoed geeft 300 gezondheid, 100 kracht en 5 harnas. De handschoenen geven 100 gezondheid, 3 kracht en 1 harnas.
Ja, de hoed is absurd sterk. We leren softwareontwerp, we balanceren geen game-economie.
from dataclasses import dataclass
@dataclass(frozen=True)
class Equipment:
name: str
health: int
power: int
armor: int
hat = Equipment("Prince Hat", health=300, power=100, armor=5)
gloves = Equipment("Prince Gloves", health=100, power=3, armor=1)@dataclass bespaart ons het zelf schrijven van de constructor voor de uitrusting. frozen=True blokkeert het gewoon herschrijven van die velden na het aanmaken; het is geen belofte dat elk mogelijk object binnen een frozen dataclass diep onveranderlijk wordt.
Hoe zetten we ze op hem?
We zouden een HeroWithHat-class kunnen maken. Dan een HeroWithGloves-class. Dan een HeroWithHatAndGloves-class.
Dan voegt iemand laarzen toe.
Ik heb al een heel hoofdstuk besteed aan het onderhouden van boogschutters. Ik weiger de rest van mijn leven te besteden aan het benoemen van elke mogelijke outfit.
Wat als we de oorspronkelijke held met rust laten en er in plaats daarvan iets omheen zetten?
De hoed vervangt de held niet. Hij omhult hem.
Stel je voor dat je de held met de hoed op naar zijn kracht vraagt.
De hoed-wrapper vraagt de held eronder: "Hoeveel kracht heb jij?" De held antwoordt: "Tien." De hoed telt er 100 bij op en geeft 110 terug.
Zet nu de handschoenen om dat resultaat.
De handschoenen vragen het object eronder naar zijn kracht. Dat object is al de held-met-hoed, dus het antwoord is 110. De handschoenen tellen er 3 bij op en geven 113 terug.
Elke laag doet zijn eigen kleine taak. Niemand hoeft de hele garderobe te kennen.
Laten we opschrijven welke vragen elke ingepakte held moet kunnen beantwoorden:
from typing import Protocol
class HeroView(Protocol):
name: str
def get_health(self) -> int: ...
def get_power(self) -> int: ...
def get_armor(self) -> int: ...
def get_equipments(self) -> str: ...
class WearEquipment:
def __init__(self, hero: HeroView, equipment: Equipment) -> None:
self.hero = hero
self.equipment = equipment
self.name = hero.name
def get_health(self) -> int:
return self.hero.get_health() + self.equipment.health
def get_power(self) -> int:
return self.hero.get_power() + self.equipment.power
def get_armor(self) -> int:
return self.hero.get_armor() + self.equipment.armor
def get_equipments(self) -> str:
return f"{self.hero.get_equipments()} -> {self.equipment.name}"De wrapper biedt dezelfde methodes die we gebruiken om de oorspronkelijke held naar gezondheid, kracht, harnas en uitrusting te vragen. Daarom kan er weer een wrapper omheen zitten: van buitenaf kunnen we dezelfde vragen stellen.
Voor dit Python-voorbeeld is die gedeelde verzameling methodes wat telt; de wrapper hoeft niet te erven van de concrete Hero-class. HeroView is een protocol: het beschrijft de structuur die een typechecker verwacht, in plaats van te eisen dat elk compatibel object dezelfde parent class heeft. Het is geen automatische runtime-validatie.
Dat is ook waarom de wrapper HeroView accepteert, in plaats van een lijst van elke concrete wrapper die we ooit zouden kunnen bedenken. Geef het een object dat aan het contract voldoet, en het kan dezelfde vragen stellen.
Laten we onze prins eindelijk aankleden.

hero = Hero("LovelyPrince")
with_hat = WearEquipment(hero, hat)
fully_equipped = WearEquipment(with_hat, gloves)
print(fully_equipped.get_health())
print(fully_equipped.get_power())
print(fully_equipped.get_armor())
print(fully_equipped.get_equipments())Uitvoer:
500
113
6
No equipment -> Prince Hat -> Prince GlovesDaar gaan we. Een redelijk bedreigende prins.
Zijn gezondheid is 100 + 300 + 100 = 500. Zijn kracht is 10 + 100 + 3 = 113. Zijn harnas is 0 + 5 + 1 = 6.
De oorspronkelijke hero geeft nog steeds zijn oorspronkelijke waarden terug. We hebben zijn methodes niet veranderd of stilletjes zijn basiswaarden overschreven. We hebben er iets omheen gebouwd.
Dat is het Decorator-patroon.
Een wrapper behoudt de interface die zijn aanroepers verwachten, terwijl hij iets toevoegt aan wat het ingepakte object doet. Omdat de volgende wrapper diezelfde interface kan gebruiken, kunnen de lagen worden gecombineerd.
Het patroon hier is WearEquipment dat een ander object inpakt. Python’s @decorator-syntax past een transformatie toe op een functie- of class-definitie; die taalfunctie en dit object-wrapping-patroon zijn verwante ideeën, maar geen inwisselbare termen. Hier maakt @dataclass handige class-methodes aan, terwijl WearEquipment de object-decorator is.
Eén spelregel verdient ook een naam: get_health() betekent hier de totale gezondheidscapaciteit. Het is niet de huidige gezondheid van de prins na een klap. Anders zou het opvragen van zijn statistieken hem elke keer lijken te genezen tot 500. Schade en herstel hebben aparte state nodig.
Maakt het uit welk item er als eerste aangaat?
Met onze vaste bonussen zijn de numerieke totalen hetzelfde: 10 + 100 + 3 is gelijk aan 10 + 3 + 100. De uitrustingsbeschrijving legt de volgorde van inpakken nog steeds vast.
Stel je nu een zegening voor die de kracht verdubbelt van wat hij ook inpakt.
Zet hem buiten de held-met-hoed: (10 + 100) × 2 = 220.
Zet de hoed buiten de gezegende basisheld: (10 × 2) + 100 = 120.
Dezelfde held. Dezelfde twee extra’s. Heel ander resultaat.
De les is niet "decorators commuteren altijd" of "volgorde verandert altijd het resultaat". Het is: weet wat elke wrapper doet voordat je beslist of zijn positie ertoe doet. Onze optelbare uitrusting maakte de rekensom toevallig vergevingsgezind.
"Lovely. Doe nu de hoed af."
Uitstekende vraag. Ik hoopte dat niemand het zou vragen.
Voor dit kleine voorbeeld bouwen we de combinatie opnieuw op met de oorspronkelijke held en de handschoenen, zonder de hoed:
without_hat = WearEquipment(hero, gloves)
assert without_hat.get_power() == 13
assert fully_equipped.get_power() == 113
assert hero.get_power() == 10We hebben een nieuwe combinatie gemaakt. We zijn niet in de oude gedoken om er iets uit te halen. Deze assertions controleren zowel het nieuwe resultaat als het feit dat de bestaande objecten nog steeds antwoorden zoals voorheen.
Een echte inventaris zou ook regels nodig hebben over uitrustingsslots, herhaalde items en het opslaan van het resultaat. Onze wrapper laat de prins vrolijk twee hoeden dragen. Een gedeelde interface is geen modepolitie.
Een keten van wrappers is niet automatisch het beste antwoord op al die requirements. Soms zijn een uitrustingslijst en een expliciete statistiekberekening makkelijker te beheren.
Dat maakt het voorbeeld niet nutteloos. Het vertelt ons wat het patroon oplost: gedrag toevoegen via compatibele lagen. Het belooft niet elke toekomstige garderobecrisis op te lossen.
Wacht. Zijn Strategy en Decorator niet allebei "stop een object in een ander object"?
Ja. Vergelijkbare bouwstenen, verschillende taken.
Ons personage delegeert een operatie aan een gekozen Strategy. Een andere aanval kiezen verandert hoe die operatie wordt uitgevoerd.
Onze uitrustings-Decorator behoudt de interface van de held en stuurt aanroepen erdoorheen, terwijl hij een bijdrage toevoegt aan het object eronder. Het verandert wat het oorspronkelijke gedrag omringt of uitbreidt.
Observer beantwoordt een heel andere vraag: wie moet horen over een verandering?
| Wat gebeurde er in onze game? | Wat moest gescheiden worden? | Gebruikt patroon |
|---|---|---|
| Een personage had verschillende aanvallen nodig. | Het personage en het gekozen aanvalsgedrag. | Strategy |
| Beweging vroeg om meerdere onafhankelijke reacties. | De aankondiging en het werk van de luisteraars. | Observer |
| Uitrusting moest stapelbare bonussen geven. | De basisheld en de optionele toevoegingen. | Decorator |
Je kunt alle drie gebruiken in één applicatie. Het hoeft niet. Hun waarde komt uit de verantwoordelijkheden die ze scheiden, niet uit het aantal patroonnamen in je README.
Jouw beurt: de volgende drie feature requests
De boogschutter vond vergiftigde pijlen. Voeg een nieuw aanvalsgedrag toe zonder Character.attack() te wijzigen. Vraag jezelf dan af of gif werkelijk een andere aanvalsstrategie is, of een effect dat aan een bestaande aanval wordt toegevoegd. Beargumenteer je keuze voordat je nog een class schrijft.
Maryam staat op precies vijf eenheden afstand. Moet ze het bericht horen? Onze code gebruikt < 5, dus het antwoord is nee. Schrijf een test voor die grens, beweeg haar iets dichterbij, en controleer de nearby-lijsten van beide spelers. Controleer ook dat herhaaldelijk bewegen geen dubbele ontvanger oplevert.
LovelyPrince vond laarzen en een riem. Voeg hun bonussen toe, verifieer de totalen, en verwijder de hoed terwijl al het andere blijft zitten. Bepaal daarna of wrappers herbouwen, of een uitrustingslijst bijhouden, de volgende requirement makkelijker maakt.

Er is geen prijs voor het door alle geweld in het patroon van vandaag persen. "Dit is simpeler als data" kan een heel goed antwoord zijn.
We begonnen met een game. We eindigden met drie patronen.
We begonnen niet met het tekenen van drie indrukwekkende diagrammen en op zoek gaan naar een plek om ze te gebruiken.
We begonnen met een boogschutter. Toen veranderden de aanvalsregels, en hadden we een manier nodig om het gedrag van het personage te scheiden. Dat bracht ons bij Strategy.
We zetten spelers op een slagveld. Toen moest beweging geïnteresseerde onderdelen van de game bijwerken, zonder dat één methode overal verantwoordelijk voor werd. Dat bracht ons bij Observer.
We gaven onze held uitrusting. Toen begonnen de combinaties zich te vermenigvuldigen, en hadden we een manier nodig om bonussen toe te voegen zonder voor elke outfit een subclass te maken. Dat bracht ons bij Decorator.
De namen zijn nuttig. Ze laten ons over de ideeën praten zonder telkens het hele avontuur opnieuw te vertellen.
Maar de namen uit je hoofd leren is niet het interessante deel. Het interessante deel is merken wanneer de code vecht tegen de verandering die je moet maken — en een betere plek vinden voor die verandering om te leven.
Stel je buiten het kasteel voor: vervang de aanvalskeuzes door werkelijk verschillende prijsberekeningen, bewegingsupdates door orderstatusnotificaties, en uitrustingswrappers door optionele timing of logging rond een rapportgenerator. Dat zijn mogelijke toepassingen van dezelfde ideeën, geen reden om alle drie patronen in elke business tool te installeren.
Dat is de vraag die ik zou meenemen naar een softwareproject bij OmniTechs: als deze requirement verandert, wat zou daarmee nog meer moeten veranderen?
Soms is het antwoord een strategy. Soms is het een kleine functie. Soms is de code al duidelijk en moeten we stoppen met herschikken.
Onze taak is niet om simpele software er indrukwekkend uit te laten zien. Het is om nuttige software begrijpelijk te houden terwijl de requirements groeien.
Ga nu die laarzen testen. LovelyPrince heeft een regenbui, een herschrijving van het chatsysteem en drie architectuurlessen overleefd. Het zou jammer zijn hem aan schoeisel te verliezen.
