Kompozycja i dziedziczenie

Jedną z najbardziej użytecznych cech C++ jest możliwość powtórnego wykorzystania kodu – code reusability. Technika ta polega na korzystaniu z istniejących klas bez zmian w ich kodzie

Istnieją dwa sposoby osiągnięcia tego celu:

  • Kompozycja – polega na tworzeniu obiektów nowych klas, które zawierają jako składowe klasy już istniejące

  • Dziedziczenie – polega na stworzeniu nowej klasy jako typu klasy istniejącej i dodaniu do niego nowego kodu, bez modyfikacji kodu już istniejącego

Kompozycja

Jeśli obiekt pewnej klasy zawiera obiekty innych klas, to podczas jego inicjalizacji wywoływane są w pierwszej kolejności (zanim wykonane zostaną instrukcje umieszczone w ciele konstruktora ) konstruktory obiektów składowych.

Porządek wywołań konstruktorów obiektów składowych określony jest przez kolejność deklaracji składowych wewnątrz klasy – nie zależy on od listy inicjalizacji.

_images/composition.svg

Implementacja kompozycji

enum class EngineType { petrol, diesel, tdi, wenkel };

class Engine
{
    EngineType type_;
    long id_;
public:
    Engine(EngineType engine_type, long id) : type_{engine_type}, id_{id}
    { /*...*/ }
    // interfejs klasy Engine
};

class Car
{
    Engine engine_;
    std::string registration_number_;
public:
    Car(EngineType engine_type, long engine_id, const std::string& registration_number)
        : Engine{engine_type, engine_id}, registration_number_{registration_number}
    {
        /*...*/
    }
    // interfejs klasy Car
};

Dziedziczenie

Dziedziczenie – umożliwia tworzenie nowych klas, które przejmują formę i funkcjonalność klas bazowych. Wprowadza relację „is-a-kind-of” między klasami.

Klasy pochodne specjalizują klasy bazowe.

Klasa pochodna dziedziczy wszystkie składowe klasy bazowej:

  • pola (dane składowe)

  • metody

Metody klasy pochodnej mają dostęp do publicznych oraz chronionych (protected) składowych klasy bazowej.

_images/inheritance-shapes.svg

C++ umożliwia następujące rodzaje dziedziczenia:

  • pojedyncze (jednobazowe)

  • wielokrotne (wielobazowe)

Definiowanie klas pochodnych

Składnia dziedziczenia:

struct Point
{
    int x, y;

    Point(int x, int y) : x{x}, y{y}
    {}
};

class Shape
{
    Point coord_;
public:
    Shape(int x = 0, int y = 0) : coord_{x, y}
    {}

    void draw() const;
};

class Circle : public Shape
{
    int radius_;
public:
    Circle(int x = 0, int y = 0, int radius = 0) : Shape{x, y}, radius_{radius}
    {}
}

Dostęp do składowych podczas dziedziczenia

class Derived : [public|protected|private] Base
{
};
_images/inheritance-types.svg

W przypadku dziedziczenia na sposób private (prywatny) istnieje możliwość wybiórczego udostępnienia wybranych składowych public lub protected z klasy bazowej.

class Base
{
public:
    int i;
protected:
    int foo();
};

class Derived : private Base
{
public:
    Base::i;
protected:
    Base::foo;
};

Konstruktor klasy bazowej

Konstruktory klasy bazowej są wywoływane w trakcie tworzenia obiektu klasy pochodnej. Konstruktor klasy bazowej może/musi być wywołany na liście inicjalizacyjnej konstruktora klasy pochodnej.

class Shape
{
private:
    Point coord_;
public:
    Shape(int x = 0, int y = 0) : coord_{x, y}
    {}

    Shape(Point coord) : coord_{coord}
    {}
};

class Line : public Shape
{
private:
    Point end_;
public:
    Line(const Point& start, const Point& end) : Shape{coord}, end_{coord}
    {
    }
};

Przesłanianie metod klasy bazowej

Jeżeli nie odpowiada nam implementacja metody klasy bazowej możemy ją przesłonić pisząc nową implementację funkcji o takiej samej sygnaturze w klasie pochodnej.

class Shape
{
   //...
public:
   void draw() const
   {
      // implementacja rysowania
   }
};

class Line : public Shape
{
    //...
public:
    void draw() const
    {
       Shape::draw();    // wywołanie metody z Shape

       // dodatkowe instrukcje rysujące
    }
};

Wykorzystanie obiektów klasy pochodnej

Obiekty klasy pochodnej posiadają składowe klasy bazowej i nowe składowe wprowadzone w klasie pochodnej.

Obiekt klasy pochodnej jest obiektem klasy bazowej.

Shape shape{1, 20};
Circle circle{1, 4, 10);

shape = circle; // wycięcie – utrata informacji o promieniu

Shape* psh = &circle; // ok
Shape& rsh = circle;  // ok

Korzystając ze wskaźnika lub referencji do klasy bazowej zawasze zamiast obiektu klasy bazowej możemy użyć obiektu klasy pochodnej.

Dziedziczenie konstruktorów

Deklaracja using może być użyta w połączeniu z konstruktorami klasy bazowej. Powoduje to niejawne deklaracje konstruktorów klasy pochodnej, które przyjmują takie same listy paramtrów co konstruktory klasy bazowej. Ich implementacja polegająca na wywołaniu wersji z klasy bazowej jest generowana tylko wtedy, gdy są one rzeczywiście użyte.

class Base
{
public:
    explicit Base(int);
    void do_something(int);
};

class Derived : public Base
{
public:
    using Base::Base; // OK in C++11
                      // implicit declaration of Derived::Derived(int)

    Derived(int, int); // overloaded inherited Base ctor
};

Użycie dziedziczenia konstruktorów w klasach pochodnych, które dodają nowe pola może być ryzykowne:

class Augmented : public Derived
{
public:
    using Derived::Derived;

private:
    std::string name_;
    int value_;
};

Augmented a {10}; // a.name_ == "" - defualt init
                  // and a.value_ is uninitialized

Polimorfizm

Referencje lub wskaźniki do typów bazowych mogą odnosić się do obiektów różnego typu (typów pochodnych). Wywołanie metody dla referencji lub wskaźnika spowoduje zachowanie odpowiednie dla pełnego typu obiektu wywoływanego. Jeśli dzieje się to w czasie działania programu, to nazywa się to późnym wiązaniem lub wiązaniem dynamicznym.

class Client
{
public:
    void render(const Shape& shape) const
    {
        //...
        shape.draw();
    }
};

//...
Circle c{1, 10, 20};
Rectangle rect{10, 30, 20, 40};
auto line = std::unique_ptr<Shape>{ std::make_unique<Line>(20, 20, 40, 40) };

Client client;
client.render(c);
client.render(rect);
client.render(*line);

Statyczne i dynamiczne wiązanie funkcji

Statyczne wiązanie funkcji:

  • Kompilator podczas kompilacji ustala typ obiektu, dla którego wywoływana jest funkcja i generuje kod wywołujący funkcję należącą do tego typu

  • Dotyczy również wskaźników i referencji

Dynamiczne wiązanie funkcji:

  • Kompilator generuje kod, który na podstawie rzeczywistego typu obiektu wywoła właściwą funkcję – decyzja ta podejmowana jest trakcie wykonywania programu

  • Dynamiczne wiązanie jest dostępne w C++ poprzez użycie słowa kluczowego virtual

Metody wirtualne

Tworzenie metod wirtualnych

Metoda wirtualna z klasy bazowej może być nadpisana w klasie pochodnej.

class Shape
{
public:
    virtual void draw() const
    {
        // definicja metody wirtualnej
        // rysowanie: Shape
    }
};

class Circle : public Shape
{
public:
    [virtual] void draw() const override
    {
        // rysowanie: Circle
    }
} ;

Kontrola nadpisywania metod wirtualnych - override

Słowo override ma specjalne znaczenie w deklaracji klas i powoduje sprawdzenie na etapie kompilacji, czy nadpisywana metoda jest zadeklarowana w taki sam sposób w klasie bazowej.

class Base
{
public:
    virtual void f();
    virtual void g() const;
    virtual void h(char);
    void k();
};

class Derived1 : public Base
{
public:
    void f(); // overrides Base::f()
    void g(); // doesn't override B::g() const
    virtual void h(char); // overrides B::h(char)
    void k(); // doesn't override
};

class Derived2: public Base
{
public:
    void f() override; // OK - overrides Base::f()
    void g() override; // error - doesn't override B::g() const
    virtual void h(char); // overrides B::h(char)
    void k() override; // error - B::k() is not virtual
};

