Handboek · hoofdstuk 35Objectgericht programmeren en softwareontwerp
Software groeit door duidelijke contracten, vervangbare onderdelen en aantoonbaar gedrag
In klimaat.ino v5 werken meten, pompen, dimmen en alarmen al samen. De Pi voegt modellen en voorspellingen toe. Je verdeelt die software in objecten die hun toestand bewaken, test hun grenzen en maakt elke wijziging terugvindbaar.

- Je kan een klasse met eigen objecttoestand en methoden ontwerpen.
- Je kan samenstelling en overerving afwegen met een interfacecontract.
- Je kan meetdata, hardware en beslislogica modulair scheiden.
- Je kan unit tests voor normale waarden, grenzen en fouten schrijven.
- Je kan met git een wijziging, review en reproduceerbare versie beschrijven.
Een object bezit samenhangende toestand
Een losse variabele temperatuur kan uit elke taak overschreven worden. Een sensorobject bewaart zijn kalibratie, laatste geldige meting en tijdstip samen. De klasse legt vast welke methoden die toestand mogen veranderen. Twee instanties van dezelfde klasse kunnen andere adressen en kalibraties hebben: hun identiteit en toestand zijn verschillend, hun gedrag volgt dezelfde definitie.
Een attribuut is opgeslagen informatie; een methode is een functie die via self de instantie kent. De constructor __init__ maakt een geldige begintoestand. Een invariant zoals ‘het maximaal vermogen is positief’ hoort na de constructor en na elke toegestane wijziging waar te blijven. Objectgericht ontwerpen draait om zulke invarianten en verantwoordelijkheden, niet om elk zelfstandig naamwoord in een klasse te veranderen.
| object | toestand | gedrag |
|---|---|---|
| Sensor S1 | adres, offset, tijd | lees, kalibreer |
| Regelaar kamer | Kp, Ki, integraal | stap, reset |
| Energiebeheer | SoC, grenzen, modus | plan, begrens |
Een gedeelde lijst kan alle sensoren besmetten
Een klasseattribuut wordt door instanties gedeeld. class Sensor: fouten=[] geeft alle sensoren dezelfde mutable lijst. Voeg je bij S1 een fout toe, dan ziet S2 die ook. Maak een eigen lijst in __init__: self.fouten=[]. Ook een standaardargument fouten=[] wordt eenmaal aangemaakt; gebruik None en maak binnen de functie een nieuwe lijst.
Python kent geen harde private attribuutgrens. Een voorloopunderscore betekent ‘intern, gebruik het contract’. Properties kunnen validatie afdwingen. Een onveranderlijke dataclass(frozen=True) is geschikt voor een meetrecord: tijd, waarde, eenheid en kwaliteit blijven bij elkaar. De dataclass garandeert niet automatisch fysieke geldigheid; die toets je expliciet.
| keuze | gevolg |
|---|---|
| self.offset | per instantie |
| klasse constante MAX_W | bewust gedeeld |
| klasse lijst fouten | gedeelde wijzigbare toestand |
| frozen meetrecord | velden niet gewoon opnieuw toewijzen |
Een objectwissel zonder regelwetwissel
- De ene adapter gebruikt SHT31, de andere een testreeks
- het contract is identiek
- Wat hoort gelijk te blijven en wat mag verschillen?
- 1De regelaar ontvangt hetzelfde meetrecord en produceert dezelfde warmtevraag: bij r=20 en Kp=200 is dat 400 W.
- 2De adapter mag verschillen in buscode, adres en interne kalibratie, zolang bereik, eenheid, leeftijd en foutgedrag blijven passen.
- 3De integratietest controleert naast het unit-resultaat ook dat de echte adapter een geldig record levert en niet blokkeert.
Interfaces dragen betekenis, geen stekkervorm
Twee sensoren met een methode lees() zijn pas verwisselbaar als hun resultaten dezelfde betekenis hebben. Een contract benoemt eenheid, bereik, tijdreferentie, maximale leeftijd en foutgedrag. Een kelvinsensor die een gewoon getal teruggeeft kan syntactisch passen maar de verwarming onmiddellijk laten uitschakelen.
De les gebruikt Meting(waarde, tijd_s, eenheid). Een Protocol beschrijft voor typecontrole de verwachte methode; Python controleert deze typehint niet vanzelf tijdens uitvoering. De regelaar voert daarom runtimecontroles uit. In echte stationslogs is tijd_s een monotone leeftijdsklok binnen een sessie; de logger bewaart daarnaast een kalenderstempel in UTC+1. Een herstart-ID voorkomt dat twee monotone tijdlijnen worden verward.
Samenstelling: een regelaar heeft een sensor
Een regelaar heeft een sensor en een actuator. Hij is geen sensor. Samenstelling past daarbij: de constructeur krijgt een sensorobject en gebruikt zijn interface. Dit heet dependency injection. De test kan een ProefSensor leveren en de installatie een SHT31-adapter zonder de regelwet te wijzigen.
Elke verantwoordelijkheid heeft één eigenaar. In i40 werd het pompconflict opgelost door één taak de pomp te laten sturen. Maak op de Pi geen tweede schrijver die rechtstreeks hetzelfde relais bestuurt. De Pi stuurt begrensde setpoints of voorstellen; de Mega blijft eigenaar van lokale actuatoren en veiligheidsmodi. Een interface die een voorstel accepteert, moet ook weigering, timeout en huidige modus terugmelden.
| laag | verantwoordelijkheid | mag niet |
|---|---|---|
| hardwareadapter | uitlezen en omzetten | bedrijfsbeleid verzinnen |
| regelaar | regelfout naar vraag | netwerk opnieuw verbinden |
| supervisor | modus en fallback | noodstop omzeilen |
| logger | bewijs bewaren | de pomp aanzetten |
Een dubbele pompschrijver opsporen
- Regeling: reservoir te laag → pomp uit
- AI: planten slap → extra pompbeurt
- Een architectuur die beide bedoelingen correct verwerkt
- 1Twee schrijvers geven racegedrag: de laatste write wint, ook als het reservoir leeg is.
- 2Maak één lokale pompcontroller. De AI levert alleen een verzoek met duur en vervaltijd; de controller controleert niveau, modus en bestaande cyclus.
- 3Een laag-niveauvoorwaarde weigert het verzoek. De weigering wordt met reden en request-ID gelogd, zodat de AI haar actie niet als uitgevoerd telt.
Overerving: een afgeleid object moet zijn belofte houden
Overerving is passend als een afgeleid type overal bruikbaar blijft waar het basistype beloofd is. Een temperatuuradapter kan van een algemene sensoradapter erven als lezen, tijdstempels en fouten hetzelfde contract behouden. De subklasse mag geen extra preconditie toevoegen die oude gebruikers niet kennen.
Een basisklasse Actuator.schrijf(vermogen_W) vervangen door een subklasse die procenten verwacht schendt het contract. Ook een blokkerende netwerkvariant schendt een contract van maximaal 5 ms, al heeft ze dezelfde methodenaam. Vermijd diepe ervingsbomen. Een kleine interface met samenstelling maakt variaties in sensor, opslag en communicatietransport vaak eenvoudiger.
Hergebruik zonder substitueerbaarheid koppelt wijzigingen aan onverwachte plaatsen. Kies een gedeelde helper of samenstelling als de betekenis van het gedrag verschilt.
Een contract maakt een fout zichtbaar
- meting 18 °C op t = 10 s
- nu = 11 s
- maximale leeftijd 5 s
- Gevraagd vermogen en gedrag bij t = 16 s
- 1De leeftijd is 1 s: binnen het contract. De fout is 2 K en de vraag is 400 W.
- 2Op t = 16 s is dezelfde meting 6 s oud. De regelaar weigert haar; de supervisor kiest de vooraf ontworpen gedegradeerde modus.
- 3Een fout teruggeven verschilt van 0 W bevelen: 0 W zou de kamer zonder beoordeling laten afkoelen.
Vervangbare meting, echte grensgevallen
Deze module draait zeven unit tests met extra subtests voor niet-eindige instellingen tijdens het bouwen. Het is een P-regelaar om de softwaregrenzen zichtbaar te maken; e39 levert de PI(D)-regelwet. De supervisor staat buiten deze unit en bepaalt de fallback. De constructor weigert ook NaN en oneindig; het setpoint en de klok krijgen hun eigen contractcontrole.
from dataclasses import dataclassfrom math import isfinitefrom typing import Protocolimport unittestimport io @dataclass(frozen=True)class Meting: waarde: float tijd_s: float eenheid: str = "degC" class Sensor(Protocol): def lees(self) -> Meting: ... class ProefSensor: def __init__(self, waarde, tijd_s): self.waarde, self.tijd_s = waarde, tijd_s def lees(self): return Meting(self.waarde, self.tijd_s) class Regelaar: def __init__(self, sensor, kp=200.0, grens=2000.0, max_leeftijd=5.0): if (not all(isfinite(x) for x in (kp, grens, max_leeftijd)) or kp < 0 or grens <= 0 or max_leeftijd <= 0): raise ValueError("ongeldige instelling") self.sensor = sensor # samenstelling, geen Sensor-subklasse self.kp, self.grens = kp, grens self.max_leeftijd = max_leeftijd def stap(self, setpoint, nu): if not isfinite(setpoint) or not -30 <= setpoint <= 60 or not isfinite(nu): raise ValueError("ongeldig setpoint of tijdstip") m = self.sensor.lees() if (m.eenheid != "degC" or not isfinite(m.waarde) or not isfinite(m.tijd_s) or not -30 <= m.waarde <= 60 or not 0 <= nu - m.tijd_s <= self.max_leeftijd): raise ValueError("meting onbruikbaar; supervisor kiest veilige modus") return min(self.grens, max(0.0, self.kp * (setpoint - m.waarde))) class TestRegelaar(unittest.TestCase): def test_normaal(self): self.assertEqual(Regelaar(ProefSensor(18,10)).stap(20,11), 400) def test_begrenzing(self): self.assertEqual(Regelaar(ProefSensor(0,10)).stap(20,11), 2000) self.assertEqual(Regelaar(ProefSensor(23,10)).stap(20,11), 0) def test_oud(self): with self.assertRaises(ValueError): Regelaar(ProefSensor(18,10)).stap(20,16) def test_nan(self): with self.assertRaises(ValueError): Regelaar(ProefSensor(float("nan"),10)).stap(20,11) def test_eenheid(self): class Kelvin: def lees(self): return Meting(291.15,10,"K") with self.assertRaises(ValueError): Regelaar(Kelvin()).stap(20,11) def test_instellingen(self): for naam in ("kp", "grens", "max_leeftijd"): for waarde in (float("nan"), float("inf"), -float("inf")): with self.subTest(naam=naam, waarde=waarde), self.assertRaises(ValueError): Regelaar(ProefSensor(18,10), **{naam:waarde}) def test_invoer(self): for r,nu in [(float("nan"),11),(float("inf"),11),(20,float("nan")),(61,11)]: with self.subTest(r=r,nu=nu), self.assertRaises(ValueError): Regelaar(ProefSensor(18,10)).stap(r,nu) log = io.StringIO()suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestRegelaar)resultaat = unittest.TextTestRunner(stream=log).run(suite)print("tests", resultaat.testsRun, "fouten", len(resultaat.errors), "mislukt", len(resultaat.failures))print("warmtevraag", Regelaar(ProefSensor(18,10)).stap(20,11), "W")assert resultaat.wasSuccessful()tests 7 fouten 0 mislukt 0 warmtevraag 400.0 W
Overerving uitvoeren, samenstelling behouden
De gekalibreerde sensor erft alleen het passende sensorcontract: waarde in °C met hetzelfde meettijdstip. Hij roept de basisconstructor en de basismethode via super() aan, voegt de offset toe en behoudt eenheid en leeftijd. De Regelaar blijft door samenstelling dezelfde sensorinterface gebruiken. Het verschil van 60 W tussen ruw en geijkt volgt uit 0,3 K × 200 W/K; overerving verandert de regelwet niet.
from dataclasses import dataclassfrom math import isfinitefrom typing import Protocolimport unittestimport io @dataclass(frozen=True)class Meting: waarde: float tijd_s: float eenheid: str = "degC" class Sensor(Protocol): def lees(self) -> Meting: ... class ProefSensor: def __init__(self, waarde, tijd_s): self.waarde, self.tijd_s = waarde, tijd_s def lees(self): return Meting(self.waarde, self.tijd_s) class Regelaar: def __init__(self, sensor, kp=200.0, grens=2000.0, max_leeftijd=5.0): if (not all(isfinite(x) for x in (kp, grens, max_leeftijd)) or kp < 0 or grens <= 0 or max_leeftijd <= 0): raise ValueError("ongeldige instelling") self.sensor = sensor # samenstelling, geen Sensor-subklasse self.kp, self.grens = kp, grens self.max_leeftijd = max_leeftijd def stap(self, setpoint, nu): if not isfinite(setpoint) or not -30 <= setpoint <= 60 or not isfinite(nu): raise ValueError("ongeldig setpoint of tijdstip") m = self.sensor.lees() if (m.eenheid != "degC" or not isfinite(m.waarde) or not isfinite(m.tijd_s) or not -30 <= m.waarde <= 60 or not 0 <= nu - m.tijd_s <= self.max_leeftijd): raise ValueError("meting onbruikbaar; supervisor kiest veilige modus") return min(self.grens, max(0.0, self.kp * (setpoint - m.waarde))) class GekalibreerdeProefSensor(ProefSensor): def __init__(self, waarde, tijd_s, offset): super().__init__(waarde, tijd_s) if not isfinite(offset): raise ValueError("offset niet eindig") self.offset = offset def lees(self): m = super().lees() return Meting(m.waarde+self.offset, m.tijd_s, m.eenheid) ruw = ProefSensor(18.3,10)geijkt = GekalibreerdeProefSensor(18.3,10,-0.3)for sensor in (ruw, geijkt): print(type(sensor).__name__, Regelaar(sensor).stap(20,11), "W")assert abs(Regelaar(geijkt).stap(20,11)-400)<1e-10ProefSensor 339.9999999999999 W GekalibreerdeProefSensor 400.0 W
Refactoren naar bestanden met gerichte imports
In een echte projectmap staat hab_meet.py met het immutable record, hab_vraag.py met de zuivere regelkern, adapters/sht31.py met I²C en app.py als samensteller. De regelkern importeert de meetdefinitie, nooit omgekeerd. Hardwareadapters importeren het contract; het contract importeert geen hardwarebibliotheek. Zo ontstaan geen cirkelimports.
Bij refactoring bewaar je eerst het bestaande gedrag in tests, verplaats je één verantwoordelijkheid en pas je de imports aan. Daarna draai je dezelfde tests. Wijzig tijdens die stap geen regelinstellingen: een verplaatsing en een gedragswijziging in één diff maken de oorzaak van een regressie onduidelijk. De onderstaande uitvoering gebruikt echte Python-imports tussen in-geheugenmodules; dezelfde bron wordt als bijlage getoond voor fysieke bestanden.
| bestand | importeert | doel |
|---|---|---|
| hab_meet.py | dataclasses | datacontract |
| hab_vraag.py | hab_meet | zuivere kern |
| adapters/sht31.py | hab_meet + hardwarelibrary | apparaatgrens |
| app.py | kern en adapters | samenstelling |
Een modulegrens werkelijk uitvoeren
Het demo-laadmechanisme registreert twee modules tijdelijk en herstelt de importomgeving achteraf. De gewone installatie gebruikt dezelfde imports uit bestanden. De uitvoer toont 400 W. Dit minimale voorbeeld demonstreert afhankelijkheidsrichting; de uitgebreide finite- en leeftijdscontroles blijven in regelaar_contract.py.
# In een echte map staan de eerste twee bronnen in aparte .py-bestanden.# Hier worden dezelfde modules in geheugen geladen voor reproduceerbare uitvoer.import sysimport typesimport importlib bronnen = { "hab_meet": "from dataclasses import dataclass\n@dataclass(frozen=True)\nclass Meting:\n waarde: float\n tijd_s: float\n", "hab_vraag": "from hab_meet import Meting\ndef vraag(m: Meting, r=20.0):\n return max(0.0,min(2000.0,200.0*(r-m.waarde)))\n"}oud = {naam:sys.modules.get(naam) for naam in bronnen}try: for naam,bron in bronnen.items(): module = types.ModuleType(naam) sys.modules[naam] = module exec(bron,module.__dict__) meten = importlib.import_module("hab_meet") regelen = importlib.import_module("hab_vraag") print("module-invoer", regelen.vraag(meten.Meting(18.0,10)), "W")finally: for naam,vorige in oud.items(): if vorige is None: sys.modules.pop(naam,None) else: sys.modules[naam] = vorigemodule-invoer 400.0 W
Unit tests: bewijs van gevallen, geen bewijs van alles
Een goede test maakt een fout zichtbaar en vertelt welk contract faalt. Normale invoer alleen is zwak: test minimum, maximum, net erbuiten, oude tijd, toekomsttijd, NaN, ontbrekende eenheid en sensoruitzondering. Een grens van leeftijd ≤ 5 s vraagt tests op 5 en net boven 5 s. NaN is bijzonder: gewone vergelijkingen leveren geen betrouwbare bereiktoets; isfinite maakt de bedoeling expliciet.
Een testdouble levert vaste invoer; een fake benadert een echte afhankelijkheid, een mock controleert interacties. Test bij voorkeur waarneembaar gedrag, niet private velden. Een unit test bewijst geen bedrading, timing of realistisch procesgedrag: integratietests verbinden onderdelen, systeemtests toetsen eisen en validatie toont bruikbaarheid voor bewoners.
| test | detecteert | mist mogelijk |
|---|---|---|
| unit | foute begrenzing | kapotte SSR |
| integratie | verkeerde eenheid adapter | reële deadline |
| hardware-in-lus | timer en signaalpad | langzame veroudering |
| bewonerproef | bedieningsprobleem | zeldzame faalwijze |
Testbaarheid verandert het ontwerp
Een regelaar die zelf de klok leest, een sensor opent en een netwerkbericht stuurt, is moeilijk reproduceerbaar. Geef tijd en meting via interfaces. Een simulatieklok kan precies een timeoutgrens aanbieden. Maak de kern zo veel mogelijk deterministisch: dezelfde invoer en toestand geven dezelfde uitvoer.
Bewaar de PID-integraal in het regelaarobject; log de overgang die hem reset. Beperk toegang tot gedeelde toestand en bepaal wie waarden schrijft. Bij gelijktijdige processen op Linux kan een meetrecord halverwege veranderd worden. Een onveranderlijk record of expliciete wachtrij maakt het overdrachtsmoment zichtbaar. Een test die uitsluitend dezelfde formule kopieert in het verwachte resultaat kan dezelfde fout herhalen; gebruik onafhankelijke handberekende gevallen.
Een staged wijziging reconstrueren
- HEAD: A
- index: B
- werkmap: C
- Betekenis van beide diffs en de volgende commit
- 1git diff vergelijkt C met B en toont alleen de laatste werkmapwijziging.
- 2git diff --staged vergelijkt B met A en toont de geselecteerde commitwijziging.
- 3git commit legt B vast; C blijft als lokale wijziging aanwezig. Voor C als bedoelde release moet je opnieuw selecteren en toetsen.
Git: drie plaatsen, één gecontroleerde wijziging
Git onderscheidt de werkmap, de index (staging area) en de commitgeschiedenis. git diff toont werkmap tegenover index; git diff --staged toont wat in de volgende commit komt. git add selecteert een momentopname: latere edits zitten niet automatisch in die selectie. git commit legt de geselecteerde inhoud met een toelichting vast.
Een branch is een verplaatsbare verwijzing naar een commit. Maak een tak voor een verandering, toets het gedrag en laat een tweede lezer het diff beoordelen. Bij een conflict vergelijkt de integrator de bedoelingen en runt hij opnieuw tests; conflictmarkers verwijderen bewijst geen correcte integratie. Gebruik voor een gedeelde fout een nieuwe herstelcommit (git revert), zodat de geschiedenis terugvindbaar blijft.
| stap | voorbeeldcommando |
|---|---|
| inspecteer | git status; git diff |
| maak werktak | git switch -c sensor-leeftijd |
| selecteer | git add regelaar.py tests/test_regelaar.py |
| review selectie | git diff --staged |
| toets | python -m unittest discover |
| leg vast | git commit -m 'Weiger verouderde temperatuurmetingen' |
| controle geschiedenis | git log --oneline |
Een versie is meer dan alleen de broncode
Een reproduceerbare stationsrelease benoemt commit-ID, configuratie, kalibratieversie, modelbestand, dataselectie en testverslag. Een broncodecommit zegt niets over een later overschreven modelbestand. Geef elk model een checksum en beschrijf welke sensor- en softwareversies ermee compatibel zijn.
Geheime sleutels horen niet in git. Een genegeerd bestand dat vroeger al gevolgd werd, blijft gevolgd; .gitignore maakt een gelekt geheim niet ongedaan. Trek een gelekte sleutel in en herstel de toegang. Een release op de Pi krijgt een terugrolpad naar een geteste vorige versie; lokale veiligheid blijft tijdens update bij de microcontroller. Een rollback van software kan ongeschikt zijn als het databankformaat al gewijzigd is: test de migratie en terugweg samen.
| manifestveld | voorbeeld |
|---|---|
| code | commit-ID |
| kalibratie | S1-versie 3 |
| model | warmte-v2 + SHA-256 |
| configuratie | vermogen 2000 W, grenzen |
| bewijs | unit-, integratie- en systeemtestverslag |
Softwaregeschiedenis: Simula maakte objecten een ontwerpmiddel
Het Noorse Computing Center beschrijft Simula als een taal ontwikkeld door Ole-Johan Dahl en Kristen Nygaard voor simulaties en als een grondslag van objectgericht programmeren. Een simulatie bevat vele gelijktijdig gedachte entiteiten met eigen toestand: dat helpt begrijpen waarom objecten meer zijn dan een nieuwe syntaxis rond functies.
De stationssoftware profiteert van hetzelfde idee: een buffervat, sensor en regelaar hebben andere toestand en verschillende levensduur. Hun grenzen moeten wel aan de echte afhankelijkheden beantwoorden. Bron: Norwegian Computing Center: geschiedenis; taalgedrag gecontroleerd in Python: classes, tests in unittest, versiebeheer in Pro Git, geraadpleegd 30-09-2026.
| simulatieobject | eigen toestand |
|---|---|
| buffervat | energie en temperatuur |
| sensor | offset en laatste record |
| regelaar | integraal en vorige meting |
Ontwerpbeslissingen zichtbaar houden
| vraag bij review | bewijs |
|---|---|
| Wie schrijft elke actuator? | één eigenaar in architectuur |
| Welke eenheid levert de adapter? | contract en test |
| Welke fout gaat naar welke modus? | supervisortabel |
| Welke versie draaide? | manifest en log |
| Kan hardware vervangen worden? | testdouble via hetzelfde contract |
De software sluit het V-model
Goede klassen verbinden het procesmodel, meetcontract en testbewijs. Een systeemgrens die helder is in software moet ook terugkomen in interfaces, eisen en storingsanalyse.
v5 is de eindversie van de lokale module.
Een modelobject rekent zonder hardwaretoegang.
Transport is een adapter met foutcontract.
Eisen verwijzen naar tests en versiebeheer.
De Pi krijgt adapters, een modelkern, predictor, planner, supervisor en logger. De Mega houdt exclusieve eigendom van lokale actuatoren. Nieuwe code wordt als manifest met toetsbewijs aangeboden.
In het kort
- Klassen bewaken samenhangende toestand en duidelijke invarianten.
- Samenstelling past bij ‘heeft’; overerving vraagt substitueerbaarheid.
- Interfaces beschrijven eenheid, tijd en foutgedrag.
- Unit tests behandelen grensgevallen; git en een manifest maken wijzigingen herleidbaar.
Wat je nu kent
- klasse
- Een type dat toestand en bijbehorende bewerkingen bundelt.
- object
- Een concrete instantie met eigen toestand en identiteit.
- samenstelling
- Een object gebruikt andere objecten als onderdelen.
- interfacecontract
- De afgesproken invoer, uitvoer, fouten en betekenis van een bewerking.
- unit test
- Een herhaalbare toets van een afgebakend softwareonderdeel met gecontroleerde afhankelijkheden.