Reguła Otwarte Zamknięte

… czyli dlaczego instrukcja switch nie czyni kodu łatwo rozszerzalnym, a dziedziczenie nie jest lekiem na całe zło.

Początkujący programiści często mają mylne spojrzenie na rozszerzalność kodu w językach obiektowych. Najczęściej ujawnia się to przez nadmiar instrukcji switch w wielu miejscach programu, często sprawdzających to samo. Inny symptom, to wprowadzenie dziedziczenia w miejscach, w których nie powinno ono występować. Przypatrzmy się przykładowi Romana, który chce zbudować prostą grę role-playing.

Roman - ciężkie dzieciństwo.

Roman w dzieciństwie był fanem gier RPG, do tego stopnia, że zdecydował się napisać własną grę. Jako że był również studentem informatyki, posiadał (w swoim własnym mniemaniu) wiedzę konieczną do tego, żeby napisać wymarzoną aplikację. Za język implementacji wybrał C++, gdyż tylko tego języka umiał używać.

Roman nie znał kompletnie inżynierii oprogramowania, bo na wykłady nie chodził, a laboratorium zaliczył za obecność. Mimo to czuł, że zanim rzuci się na głęboką wodę powinien wypróbować swoją koncepcję gry na jakimś trywialnym przykładzie, którego istota przypominałaby jednak to, z czym miał się zmierzyć (innymi słowy - zamierzał skonstruować edukacyjny prototyp). Oto jak jego spojrzenie na sprawę ewoluowało z biegiem czasu…

Podejście pierwsze: dziedziczenie.

Roman doszedł do wniosku, że każda postać w grze będzie miała własną klasę. Jako że był dosyć błyskotliwy, wydedukował szybko, że zarówno potwory napotykane podczas gry jak i sterowani przez gracza bohaterowie mają pewne wspólne zachowania, dlatego powinny mieć wspólnego rodzica w postaci klasy Postać. Początek hierarchii dziedziczenia, jaki naszkicował Roman wyglądał w ten sposób:

class Postać
{
    public:
        virtual void zaatakuj(Postać* innaPostać) = 0;
        virtual void brońSię() = 0;
        virtual void narysujSię() = 0;
        void ustawPunktyŻycia(unsigned int nowePunktyŻycia);
        unsigned int pobierzPunktyŻycia() const;
        //...
};
 
class Bohater : public Postać
{
    public:
        virtual void zaatakuj(Postać* innaPostać){};
        virtual void brońSię(){};
        virtual void narysujSię(){};
        //...
};
 
class Wróg : public Postać
{
    public:
        virtual void zaatakuj(Postać* innaPostać){};
        virtual void brońSię(){};
        virtual void narysujSię() {};
        //...
};

Roman czuł, że to podejście jest dobre. metody zaatakuj(), brońSię() i narysujSię() były wirtualne w klasie bazowej i mogly być potem dowolnie zaimplementowane w zależności od konkretnej postaci. Pomyślał sobie tak: "W ten sposób można implementować oddzielne wiele rodzajów ataków, bo przecież wojownik z dzidą atakuje inaczej niż wojownik z mieczem… zaraz!". Romek wydedukował, że jego podejście było nie całkiem sensowne. Przecież w każdym szanującym się RPGu postać ma możliwość zmiany ekwipunku. Wojownik w ciężkim pancerzu, skórzanych rękawicach i z tarczą broni się inaczej od maga w szacie, wełnianych rękawicach i bez tarczy. "Jeśli dla każdej kombinacji ekwipunku stworzę osobną klasę" - myślał dalej Romek - "to będzie katastrofa - klas będzie wiele i będę musiał cały czas przepisywać dane o bohaterach do innych obiektów, jeśli zmianie ulegnie ich ekwipunek. To się mija z celem… a poza tym te nazwy… SamurajZMieczemDwuręcznymPlusDwaIMagicznąZbrojąAlchemika… absurd…".

Podejście drugie: Atak Case'ów.

Roman postanowił zatem napisać tylko jedną klasę dla Bohatera dziedziczącą od klasy Postać. Poprzednie podejście musiało zawieść, gdyż próbowało uporządkować coś, co jest dynamiczne (typ aktualnie noszonego ekwipunku) za pomocą mechanizmu statycznego (typy danych). Tym razem Romek postanowił zrobić to jak należy - w sposób dynamiczny, tak, żeby np. atak zależał od trzymanego oręża, który można by dowolnie zmieniać. Npisał zatem naprędce taki oto kod:

enum Broń
{
    MIECZ_JEDNORĘCZNY,
    MIECZ_DWURĘCZNY,
    MIECZ_PÓŁTORARĘCZNY,
    PROCA,
    ŁUK,
    //...
};
 
