Een kort overzicht van objectgericht softwareontwerp

Gedemonstreerd door de klassen van een rollenspel te implementeren

Zeppelin van Richard Wright

Invoering

De meeste moderne programmeertalen ondersteunen en stimuleren object-georiënteerd programmeren (OOP). Hoewel we de laatste tijd een kleine verschuiving hiervan zien, terwijl mensen talen gaan gebruiken die niet sterk worden beïnvloed door OOP (zoals Go, Rust, Elixir, Elm, Scala), hebben de meeste nog steeds objecten. De ontwerpprincipes die we hier gaan schetsen, zijn ook van toepassing op niet-OOP-talen.

Om te slagen in het schrijven van duidelijke, hoogwaardige, onderhoudbare en uitbreidbare code, moet u op de hoogte zijn van ontwerpprincipes die zich in tientallen jaren ervaring hebben bewezen.

Openbaarmaking: het voorbeeld dat we zullen doornemen zal in Python zijn. Voorbeelden zijn er om een ​​punt te bewijzen en kunnen op andere, voor de hand liggende manieren slordig zijn.

Objecttypen

Omdat we onze code rond objecten gaan modelleren, zou het handig zijn om onderscheid te maken tussen hun verschillende verantwoordelijkheden en variaties.

Er zijn drie soorten objecten:

1. Entiteitsobject

Dit object komt meestal overeen met een real-world entiteit in de probleemruimte. Stel dat we een rollenspel (RPG) bouwen, een entiteitobject zou onze eenvoudige Hero-klasse zijn:

Deze objecten bevatten over het algemeen eigenschappen over zichzelf (zoals gezondheid of mana) en kunnen via bepaalde regels worden gewijzigd.

2. Besturingsobject

Besturingsobjecten (soms ook Manager-objecten genoemd) zijn verantwoordelijk voor de coördinatie van andere objecten. Dit zijn objecten die andere objecten besturen en gebruiken. Een goed voorbeeld in onze RPG-analogie is de Fight-klasse, die twee helden bestuurt en ze laat vechten.

Het inkapselen van de logica voor een gevecht in zo'n klasse biedt je meerdere voordelen: een daarvan is de gemakkelijke uitbreidbaarheid van de actie. Je kunt heel gemakkelijk een non-player character (NPC) type doorgeven voor de held om te vechten, op voorwaarde dat deze dezelfde API blootlegt. Je kunt ook heel gemakkelijk de klasse erven en sommige functies negeren om aan je behoeften te voldoen.

3. Grensobject

Dit zijn objecten die op de grens van uw systeem zitten. Elk object dat invoer van of naar een ander systeem produceert - ongeacht of dat systeem een ​​Gebruiker, het internet of een database is - kan worden geclassificeerd als een grensobject.

Deze grensobjecten zijn verantwoordelijk voor het vertalen van informatie in en uit ons systeem. In een voorbeeld waarbij we gebruikersopdrachten nemen, hebben we het grensobject nodig om een ​​toetsenbordinvoer (zoals een spatiebalk) te vertalen naar een herkenbare domeingebeurtenis (zoals een karaktersprong).

Bonus: Waardeobject

Waardeobjecten vertegenwoordigen een eenvoudige waarde in uw domein. Ze zijn onveranderlijk en hebben geen identiteit.

Als we ze in ons spel zouden opnemen, zou een klasse Geld of Schade goed passen. Met deze objecten kunnen we gemakkelijk gerelateerde functies onderscheiden, vinden en debuggen, terwijl de naïeve benadering van het gebruik van een primitief type - een reeks gehele getallen of één geheel getal - dat niet doet.

Ze kunnen worden geclassificeerd als een subcategorie van entiteitsobjecten.

Belangrijkste ontwerpprincipes

Ontwerpprincipes zijn regels in softwareontwerp die zich door de jaren heen waardevol hebben bewezen. Als u ze strikt opvolgt, weet u zeker dat uw software van topkwaliteit is.

Abstractie

Abstractie is het idee om een ​​concept in een bepaalde context te vereenvoudigen tot de essentie. Hiermee kunt u het concept beter begrijpen door het te strippen tot een vereenvoudigde versie.

De bovenstaande voorbeelden illustreren abstractie - kijk hoe de Fight-klasse is gestructureerd. De manier waarop je het gebruikt is zo eenvoudig mogelijk - je geeft het twee helden als argumenten in instantiatie en roept de methode fight () aan. Niets meer niets minder.

Abstractie in uw code moet de regel van de minste verrassing volgen. Je abstractie zal niemand verbazen met onnodig en niet-gerelateerd gedrag / eigenschappen. Met andere woorden - het zou intuïtief moeten zijn.

Merk op dat onze Hero # take_damage () functie niet iets onverwachts doet, zoals ons karakter bij overlijden verwijderen. Maar we kunnen verwachten dat het ons karakter zal doden als zijn gezondheid onder nul gaat.

inkapseling

Inkapseling kan worden gezien als iets in een capsule stoppen - u beperkt de blootstelling ervan aan de buitenwereld. In software helpt het beperken van de toegang tot innerlijke objecten en eigenschappen met gegevensintegriteit.

Inkapseling black-boxes innerlijke logica en maakt uw klassen gemakkelijker te beheren, omdat u weet welk deel door andere systemen wordt gebruikt en wat niet. Dit betekent dat je de innerlijke logica gemakkelijk kunt bewerken terwijl je de openbare delen behoudt en er zeker van bent dat je niets hebt gebroken. Als bijwerking wordt het werken met de ingekapselde functionaliteit van buitenaf eenvoudiger omdat u minder dingen hebt om over na te denken.

In de meeste talen gebeurt dit via de zogenaamde toegangsmodificaties (privé, beveiligd, enzovoort). Python is hier niet het beste voorbeeld van, omdat het dergelijke expliciete modificaties mist die in de runtime zijn ingebouwd, maar we gebruiken conventies om dit te omzeilen. Het voorvoegsel _ van de variabelen / methoden geven aan dat ze privé zijn.

Stel je bijvoorbeeld voor dat we onze Fight # _run_attack-methode wijzigen om een ​​booleaanse variabele te retourneren die aangeeft of het gevecht is afgelopen in plaats van een uitzondering te genereren. We zullen weten dat de enige code die we mogelijk hebben gebroken binnen de klasse Fight is, omdat we de methode privé hebben gemaakt.

Vergeet niet dat code vaker wordt gewijzigd dan opnieuw geschreven. Je code kunnen wijzigen met zo duidelijk en zo weinig mogelijk repercussies is flexibiliteit die je als ontwikkelaar wilt.

Ontleding

Ontleding is de actie waarbij een object in meerdere afzonderlijke kleinere delen wordt gesplitst. Deze delen zijn gemakkelijker te begrijpen, te onderhouden en te programmeren.

Stel je voor dat we meer RPG-functies zoals buffs, inventaris, uitrusting en karakterattributen bovenop onze held wilden opnemen:

Ik neem aan dat je kunt zien dat deze code behoorlijk rommelig wordt. Ons Hero-object doet teveel dingen tegelijk en deze code wordt daardoor behoorlijk broos.

Eén uithoudingspunt is bijvoorbeeld 5 gezondheid waard. Als we dit in de toekomst ooit willen wijzigen om het gezondheid 6 waard te maken, moeten we de implementatie op meerdere plaatsen wijzigen.

Het antwoord is om het Hero-object te ontleden in meerdere kleinere objecten die elk een deel van de functionaliteit omvatten.

Een schonere architectuur

Nu, na het ontbinden van de functionaliteit van ons Hero-object in HeroAttributes, HeroInventory, HeroEquipment en HeroBuff-objecten, zal het toevoegen van toekomstige functionaliteit eenvoudiger, meer ingekapseld en beter geabstraheerd zijn. Je kunt zien dat onze code veel schoner en duidelijker is over wat het doet.

Er zijn drie soorten ontbindingsrelaties:

  • associatie - Definieert een losse relatie tussen twee componenten. Beide componenten zijn niet afhankelijk van elkaar, maar kunnen samenwerken.

Voorbeeld: Held en een Zone-object.

  • aggregatie - Definieert een zwakke “has-a” -relatie tussen een geheel en zijn delen. Beschouwd als zwak, omdat de delen zonder het geheel kunnen bestaan.

Voorbeeld: HeroInventory en Item.
Een HeroInventory kan veel items bevatten en een item kan tot elke HeroInventory behoren (zoals handelsartikelen).

  • compositie - Een sterke “has-a” -relatie waarbij het geheel en het deel niet zonder elkaar kunnen bestaan. De delen kunnen niet worden gedeeld, omdat het geheel afhangt van die exacte delen.

Voorbeeld: Hero en HeroAttributes.
Dit zijn de kenmerken van de held - je kunt de eigenaar niet wijzigen.

Generalisatie

Generalisatie is misschien wel het belangrijkste ontwerpprincipe - het is het proces waarbij gedeelde kenmerken worden geëxtraheerd en op één plek worden gecombineerd. We kennen allemaal het concept van functies en klasse-overerving - beide zijn een soort generalisatie.

Een vergelijking kan dingen ophelderen: terwijl abstractie de complexiteit vermindert door onnodige details te verbergen, vermindert generalisatie de complexiteit door meerdere entiteiten met vergelijkbare functies te vervangen door een enkele constructie.

