Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Dlaczego unikamy wielodziedziczenia?


2019-05-23, 00:00

Wielodziedziczenie to cecha programowania obiektowego, od której wielu programistów stara się odchodzić. Bardzo często spotykamy w sieci pytania na temat wielodziedziczenia, po czym otrzymujemy odpowiedzi w formie: “nie używaj, bo nie.”. Dzisiaj przedstawimy życiowy przykład, który przemawia przeciw wielodziedziczeniu właśnie.

Czym jest wielodziedziczenie?

Wielodziedziczeniem nazywamy sytuację, kiedy jedna klasa pochodna dziedziczy z conajmniej dwóch klas bazowych. W C++ możemy najprościej przedstawić to w następujący sposób:

class Bird {
    void fly() {};
};

class Reptile {
    void roar() {};
};

class Dragon : public Bird, public Reptile {

};

Jak to wygląda u sąsiada?

Część języków programowania zorientowanych obiektowo takich jak C++, Python, Scala czy Perl wspierają dziedziczenie po więcej niż jednej klasie bazowej. Są również takie języki jak Java, C# oraz Ruby, które pozwalają dziedziczyć po maksymalnie jednej klasie. Jest jeszcze PHP, które początkowo nie zezwalało na wielodziedziczenie, jednak od wersji 5.4 wprowadzony został mechanizm trait’ów, będący jego protezą. Co jest z tym mechanizmem nie tak, że część z języków programowania nie zezwala na jego użycie?

Problem diamentu

Najbardziej znanym problemem związanym z wykorzystaniem wielodziedziczenia jest problem diamentu (Diamond Problem). Przypadek ten polega na tym, że w ramach wielodziedziczenia jedna klasa istnieje kilkukrotnie w jednym drzewie dziedziczenia.

Poniższy kod idealnie odwzorowuje ten problem:

class Object {

public:

    void equals() {

    };

};

class Rectangle : public Object {

};

class Image : public Object {

};

class Button : public Rectangle, public Image {

};

int main() {
    Button b;
    b.equals();
}

Niestety, ten kod nie skompiluje się. Dostaniemy taki oto komunikat o błędzie:

main.cpp: In function ‘int main()’:
main.cpp:23:7: error: request for member ‘equals’ is ambiguous
     b.equals();
       ^
main.cpp:5:6: note: candidates are: void Object::equals()
 void equals() {};
      ^
main.cpp:5:6: note:                 void Object::equals()

W tym przypadku klasa Object występuje dwukrotnie w naszym drzewie: po raz pierwszy ze strony klasy Rectangle, po raz drugi od strony klasy Clickable. W związku z tym mamy dwie implementacje metody equals() w drzewie dziedziczenia, co sprowadza się do podobnej sytuacji, co omówiony przeze mnie pierwszy problem. Wspomnę, że jest to bardzo popularny problem, wychodzący poza C++. W innych językach programowania również możemy natknąć się na ten przypadek.

Z pomocą nadchodzi… wirtualne dziedziczenie!

Na pierwszy rzut oka, nie jest łatwo pozbyć się diamentu. W większości sytuacji trzeba byłoby zmienić część założeń, co niejednokrotnie wiąże się z dużym nakładem pracy. Na szczęście jest bardzo prosty sposób na pozbycie się tego problemu: we wszystkich miejscach, w których dziedziczymy po tej samej klasie potrzebujemy wykorzystać dziedziczenie wirtualne. Jest to mechanizm, który powoduje, że ta konkretna klasa bazowa pojawi się tylko jeden raz w naszym drzewie dziedziczenia. Kiedy poprawimy nasz kod na:

class Object {

public:

    void equals() {

    };

};

class Rectangle : public virtual Object {

};

class Image : public virtual Object {

};

class Button : public Rectangle, public Image {

};

int main() {
    Button b;
    b.equals();
}

to nasz kod nie dość, że skompiluje się, to jeszcze będzie działał tak, jak sobie założyliśmy.

Przykład z życia wzięty

Wszystko wygląda na OK… problem jest, rozwiązanie również. Dlaczego w takim razie odchodzi się od dziedziczenia po wielu rodzicach? Aby odpowiedzieć na to pytanie, przyjżyjmy się poniższemu problemowi, bliższemu życiu codziennemu.

Załóżmy, że mamy klasy jak na listingu poniżej:

class BaseHtmlItem {

public: HtmlCode generateHtml() {

}

};

class Link : public BaseHtmlItem {

public: void doRedirectAfterClick() {

}

};

class Image : public BaseHtmlItem {

public: void setBackgroundImage(string image) {

}

};

Dodatkowo przyjmimy, że jest to część kodu zaszyta w bibliotece, do której kodu dostępu nie mamy. Korzystając z tej biblioteki, próbujemy utworzyć link z obrazkiem. Może to wyglądać w następujący sposób:

class ImageLink : public Link, public Image {

};

int main(int argc, char* argv[]) {
    ImageLink imageLink;
    imageLink.generateHtml();
}

Na pierwszy rzut oka wszystko wygląda git. Prawie wszystko. Kod nie skompiluje się, ponieważ mamy dziedziczenie diamentowe. Niedawno przeczytaliśmy o tym, że możemy zastosować dziedziczenie wirtualne, więc… No właśnie! Nie możemy tego zrobić, ponieważ nie mamy dostępu do kodu biblioteki, z której korzystamy. Jej twórca nie przewidział tego, że ktoś będzie chciał połączyć te dwie klasy ze sobą. Być może, nawet nie wie, czym jest problem diamentu przy wielodziedziczeniu. Możliwe, że są jeszcze inne powody, nam nieznane, które spowodowały, że kod tej biblioteki wygląda tak, a nie inaczej. Jest to powód, dla którego lepiej jest unikać wielodziedziczenia, przynajmniej pod kątem klas, na które nie mamy wpływu.