....//klasy Postać i Wróg jak powyżej.
 
class Bohater : public Postać
{
    public:
        virtual void zaatakuj(Postać* innaPostać){};
        virtual void brońSię(){};
        virtual void narysujSię(){};
        void ustawBroń(Broń nowaBroń);
        Broń pobierzBroń() const;
 
        //...
    private:
        Broń mojaBroń;
};

Natomiast metodę zaatakuj() zaimplementował w ten sposób (pamiętajmy, że to tylko prototyp, więc Roman nie wziął pod uwagę wpływu statystyk postaci na zadawane obrażenia i innych czynników):

void Bohater::zaatakuj(Bohater* innaPostać)
{
    assert(innaPostać != 0);
 
    switch(mojaBroń)
    {
        case MIECZ_JEDNORĘCZNY:
            innaPostać->ustawPunktyŻycia(innyBohater->pobierzPunktyŻycia() - 10);
            break;
        case MIECZ_DWURĘCZNY:
            innaPostać->ustawPunktyŻycia(innyBohater->pobierzPunktyŻycia() - 20);
            break;
        case MIECZ_PÓŁTORARĘCZNY:
            innaPostać->ustawPunktyŻycia(innyBohater->pobierzPunktyŻycia() - 5);
            break;
        case PROCA:
            innaPostać->ustawPunktyŻycia(innyBohater->pobierzPunktyŻycia() - 3);
            break;
        case ŁUK:
            innaPostać->ustawPunktyŻycia(innyBohater->pobierzPunktyŻycia() - 6);
            break;
        default:
            std::clog << "wykryto nienznaną wartość broni."
            std::clog << " Chyba coś zapomniałeś uzupełnić w kodzie..." << std::endl;
            assert(false);
    }
}

Zadowolony ze swojego rozwiązania, Roman postanowił pokazać kod swojemu nauczycielowi informatyki panu K., którego zdolności programistyczne bardzo cenił. Spodziewał się pochwały, jednak nauczyciel spojrzał najpierw surowym wzrokiem na kod, a potem pobłażliwie na Romana. "Jeszcze długa droga przed Tobą" - rzekł. "jeśli chcesz, to powiem Ci, co można zrobić, by Twój kod był bardziej elastyczny i łatwiejszy w modyfikacji a jednocześnie bardziej odporny na błędy". Roman zgodził się, zatem pan K. przystąpił do przedstawiania swojego punktu widzenia.

Podejście trzecie: wzorzec Strategia i dowiązania dynamiczne.

"Po pierwsze" - Rzekł K. - "Spójrz na ten blok switch. Są tam zamknięte wszystkie istniejące strategie wykonywania ataku. Oznacza to, że za każdym razem, gdy będziesz chciał dodać nową broń, będziesz musiał modyfikować istniejący kod, ryzykując wprowadzenie w nim błędów. Po drugie, wyobraź sobie, że będziesz miał trzy miejsca, w których będziesz coś robić w zależności od typu broni. Jeśli dodasz nową broń, będziesz musiał dodać nowego case'a w trzech miejscach. A co jeśli o tym zapomnisz (co jest dość prawdopodobne, kiedy kod się rozrośnie)? Generalnie nie jest to dobry pomysł."

Romek zamyślił się. K. miał rację. "Co zatem zobić, żeby wyeliminować te wady?" - zapytał. K. odpowiedział: "Dam Ci radę, której prawie zawsze warto się trzymać, jeśli chce się osiągnąć zarządzalny i elastyczny kod:

Kapsułkuj to, co jest zmienne.

"Podążając za tą radą" - ciągnął dalej K. - "powinniśmy od razu zauważyć, że zmienny jest typ ekwipunku. Jest to kolejny powód, żeby zamknąć np. typ broni w osobnej klasie (pierwszym powodem jest to, że jest to doskonały kandydat na klasę, bo ma zespół danych i operacji ze sobą powiązanych). Jeśli tak się stanie, do tej klasy powinna zostać przeniesiona strategia ataku tą bronią. Uzyskamy w ten sposob ten efekt, że ogólny sposób działania, którego prawdopodobnie po przetestowaniu nie będziemy już modyfikować, będzie zawarty w klasie Postać, natomiast strategię, ataku, czyli coś, co caly czas może się zmieniać, nawet dynamicznie w ciągu gry, zaszyjemy w osobnych obiektach. To rozumowanie jest właśnie podstawą wzorca Strategia". To powiedziawszy, K. siadł do swojego ulubionego edytora i w parę chwil wyczarował następujący prototyp (nie trzymający się wielu konwencji kodowania w C++, ale nie to było najistotniejsze w tym momencie):