In het gegeven voorbeeld hebben we de functionaliteit van onze gemeenschappelijke Hero- en NPC-klassen gegeneraliseerd in een gemeenschappelijke voorouder die Entity wordt genoemd. Dit wordt altijd bereikt door overerving.

In plaats van dat onze NPC- en Hero-klassen alle methoden tweemaal implementeerden en het DRY-principe schenden, hebben we de complexiteit verminderd door hun gemeenschappelijke functionaliteit naar een basisklasse te verplaatsen.

Als waarschuwing: overdrijf de erfenis niet. Veel ervaren mensen raden u aan om de voorkeur te geven aan compositie boven erfenis.

Overerving wordt vaak misbruikt door amateur-programmeurs, waarschijnlijk omdat het een van de eerste OOP-technieken is die ze begrijpen vanwege de eenvoud.

Samenstelling

Compositie is het principe van het combineren van meerdere objecten in een meer complexe. Praktisch gezegd - het maakt exemplaren van objecten en gebruikt hun functionaliteit in plaats van het rechtstreeks te erven.

Een object dat compositie gebruikt, kan een composietobject worden genoemd. Het is belangrijk dat deze composiet eenvoudiger is dan de som van zijn collega's. Bij het combineren van meerdere klassen in één willen we het abstractieniveau verhogen en het object eenvoudiger maken.

De API van het samengestelde object moet de binnenste componenten en de interacties daartussen verbergen. Denk aan een mechanische klok, deze heeft drie wijzers voor het weergeven van de tijd en één knop voor instelling - maar bevat intern tientallen bewegende en onderling afhankelijke onderdelen.

Zoals ik al zei, heeft compositie de voorkeur boven overerving, wat betekent dat je ernaar moet streven om gemeenschappelijke functionaliteit te verplaatsen naar een afzonderlijk object dat klassen vervolgens gebruiken - in plaats van het op te slaan in een basisklasse die je hebt geërfd.

Laten we een mogelijk probleem illustreren met overgeërfde functionaliteit:

We hebben zojuist beweging aan onze game toegevoegd.

Zoals we hebben geleerd, hebben we in plaats van de code te dupliceren, generalisatie gebruikt om de functies move_right en move_left in de klasse Entity te plaatsen.

Oké, wat nu als we mounts in de game wilden introduceren?

een goede berg :)

Mounts moeten ook naar links en rechts bewegen, maar kunnen niet aanvallen. Denk er eens over na - ze hebben misschien niet eens gezondheid!

Ik weet wat uw oplossing is:

Verplaats de verplaatsingslogica eenvoudig naar een afzonderlijke klasse MoveableEntity of MoveableObject die alleen die functionaliteit heeft. De klasse Mount kan dat dan erven.

Wat doen we dan als we bergen willen die wel gezond zijn maar niet kunnen aanvallen? Meer opsplitsen in subklassen? Ik hoop dat je kunt zien hoe onze klassenhiërarchie complex zou worden, hoewel onze bedrijfslogica nog steeds vrij eenvoudig is.

Een iets betere aanpak zou zijn om de bewegingslogica samen te vatten in een klasse Beweging (of een betere naam) en deze te instantiëren in de klassen die deze mogelijk nodig hebben. Dit zal de functionaliteit mooi inpakken en herbruikbaar maken voor alle soorten objecten, niet beperkt tot Entity.

Hoera, compositie!

Disclaimer voor kritisch denken

Hoewel deze ontwerpprincipes door tientallen jaren ervaring zijn gevormd, is het nog steeds uiterst belangrijk dat u kritisch kunt nadenken voordat u blindelings een principe op uw code toepast.

Zoals alle dingen kan teveel een slechte zaak zijn. Soms kunnen principes te ver worden doorgevoerd, kun je er te slim mee omgaan en eindigen met iets dat eigenlijk moeilijker is om mee te werken.

Als ingenieur is uw belangrijkste eigenschap uw vermogen om de beste aanpak voor uw unieke situatie kritisch te evalueren, niet blindelings te volgen en willekeurige regels toe te passen.

Cohesie, koppeling en scheiding van problemen

Samenhang

Cohesie staat voor de duidelijkheid van verantwoordelijkheden binnen een module of met andere woorden: de complexiteit ervan.

Als uw klas één taak uitvoert en niets anders, of een duidelijk doel heeft, heeft die klas een hoge cohesie. Aan de andere kant, als het enigszins onduidelijk is in wat het doet of meer dan één doel heeft, heeft het een lage cohesie.

