Informatietechnologie
nog eens goed uitgelegd
softwareontwikkeling | realisatie | laatst bijgewerkt op 2022-11-04

SOLID

Een set van principes die softwareontwikkelaars stimuleert in discussie te gaan over hoe de software ontworpen dient te worden.

In softwareontwikkeling is er een groot aantal principes waarover je kunt nadenken en eventueel je ontwerp aan kunt aanpassen. Het idee van deze principes is om softwareontwikkelaars in discussie te laten gaan over hoe de software ontworpen dient te worden. De meeste principes leveren ook geen hapklare oplossing die je zo over kunt nemen, en het doel is derhalve ook niet om alles precies op een dezelfde manier te implementeren, maar juist afhankelijk van de casus een zo goed mogelijke (of zomin mogelijk slechte) oplossing te verzinnen voor een probleem. De principes zijn dus meer richtlijnen dan verplichte regels, en het is prima mogelijk dat je je bewust niet aan de richtlijn houdt, om een goede reden. Het idee is dan wel dat je deze goede reden ergens documenteert zodat je collega's ook begrijpen waarom je het zo gedaan hebt. Het is een theorie van de orde "zou eigenlijk moeten", maar het kan best zijn dat we het toch anders doen. Softwareontwerpen is een beetje een kunst, er is niet 1 allesomvattende methode die voor elk probleem de juiste oplossing biedt. Het gaat om het afwegen van verschillende zaken en mogelijke oplossingen en daar een weg in te kiezen die je op dit moment met de huidige kennis van de context, zo goed mogelijk kunt onderbouwen. Het is dus per definitie een niet-exacte wetenschap, waarmee je een beetje kunt spelen.

De naam SOLID staat voor 'Single Responsibility', 'Open-Closed', 'Liskov substitution', 'Interface segregation' en 'Dependency inversion', in het Nederlands losjes vertaald als 'Enkelvoudige verantwoordelijkheid', 'Open, maar gesloten', 'Liskov-uitwisseling', 'Interface-segregatie' en 'Afhankelijkheidsomkering'. Waarbij deze vijf principes een richtlijn geven waaraan elk stuk software eigenlijk zou moeten voldoen. In andere woorden: mocht je software ontwerpen die strijdig is met 1 van deze principes, dan mag dat best, maar dien je toe te lichten waarom je het zo ontworpen hebt. Mocht die toelichting ontbreken, dan is dat dus eigenlijk verkeerd. SOLID daagt je uit om na te denken over wat en waarom, maar niet zo zeer over hoe.

Single Responsibility

Het Single Responsibility Principe (SRP) zegt dat elk (in de software gedefinieerd) type maar 1 duidelijk afgebakende complete verantwoordelijkheid zou moeten hebben. Soms lees je ook dat een klasse derhalve bij gevolg dus maar 1 reden mag hebben om te mogen veranderen. De 'reden om te veranderen' is een beetje vaag, maar stel je het volgende voor: Ik maak een applicatie die door de gebruiker ingevoerde waardes verzamelt in 1 ingevuld formulier, vervolgens aan de hand van dat formulier een rapport genereert en vervolgens dat rapport opslaat als een bestand op disk. Natuurlijk kan ik dit allemaal in 1 klasse definiëren en gaat het prima werken, maar dit zijn eigenlijk 3 verschillende verantwoordelijkheden. Het achterliggende idee van het SRP is dat software regelmatig wijzigt, en als ik ervoor zou kunnen zorgen dat bij een bepaalde wijziging zo min mogelijk klassen betrokken zijn, dan hoef ik ook niet alles helemaal opnieuw te testen. Dat wat niet verandert, hoeft niet weer getest te worden. Dus in de meest slechte wereld levert elke wijziging een verandering op in alle klassen die ik heb, een ware hel voor de testers. Het tegenovergestelde daarvan is dat een bepaalde wijziging altijd maar precies 1 verandering in 1 klasse oplevert. En dat laatste is het doel van het SRP. In het bovengenoemde voorbeeld, stel je voor dat het rapport op een andere manier geformuleerd dient te worden, maar het vergaren van gebruikersinvoer en het opslaan op disk veranderen niet. Als ik deze drie verantwoordelijkheden in drie verschillende klassen gedefinieerd heb, dan hoeven er twee niet te veranderen.

