SOLID Deel 2 - Het open / gesloten principe

Single Responsibility (SRP), Open / Closed (OCP), Liskov's Substitution, Interface Segregation en Dependency Inversion. Vijf behendige principes die je elke keer moeten begeleiden als je code moet schrijven.

Definitie

Software-entiteiten (klassen, modules, functies, enz.) Zouden open moeten staan ​​voor uitbreiding, maar mogen niet worden gewijzigd.

Het open / gesloten principe, OCP in het kort, wordt gecrediteerd aan Bertrand Mayer, een Franse programmeur, die het voor het eerst publiceerde in zijn boek n Object-Oriented Software Construction in 1988.

Het principe steeg in populariteit in de vroege jaren 2000 toen het een van de SOLID-principes werd, gedefinieerd door Robert C. Martin in zijn boek Agile Software Development, Principles, Patterns and Practices en later opnieuw gepubliceerd in de C # -versie van het boek Agile Principles, Patterns en Praktijken in C #.

Waar we het in feite over hebben, is om onze modules, klassen en functies zo te ontwerpen dat wanneer een nieuwe functionaliteit nodig is, we onze bestaande code niet moeten wijzigen, maar eerder nieuwe code moeten schrijven die door bestaande code zal worden gebruikt. Dit klinkt een beetje vreemd, vooral als we werken in talen zoals Java, C, C ++ of C #, waar het niet alleen van toepassing is op de broncode zelf, maar ook op het binaire bestand. We willen nieuwe functies maken op manieren die niet vereisen dat we bestaande binaire bestanden, uitvoerbare bestanden of DLL's opnieuw implementeren.

OCP in de SOLID-context

Naarmate we verdergaan met deze tutorials, kunnen we elk nieuw principe in de context plaatsen van de al besproken. We hebben al gesproken over de Single Responsibility (SRP) die beweerde dat een module maar één reden hoeft te hebben om te veranderen. Als we nadenken over OCP en SRP, kunnen we constateren dat ze complementair zijn. Een code die specifiek is ontworpen met SRP in gedachten, komt in de buurt van de OCP-principes of is gemakkelijk in overeenstemming met die principes. Wanneer we code hebben die één enkele reden heeft om te veranderen, zal de introductie van een nieuwe functie een secundaire reden voor die wijziging creëren. Dus zowel SRP als OCP zouden worden geschonden. Op dezelfde manier, als we code hebben die alleen zou moeten veranderen als de hoofdfunctie verandert en ongewijzigd zou moeten blijven als een nieuwe functie eraan wordt toegevoegd, dus OCP respecterend, zal SRP meestal ook respecteren.

Dit betekent niet dat SRP altijd leidt tot OCP of omgekeerd, maar in de meeste gevallen als een van hen wordt gerespecteerd, is het bereiken van de tweede vrij eenvoudig.

Het voor de hand liggende voorbeeld van OCP-schending

Vanuit puur technisch oogpunt is het Open / Gesloten-principe heel eenvoudig. Een eenvoudige relatie tussen twee klassen, zoals de onderstaande, is in strijd met de OCP.


De Gebruiker class gebruikt de Logica klasse direct. Als we een seconde moeten implementeren Logica klasse op een manier die ons in staat stelt om zowel de huidige als de nieuwe te gebruiken, het bestaande Logica klas moet worden gewijzigd. Gebruiker is direct gekoppeld aan de implementatie van Logica, er is geen manier voor ons om een ​​nieuwe te bieden Logica zonder de huidige te beïnvloeden. En als we het hebben over statisch getypeerde talen, is het heel goed mogelijk dat de Gebruiker klas vereist ook veranderingen. Als we het hebben over gecompileerde talen, dan zijn het zeker beide Gebruiker uitvoerbaar en de Logica uitvoerbare of dynamische bibliotheek vereist hercompilatie en herschikking naar onze klanten, een proces dat we waar mogelijk willen vermijden.

Toon mij de code

Alleen gebaseerd op het schema hierboven, kan men afleiden dat elke klasse die rechtstreeks een andere klasse gebruikt, feitelijk het open / gesloten principe zou schenden. En dat klopt, strikt genomen. Ik vond het heel interessant om de limieten te vinden, het moment waarop je de grens trekt en besluit dat het moeilijker is om OCP te respecteren dan bestaande code te wijzigen, of de architecturale kosten rechtvaardigen niet de kosten van het veranderen van bestaande code.

Laten we zeggen dat we een klasse willen schrijven die vooruitgang kan bieden als een percentage voor een bestand dat is gedownload via onze applicatie. We zullen twee hoofdklassen hebben, een Vooruitgang en een het dossier, en ik kan me voorstellen dat we ze willen gebruiken zoals in de onderstaande test.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ file-> length = 200; $ bestand-> verzonden = 100; $ voortgang = nieuwe voortgang ($ bestand); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

In deze test zijn we een gebruiker van Vooruitgang. We willen een waarde krijgen als percentage, ongeacht de daadwerkelijke bestandsgrootte. We gebruiken het dossier als de bron van informatie voor onze Vooruitgang. Een bestand heeft een lengte in bytes en een veld met de naam verzonden de hoeveelheid gegevens vertegenwoordigen die wordt verzonden naar degene die de download doet. Het maakt ons niet uit hoe deze waarden in de toepassing worden bijgewerkt. We kunnen aannemen dat er een magische logica is die het voor ons doet, dus in een test kunnen we ze expliciet instellen.

class File public $ length; public $ verzonden; 

De het dossier class is gewoon een eenvoudig data-object dat de twee velden bevat. Natuurlijk zou het in het echte leven waarschijnlijk ook andere informatie en gedrag bevatten, zoals bestandsnaam, pad, relatief pad, huidige directory, type, permissies enzovoort.

class Progress private $ file; function __construct (Bestand $ bestand) $ dit-> bestand = $ bestand;  functie getAsPercent () return $ this-> file-> sent * 100 / $ this-> file-> length; 

Vooruitgang is gewoon een klas die een het dossier in zijn constructor. Voor de duidelijkheid hebben we het type variabele in de parameters van de constructor opgegeven. Er is één handige methode voor Vooruitgang, getAsPercent (), waarmee de verzonden waarden en lengte worden overgenomen het dossier en transformeer ze in een percentage. Eenvoudig, en het werkt.

Testen begonnen om 17:39 uur ... PHPUnit 3.7.28 door Sebastian Bergmann ... Tijd: 15 ms, geheugen: 2.50Mb OK (1 test, 1 bewering)

Deze code lijkt in orde te zijn, maar het is in strijd met het Open / Gesloten principe. Maar waarom? En hoe?

Vereisten wijzigen

Elke applicatie die naar verwachting in de tijd zal evolueren, heeft nieuwe functies nodig. Een nieuwe functie voor onze toepassing zou kunnen zijn om het streamen van muziek toe te staan, in plaats van alleen het downloaden van bestanden. het dossierDe lengte wordt weergegeven in bytes, de duur van de muziek in seconden. We willen een mooie voortgangsbalk bieden aan onze luisteraars, maar kunnen we degene die we al hebben hergebruiken?

Nee we kunnen niet. Onze vooruitgang is gebonden aan het dossier. Het begrijpt alleen bestanden, hoewel het ook op muziekcontent kan worden toegepast. Maar om dat te doen, moeten we het aanpassen, we moeten maken Vooruitgang weten over Muziek en het dossier. Als ons ontwerp OCP respecteert, hoeven we het niet aan te raken het dossier of Vooruitgang. We zouden gewoon het bestaande kunnen hergebruiken Vooruitgang en pas het toe op Muziek.

Oplossing 1: profiteer van de dynamische aard van PHP

Dynamisch getypeerde talen hebben het voordeel dat ze de soorten objecten tijdens runtime raden. Hiermee kunnen we het typehint verwijderen van Vooruitgang'constructor en de code zal nog steeds werken.