U wilt dat uw klassen een hoge cohesie hebben. Ze zouden slechts één verantwoordelijkheid moeten hebben en als je merkt dat ze meer hebben - is het misschien tijd om het te splitsen.

Koppelen

Koppeling legt de complexiteit vast tussen het verbinden van verschillende klassen. U wilt dat uw klassen zo weinig en zo eenvoudig mogelijk verbinding maken met andere klassen, zodat u ze kunt uitwisselen in toekomstige evenementen (zoals het wijzigen van webframework). Het doel is om een ​​losse koppeling te hebben.

In veel talen wordt dit bereikt door intensief gebruik van interfaces - ze abstraheren de specifieke klasse die de logica hanteert en vertegenwoordigen een soort adapterlaag waarin elke klasse zichzelf kan aansluiten.

Scheiding van zorgen

Separation of Concerns (SoC) is het idee dat een softwaresysteem moet worden opgesplitst in onderdelen die elkaar niet overlappen qua functionaliteit. Of zoals de naam al zegt - bezorgdheid - een algemene term voor alles wat een oplossing voor een probleem biedt - moet op verschillende plaatsen worden gescheiden.

Een webpagina is hier een goed voorbeeld van - het heeft zijn drie lagen (Informatie, Presentatie en Gedrag) gescheiden in drie plaatsen (respectievelijk HTML, CSS en JavaScript).

Als je het RPG Hero-voorbeeld opnieuw bekijkt, zul je zien dat het in het begin veel zorgen had (buffs toepassen, aanvalschade berekenen, inventaris behandelen, items uitrusten, attributen beheren). We hebben die zorgen door ontbinding gescheiden in meer samenhangende klassen die hun details abstraheren en inkapselen. Onze Hero-klasse fungeert nu als een samengesteld object en is veel eenvoudiger dan voorheen.

Afbetalen

Het toepassen van dergelijke principes lijkt misschien te ingewikkeld voor zo'n klein stukje code. De waarheid is dat het een must is voor elk softwareproject dat u in de toekomst wilt ontwikkelen en onderhouden. Het schrijven van dergelijke code heeft in het begin wat overhead, maar op de lange termijn loont het meerdere keren.

Deze principes zorgen ervoor dat ons systeem meer is:

  • Uitbreidbaar: hoge cohesie maakt het eenvoudiger om nieuwe modules te implementeren zonder zorgen over niet-gerelateerde functionaliteit. Lage koppeling betekent dat een nieuwe module minder dingen hoeft aan te sluiten en daarom gemakkelijker te implementeren is.
  • Onderhoudbaar: Lage koppeling zorgt ervoor dat een wijziging in een module over het algemeen geen invloed heeft op andere. Hoge cohesie zorgt voor een wijziging van de systeemvereisten, waarbij zo min mogelijk klassen moeten worden aangepast.
  • Herbruikbaar: hoge cohesie zorgt ervoor dat de functionaliteit van een module compleet en goed gedefinieerd is. Lage koppeling maakt de module minder afhankelijk van de rest van het systeem, waardoor het gemakkelijker wordt hergebruikt in andere software.

Overzicht

We zijn begonnen met de introductie van enkele elementaire objecttypen op hoog niveau (Entity, Boundary en Control).

We hebben vervolgens belangrijke principes geleerd bij het structureren van genoemde objecten (abstractie, generalisatie, samenstelling, ontleding en inkapseling).

Om dit op te volgen hebben we twee maatstaven voor softwarekwaliteit geïntroduceerd (koppeling en cohesie) en hebben we geleerd wat de voordelen zijn van het toepassen van deze principes.

Ik hoop dat dit artikel een nuttig overzicht biedt van enkele ontwerpprincipes. Als je jezelf verder wilt leren op dit gebied, zijn hier enkele bronnen die ik zou aanbevelen.

Verdere lezingen

Ontwerppatronen: elementen van herbruikbare objectgeoriënteerde software - waarschijnlijk het meest invloedrijke boek ter wereld. Een beetje gedateerd in zijn voorbeelden (C ++ 98) maar de patronen en ideeën blijven zeer relevant.

Growing Object-Oriented Software Guided by Tests - Een geweldig boek dat laat zien hoe je principes uit dit artikel (en meer) praktisch kunt toepassen door een project te doorlopen.

Effectief softwareontwerp - Een topblog met veel meer dan ontwerpinzichten.

Softwareontwerp en architectuurspecialisatie - Een geweldige serie van 4 videocursussen die u effectief ontwerp leren tijdens de toepassing ervan op een project dat alle vier de cursussen omvat.

Als dit overzicht informatief voor u is geweest, overweeg dan om het aantal klappen te geven dat u denkt dat het verdient, zodat meer mensen erover kunnen struikelen en er waarde aan kunnen verdienen.