Single Responsibility Principle. Not as simple as it seems.

Single Responsibility Principle. Not as simple as it seems. Het enkelvoudige verantwoordelijkheidsprincipe, ook wel het principe van enkele verantwoordelijkheid genoemd,
is ook het principe van enkele veranderlijkheid — een zeer lastig te begrijpen kwestie en een zenuwachtig onderwerp tijdens een programmeurssollicitatie.

Mijn eerste serieuze kennismaking met dit principe vond plaats aan het begin van het eerste jaar, toen we als jonge en onervaren studenten naar het bos werden gebracht, om van larven echte studenten te maken.

In het bos verdeelden we ons in groepen van 8-9 personen en organiseerden we een wedstrijd — welke groep het snelst een fles vodka opdronk, op voorwaarde dat de eerste persoon van de groep de vodka in een glas inschenkt, de tweede drinkt en de derde een hapje neemt. De deelnemer die zijn taak voltooit, gaat achteraan in de rij staan.

Een situatie waarin de grootte van de rij deelbaar was door drie, was een goede implementatie van SRP.

Definitie 1. Enkele verantwoordelijkheid.

De officiële definitie van het enkele verantwoordelijkheidsprincipe (SRP) stelt dat elk object zijn eigen verantwoordelijkheid en bestaansreden heeft, en dat deze verantwoordelijkheid slechts één is.

Laten we het object "Tippler" bekijken (Tippler).
Om het principe van SRP te volgen, verdelen we de verantwoordelijkheden onder drie personen:

  • Iemand schenkt in (PourOperation)
  • Iemand drinkt (DrinkUpOperation)
  • Iemand neemt een hap (TakeBiteOperation)

Elke deelnemer aan het proces is verantwoordelijk voor één component van het proces, dat wil zeggen dat hij een atomische verantwoordelijkheid heeft — drinken, inschenken of eten.

Tippler is op zijn beurt een facade voor deze operaties:

class Tippler {
    //...
    void Act(){
        _pourOperation.Do() // inschenken
        _drinkUpOperation.Do() // drinken
        _takeBiteOperation.Do() // hap nemen
    }
}

Single Responsibility Principle. Not as simple as it seems.

Waarom?

De programmeur schrijft code voor de aapmens, en de aapmens is onoplettend, dom en haastig. Hij kan ongeveer 3 tot 7 termen tegelijkertijd begrijpen en onthouden.
In het geval van de Tippler zijn er drie termen. Maar als we de code in één blok schrijven, zullen er handen, glassen, vechtpartijen en eindeloze discussies over politiek in verschijnen. En dit alles zal zich in het lichaam van één methode bevinden. Ik weet zeker dat je zo'n code in je praktijk hebt gezien. Geen humane uitdaging voor de psyche.

Aan de andere kant is de mens-apen gefocust op het modelleren van objecten uit de echte wereld in zijn hoofd. In zijn verbeelding kan hij ze botsen, nieuwe objecten samenstellen en net zo goed weer uit elkaar halen. Stel je een oud autokmodel voor. Je kunt in je verbeelding de deur openen, het deurpaneel losdraaien en de mechanismen van de raamopener zien, waarin tandwielen zitten. Maar je kunt niet alle componenten van de auto tegelijkertijd zien, in één 'lijst'. Tenminste, de 'mens-apen' kan dat niet.

Daarom decomponeren programmeurs complexe mechanismen in een set van minder complexe en functionele elementen. Echter, decomponeren kan op verschillende manieren: bij veel oude auto's komt het luchtkanaal uit in de deur, terwijl bij moderne auto's een elektronische storing van het slot voorkomt dat de motor start, wat lastig is bij het repareren.

Dus, SRP is een principe dat uitlegt HOE te decomponeren, dat wil zeggen waar de scheidingslijn moet liggen..

Het zegt dat je moet decomponeren op basis van het principe van 'verantwoordelijkheidsscheiding', dat wil zeggen op basis van de taken van bepaalde objecten.

Single Responsibility Principle. Not as simple as it seems.

Laten we terugkeren naar de drinker en de voordelen die de mens-apen krijgt bij het decomponeren:

  • De code is op elk niveau glashelder.
  • Code kan door meerdere programmeurs tegelijkertijd worden geschreven (iedereen schrijft een apart element).
  • Automatische tests worden eenvoudiger — hoe eenvoudiger het element, hoe gemakkelijker het te testen is.
  • Er ontstaat samenstelling van code — je kunt vervangen DrinkUpOperation door een operatie waarin de drinker vloeistof onder de tafel giet. Of vervang de schenking door een operatie waarin je wijn en water of wodka en bier mengt. Afhankelijk van de eisen van het bedrijf kun je alles doen, zonder de code van de methode aan te raken. Tippler.Act.
  • Uit deze operaties kun je de vreters samenstellen (door alleen gebruik te maken van TakeBitOperation), de alcoholist (door alleen DrinkUpOperation rechtstreeks uit de fles) en voldoen aan veel andere bedrijfsvereisten.