Domyślne dziedziczenie wirtualne i kolejny problem

Kontynuując problem z poprzedniego akapitu: ktoś mógłby powiedzieć: w takim razie dziedziczmy wirtualnie wszędzie! Niestety, ale tak kolorowo nie jest. Domyślne stosowanie dziedziczenia wirtualnego “na zapas” potrafi wygenerować kolejny problem.

Zmieńmy nieco nasz kod:

class BaseHtmlItem {

public:

    BaseHtmlItem(string tag) {

    }
    HtmlCode generateHtml() {

    }

private:

    string tag;

};

class Link : public virtual BaseHtmlItem {

public:

    Link(string tag) : BaseHtmlItem(tag) {

    }
    void insertContent(string content) {

    }

};

class Image : public virtual BaseHtmlItem {

public:

    Image(string tag) : BaseHtmlItem(tag) {

    }
    void setBackgroundImage(std::string image) {

    }

};

Dziedziczymy wirtualnie. Super, mamy to, czego chcieliśmy. Możemy zatem wykorzystać nasz kod:

class ImageLink : public Link, public Image {

public: ImageLink(string tag) : Link("a"), Image("img"), BaseHtmlItem("?") {

}

};

Ponieważ dziedziczymy wirtualnie, to klasa BaseHtmlItem pojawia się tylko raz w naszym drzewie dziedziczenia. To zrzuca odpowiedzialność inicjalizacji tej klasy na klasę łączącą dwie gałęzie dziedziczenia. Zatem pytanie brzmi: w jaki sposób zainicjalizować tę klasę, aby całość działała poprawnie? Na moje oko mamy tutaj konflikt, który nie warto rozwiązywać. Warto jest zastanowić się nad zmianą koncepcji wykonania zadania.

Zmiana podejścia do problemu

Wydawać by się mogło, że jesteśmy w sytuacji bez wyjścia. Bez dziedziczenia wirtualnego jest źle, a z wirtualnym jeszcze gorzej. Jest jednak jeden sposób na to, by rozwiązać problem. Należy jednak spojrzeć na nasz problem nieco inaczej.

Problemy, z którymi się spotykamy powstają, ponieważ próbujemy na siłę połączyć ze sobą dwie klasy, których z jakiegoś powodu połączyć się nie da. Co powiecie, jeżeli zamiast dziedziczenia zastosujemy… kompozycję? Od dawien dawna wiadomo, że kompozycja jest alternatywą dla dziedziczenia. Spójrzmy, jak może wyglądać nasz kod, kiedy zamiast wielodziedziczenia korzystamy z kompozycji:

class ImageLink {

Image image;
Link link;

public:

    ImageLink() : image("img"), link("a") {

    }

    HtmlCode generateHtml() {
        link.insertContent(image.generateHtml());
        return link.generateHtml();
    }

};

Jak możemy zauważyć, nie ma tutaj żadnych problemów z dziedziczeniem diamentowym, nie ma również problemów z inicjalizacją klasy bazowej w klasie łączącej dwie linie dziedziczenia. Dodatkowo, nasz kod wygląda bardzo czysto i ekspresywnie.

Wielodziedziczenie, na które pozwalam sobie sam

Jest jeden szczególny przypadek, w którym pozwalam sobie (prawie) bez wahania na wykorzystanie wielodziedziczenia. O jakim przypadku mowa? O stosowaniu interfejsów. C++ nie wspiera czystej postaci interfejsów, takich jak to bywa w językach typu Java czy PHP, ciągle ale jesteśmy w stanie stworzyć mechanizm, który zachowuje się bardzo podobnie. W C++ za interfejs możemy przyjąć klasę abstrakcyjną, która wszystkie metody są wirtualne, niezaimplementowane. Popatrzmy sobie na przykładowy kod:

class DebugItemInterface {

public:

    virtual void print() = 0;

};

class OutputItemInterface {

public:

    virtual void print() = 0;

};

class CurrentItem : public DebugItemInterface, public OutputItemInterface {

public:

    void print() override {

    }

};

int main(int argc, char* argv[]) {
    CurrentItem c;
    c.print();
}

Nawet w tak ciekawej sytuacji, którą mamy powyżej, kod skompiluje się i będzie działał poprawnie. Pomimo, że dwie klasy bazowe posiadają metodę o dokładnie takiej samej sygnaturze (nazwa, zwracany typ oraz typy i kolejność parametrów), to nie otrzymamy komunikatu o błędzie member ‘print’ is ambiguous. Dzieje się tak, ponieważ błąd ten dotyczy implementacji metody, a nie jej sygnatury. W naszym kodzie jest tylko jedna implementacja tej metody, dlatego kompilator zawsze wie, której wersji użyć.

Podsumowanie

Ponieważ ten wpis nie ma na celu odpowiedzi na pytanie “Wielodziedziczyć, czy nie?”, temat ten nie został tutaj poruszony. Jego celem było przedstawienie klasycznego przypadku, w którym wpędzimy się w kozi róg, jeżeli postawimy na dziedziczenie po kilku klasach bazowych bez choćby krótkiej reflekcji. Dodatkowo poruszony został temat przypadku, w którym ja najczęściej stawiam na wielodziedziczenie właśnie.



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.



Podobne wpisy


Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności