We schrijven allemaal wel eens code die op een later moment een punt van frustratie wordt voor onszelf, of onze collega’s. Soms copy-paste je een stuk code (dupliceren) en komt het er niet van om dit op te schonen. Of je maakt een ontwerp beslissing die in eerste instantie goed lijkt, maar waarvan later blijkt dat het een tijdrovende fout is geweest.
In deze blog zal ik twee grote frustratiepunten beschrijven, met de focus op Java, wanneer je code probeert te veranderen: dupliceren (copy-pasten) en het onnodig koppelen (coupling) ten gevolge van overerving (inheritance).
Het kan erg frustrerend zijn wanneer je denkt klaar te zijn met het aanbrengen van een verandering, om er dan achter te komen dat je dezelfde verandering op een tiental andere plekken in je code moet herhalen, voordat het goed zal werken.
Tijdens het ontwerpen van een systeem, zou je altijd het DRY principe moeten volgen: Elk stukje kennis moet een enkele, eenduidige, belangrijke vertegenwoordiging hebben binnen een systeem. Echter, betekent dit niet dat elke identieke regel tekst of code een duplicaat is. Het kan ook gewoon zo zijn dat de implementatie identiek is.
DRY gaat over het dupliceren van kennis, die onder andere voorkomt in:
· Documentatie
· Functionaliteit
· En natuurlijk, Code
Vaak vergeet men dat documentatie ook geüpdatet moet worden, samen met de veranderingen die je maakt aan je codebase. Het enige wat erger is dan geen documentatie, is verkeerde of verouderde documentatie. Verouderde documentatie kan misleidend zijn en kan je een hoop extra werk kosten.
Commentaar, of ‘comments’, is slechts een andere vorm van duplicatie. Functies en variabelen met duidelijke namen hebben geen comments nodig. Comments binnen methodes zijn een teken dat de methode te lang is. Deze zouden opgedeeld moeten worden in aparte methodes, met duidelijk beschrijvende namen.
Developers die niet weten wat hun teamgenoten aan het bouwen zijn, lopen het risico om vergelijkbare code te schrijven. Hierdoor dupliceren ze onbewust functionaliteit die al bestaat. Dit kan alleen worden verminderd door betere communicatie. Actieve en frequente communicatie tijdens de dagelijkse stand-ups, forums, etc. Het is dan ook verstandig om de source code en documentatie van anderen te lezen tijdens code reviews of informeel.
En dan nu: duplicatie in code. Het is het beste om klein te beginnen met het verwijderen hiervan. Allereerst lokaliseer en verwijder je kleinere stukjes gedupliceerde code in aparte methodes. Hierdoor wordt het makkelijker om daarna de grotere stukken van gedupliceerde code te lokaliseren en verwijderen. Vergeet niet om test units te draaien na elke grote verandering, zodat je niet elke keer te veel werk moet terugdraaien.
Verplaats gedupliceerde code van verschillende klassen, naar zijn eigen klasse. Na het verwijderen, moet de nieuwe klasse gekoppeld worden aan de klasse die gebruikt wordt. In Java heb je twee opties:
• Optie 1:een 'has a'-relatie. De originele klassen kunnen een fractie van de nieuwe klasse krijgen als een private attribute. Dit wordt ook wel composition of delegation genoemd, omdat je functionaliteit delegeert aan componenten van een andere klasse.
• Optie 2: Een "is-a" -relatie. De originele klassen kunnen de nieuwe klasse uitbreiden.
Het belangrijkste om rekening mee te houden wanneer je een implementatieoptie kiest, is: Erfbelasting. Je moet nog een verandering maken en denkt dat je klaar bent. Maar helaas, je komt erachter dat je zojuist de code op een tiental andere plekken hebt gebroken, omdat meerdere klassen sterk met elkaar gekoppeld zijn.
Gelukkig zijn er een aantal goede methodes als het gaat om het verminderen van onnodige koppelingen. Eén daarvan is: Betaal geen erfbelasting. Inheritance/overerving is een vorm van koppeling en moet dus vermeden worden indien mogelijk.
Waarschijnlijk is dit niet wat je geleerd hebt. Inheritance gebruiken voor gedragssamenstelling en polymorfisme is een gangbare praktijk die je in elk OOP-handboek aantreft. In de praktijk heeft het echter een aantal ernstige nadelen.
Allereerste: dit zul je je misschien niet realiseren, maar wanneer een klasse is overerft, ontstaat er koppeling tussen meerdere concrete classes. Een kind klasse is gekoppeld aan een ouder klasse, een "grootouder klasse" enzovoorts. Maar elk object dat dit kind klasse gebruikt, is ook gekoppeld aan al zijn voorouders.
Ten tweede: in een beperkt domein is het logisch dat een auto een soort voertuig is. Maar zodra het domein waarin je werkt gecompliceerder en realistischer wordt, worden deze modellen complexer en bijna onmogelijk te vormen. Bijvoorbeeld: wat gebeurd er wanneer een auto ook een soort eigenvermogen, te verzekeren object, etc. is. In dit geval heeft de auto dus meerdere overervingen nodig. Dat is niet alleen onmogelijk in sommige talen, zoals Java, maar indien het wel mogelijk is, eindig je vaak met complexe modellen die je alleen maar hoofdpijn bezorgen.
In plaats daarvan, kun je één van deze technieken gebruiken, zodat je nooit meer inheritance nodig hebt:
Interfaces: het implementeren van interfaces geeft je polymorfisme zonder inheritance. Deze objecten bevatten normaal gesproken geen implementatie en een klasse kan een oneindig aantal implementeren
Delegation: door het implementeren van ‘class instance’ als een attribute, kun je alle onnodige methodes van de klasse die je niet gebruikt verbergen. Hiermee verminder je grotendeels de complexiteit. Dit betekent echter wel dat je meer code zal moeten schrijven.
Mixins: het principe van een mixin is simpel. Je wil klassen en objecten kunnen uitbreiden met nieuwe functionaliteit, zonder inheritance te gebruiken. Stel dat je twee gerelateerde functies zou hebben, genaamd 'find(id)' en 'findAll()', en je wil dat meerdere klassen in staat zijn om deze functies te gebruiken. Je wil natuurlijk geen inheritance gebruiken, maar delegation voegt weer onnodige code toe. Als de taal waarin je programmeert over deze optie beschikt, zou het er ongeveer zo uitzien:
// in sudo code
mixin CommonFinders {
def find(id){ … }
def findAll(){ … }
}
class AccountRecord implements BasicRecord with CommonFinders { … }
Dit is helaas niet beschikbaar in Java, maar sinds Java 8 kunnen we interfaces definiëren met (public) default methodes. Hierdoor kunnen we methodes toevoegen met implementaties aan interfaces. In Java 9 is de mogelijkheid toegevoegd om private methods in interfaces te gebruiken, wat het mogelijk maakt om schone default methods te hebben. In Java kunnen we een mixin die een interface gebruikt op de volgende manier nastreven:
// in Java 9+ code
public interface CommonFindersMixIn {
default Record find(String id) { … }
default Record findAll() { … }
// some private methods
// private void example () { … }
}
class AccountRecord implements BasicRecord, CommonFindersMixin { .. }
Eén probleem blijft bestaan. Omdat private attributes nog steeds niet toegestaan zijn binnen interfaces, beperkt dit de opties die je hebt wanneer je complexe methodes schrijft. In dit geval zal je dus geen andere keuze hebben dan delegation te gebruiken en dus nog een beetje meer code te moeten schrijven.
Duplicatie en inheritance zijn niets meer dan anti-patterns. Ze hebben voordelen op de korte termijn, maar bezorgen je uiteindelijk alleen maar problemen. Clean code is niet alleen beknopt en zonder duplicatie, maar het vergroot ook geen concrete classes tenzij dit absoluut noodzakelijk is.
Referenties
David Thomas and Andrew Hunt (2020): The Pragmatic Programmer.
Michael Feathers (2005): Working Effectively with Legacy Code.
Nicolo Pignatelli (2018): Inheritance Is Evil. Stop Using It.