(Oh, kennelijk is dit al het OCP-principe, en heb ik de verantwoordelijkheid van deze post geschonden)

En natuurlijk zijn er nadelen:

  • Je zult meer typen moeten creëren.
  • De drinker zal voor het eerst een paar uur later drinken dan hij zou kunnen.

Definitie 2. Eenduidige wijzigbaarheid.

Heren, heren! De klasse van de drinker heeft ook maar één verantwoordelijkheid — hij drinkt! En eigenlijk is het woord 'verantwoordelijkheid' een extreem vaag begrip. Iemand is verantwoordelijk voor het lot van de mensheid, terwijl iemand anders verantwoordelijk is voor het optillen van omgevallen pingwijnen op de polen.

Laten we twee implementaties van de drinker bekijken. De eerste, hierboven genoemd, bevat drie klassen — inschenken, drinken en snacken.

De tweede, geschreven volgens de methodologie 'Vooruit en alleen maar vooruit', bevat alle logica in de methode Act:

//Не тратьте время  на изучение этого класса. Лучше съешьте печеньку
сlass BrutTippler {
   //...
   void Act(){
        // наливаем
    if(!_hand.TryDischarge(from:_bottle, to:_glass, size:_glass.Capacity))
        throw new OverdrunkException();

    // выпиваем
    if(!_hand.TryDrink(from: _glass,  size: _glass.Capacity))
        throw new OverdrunkException();

    //Закусываем
    for(int i = 0; i< 3; i++){
        var food = _foodStore.TakeOrDefault();
        if(food==null)
            throw new FoodIsOverException();

        _hand.TryEat(food);
    }
   }
}

Beide klassen lijken vanuit het oogpunt van een buitenstaander absoluut identiek en vervullen de enige verantwoordelijkheid 'drinken'.

Wat een gênant

Dan duiken we het internet in en ontdekken een andere definitie van SRP — Het Principe van Eenzelfde Veranderlijkheid (Single Changeability Principle).

SCP stelt dat 'Een module heeft één en alleen één reden voor verandering'. Dus 'Verantwoordelijkheid is een reden voor verandering'.

(Het lijkt erop dat de jongens die de oorspronkelijke definitie bedachten, overtuigd waren van de telepathische vermogens van de mens-aap)

Nu valt alles op zijn plaats. We kunnen de inschenk-, drink- en snackprocedures afzonderlijk wijzigen, en binnen de drinker kunnen we alleen de volgorde en samenstelling van de operaties veranderen, bijvoorbeeld door de snack voor het drinken te verplaatsen of het voorlezen van een toost toe te voegen.

In de benadering 'Vooruit en alleen maar vooruit', kan alles wat veranderd kan worden, alleen in de methode Act. Dit kan leesbaar en effectief zijn als er weinig logica is en deze zelden verandert, maar vaak eindigt dit in verschrikkelijke methoden van 500 regels elk, met meer if-structuren dan nodig is voor Rusland om lid te worden van de NAVO.

Definitie 3. Lokalisatie van wijzigingen.

Drankers begrijpen vaak niet waarom ze in een vreemd appartement zijn wakker geworden, of waar hun mobiel is. Het is tijd om gedetailleerde logging toe te voegen.

Laten we de logging beginnen met het inschenken:

class PourOperation: IOperation{
    PourOperation(ILogger log 
/*....*/){
/*...*/}
    //...
    void Do(){
        _log.Log($"Voor inschenken met {_hand} en {_bottle}");
        // Drink business logic ...
        _log.Log($"Na inschenken met {_hand} en {_bottle}");
    }
}

door het te encapsuleren in PourOperation, we acted wisely in terms of responsibility and encapsulation, but now we have a dilemma with the variability principle. Besides the operation itself, which can change, the logging itself is also becoming variable. We will need to separate it and create a special logger for the pouring operation:

interface IPourLogger{
    void LogBefore(IHand, IBottle){}
    void LogAfter(IHand, IBottle){}
    void OnError(IHand, IBottle, Exception){}
}

class PourOperation: IOperation{
    PourOperation(IPourLogger log /*....*/){/*...*/}
    //...
    void Do(){
        _log.LogBefore(_hand, _bottle);
        try{
             //... business logic
             _log.LogAfter(_hand, _bottle);
        }
        catch(exception e){
            _log.OnError(_hand, _bottle, e);
        }
    }
}