full fig.1: Het bovenstaande voorbeeld van SRP schematisch weergegeven. Links versie 1 en rechts versie 2 waarin de wijziging in de operatie generateReport doorgevoerd is, boven zonder SRP en onder met SRP. Omdat in het onderste voorbeeld twee klassen niet aangepast zijn, hoeven deze niet opnieuw getest te worden.

Open-closed

Het Open-Closed Principle (OCP) heeft een wat minder zuivere definitie omdat het idee al wat langer bestaat en er in de loop van tijd wat aan gesleuteld is, maar in grote lijnen betekent het dat een klasse open moet staan voor uitbreiding, maar gesloten moet zijn voor aanpassing. In andere woorden: Het moet mogelijk zijn om de verantwoordelijkheid van de klasse in een andere context te vergroten, zonder dat de klasse daardoor niet meer aan de huidige verantwoordelijkheid zou kunnen voldoen en dus in de eerdere context niet meer naar behoren werkt. Een aardig voorbeeld uit de werkelijke wereld is een boormachine, wat in feite niet meer is dan een elektromotor die een kop laat ronddraaien. Door op die kop een boor te zetten gaat de machine gaatjes boren, maar het is ook mogelijk om er een schroefbit op te zetten zodat het een schroevendraaier wordt. En tevens zijn er allerlei andere kopjes te koop zodat je de boormachine bijvoorbeeld ook als schuurmachine zou kunnen gebruiken. Echter, de machine zelf is een gesloten geheel. Het is niet de bedoeling dat je aan de binnenkant van de machine iets aanpast zodat de elektromotor anders zou gaan werken. De machine zelf wordt als een werkend systeem geleverd en jij mag die werking uitsluitend uitbreiden, niet aanpassen. In softwareontwikkeling, en daarmee in het OCP, is het eigenlijk net zo, je dient je klassen zo te definiëren dat iemand anders (of jij zelf later) de klasse zou kunnen uitbreiden, maar op zo een manier dat de kernverantwoordelijkheid hierdoor niet op de tocht komt te staan. Er is in UML geen manier om specifiek aan te duiden op welke wijze een klasse voldoet aan het OCP. In UML wordt er namelijk van uitgegaan dat alle klassen dit per definitie doen en je dit dus niet expliciet hoeft aan te geven. Het is dus per definitie niet mogelijk om in een afgeleide klasse een operatie van de basisklasse te overstemmen (override). Wanneer het de intentie is van de onderwerper dat dit wel mogelijk is, dan dient de operatie in de basisklasse abstract gemarkeerd te worden, en heeft deze geen implementatie meer. De constructie waarin de basisklasse een standaardimplementatie heeft die in een afgeleide klasse overstemt mag worden (dit is een valide, doch redelijk zeldzame constructie) kan in UML niet eenduidig aangegeven worden. Ik stel voor dat je dit dan met een notitie aangeeft of omschrijft in de toelichting bij het klassendiagram.

Helaas zijn er programmeertalen waarin het OCP niet handig verwerkt is, Java is hiervan een goed voorbeeld. In deze talen dien je namelijk expliciet aan te geven welke stukken van de klasse gesloten zijn, in plaats van dat je aangeeft welke stukken open zijn. In Java doe je dit bijvoorbeeld met een sleutelwoord final. Het nadeel van deze aanpak is dat wanneer ik per ongeluk vergeet iets als gesloten te markeren, het plotseling open is en een afgeleide klasse het zou kunnen aanpassen. In andere programeertalen dien ik iets expliciet open te markeren voordat een afgeleide klassen het zou kunnen overstemmen (override). Mocht ik dit vergeten, dan is dit misschien onhandig, maar niet direct schadelijk. Daarnaast zijn er (modernere) programmeertalen waarin overerving niet mogelijk is (bijvoorbeeld Go), en wordt dit probleem ontweken. Deze talen bieden uitbreidbaarheid aan in de vorm van compositie waarin het niet mogelijk is om een operatie te overstemmen, maar alleen een nieuwe variant toegevoegd kan worden.