class Bron;
 
class Postac
{
    public:
        virtual void zaatakuj(Postac* innaPostac) = 0;
        virtual void bronSie() = 0;
        virtual void narysujSie() = 0;
        virtual void ustawPunktyZycia(int nowePunktyZycia) = 0;
        virtual unsigned int pobierzPunktyZycia() const = 0;
        virtual Bron* pobierzBron() const = 0;
        virtual void ustawBron(Bron* nowaBron) = 0;
 
        //...
};
 
class Bohater : public Postac
{
    public:
        void zaatakuj(Postac* innaPostac){};
        virtual void bronSie(){};
        virtual void narysujSie(){};
 
        virtual void ustawBron(Bron* nowaBron)
        {
            assert(nowaBron != 0);
            mojaBron = nowaBron;
        }
 
        virtual Bron* pobierzBron() const
        {
            return mojaBron;
        }
 
        virtual void ustawPunktyZycia(int nowePunktyZycia) {};
        virtual unsigned int pobierzPunktyZycia() const {};
 
        //...
    private:
        Bron* mojaBron;
};
 
class Wrog : public Postac
{
    public:
        void zaatakuj(Postac* innaPostac){};
        virtual void bronSie(){};
        virtual void narysujSie(){};
        virtual void ustawBron(Bron* nowaBron){};
        virtual Bron* pobierzBron() const{};
        virtual void ustawBron(const Bron* nowaBron){};
        virtual void ustawPunktyZycia(int nowePunktyZycia){};
        virtual unsigned int pobierzPunktyZycia() const {};
        //...
};
 
//Bronie - prymitywny przykład:
 
class Bron
{
public:
    Bron(int poczatkowaMoc)
    {
        moc = poczatkowaMoc;    
    }
 
    virtual std::string zaatakuj(Postac* mojaPostac, Postac* innaPostac) = 0;
 
    int pobierzMoc() const
    {
        return moc;
    }
 
    virtual void ustawMoc(int nowaMoc)
    {
        moc = nowaMoc;
    }
private:
    unsigned int moc;
 
};
 
class NormalnaBron : public Bron
{
public:
    NormalnaBron(int poczatkowaMoc) : Bron(poczatkowaMoc) 
    {
        //EMPTY BODY
    }
 
    std::string zaatakuj(Postac* mojaPostac, Postac* innaPostac)
    {
        innaPostac->ustawPunktyZycia(innaPostac->pobierzPunktyZycia() - pobierzMoc());
        return "Jakiś komunikat typu: Poldek zaatakował gienka za 15 punktów życia";    
    }
}; 
 
class MagicznaBronPolowicznegoAtaku : public Bron
{
public:
    MagicznaBronPolowicznegoAtaku(int poczatkowaMoc)  : Bron(poczatkowaMoc)
    {
        //EMPTY BODY    
    }    
 
    std::string zaatakuj(Postac* mojaPostac, Postac* innaPostac)
    {
 
        innaPostac->ustawPunktyZycia(innaPostac->pobierzPunktyZycia() - pobierzMoc());
        innaPostac->ustawPunktyZycia(innaPostac->pobierzPunktyZycia() - pobierzMoc()/2);
        return "Jakiś komunikat typu: Poldek zaatakował gienka za 15 punktów życia";    
    }
}; 
 
int main(int argc, char** argv)
{
    Bohater mojBohater;
    Wrog wrog;
    NormalnaBron maczuga(12);
    mojBohater.ustawBron(&maczuga);
    mojBohater.zaatakuj(&wrog);
    return 0;
}

Gdy tylko zakończył klepać, K. pokazał kod Romkowi. "Kod jest niechlujny i niekompletny" - zaczął - "ale dobrze pokazuje to co miałem na myśli. Zauważ, że zamknęliśmy strategię ataku w osobnym obiekcie. Nowe zachowania możemy dodawać nie modyfikując klasy Bohater, lecz tylko dopisując nowe podklasy klasy Broń. Jest to bardzo użyteczne, gdyż raz przetestujemy klasę Bohater, nie musimy się martwić, że wprowadzimy do niej dodatkowe błędy - jesteśmy pewni (o ile można być pewnym na skutek przejścia testów klasy), że działa dobrze. Takie podejście nosi nazwę Reguły Otwarte-Zamknięte, która mówi że:

Klasa powinna być projektowana tak, żeby była zamknięta na zmiany i jednocześnie otwarta na rozszerzanie.

Podejście czwarte: Factory Guy.

O ile nie zaznaczono inaczej, treść tej strony objęta jest licencją Creative Commons Attribution-Share Alike 2.5 License.