The attentive reader will notice that LogAfter, LogBefore en OnError can also change separately, and in analogy with previous actions, it will create three classes: PourLoggerBefore, PourLoggerAfter en PourErrorLogger.

Recalling that there are three operations for the drinker, we end up with nine logging classes. In total, the drinker consists of 14 (!!!) classes.

Hyperbole? Hardly! The person-monkey with a decomposition grenade will shatter the 'pourer' into a decanter, glass, pouring operators, water supply service, physical model of molecule collisions, and next quarter will try to untangle dependencies without global variables. And believe me, he won't stop.

It's at this point that many conclude that SRP is a fairy tale from pink kingdoms, and they go to twist the noodles...

… and never learn about the existence of the third definition of SRP:

The Single Responsibility Principle states that similar things that change should be kept in one place. or “Things that change together should be kept in one place.”

That is, if we change the logging of an operation, we must change it in one place.

This is a very important point — since all the explanations of SRP above stated that we should split types as long as they can be split, thus imposing a 'top-down restriction' on the size of the object, now we are also talking about a 'bottom-up restriction.' In other words, SRP not only requires 'to split while it can be split,' but also not to go overboard — 'not to split linked things.'. This is a great battle of Occam's razor against the monkey-man!

Single Responsibility Principle. Not as simple as it seems.

Now the drinker should find it easier. Besides not having to split the IPourLogger logger into three classes, we can also combine all the loggers into one type:

class OperationLogger{
    public OperationLogger(string operationName){
/*..*/}
    public void LogBefore(object[] args){
/*...*/}
    public void LogAfter(object[] args){
/*..*/}
    public void LogError(object[] args, exception e){
/*..*/}
}

En als we een vierde type operatie zouden toevoegen, is de logging daarvoor al gereed. De code van de operaties zelf is schoon en vrij van infrastructuurgeluid.

Als resultaat hebben we 5 klassen om de taak van het drinken op te lossen:

  • Invoeren van de operatie
  • Uitvoeren van de operatie
  • Eten van de operatie
  • Logger
  • Facade van de drinker

Elke klasse is strikt verantwoordelijk voor één functionaliteit en heeft één reden voor wijziging. Alle vergelijkbare regels voor wijziging liggen dicht bij elkaar.

Voorbeeld uit het echte leven

Een keer schreven we een service voor automatische registratie van B2B-klanten. En er verscheen een GOD-methode van 200 regels met zulke inhoud:

  • Ga naar 1C en maak een rekening aan
  • Neem deze rekening mee naar de betalingsmodule en registreer het daar
  • Controleer of er geen account met deze rekening is aangemaakt in het hoofdsysteem de server
  • Maak een nieuw account aan
  • Voeg het registratie resultaat in de betalingsmodule en het 1C-nummer toe aan de registratie resultaten service
  • Voeg informatie over het account aan deze tabel toe
  • Creëer een puntnummer voor deze klant in de service voor punten. Stuur het rekeningnummer naar deze service.

En in deze lijst stonden nog ongeveer 10 zakelijke operaties met vreselijke onderlinge afhankelijkheid. Het rekeningobject was bijna voor iedereen nodig. Het punt-id en de naam van de klant waren in de helft van de oproepen nodig.

Na een uur refactoren konden we de infrastructuurcode en sommige nuances van het werken met de account in aparte methoden/klassen scheiden. De GOD-methode was lichter, maar er bleven 100 regels code over die zich maar niet wilden ontwarren.

Pas enkele dagen later kwam het besef dat de essentie van deze "verlichte" methode de zakelijke algoritme zelf is. En dat de oorspronkelijke beschrijving van het technische ontwerp vrij complex was. En dat het feit dat we probeerden deze methode in stukken te breken, een schending van SRP zou zijn en niet andersom.

Formalisme.

Het is tijd om onze drinker met rust te laten. Veeg de tranen weg - we zullen ooit naar hem terugkeren. Maar nu willen we de kennis uit dit artikel formaliseren.

Formalisme 1. Definitie van SRP

  1. Scheiding van elementen zodat elk van hen verantwoordelijk is voor één ding.
  2. Verantwoordelijkheid wordt ontcijferd als 'reden voor wijziging'. Dit betekent dat elk element slechts één reden voor wijziging heeft, in termen van zakelijke logica.
  3. Potentiële wijzigingen in de bedrijfslogica moeten worden gelokaliseerd. Wijzigbare gesynchroniseerde elementen moeten naast elkaar staan.

Formalism 2. Vereiste zelfcontrolecriteria.

Ik ben niet voldoende criteria voor de uitvoering van SRP tegengekomen. Maar er zijn noodzakelijke voorwaarden:

