Start Kontakt

Zasada SOLID (1/4)

Zasada SOLID została zaproponowana przez Roberta Martina w książce Agile Software Development, Principles, Patterns, and Practices. SOLID to tak naprawdę pięć zasad, których początkowe litery tworzą docelową nazwę (jest to więc akronim, więc zapisuje się go również w postaci S.O.L.I.D.).

Jak zwykle, stosowanie zasad powinno być rozsądne i uwzględniać określony kontekst. Jak wspominałem na głównej stronie, programowanie to też sztuka, więc nie można być ślepym rzemieślnikiem, który bezmyślnie stosuje zasady w taki sam sposób wszędzie, gdzie tylko się da.

Mogłoby się okazać na przykład, że po uwzględnieniu zasady SOLID program stałby się bardziej skomplikowany i trudniejszy do zrozumienia. W takim przypadku trzeba oczywiście nie brać jej pod uwagę. Pamiętajmy, że zasady nie są obowiązkowe.

Po tym krótkim wstępie przeanalizujmy poszczególne składowe zasady SOLID w kontekście języka C++.

Single Responsibility Principle

W tej zasadzie chodzi o to, że dana klasa powinna być odpowiedzialna za pojedynczą, określoną funkcjonalność. Nie należy tworzyć rozbudowanych klas, które zawierają w sobie wiele różnych funkcjonalności.

Oczywiście ma to sens w przypadku większych programów, w których stopień komplikacji jest dość znaczny. Łatwiej znaleźć i zmodyfikować klasę, która wykonuje jedno działanie, niż taką, która zawiera mnóstwo metod obsługujących różne sytuacje. Zauważmy, że w tym drugim przypadku częściej może dochodzić do zmiany klasy, a co za tym idzie, zwiększa się prawdopodobieństwo tego, że przestanie ona działać poprawnie również dla przypadków, które nie były modyfikowane. W związku z tym konieczne będzie przeprowadzanie bardziej dokładnych i długotrwałych testów regresyjnych.

A oto przykład. Załóżmy, że programujemy grę fabularną RPG, w której postacie zbierają różne przedmioty. Są one przechowywane na liście inwentarza zdefiniowanej za pomocą klasy Inventory. Klasa pozwala między innymi na uzyskanie dostępu do poszczególnego elementu inwentarza (getItem), a także na wyświetlenie ich wszystkich na ekranie (displayAllItems).

class Item
{
public:
    int getCount(void) { return items; }
    // inne metody

private:
    int items;
    // inne zmienne składowe, np. nazwa, liczba punktów zdrowia itp.
};

class Inventory
{
public:
    Item* getItem(const int itemPos);
    void displayAllItems(void);

private:
    std::vector<Item*> items;
};

Problem polega na tym, że klasa Inventory zarówno zajmuje się przechowywaniem przedmiotów, jaki i zupełnie czymś innym, czyli ich wyświetlaniem. Te dwie funkcjonalności powinny być oddzielone od siebie.

Rozwiązanie polega na tym, by utworzyć nową klasę odpowiedzialną za wyświetlanie przedmiotów:

class Inventory
{
public:
    Item* getItem(const int itemPos);
    
private:
    std::vector<Item*> items;
};

class InventoryDisplayManager
{
public:
    void displayAllItems(const Inventory inv) const;
};

Z klasy Inventory zniknęła metoda wyświetlająca przedmioty, natomiast powstała dodatkowa klasa InventoryDisplayManager, której zadaniem jest wyświetlanie zawartości inwentarza. Każda z klas specjalizuje się teraz w czymś innym. Jeśli trzeba będzie zmodyfikować sposób wyświetlania zawartości inwentarza, wystarczy zająć się klasą InventoryDisplayManager - klasa Inventory pozostanie niezmieniona.

Open/Closed Principle

Ta zasada oznacza, że podczas wdrażania nowych funkcjonalności istniejący kod nie powinien być zmieniany.

Ktoś może jednak zadać pytanie: jak to? Nie można zmieniać kodu? To w jaki sposób w ogóle dodać nową funkcjonalność?