Liskov substitution

Het Liskov Substitution Principle (LSP) is een relatief eenvoudig idee, dat in de meeste moderne objectgeoriënteerde programmeertalen min of meer vanzelf goed gaat. Het idee achter het principe is dat een object van type X als parameter, vervangen moet kunnen worden door een object van type Y, mits Y een subklasse is van X. In andere woorden: Stel ik maak een methode die als parameter een object van het type Auto verwacht, en later maak ik een klasse die afgeleid is van Auto, bijvoorbeeld Taxi. In dat geval moet de eerder genoemde methode correct blijven werken ook als ik een object van het type Taxi als parameter meegeef. In de beginjaren van objectgeoriënteerd programmeren was dit wel ooit een probleem omdat de programmeertaal dit soort gedrag niet afdwong. Nieuwere talen dwingen dit per definitie af en het is niet zo gemakkelijk om dit verkeerd te implementeren. Het LSP wordt daarom tegenwoordig meer gebruikt in de ontwerpfase om goed te beredeneren of er überhaupt een overervingsstructuur aan de orde is, dus om te bepalen of klasse Y uit mijn voorbeeld hierboven eigenlijk wel een subklasse is van klasse X. Het idee is dan dat wanneer er een operatie in het systeem bestaat die wel goed om kan gaan met X maar niet met Y, dat wellicht Y geen subtype is van X en er dus geen sprake is van overerving. In een dergelijk geval dien je aan een andere structuur te denken, bijvoorbeeld compositie. Nu is het per definitie wel goed om vaker compositie te kiezen in gevallen waar overerving ook zou kunnen, omdat dit de uitbreidbaarheid van de applicatie te goede komt, dus wellicht dat het LSP je kan helpen in het onderbouwen van een dergelijke keuze.

Interface segregation

Voor veel software-ontwikkelaars is het idee achter het Interface segregation principe (ISP) lastig toe te passen, omdat het veronderstelt dat je kennis hebt van de gebruiker van je software en in de werkelijkheid is deze kennis vaak erg beperkt. Ook wordt meestal niet een menselijke gebruiker bedoelt, maar juist een andere applicatie (client). Het principe zegt dat jouw software voor elk type gebruiker (client) een eigen interface zou moeten definiëren. Om het nog een stapje verwarrender te maken betekent interface op dit niveau niet hetzelfde als een interface in je programmeertaal. Wellicht als je het woord 'interface' leest denk je aan zo'n definitie van methoden die door een andere klasse geïmplementeerd kan worden, wat terecht is, want die dingen heten inderdaad 'interface', maar dat is niet wat hier bedoeld wordt. Hier is de definitie van interface "de verzameling van publieke operaties die jouw package beschikbaar stelt". Stel je voor, ik maak een magazijnbeheerapplicatie. In de analyse heb ik ontdekt dat er verschillende use-cases zijn, denk hierbij aan voorraad toevoegen, rapportages opmaken, productcategorie toevoegen enz enz. Ik programmeer al deze use-cases en zet ze samen in 1 package. Dit is logische want de magazijnbeheerapplicatie is 1 ding. Echter, wanneer mijn collega die de verschillende user-interfaces gaat maken voor deze applicatie, nu in zijn of haar ontwikkelstudio mijn package importeert, dan produceert deze een lijst van alle publieke operaties. Het gekke is nu dat wanneer mijn collega bezig is met de user-interface voor de rapporteur er ook toegang is tot operaties die de rapporteur eigenlijk niet doet, denk aan het toevoegen van voorraad, of wellicht iets uit voorraad nemen of zo. Hierdoor heeft de user-interfaceapplicatie voor de rapporteur plotseling een afhankelijkheid naar iets dat niet nodig is, en mocht in de toekomst mijn package veranderen, dan heeft dat mogelijk gevolgen voor deze applicatie die dan opnieuw getest dient te worden. Het zou dan helemaal van de dolle zijn als mijn veranderingen uitsluitend in die operaties zijn die deze user-interfaceapplicatie helemaal niet gebruikt, want dan is het testen misschien helemaal voor niets. Het interface-segregation principe zegt dat je na moet denken over de mogelijke gebruikers (client) van jouw package en de publieke operaties van jouw package zo op moet delen dat geen enkele gebruiker operaties aangeboden krijgt die niet voor zijn of haar rol geschikt zijn. In het geval van de magazijnbeheerapplicatie zou ik mijn operaties misschien opdelen in: deze zijn geschikt voor de voorraadbeheerdapplicatie, deze zijn geschikt voor de rapporteursapplicatie, en deze zijn geschikt voor de systeembeheerapplicatie. Het moeilijke van dit principe zit hem in het feit dat ik meestal niet van te voren goed weet welke clients er straks met mijn package aan de slag gaan, en daarom uit voorzichtigheid maar alles aan iedereen aanbied in de veronderstelling dat je beter te veel kunt hebben, dan te weinig. Maar dat laatste is dus precies niet waar.

