Zasada pojedynczej odpowiedzialności, znana również jako zasada jedynej odpowiedzialności,
znana również jako zasada jedynej zmienności — to niezwykle śliski temat zrozumienia i bardzo drażliwa kwestia na rozmowie kwalifikacyjnej dla programistów.
Moje pierwsze poważne spotkanie z tą zasadą miało miejsce na początku pierwszego roku studiów, kiedy to młodych i zielonych wywieźli nas do lasu, aby ze studentów larw uczynić prawdziwych studentów.
W lesie podzielono nas na grupy po 8-9 osób w każdej i zorganizowano zawody — która grupa szybciej wypije butelkę wódki, pod warunkiem, że pierwszy człowiek z grupy nalewa wódkę do kieliszka, drugi pije, a trzeci zakąsza. Wykonujący swoją operację uczestnik ustawia się na końcu kolejki grupy.
Sytuacja, gdy rozmiar kolejki był wielokrotnością trzech, była dobrym odwzorowaniem SRP.
Definicja 1. Pojedyncza odpowiedzialność.
Oficjalna definicja zasady pojedynczej odpowiedzialności (SRP) mówi, że każdy obiekt ma swoją odpowiedzialność i powód istnienia, a ta odpowiedzialność jest tylko jedna.
Rozważmy obiekt „Wypijacz” (Tippler).
Aby wypełnić zasadę SRP, podzielmy obowiązki pomiędzy trzech:
- Jeden nalewa (PourOperation)
- Jeden pije (DrinkUpOperation)
- Jeden zakąsza (TakeBiteOperation)
Każdy z uczestników procesu jest odpowiedzialny za jeden komponent procesu, to znaczy ma jedną atomową odpowiedzialność — wypić, nalać lub zakąsić.
Wypijacz, z kolei, jest fasadą dla tych operacji:
class Tippler {
//...
void Act() {
_pourOperation.Do() // nalać
_drinkUpOperation.Do() // wypić
_takeBiteOperation.Do() // zakąsić
}
}
Dlaczego?
Programista tworzy kod dla małpy, a małpa jest nieuważna, głupia i wiecznie gdzieś się spieszy. Może zrozumieć i utrzymać około 3 — 7 terminów w jednym momencie.
W przypadku wypijacza tych terminów jest trzy. Jednak jeśli napiszemy kod w jednym kawałku, pojawią się w nim ręce, kieliszki, bójki i nieskończone spory o politykę. A wszystko to będzie w ciele jednej metody. Jestem pewien, że widzieliście taki kod w swojej praktyce. To nie jest zbyt humanitarne wyzwanie dla psychiki.
Z drugiej strony, człowiek-małpa jest skupiony na modelowaniu obiektów rzeczywistego świata w swoim umyśle. W swojej wyobraźni może je łączyć, tworzyć nowe obiekty, a także je rozkładać. Wyobraź sobie starą modelę samochodu. Możesz w swojej wyobraźni otworzyć drzwi, odkręcić tapicerkę drzwi i zobaczyć mechanizmy podnośników szyb, wewnątrz których znajdują się koła zębate. Ale nie możesz zobaczyć wszystkich komponentów samochodu jednocześnie, w jednym „widoku”. Przynajmniej „człowiek-małpa” nie może.
Dlatego programiści-deweloperzy dekomponują skomplikowane mechanizmy na zestaw mniej złożonych i działających elementów. Można to jednak robić na różne sposoby: w wielu starych samochodach — przewód powietrzny wychodzi do drzwi, a w nowoczesnych — awaria elektroniki zamka uniemożliwia uruchomienie silnika, co sprawia problemy przy naprawie.
Przechodząc do sedna, SRP to zasada wyjaśniająca, JAK dekomponować, to znaczy, gdzie przeprowadzić linię podziału..
Mówi, że dekomponować należy według zasady podziału „odpowiedzialności”, to znaczy według zadań danych obiektów.
Wróćmy do pijaka i korzyści, jakie uzyskuje człowiek-małpa przy dekompozycji:
- Kod stał się przejrzysty na każdym poziomie.
- Kod mogą pisać jednocześnie kilku programistów (każdy pisze oddzielny element).
- Ułatwia to automatyczne testowanie — im prostszy element, tym łatwiej go testować.
- Pojawia się kompozycyjność kodu — można zastąpić DrinkUpOperation operacją, w której pijak wylewa płyn pod stół. Lub zastąpić operację nalewania operacją, w której mieszasz wino i wodę lub wódkę i piwo. W zależności od wymagań biznesowych można wszystko, nie dotykając kodu metody. Tippler.Act.
- Z tych operacji możesz złożyć obżartucha (używając tylko TakeBitOperation), alkoholika (używając tylko DrinkUpOperation bezpośrednio z butelki) i spełnić wiele innych wymagań biznesowych.
(Ojej, wydaje mi się, że to już zasada OCP, i złamałem odpowiedzialność tego posta.)
I oczywiście minusy:
- Będzie trzeba stworzyć więcej typów.
- Pijak wypije po raz pierwszy o kilka godzin później, niż mógłby.
Definicja 2. Jednolita zmienność.
Proszę, panowie! Klasa pijaka ma jedno zadanie - pić! A w ogóle, słowo „odpowiedzialność” jest pojęciem dość rozmytym. Ktoś jest odpowiedzialny za los ludzkości, a ktoś inny za podnoszenie przewróconych pingwinów na biegunie.
Rozważmy dwie realizacje pijaka. Pierwsza, opisana powyżej, zawiera trzy klasy - nalać, wypić i zakąsić.
Druga, napisana w metodologii „Naprzód i tylko naprzód”, zawiera całą logikę w metodzie 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);
}
}
}Obie te klasy, z punktu widzenia zewnętrznego obserwatora, wyglądają całkowicie identycznie i mają jedną odpowiedzialność „wypić”.
Kłopot!
Wtedy zaglądamy do internetu i znajdujemy inne definicje SRP - Zasada Jednej Zmiany (Single Changeability Principle).
SCP mówi, że „Moduł ma jeden i tylko jeden powód do zmiany„. To znaczy „Odpowiedzialność to powód do zmiany”.
(Wygląda na to, że goście, którzy wymyślili pierwotną definicję, byli pewni telepatycznych zdolności człowieka-małpy)
Teraz wszystko staje się jasne. Możemy oddzielnie zmieniać procedury nalewania, picia i zakąszania, a w samym pijaku możemy zmienić tylko kolejność i skład operacji, na przykład przesuwając zakąskę przed wypiciem lub dodając czytanie toastu.
W podejściu „Naprzód i tylko naprzód”, wszystko, co można zmienić - zmienia się tylko w metodzie Act. Może to być czytelne i efektywne, gdy logiki jest mało i rzadko się zmienia, ale często kończy się okropnymi metodami po 500 linii w każdej, z liczbą if-ów większą, niż potrzeba do wstąpienia Rosji do NATO.
Definicja 3. Lokalizacja zmian.
Pijacy często nie rozumieją, dlaczego obudzili się w obcym mieszkaniu, lub gdzie jest ich telefon komórkowy. Nadszedł czas, aby dodać szczegółowe logowanie.
Zacznijmy logowanie od procesu nalewania:
class PourOperation: IOperation{
PourOperation(ILogger log /*....*/){/*...*/}
//...
void Do(){
_log.Log($"Przed nalaniem z {_hand} i {_bottle}");
//Logika biznesowa nalewania ...
_log.Log($"Po nalaniu z {_hand} i {_bottle}");
}
}Inkapshuąc ją w PourOperation, postąpiliśmy mądrze z punktu widzenia odpowiedzialności i enkapsulacji, ale teraz mamy zamieszanie z zasadą zmienności. Oprócz samej operacji, która może się zmieniać, zmienna staje się również sama logika. Będziemy musieli podzielić i stworzyć specjalny logger dla operacji nalewania:
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{
//... logika biznesowa
_log.LogAfter(_hand, _bottle);
}
catch(exception e){
_log.OnError(_hand, _bottle, e)
}
}
}Uważny czytelnik zauważy, że LogAfter, LogBefore i OnError również mogą zmieniać się oddzielnie, a w analogii do poprzednich działań stworzy trzy klasy: PourLoggerBefore, PourLoggerAfter i PourErrorLogger.
A przypominając, że mamy trzy operacje dla nalewania, otrzymujemy dziewięć klas logowania. W sumie cały proces nalewania składa się z 14 (!!!) klas.
Hipotetycznie? Niekoniecznie! Człowiek-małpa z granatem dekompozycyjnym zmieni “nalewacza” na karafkę, szklankę, operatorów nalewania, serwis podawania wody, fizyczny model kolizji cząsteczek, a następny kwartał będzie próbował rozwiązać zależności bez zmiennych globalnych. I uwierzcie — nie przestanie.
Właśnie w tym momencie wielu dochodzi do wniosku, że SRP to bajki z różowych królestw i odchodzą, aby splatać historie...
… nie poznając istnienia trzeciej definicji SRP:
„Zasada pojedynczej odpowiedzialności mówi, że podobne rzeczy, które mogą ulec zmianie, powinny być przechowywane w jednym miejscu„. lub “To, co zmienia się razem, powinno być przechowywane w jednym miejscu.”
A więc, jeśli zmieniamy logowanie operacji, musimy to zmieniać w jednym miejscu.
To bardzo ważny punkt — ponieważ wszystkie wcześniejsze wyjaśnienia SRP mówiły o tym, że trzeba fragmentować typy, aż będą się dzielić, co narzucało „górne ograniczenie” na rozmiar obiektu, teraz mówimy również o „dolnym ograniczeniu”. Innymi słowy, SRP nie tylko wymaga „dzielenia, dopóki jest możliwe”, ale również, aby się nie przesadzić — „nie dzielić współzwiązanych rzeczy”.. To wielka bitwa brzytwy Ockhama z człowiekiem-małpą!
Teraz powinno być łatwiej. Oprócz tego, że nie trzeba dzielić logera IPourLogger na trzy klasy, możemy również połączyć wszystkie loggery w jeden typ:
class OperationLogger{
public OperationLogger(string operationName){
/*..*/}
public void LogBefore(object[] args){
/*...*/}
public void LogAfter(object[] args){
/*..*/}
public void LogError(object[] args, exception e){
/*..*/}
}A jeśli dodamy czwarty typ operacji, to logowanie dla niego jest już gotowe. A sam kod operacji jest czysty i wolny od szumów infrastrukturalnych.
W rezultacie mamy 5 klas do rozwiązania zadania nalewania:
- Operacja nalewania
- Operacja wypicia
- Operacja podjadania
- Logger
- Fasada pijaka
Każda z nich odpowiada ściśle za jedną funkcjonalność, ma jeden powód do zmiany. Wszystkie podobne zasady do zmiany są obok siebie.
Przykład z życia wzięty
Pewnego razu pisaliśmy serwis automatycznej rejestracji klienta B2B. I pojawiła się metoda GOD — na 200 linijek podobnej treści:
- Idź do 1C i załóż konto
- Z tym kontem idź do modułu płatności i załóż je tam
- Sprawdź, czy konto z tym kontem nie zostało stworzone w głównym serwerze
- Stwórz nowe konto
- Dodaj wynik rejestracji w module płatności oraz numer 1C do serwisu wyników rejestracji
- Dodaj do tej tabeli informacje o koncie
- Stwórz numer punktu dla tego klienta w serwisie punktów. Przekaż do tego serwisu numer konta 1C.
A w tej liście było jeszcze około 10 operacji biznesowych z przerażającą powiązaniem. Obiekt konta był potrzebny niemal wszystkim. Identyfikator punktu i nazwa klienta były potrzebne w połowie wywołań.
Po godzinnej refaktoryzacji udało nam się oddzielić kod infrastrukturalny i pewne niuanse związane z kontem w osobne metody/klasy. Metoda God została uproszczona, ale pozostało 100 linijek kodu, które wcale nie chciały się rozwiązać.
Dopiero po kilku dniach przyszło zrozumienie, że istota tej „ulepszonej” metody — to biznesowy algorytm. I że pierwotny opis specyfikacji był dość skomplikowany. I to właśnie próba podziału tej metody na kawałki będzie naruszeniem SRP, a nie odwrotnie.
Formalizm.
Nadszedł czas, aby zostawić naszego pijaka w spokoju. Wytrzyj łzy — na pewno do niego wrócimy kiedykolwiek. A teraz sformalizujmy wiedzę z tego artykułu.
Formalizm 1. Definicja SRP
- Dziel elementy tak, aby każdy z nich był odpowiedzialny za coś jednego.
- Odpowiedzialność odnosi się do „powodu do zmiany”. To znaczy, że każdy element ma tylko jeden powód do zmiany, w terminach logiki biznesowej.
- Potencjalne zmiany logiki biznesowej powinny być lokalizowane. Elementy, które mogą być zmieniane synchronnie, powinny być blisko siebie.
Formalizm 2. Wymagane kryteria samosprawdzania.
Nie natknąłem się na wystarczające kryteria spełnienia SRP. Ale są niezbędne warunki:
1) Zadaj sobie pytanie – co robi ta klasa/ metoda/ moduł/ usługa. Powinieneś odpowiedzieć na nie prostą definicją. (Dziękuję) )
wyjaśnienia
Jednak czasami zdefiniowanie prostego pojęcia jest bardzo trudne.
2) Naprawa pewnego błędu lub dodanie nowej funkcji wpływa na minimalną liczbę plików/ klas. W idealnym przypadku – jeden.
wyjaśnienia
Ponieważ odpowiedzialność (za funkcję lub błąd) jest enkapsulowana w jednym pliku/ klasie, wiesz dokładnie, gdzie szukać i co poprawiać. Na przykład: funkcja zmiany sposób logowania operacji wymaga jedynie zmiany loggera. Nie ma potrzeby przeszukiwania całego pozostałego kodu.
Inny przykład – dodanie nowego kontrolera UI, podobnego do poprzednich. Jeśli zmusza cię to do dodania 10 różnych bytów i 15 różnych konwerterów – wydaje się, że 'przerobiłeś to'.
3) Jeśli kilku programistów pracuje nad różnymi funkcjami twojego projektu, prawdopodobieństwo konfliktu scalania, czyli prawdopodobieństwo, że ten sam plik/ klasa zostanie zmieniona przez kilku programistów jednocześnie – jest minimalne.
wyjaśnienia
Jeśli przy dodaniu nowej operacji „Wylać wódkę pod stół” musisz dotknąć loggera, operacji wypicia i wylania – to wyglada na to, że odpowiedzialności są źle podzielone. Oczywiście, nie zawsze jest to możliwe, ale trzeba starać się obniżyć ten wskaźnik.
4) Przy pytaniu wyjaśniającym o logikę biznesową (od programisty lub menedżera) sięgasz ściśle do jednej klasy/ pliku i uzyskujesz informacje tylko stamtąd.
wyjaśnienia
Funkcje, zasady lub algorytmy są zwięźle napisane, każdy w jednym miejscu, a nie rozrzucane flagami po całej przestrzeni kodu.
5) Nazewnictwo jest zrozumiałe.
wyjaśnienia
Nasza klasa lub metoda jest odpowiedzialna za coś jednego, a odpowiedzialność jest odzwierciedlona w jej nazwie.
AllManagersManagerService – najprawdopodobniej klasa Boga.
LocalPayment – prawdopodobnie nie.
Formalizm 3. Metodyka rozwoju „Ockham-first”.
Na początku projektowania, człowiek-małpa nie zna i nie czuje wszystkich niuansów rozwiązywanego zadania i może popełnić błąd. Można pomylić się na różne sposoby:
- Tworzenie zbyt dużych obiektów przez łączenie różnych odpowiedzialności
- Rozdzielenie jednej odpowiedzialności na wiele różnych typów
- Błędne określenie granic odpowiedzialności
Ważne jest, aby zapamiętać zasadę: „lepiej pomylić się na dużą stronę” lub „jeśli nie jesteś pewny – nie dziel”. Na przykład, jeśli twoja klasa łączy w sobie dwie odpowiedzialności – nadal jest zrozumiała i można ją podzielić na dwie z minimalnymi zmianami w kodzie klienta. Tworzenie kubka z odłamków szkła jest zazwyczaj trudniejsze z powodu rozproszonego kontekstu w wielu plikach i braku niezbędnych zależności w kodzie klienta.
Czas zakończyć
Zastosowanie SRP nie ogranicza się do OOP i SOLID. Stosuje się go do metod, funkcji, klas, modułów, mikroserwisów i usług. Jest stosowany zarówno w prostym, jak i w skomplikowanym rozwoju, wszędzie sprawiając, że świat staje się nieco lepszy. Jeśli się nad tym zastanowić, to jest to chyba jeden z fundamentów całej inżynierii. Inżynieria mechaniczna, systemy kontroli i w ogóle wszystkie złożone systemy – budowane są z komponentów, a „niedostateczne podzielenie” pozbawia konstruktorów elastyczności, „nadmierne podzielenie” – efektywności, a błędne granice – zdrowego rozsądku i spokoju ducha.
SRP nie jest wymyślone przez naturę i nie jest częścią nauk ścisłych. Wyności się z naszych biologicznych i psychologicznych ograniczeń. To tylko sposób na kontrolowanie i rozwijanie złożonych systemów przy użyciu mózgu człowieka-małpy. Mówi nam, jak dekomponować system. Początkowa formuła wymagała znacznego talentu telepatycznego, ale mam nadzieję, że ten artykuł nieco rozproszył mgłę.
Źródło: habr.com