class Progress private $ file; function __construct ($ file) $ this-> file = $ file;  functie getAsPercent () return $ this-> file-> sent * 100 / $ this-> file-> length; 

Nu kunnen we alles gooien Vooruitgang. En met alles bedoel ik letterlijk alles:

class Music public $ length; public $ verzonden; openbare $ artiest; openbaar $ album; public $ releaseDate; functie getAlbumCoverFile () terug 'Afbeeldingen / Omslagen /'. $ this-> artiest. '/'. $ dit-> album. '.Png'; 

En een Muziek Klasse zoals die hierboven zal prima werken. We kunnen het eenvoudig testen met een erg vergelijkbare test het dossier.

function testItCanGetTheProgressOfAMusicStreamAsAPercent () $ music = new Music (); $ muziek-> lengte = 200; $ muziek-> verzonden = 100; $ progress = new Progress ($ music); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Dus eigenlijk kan elke meetbare inhoud worden gebruikt met de Vooruitgang klasse. Misschien moeten we dit in code aangeven door de naam van de variabele ook te wijzigen:

class Progress private $ meetableContent; function __construct ($ meetableContent) $ this-> meetableContent = $ meetableContent;  functie getAsPercent () return $ this-> meetableContent-> sent * 100 / $ this-> meetableContent-> length; 

Goed, maar we hebben een enorm probleem met deze aanpak. Toen we het hadden het dossier gespecificeerd als een typehint, waren we positief over wat onze klasse aankan. Het was expliciet en als er iets anders binnenkwam, vertelde een mooie fout ons dat.

Argument 1 doorgegeven aan Progress :: __ construct () moet een exemplaar zijn van Bestand, exemplaar van Muziek gegeven.

Maar zonder het typehint, moeten we vertrouwen op het feit dat alles wat binnenkomt twee openbare variabelen zal hebben van een aantal exacte namen zoals "lengte"en"verzonden"Anders zullen we een geweigerde legaat hebben.

Weigerde legaat: een klasse die een methode van een basisklasse op een zodanige manier overschrijft dat het contract van de basisklasse niet wordt gehonoreerd door de afgeleide klasse. ~ Bron Wikipedia.

Dit is een van de code ruikt gepresenteerd in de Detecting Code Smells premium course. Kortom, we willen niet proberen methoden of toegangsvelden te gebruiken voor objecten die niet voldoen aan ons contract. Toen we een typehint hadden, werd het contract er door gespecificeerd. De velden en methoden van de het dossier klasse. Nu we niets hebben, kunnen we alles verzenden, zelfs een string, en het zou resulteren in een lelijke fout.

function testItFailsWithAParameterThatDoesNotRespectTheImplicitContract () $ progress = new Progress ('some string'); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Een test als deze, waarin we een eenvoudige reeks insturen, levert een geweigerde legaat op:

Proberen om eigendom van niet-object te krijgen.

Hoewel het eindresultaat in beide gevallen hetzelfde is, wat betekent dat de code breekt, leverde de eerste een leuk bericht op. Deze is echter erg obscuur. Er is geen manier om te weten wat de variabele is - een string in ons geval - en welke eigenschappen werden gezocht en niet gevonden. Het is moeilijk om te debuggen en het probleem op te lossen. Een programmeur moet de. Openen Vooruitgang klasse en lees het en begrijp het. Het contract, in dit geval, wanneer we niet expliciet het typehint opgeven, wordt bepaald door het gedrag van Vooruitgang. Het is een impliciet contract, alleen bekend bij Vooruitgang. In ons voorbeeld wordt dit gedefinieerd door de toegang tot de twee velden, verzonden en lengte, in de getAsPercent () methode. In het echte leven kan het impliciete contract zeer complex en moeilijk te ontdekken zijn door gewoon een paar seconden naar de klas te zoeken.

Deze oplossing wordt alleen aanbevolen als geen van de andere onderstaande suggesties eenvoudig kan worden geïmplementeerd of als ze ernstige architecturale veranderingen teweegbrengen die de inspanning niet rechtvaardigen.