Soms wordt het ISP ook gebruikt in stand-alone N-tier-applicaties tussen de lagen van de applicatie. Als dit volgens de softwareontwikkelaars een onderbouwbaar voordeel oplevert dan lijkt me dit prima, maar persoonlijk ben ik daar niet zo'n voorstander van. Het idee van een stand-alone N-tier-applicatie is dat het systeem na het compileren een monoliet is (in tegenstelling tot gedistribueerde applicaties die in stukjes als 1 systeem samenwerken), maar alvorens het compileren een gehele laag vervangen kan worden door een andere gehele laag. Als ik op het punt sta om een laag te vervangen, is het wel handig dat ik goed weet op welke punten ik de nieuwe laag dien aan te sluiten. Ik zie dit een beetje als een auto met aanhanger, waarbij het handig is om te weten welke stekkertjes ik, naast de trekhaak, dien aan te sluiten alvorens de aanhanger goed vast zit. Als dit dan heel veel verschillende, erg specifieke stekkertjes zijn dan is het vinden van een geschikte aanhanger voor mijn auto bijzonder lastig. Daarnaast lijkt het me ook onzin om meerdere verschillende aanhangers te vinden die elk een paar stekkertjes aansluiten, als dit überhaupt al gaat werken. Het is bij stand-alone N-tier of 'geheel alles' of 'geheel niets', de gehele laag dient vervangen te worden. Als dit maar 1 duidelijk gedefinieerde stekker is, die of past, of niet, dan maakt dit het proces van aansluiten gemakkelijker. Bovendien, maakt het voor de software niet uit, want deze wordt toch als monoliet gecompileerd. Het is vooral voor de mens belangrijk om te zien of laag X op laag Y past. Let op dat het bij gedistribueerde systemen, die wel als losse onderdelen samenwerken om 1 geheel te vormen, natuurlijk een ander verhaal is en daar interfacesegregatie op rolniveau mogelijk essentieel is.

Dependency inversion