1) Stel jezelf de vraag — wat doet deze klasse/methode/module/service. Je moet het kunnen beantwoorden met een eenvoudige definitie. (bedankt Brightori )

verklaringen

Toch is het soms erg moeilijk om een eenvoudige definitie te vinden.

2) Het fixen van een bepaald bug of het toevoegen van een nieuwe functie raakt het minimale aantal bestanden/klassen. Bij voorkeur — één.

verklaringen

Aangezien de verantwoordelijkheid (voor de functie of bug) gecapsuleerd is in één bestand/klasse, weet je precies waar je moet zoeken en wat je moet aanpassen. Bijvoorbeeld: de functie voor het wijzigen van de uitvoer van de logging van operaties zal alleen de logger vereisen te wijzigen. Je hoeft niet door de rest van de code te rennen.

Een ander voorbeeld — het toevoegen van een nieuwe UI-controle die vergelijkbaar is met eerdere. Als dit je dwingt 10 verschillende entiteiten en 15 verschillende converters toe te voegen — lijkt het erop dat je 'overgedimensioneerd' hebt.

3) Als verschillende ontwikkelaars aan verschillende functies van je project werken, is de kans op een merge-conflict, dat wil zeggen de kans dat hetzelfde bestand/klasse door meerdere ontwikkelaars tegelijk wordt gewijzigd, minimaal.

verklaringen

Als je bij het toevoegen van een nieuwe actie 'Giet wodka onder de tafel' de logger, de actie van het drinken en het gieten moet beïnvloeden — dan lijkt het erop dat de verantwoordelijkheden scheef verdeeld zijn. Natuurlijk is dit niet altijd mogelijk, maar het is belangrijk om te proberen deze indicator te verlagen.

4) Bij een verduidelijkende vraag over de bedrijfslogica (van een ontwikkelaar of manager) kijk je strikt in één klasse/bestand en krijg je informatie alleen daarvandaan.

verklaringen

Functies, regels of algoritmes zijn compact geschreven, elk op één plaats, en niet verspreid over de code met vlaggen.

5) Naamgeving is duidelijk.

verklaringen

Onze klasse of methode is verantwoordelijk voor iets eenduidigs, en de verantwoordelijkheid is weerspiegeld in de naam.

AllManagersManagerService — waarschijnlijk een God-klasse.
LocalPayment — waarschijnlijk niet.

Formalism 3. Ontwikkelingsmethodiek 'Ockham-first'.

Aan het begin van de ontwerpfase weet de mens-aap niet alle fijne kneepjes van het op te lossen probleem en kan hij een fout maken. Er zijn verschillende manieren om fouten te maken:

  • Te grote objecten maken door verschillende verantwoordelijkheden samen te voegen.
  • Verdeeld, de unieke verantwoordelijkheid splitsen in veel verschillende soorten.
  • Onjuist de grenzen van verantwoordelijkheden definiëren.

Het is belangrijk om de regel te onthouden: "het is beter om te veel te doen", of "als je twijfelt, splitst niet". Als bijvoorbeeld je klasse twee verantwoordelijkheden verzamelt, is het nog steeds begrijpelijk en kan deze worden opgesplitst in twee met minimale wijzigingen in de klantcode. Een glas maken van glasscherven is meestal moeilijker vanwege de verspreide context over meerdere bestanden en het ontbreken van de noodzakelijke afhankelijkheden in de klantcode.

Het is tijd om af te ronden.

Het toepassingsgebied van SRP is niet beperkt tot OOP en SOLID. Het is toepasbaar op methoden, functies, klassen, modules, microservices en services. Het is van toepassing op zowel 'figaks-figaks-en-in-product' als 'rocket-science' ontwikkeling, en maakt de wereld overal een beetje beter. Als je erover nadenkt, is dit haast een fundamenteel principe van alle techniek. Werktuigbouwkunde, regelsystemen en eigenlijk alle complexe systemen zijn opgebouwd uit componenten, en 'onderdelen niet te splitsen' ontnemen de ontwerpers flexibiliteit, 'te veel splitsen' efficiëntie, en onjuiste grenzen - rede en gemoedsrust.

Single Responsibility Principle. Not as simple as it seems.

SRP is niet door de natuur uitgevonden en maakt geen deel uit van exacte wetenschappen. Het komt voort uit onze biologische en psychologische beperkingen. Het is gewoon een manier om complexe systemen te beheersen en te ontwikkelen met behulp van de menselijke geest. Het vertelt ons hoe we een systeem kunnen decomponeer. De oorspronkelijke formulering vereiste aanzienlijke telepathische vaardigheden, maar ik hoop dat dit artikel een beetje de rookgordijnen heeft opgehelderd.

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster