Zasada jednej odpowiedzialności – definicja, przykłady i korzyści

Programowanie jest stosunkowo młodym zawodem w porównaniu z innymi branżami, np. budownictwem. W ciągu kilkudziesięciu lat jego istnienia powstało jednak wiele koncepcji, standardów i zasad, które należy uwzględniać w trakcie wykonywania pracy. Podobnie jak w pozostałych dziedzinach gospodarki, w których chodzi o wytwarzanie produktów o zadowalającej jakości – tak i w programowaniu, w którym rolę takiej rzeczy pełni oprogramowanie – zachowywanie dobrych praktyk ma służyć tworzeniu solidnych i wydajnych rozwiązań technologicznych. Przykładem takich norm są reguły wyrażone akronimem SOLID. Każdy, kto zaczyna programować, prędzej czy później spotyka się z tym skrótowcem. Słowo SOLID składa się z pierwszych liter pięciu zasad, które dotyczą programowania zorientowanego obiektowo. Ich przestrzeganie pomaga w pisaniu czytelnego i łatwego w utrzymaniu kodu.

Pierwszą z nich jest zasada jednej odpowiedzialności (ang. single responsibility principle, SRP). Została sformułowana przez znanego i cenionego programistę Roberta Martina w latach 90. Martin opracował ją na bazie różnych koncepcji innych autorów, m.in. Larrego Constantine'a.

Według SRP moduł, klasa lub funkcja (w dalszej części tekstu nazywane również jednostkami kodu) powinny mieć tylko jeden powód do zmiany. Sama definicja może być niewystarczająca, żeby w pełni zrozumieć, co jest istotą tej zasady, dlatego zaprezentowane zostaną dwa przykłady.

Przykłady

Pierwszym będzie funkcja o następującej definicji:

function daysToNewSchoolYear(DateTime $firstDayOfSchool): void
{
    $today = new DateTime();
    $differenceInSeconds = $firstDayOfSchool->getTimestamp() - $today->getTimestamp();
    $dayInSeconds = 86400;
    $differenceInDays = ceil($differenceInSeconds / $dayInSeconds);
    $daysLeft = $differenceInDays === 1 ? 'pozostał' : 'pozostało(y)';
    $days = $differenceInDays === 1 ? 'dzień' : 'dni';

    printf('<p>Do rozpoczęcia nowego roku szkolnego %s <strong>%d %s</strong>.</p>', $daysLeft, $differenceInDays, $days);
}

Powyższa funkcja jako argument przyjmuje datę inauguracji nowego roku szkolnego, następnie oblicza ile dni pozostało do jego rozpoczęcia, a na końcu wyświetla tekst z odpowiednią informacją. Nie zawiera ona wielu linii kodu i na pierwszy rzut oka może wydawać się w porządku. Nie spełnia jednak zasady jednej odpowiedzialności, ponieważ są dwa powody do jej zmiany.

Pierwsza modyfikacja dotyczy obliczania liczby dni pozostałych do rozpoczęcia roku szkolnego. Funkcja oblicza tę wartość względem aktualnej daty. Można sobie wyobrazić, że zmieniają się wymagania i ma zwracać wartość dla dowolnego dnia, który będzie podawany jako drugi argument. Przyczyn, dlaczego należy zmienić funkcję może być więcej, ale chodzi o to, że wszystkie są związane z algorytmem obliczającym liczbę dni.

Drugim powodem może być zmiana formatu wyświetlanego tekstu z HTML-a na inny, np. Markdown. Tu również może być więcej przyczyn, ale wszystkie są związane z prezentowaniem wyniku tekstowego.

Zmiany w algorytmie obliczającym liczbę dni oraz te, dotyczące wyświetlania tekstu, nie są ze sobą związane. Oznacza to, że funkcja powinna zostać rozdzielona na dwie mniejsze, które będą bardziej wyspecjalizowane, tak że zgrupują one powiązane ze sobą odpowiedzialności. Ich definicje mogą być następujące:

function calculateDaysToNewSchoolYear(DateTime $firstDayOfSchool): int
{
    $today = new DateTime();
    $differenceInSeconds = $firstDayOfSchool->getTimestamp() - $today->getTimestamp();
    $dayInSeconds = 86400;

    return ceil($differenceInSeconds / $dayInSeconds);
}

Funkcja calculateDaysToNewSchoolYear() odpowiada wyłącznie za obliczanie liczby dni do rozpoczęcia roku szkolnego. Wszystkie ewentualne zmiany będą zatem związane tylko z tym algorytmem. Konieczność wymiany formatu zwracanego tekstu nie spowoduje, że trzeba będzie również zmodyfikować tę funkcję.

function printDaysToNewSchoolYear(int $days): void
{
    $daysLeft = $differenceInDays === 1 ? 'pozostał' : 'pozostało(y)';
    $daysWord = $differenceInDays === 1 ? 'dzień' : 'dni';

    printf('<p>Do rozpoczęcia nowego roku szkolnego %s <strong>%d %s</strong>.</p>', $daysLeft, $days, $daysWord);
}

Funkcja printDaysToNewSchoolYear() odpowiada wyłącznie za wyświetlanie tekstu z informacją o tym, za ile dni rozpoczyna się rok szkolny. Sama ich liczba jest obliczona w innym miejscu i przekazana do tej funkcji jako argument. Dzięki takiemu rozwiązaniu zmiany związane z prezentacją tekstu nie będą wpływały na algorytm obliczający wynik.

Zadania wykonywane przez pierwotną funkcję zostały przydzielone dwóm mniejszym, tak że każda z nich spełnia SRP.

Jako drugi przykład można podać klasę o następującej definicji:

class Person
{
    private string $firstName;
    private string $lastName;
    private DateTime $birthdate;

    public function __construct(string $firstName, string $lastName, DateTime $birthdate)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->birthdate = $birthdate;
    }

    

    public function getFirstName(): string
    {
        return $this->firstName;
    }

    public function getLastName(): string
    {
        return $this->lastName;
    }

    public function getBirthdate(): DateTime
    {
        return $this->birthdate;
    }

    public function getAge(): int
    {
        $today = new DateTime();

        return $today->diff($this->birthdate)->y;
    }

    public function save(): void
    {
        $file = new SplFileObject('person.json', 'w');

        $personData = json_encode([
            'firstName' => $this->firstName,
            'lastName' => $this->lastName,
            'birthdate' => $this->birthdate->format('Y-m-d'),
        ]);

        $file->fwrite($personData);
    }
}

Powyższy kod też jest dość prosty. Konstruktor przyjmuje jako argumenty imię, nazwisko oraz datę urodzenia. Klasa umożliwia obliczenie wieku na podstawie podanej daty oraz zapisanie danych do pliku. Pomimo braku złożoności nie spełnia jednak SRP, ponieważ też są dwa powody do jej zmiany.

Pierwszym są zmiany związane z obsługą danych osobowych. Może to być dodanie jakichś nowych informacji lub algorytmu, który będzie zajmował się ich przetwarzaniem.

Drugim powodem są zmiany związane z ich przechowywaniem. Może zajść konieczność zapisywania informacji do bazy danych, a wtedy należałoby wprowadzić modyfikacje w klasie Person, co mogłoby mieć wpływ na algorytmy dotyczące samego przetwarzania.

Klasę należy zatem podzielić na dwie mniejsze, bardziej wyspecjalizowane, podobnie jak w przykładzie z funkcją. Ich definicje mogą wyglądać w następujący sposób:

class Person
{
    private string $firstName;
    private string $lastName;
    private DateTime $birthdate;

    public function __construct(string $firstName, string $lastName, DateTime $birthdate)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->birthdate = $birthdate;
    }

    public function getFirstName(): string
    {
        return $this->firstName;
    }

    public function getLastName(): string
    {
        return $this->lastName;
    }

    public function getBirthdate(): DateTime
    {
        return $this->birthdate;
    }

    public function getAge(): int
    {
        $today = new DateTime();

        return $today->diff($this->birthdate)->y;
    }

    public function toArray(): string
    {
        return [
            'firstName' => $this->firstName,
            'lastName' => $this->lastName,
            'birthdate' => $this->birthdate->format('Y-m-d'),
        ];
    }
}


Z klasy Person znikła metoda save(). Został w niej jedynie kod odpowiedzialny za obsługę danych osobowych. Dodana została również nowa metoda toArray(), która zwraca wszystkie dane w tablicy asocjacyjnej.

class PersonStorage
{
    public function save(Person $person): void
    {
        $file = new SplFileObject('person.json', 'w');
        $json = json_encode($person->toArray());

        $file->fwrite($json);
    }
}

Nowa klasa PersonStorage odpowiada wyłącznie za przechowywanie danych udostępnianych przez tę o nazwie Person. Utworzenie metody toArray() w tej drugiej jest tutaj bardzo przydatne, ponieważ zapobiega konieczności modyfikowania pierwszej w przypadku dodania nowych rodzajów danych.

Korzyści SRP

Po zaprezentowaniu przykładów czas na przedstawienie korzyści, jakie daje stosowanie zasady jednej odpowiedzialności.

Dzięki stosowaniu SRP poszczególne funkcje, klasy czy moduły są małe, a z tego wynika kilka pozytywnych skutków. Kod jest czytelniejszy, a zatem łatwiejszy w utrzymaniu. Po drugie zmniejsza się liczba błędów w oprogramowaniu, ponieważ w setkach czy nawet tysiącach linii bardzo łatwo jest coś przeoczyć. Z dużo mniejszym trudnem wychwytuje się różne niedociągnięcia, gdy nie ma zbyt wielu instrukcji do przeanalizowania. Sama praca natomiast staje się wydajniejsza, bo oszczędza się czas na takich czynnościach jak na przykład debugowanie.

Dla mniejszych jednostek kodu jest także łatwiej pisać testy, dzięki czemu ich utrzymanie generuje mniejsze koszty. To również jest ważne, ponieważ testy są kodem, o który również należy dbać.

Jest jeszcze jedna bardzo ważna korzyść, jaką daje SRP. Pozwala uniknąć sytuacji, w której wprowadzenie zmian w jakimś obszarze kodu, powoduje skutki uboczne w innym. Nie jest dobrze, kiedy modyfikacja w jednym miejscu aplikacji powoduje problemy w innym, zupełnie niezwiązanym z tym pierwszym. Grupowanie algorytmów, które są odpowiedzialne za te same funkcjonalności aplikacji zapobiega niekorzystnym konsekwencjom.

Trening czyni mistrza

Korzyści, jakie daje stosowanie zasady jednej odpowiedzialności, są zachęcające do wypróbowania jej w praktyce. Pewną trudność może nastręczyć rozpoznanie, czy jednostka kodu jeszcze spełnia SRP, czy już nie i to zarówno w czasie pisania nowego kodu, jak i refaktoryzacji już istniejącego.

Na pierwszy rzut oka można to ocenić po liczbie linii, z jakiej się składa. W programowaniu nie ma arbitralnych reguł, które formułowałyby, ile maksymalnie może ich być. Są jednak liczby, które powinny wzbudzić czujność. Optymalną propozycją może być 20 wierszy dla funkcji i metody oraz 200 dla klasy. Nie oznacza to, że dłuższy kod na pewno nie spełnia SRP, ale jest to sygnał, by się nad tym zastanowić. Może ich być więcej, a zasada jednej odpowiedzialności będzie zachowana. Należy jednak pamiętać, że im więcej linii, tym trudniej zrozumieć kod osobie, która go czyta. Trudniej też jest go utrzymywać. Może być też na odwrót. Linii kodu będzie mało, a zasada nie będzie spełniona tak jak w funkcji daysToNewSchoolYear() w pierwszym przykładzie.

Zwracanie uwagi na długość kodu może być pomocne, ale ważniejszą kwestią jest dobre zrozumienie, czym jest jego odpowiedzialność i powód do zmiany. Poprzez to drugie nie należy rozumieć jednej czynności, którą można wykonać na kodzie, bo to prowadziłoby do pisania mikroskopijnych jednostek kodu, co mijałoby się z celem. Funkcja, klasa czy moduł ma grupować pewną koncepcję. Zmian może być zatem więcej, natomiast jeśli wszystkie dotyczą tego samego algorytmu, to jest spełniona zasada jednej odpowiedzialności. Autor SRP zwraca uwagę na to, że chodzi w niej o ludzi. Należy zadać sobie pytanie, od jakiej osoby lub grupy osób będą pochodziły zadania wprowadzenia zmian w kodzie. Takie modyfikacje powinny być wykonywane tylko w jednym obszarze aplikacji (od charakteru zmiany zależy czy będzie to funkcja, klasa czy też moduł).

Podsumowanie

Po zapoznaniu się z definicją, przykładami i korzyściami, jakie daje zasada jednej odpowiedzialności, warto wypróbować ją w praktyce i zobaczyć, jak wpływa na pisany kod. W miarę treningu jej stosowanie staje się coraz łatwiejsze, a praca wydajniejsza. SRP to pierwsza z zasad składających się na akronim SOLID. Korzystne jest również zapoznanie się z pozostałymi regułami, by móc pisać naprawdę SOLIDny kod.

Autorem tekstu jest Szymon Czembor.