Het Dependency Inversion Principle (DIP) stelt dat "hoger" gelegen componenten van de software niet afhankelijk mogen zijn van "lager" gelegen componenten van de software, maar juist andersom. Waarbij "hoger" en "lager" een beetje vage termen zijn, maar je kunt hierbij denken aan dat wanneer een component "dichter bij de kern activiteit" is, dan is deze hoger. Ooit wordt ook wel gesteld dat "hoger" betekent "dichter bij de gebruiker", maar dat heeft als nadeel dat de gebruikersinterface dan de hoogste component zou zijn, wat wellicht onhandigheden oplevert tijdens de implementatie als de kern van de applicatie plots een afhankelijkheid naar de gebruikersinterface zou krijgen, maar geheel onmogelijk is dit niet. De bedoeling van het DIP is dat een consument niet afhankelijk mag zijn van een leverancier. Dit voelt misschien initieel wat vreemd omdat in de wereld heel veel consumenten afhankelijk zijn van hun leverancier, maar eigenlijk is dat niet zo. Leveranciers zijn namelijk afhankelijk van hun consumenten. Wanneer een leverancier geen consumenten meer heeft, sluit deze direct, maar een consument zou, bij het wegvallen van een leverancier, gewoonlijk overschakelen op een andere leverancier. De crux zit hem in de behoefte. De consument dicteert een bepaalde behoefte: "Ik ben op zoek naar een leverancier die X, Y en Z kan leveren". En daaraan voldoen dan verschillende leveranciers. Afhankelijk van bepaalde voorwaarden kiest de consument een bepaalde leverancier en de samenwerking start. Mocht er in de toekomst een reden zijn om van leverancier te wisselen dan kan dat, want de relatie tussen de consument en de leverancier is ontkoppelt. Dat laatste is ook direct het doel van het DIP. Stel dat ik een softwarecomponent maak die een afhankelijkheid heeft naar 1 specifieke andere softwarecomponent als leverancier voor bepaalde informatie, bijvoorbeeld een dataleverancier die gegevens uit een database haalt. Het gevolg van deze afhankelijkheid is dat mijn component alleen kan functioneren als de datacomponent aanwezig is en ook functioneert. Mocht er iets zijn waardoor deze niet kan functioneren dan is plots mijn component ook nutteloos.

full fig.2: Traditionele opzet waarin een consument afhankelijk is van een leverancier. De dependency tussen de packages is van Application naar Data, wat als gevolg heeft dat de Application-package altijd alleen maar met deze specifieke Data-package kan samenwerken.

Bovenstaande afhankelijk wil ik liever niet, en ik zou daarom mijn component willen ontkoppelen van de datacomponent, zodat ik altijd kan wisselen naar een andere datacomponent. Om dit te kunnen bewerkstelligen is het belangrijk dat ik definieer wat mijn component precies nodig heeft van de leverancier, zonder dat ik iets concreets van die leverancier specificeer. Elke leverancier is in principe geschikt, mits deze voldoet aan mijn vereisten. Deze vereisten zet ik in een interface en elke datacomponent die mijn interface implementeert, mag mijn dataleverancier zijn. Op deze manier draait de afhankelijkheid zich om.

full fig.3: De dependency omgedraaid. De Application-package en de Data-package zijn ontkoppelt want het maakt nu voor de consument niet uit welke klasse de leverancier is, zolang deze maar voldoet aan de interface.

Het DIP heeft de term "inversion" in zich omdat je van nature wellicht zou denken dat een consument afhankelijk is van een leverancier en als je je software zo ontwikkelt dan werkt dat prima. Het nadeel zit in het feit dat dit minder flexibel is en je niet afhankelijk van de situatie van leverancier kunt wisselen. Het is dus beter om deze afhankelijkheid om te draaien (inverse) en te stellen dat verschillende leveranciers afhankelijk zijn van de consument. Bij gevolg dient deze consument dan te specificeren wat de vereisten zijn waaraan de verschillende leveranciers dienen te voldoen zodat meerdere leveranciers geschikt kunnen zijn.

full fig.3: De ontkoppeling in werking, waarin elke leverancier die voldoet aan de interface, geschikt is als leverancier voor de consument. De specifieke Data-package kan vervangen worden door een andere Data-package (alternatief) die dezelfde functionaliteit aanbiedt, maar misschien concreet op een andere wijze geïmplementeerd is.

Conclusie

SOLID biedt een vijftal principe die je in staat stellen om over je eigen ontwerpen na te denken. Veel ontwerpproblemen bestaan al wat langer en er zijn daarom richtlijnen bedacht over hoe je het beste met deze problemen kunt omgaan. Uiteraard blijft het ontwerpen van software ook een beetje een kunst en is er niet altijd precies 1 perfecte oplossing voor het probleem dat je probeert op te lossen. SOLID helpt je om "in de juiste richting" te denken en helpt je om te overleggen met je collega's. Het is echter prima valide om een oplossing te kiezen die strijdig is met de principes van SOLID, maar dan dien je dit te onderbouwen in je documentatie.