Należy odróżnić dwa działania: modyfikację i rozbudowę. Kod powinien być "zamknięty" (closed) na modyfikacje, ale "otwarty" (open) na rozbudowywanie. Przez modyfikację rozumie się zmianę istniejących klas, a przez rozbudowę poszerzanie je poprzez tworzenie klas pochodnych.

Modyfikowanie istniejących klas jest niebezpieczne, ponieważ po pierwsze zwiększa ich poziom komplikacji, po drugie może spowodować, że klasa zacznie być odpowiedzialna za wiele funkcjonalności (patrz zasada Single Responsibility Principle), a po trzecie mogą się pojawić błędy, które ujawnią się dopiero po jakimś czasie.

Zakładając, że klasa została wystarczająco dobrze przetestowana, nie ma potrzeby, by w nią ingerować. Powinno się ją zostawić taką, jaka jest, a zamiast tego utworzyć klasę pochodną i przeciążyć odpowiednie metody klasy nadrzędnej.

Wróćmy do przykładu: w naszej grze musimy w jakiś sposób wyznaczać siłę (poziom mocy) poszczególnych typów postaci. Elfy są dość słabe fizycznie, natomiast gobliny mocne. Utworzyliśmy klasę wyliczeniową CHARACTERS typów postaci, a także klasę CharacterManager odpowiadającą za wyznaczanie parameterów postaci.

enum class CHARACTERS { ELF, GOBLIN, HOBBIT };

class CharacterManager
{
public:
    int getPower(CHARACTERS ch)
    {
        switch (ch)
        {
        case CHARACTERS::ELF:
            return 50;
        case CHARACTERS::GOBLIN:
            return 100 + 2 * getWeight(ch);
        case CHARACTERS::HOBBIT:
            return 80;
        default:
            return -1;
        }
    }
    int getWeight(CHARACTERS ch);
};

W przypadku, gdybyśmy chcieli dodać nowy typ postaci (np. człowieka), musielibyśmy zmodyfikować klasę CharacterManager (dodać nowy case w metodzie getPower, być może rozbudować metodę getWeight), co mogłoby spowodować pojawienie się jakiegoś błędu, którego nie zauważylibyśmy.

Problem można rozwiązać lepiej. Utwórzmy klasę abstrakcyjną definiującą ogólną postać, z której będziemy następnie dziedziczyć klasy, by uzyskać określone typy postaci.

class Character
{
public:
    virtual int getPower(void) const = 0;
    int getWeight(void) const { return weight; }
    void setWeight(const int w) { weight = w; }

private:
    int weight;
};

class Elf : public Character
{
    virtual int getPower(void) const override
    {
        return 50;
    }
};

class Goblin : public Character
{
    virtual int getPower(void) const override
    {
        return 100 + 2 * getWeight();
    }
};

class Hobbit : public Character
{
    virtual int getPower(void) const override
    {
        return 80;
    }
};

class CharacterManager
{
public:
    int getPower(Character *ch)
    {
        return ch->getPower();
    }
};

W każdej z dziedziczonych klas przeciążamy metodę getPower uzyskując różne charakterystyki w zależności od typu postaci. Zauważmy, że nie jest już potrzebna klasa wyliczeniowa CHARACTERS. Również klasa CharacterManager bardzo się uprościła. Gdybyśmy chcieli dodać nowy typ postaci, wystarczyłoby odziedziczyć kolejną klasę po Character, a w klasie CharacterManager nie trzeba byłoby nic zmieniać.

Oto przykładowe użycie powyższych klas:

int main()
{
    Character *ch;
    CharacterManager cm;
    Elf elf;
    Goblin goblin;
    goblin.setWeight(120);
    Hobbit hobbit;

    ch = &elf;
    std::cout << "Elf: " << cm.getPower(ch) << std::endl;
    ch = &goblin;
    std::cout << "Goblin: " << cm.getPower(ch) << std::endl;
    ch = &hobbit;
    std::cout << "Hobbit: " << cm.getPower(ch) << std::endl;
}

Uzyskaliśmy następujące wyniki:

Elf: 50
Goblin: 340
Hobbit: 80

Powyższy kod mógł zostać uproszczony dzięki zastosowaniu polimorfizmu. Jest to naprawdę potężna opcja języka C++.

W kolejnym artykule analiza najbardziej formalnego składnika zasady SOLID - zasady podstawienia Liskov.