Oplossing 2: gebruik het ontwerppatroon voor strategieën

Dit is de meest voorkomende en waarschijnlijk de meest geschikte oplossing om OCP te respecteren. Het is eenvoudig en effectief.


Het strategiepatroon introduceert eenvoudig het gebruik van een interface. Een interface is een speciaal type entiteit in Object Oriented Programming (OOP) dat een contract tussen een client en een serverklasse definieert. Beide klassen volgen het contract om het verwachte gedrag te garanderen. Er kunnen verschillende, niet-gerelateerde serverklassen zijn die dezelfde overeenkomst respecteren en dus in staat zijn dezelfde clientklasse te bedienen.

interface Meetbare function getLength (); functie getSent (); 

In een interface kunnen we alleen gedrag definiëren. Daarom zullen we in plaats van openbare variabelen direct te gebruiken, moeten nadenken over het gebruik van gasvangers en setters. Het aanpassen van de andere klassen zal op dit moment niet moeilijk zijn. Onze IDE kan het grootste deel van de klus doen.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ File-> setlength (200); $ File-> setSent (100); $ voortgang = nieuwe voortgang ($ bestand); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Zoals gewoonlijk beginnen we met onze tests. We moeten setters gebruiken om de waarden in te stellen. Als dit als verplicht wordt beschouwd, kunnen deze setters ook worden gedefinieerd in de Meetbaar interface. Wees echter voorzichtig met wat je daar neerzet. De interface is om het contract tussen de clientklasse te definiëren Vooruitgang en de verschillende serverklassen houden van het dossier en Muziek. Doet Vooruitgang moeten de waarden worden ingesteld? Waarschijnlijk niet. Het is dus zeer onwaarschijnlijk dat de setters in de interface moeten worden gedefinieerd. Als u de setters daar zou definiëren, zou u ook alle serverklassen dwingen om setters te implementeren. Voor sommigen van hen kan het logisch zijn om setters te hebben, maar anderen kunnen zich heel anders gedragen. Wat als we onze willen gebruiken? Vooruitgang klas om de temperatuur van onze oven te tonen? De OvenTemperature klasse kan worden geïnitialiseerd met de waarden in de constructor of de informatie van een derde klasse verkrijgen. Wie weet? Om setters in die klas te hebben, zou vreemd zijn.

class Bestandsimplementaties Meetbaar private $ length; privé $ verzonden; openbare $ bestandsnaam; public $ owner; functie setLength ($ lengte) $ this-> length = $ length;  functie getLength () return $ this-> length;  function setSent ($ sent) $ this-> sent = $ sent;  functie getSent () return $ this-> verzonden;  functie getRelativePath () return dirname ($ this-> bestandsnaam);  functie getFullPath () retourneer realpath ($ this-> getRelativePath ()); 

De het dossier klasse is enigszins aangepast om aan de bovenstaande vereisten te voldoen. Het implementeert nu de Meetbaar interface en heeft setters en getters voor de velden waarin we geïnteresseerd zijn. Muziek is erg vergelijkbaar, je kunt de inhoud in de bijgevoegde broncode controleren. We zijn bijna klaar.

class Progress private $ meetableContent; function __construct (meetbare $ meetbare inhoud) $ this-> meetableContent = $ meetableContent;  functie getAsPercent () return $ this-> meetableContent-> getSent () * 100 / $ this-> meetableContent-> getLength (); 

Vooruitgang had ook een kleine update nodig. We kunnen nu in de constructor een type opgeven, met behulp van typeflits. Het verwachte type is Meetbaar. Nu hebben we een expliciet contract. Vooruitgang kan er zeker van zijn dat de benaderde methoden altijd aanwezig zijn omdat ze zijn gedefinieerd in de Meetbaar interface. het dossier en Muziek kunnen er ook zeker van zijn dat ze alles kunnen bieden waar ze voor nodig zijn Vooruitgang door simpelweg alle methoden op de interface te implementeren, een vereiste wanneer een klasse een interface implementeert.

Dit ontwerppatroon wordt in meer detail uitgelegd in de cursus Agile Design Patterns.

Een opmerking over de naamgeving van de interface

Mensen noemen vaak interfaces met een hoofdletter ik voor hen, of met het woord "Interface"bevestigd aan het einde, zoals Ifile of FileInterface. Dit is een notatie in oude stijl opgelegd door een aantal verouderde standaarden. We zijn zo lang voorbij de Hongaarse notaties of de noodzaak om het type van een variabele of een object in zijn naam op te geven om het gemakkelijker te kunnen identificeren. IDE's identificeren iets in een fractie van een seconde voor ons. Dit stelt ons in staat om ons te concentreren op wat we eigenlijk willen abstraheren.

Interfaces behoren tot hun klanten. Ja. Wanneer u een interface een naam wilt geven, moet u aan de klant denken en de implementatie vergeten. Toen we onze interface Measurable vernoemden, deden we dit met het oog op Progress. Als ik een vooruitgang zou zijn, wat zou ik dan nodig hebben om het percentage te kunnen leveren? Het antwoord is eenvoudig, iets wat we kunnen meten. Dus de naam Meetbaar.

Een andere reden is dat de implementatie van verschillende domeinen kan zijn. In ons geval zijn er bestanden en muziek. Maar we kunnen heel goed onze hergebruiken Vooruitgang in een racesimulator. In dat geval zijn de gemeten klassen Snelheid, Brandstof, enz. Leuk, nietwaar?

Oplossing 3: gebruik het sjabloonontwerppatroon

Het ontwerppatroon van de sjabloonmethode lijkt sterk op de strategie, maar in plaats van een interface gebruikt het een abstracte klasse. Het wordt aanbevolen om een ​​Template Method-patroon te gebruiken wanneer we een client hebben die zeer specifiek is voor onze applicatie, met verminderde herbruikbaarheid en wanneer de serverklassen gemeenschappelijk gedrag vertonen.


Dit ontwerppatroon wordt in meer detail uitgelegd in de cursus Agile Design Patterns.

Een hoger niveauoverzicht

Dus, hoe beïnvloedt dit alles onze architectuur op hoog niveau?


Als bovenstaande afbeelding de huidige architectuur van onze applicatie weergeeft, zou het toevoegen van een nieuwe module met vijf nieuwe klassen (de blauwe) ons ontwerp op een gematigde manier moeten beïnvloeden (rode klasse).


In de meeste systemen kun je absoluut geen effect verwachten op de bestaande code wanneer nieuwe klassen worden geïntroduceerd. Echter, het respecteren van het Open / Gesloten Principe zal de klassen en modules die constante verandering vereisen aanzienlijk verminderen.

Zoals met elk ander principe, probeer van tevoren niet aan alles te denken. Als u dit doet, krijgt u voor elk van uw klassen een interface. Een dergelijk ontwerp zal moeilijk te onderhouden en te begrijpen zijn. Meestal is de veiligste manier om na te denken over de mogelijkheden en of u kunt bepalen of er andere soorten serverklassen zijn. Vaak kunt u zich een nieuwe functie voorstellen of kunt u er een vinden op de backlog van het project die een andere serverklasse zal produceren. Voeg in die gevallen de interface vanaf het begin toe. Als je niet kunt bepalen of als je het niet zeker weet, laat het dan gewoon weg. Laat de volgende programmeur, of misschien zelfs uzelf, de interface toevoegen wanneer u een tweede implementatie nodig hebt.

Laatste gedachten

Als u uw discipline volgt en interfaces toevoegt zodra een tweede server nodig is, zullen er maar weinig wijzigingen zijn. Onthoud dat als code eenmaal nodig is, er een grote kans is dat deze opnieuw moet worden gewijzigd. Wanneer die mogelijkheid werkelijkheid wordt, bespaart OCP u veel tijd en moeite.

Bedankt voor het lezen.