Wywoływanie metody wirtualnej

using ShapePtr = std::unique_ptr<Shape>;

void render_shapes(const std::vector<ShapePtr>& shapes)
{
   for(const auto& s : shapes)
      s->draw();
   }
}

auto l1 = ShapePtr{ std::make_unique<Line>(1, 2, 20, 30) };
auto l2 = ShapePtr{ std::make_unique<Line>(20, 10, 15, 50) };

vector<ShapePtr> shapes;

shapes.push_back(ShapePtr{ make_unique<Circle>(10, 20, 30) });
shapes.push_back(move(l1));
shapes.push_back(move(l2));

render(shapes);

Użycie metody z klasy bazowej

Możliwe jest wywołanie metody z klasy bazowej wewnątrz metody w klasie pochodnej.

class Line: public Shape
{
public:
    virtual void move_to(const Point& new_coord)
    {
        Point old_coord = coord();

        Shape::move_to(new_coord);

        end_coord.x += ( coord().x - old_coord.x );
        end_coord.y += ( coord().y - old_coord.y );
   }
};

Metody czysto wirtualne

Metoda czysto wirtualna nie posiada implementacji. Sygnatura metody jest poprzedzona słowem kluczowym virtual a sama metoda jest zainicjowana zerem.

class Shape
{
public:
    virtual void draw() const = 0;  // pure virtual method
};

Definicja klasy jest niekompletna tak długo, jak długo zawiera ona jakąkolwiek metodę czysto wirtualną.

Klasy abstrakcyjne

Klasa abstrakcyjna - klasa która nie reprezentuje żadnego konkretnego obiektu. Tworzenie obiektu klasy abstrakcyjnej nie ma sensu. Posiada przynajmniej jedną metodę czysto wirtualną.

Przykład:

// klasa abstrakcyjna
class Shape
{
    Point coord_;

public:
    Shape(int x = 0, int y = 0) : coord_{x, y}
    {
    }

    Point coordinates() const
    {
        return coord_;
    }

    virtual ~Shape() = default;

    virtual void draw() const = 0; // metoda czysto wirtualna
};

Klasa abstrakcyjna definiuje atrybuty oraz operacje wspólne dla wszystkich obiektów klas, które będą dziedziczyły po tej klasie bazowej.

Przeciwieństwem klasy abstrakcyjnej jest klasa konkretna. Tworzenie obiektów tej klasy ma sens i jest możliwe.

Abstrakcyjne klasy bazowe

Abstrakcyjne klasy bazowe stanowią jeden z podstawowych środków umożliwiających podział rozbudowanych programów na moduły i komponenty. Ich interfejs definiuje kontrakt między obiektem, który dostarcza pewnej usługi i obiektem, który z niej korzysta.

Umożliwiają rozdzielenie interfejsu i implementacji. Jeśli umowa ta zostanie zdefiniowana za pomocą abstrakcyjnej klasy bazowej, strony tej umowy mogą być tworzone i modyfikowane niezależnie od siebie.

Destruktory wirtualne

Klasa jest właściwie przygotowana do uczestniczenia w procesie dziedziczenia tylko wtedy, gdy posiada wirtualny destruktor.

Wirtualny destruktor powinien być zdefiniowany nawet wówczas, gdy klasa bazowa nie przewiduje posiadania własnego destruktora. Domyślny destruktor nie jest destruktorem wirtualnym.

class Shape
{
   // ...
public:
    virtual ~Shape()
    {
        // implementacja
    }
};

// ...

Shape* ptrShape = new Circle(new Coord(1, 2), 10);
delete ptrShape;  // teraz bezpieczne

W przypadku trywialnej implentacji destruktora w klasie bazowej możemy go zainicjować słowem default:

class Shape
{
   // ...
public:
    virtual ~Shape() = default;
};

Blokowanie dziedziczenia lub nadpisywania metod - final

Klasy lub implementacje metod wirtualnych mogą być oznaczone jako skończone z wykorzystaniem słowa final

class NoInheritable final
{
    // ...
};

class Derived : public NoInheritable // error - base marked as final
{
    // ...
};

class Base
{
public:
    virtual void f() const;
};

class Derived1 : public Base
{
public:
    virtual void f() const final; // enables additional optimization
};

class Derived2 : public Derived1
{
public:
    virtual void f() const; // error - f() attempts to override Derived1::f()
                            // marked as final
};