Witajcie w kolejnym wpisie z serii o programowaniu obiektowym! Poprzednio dowiedzieliście się czym jest paradygmat programowania obiektowego, a także poznaliście podstawowe filary obiektowości. Dzisiaj omówimy bliżej jeden z nich: dziedziczenie. Dziedziczenie to najbardziej charakterystyczny paradygmat, z którego słyną języki obiektowe, dlatego ważne jest jego dobre zrozumienie.
Czym jest dziedziczenie?
Zacznijmy od krótkiego przypomnienia. Dziedziczenie jest mechanizmem, który pozwala na współdzielenie własności i zachowań pomiędzy klasami. Mówiąc ściślej, dzięki temu mechanizmowi możemy tworzyć bardziej wyspecjalizowane klasy na podstawie klas bazowych. Oszczędzamy w ten sposób czas i pamięć, ponieważ nie musimy tworzyć kilkukrotnie klas posiadających te same własności.
Żeby lepiej zrozumieć dziedziczenie, przejdźmy do przykładu. Powiedzmy, że chcemy stworzyć reprezentację firmy produkującej pojazdy: firma ta produkuje auta, motocykle, autobusy i traktory. Na pierwszy rzut oka wydawać by się mogło, że wystarczy stworzyć klasę dla każdego z tych pojazdów. Łatwo jednak zauważyć, że każdy z nich posiada pewne wspólne cechy, jak np. pojemność silnika, ilość koni mechanicznych, typ paliwa, czy kolor – są to ich współdzielone właściwości. Ponadto każdy z nich może jechać w przód, w tył, a także hamować – są to ich wspólne zachowania.
W takiej sytuacji wystarczy stworzyć klasę Vehicle, następnie na jej podstawie utworzyć klasy Car, Motorbike, Bus oraz Tractor. Dzięki temu każdy z tych obiektów będzie miał cechy charakterystyczne dla pojazdów mechanicznych, określone w klasie Vehicle. Dodatkowo każda z tych klas będzie mogła implementować charakterystyczne dla siebie pola i metody.
Dziedziczenie konstruktorów
Gdy tworzymy klasy pochodne, to dziedziczą one wszystkie widoczne cechy klasy bazowej (o widoczności opowiemy więcej za chwilę). Do tych cech możemy również wliczyć konstruktory, oraz destruktory. Konstruktor jest specjalną metodą, która jest wywoływana w trakcie tworzenia instancji klasy. Często przyjmuje ona kilka parametrów, wymaganych do inicjalizacji zmiennych. Destruktor zaś jest metodą, którą wywołuje się w momencie usuwania instancji klasy – tutaj powinniśmy po sobie „posprzątać”, czyli zwolnić wszelką pamięć zajmowaną przez elementy klasy.
Dzięki dziedziczeniu konstruktorów nie musimy wielokrotnie powtarzać tych samych czynności w konstruktorach klas pochodnych. Warto tu jednak zaznaczyć, że przy tworzeniu instancji klasy pochodnej najpierw wywoływany jest konstruktor klasy bazowej. Konstruktor klasy pochodnej, zanim zacznie jakiekolwiek inne działanie, odwołuje się bezpośrednio do konstruktora klasy bazowej.
Widoczność pól i metod
Tworząc klasę bazową trzeba pamiętać o zakresie widoczności pól i metod. Metody i pola private są widoczne tylko i wyłącznie w obrębie danej klasy – żadna z innych klas, nawet dziedziczących, nie ma do nich dostępu. Słowo kluczowe public ujawnia je każdemu, kto ma dostęp do instancji danych klas. Co w sytuacji, w której nie chcemy ujawniać pola/metody, ale chcemy, żeby były one dostępne dla klas dziedziczących? Wystarczy użyć zakresu protected – wszystko oznaczone tym słówkiem będzie widoczne tylko i wyłącznie w obrębie danej klasy i jej pochodnych.
Zakres dziedziczenia
W języku C++ możliwe jest również określenie zakresu dziedziczenia po klasie bazowej. Zakres ten możemy ustalić przy użyciu modyfikatora tuż przed nazwą dziedziczonej klasy. Podobnie jak w przypadku metod i pól klasy rozróżniamy trzy zakresy:
- public – zakres dostępności metod i pól nie ulega zmianie po odziedziczeniu
- protected – metody i pola z zakresu public i protected po odziedziczeniu zyskują zakres protected;
- private – wszelkie publiczne i chronione metody/pola po odziedziczeniu będą dostępne jedynie w zakresie private
Należy pamiętać, że w każdym z zakresów dziedziczenia prywatne metody/pola nie są widoczne dla klasy pochodnej.
Nadpisywanie metod
Dziedzicząc klasę mamy możliwość nadpisać niektóre z jej metod, dzięki czemu możemy zmienić jej zachowanie, jeżeli nasza pochodna klasa tego wymaga. Gdy metoda nie została w żaden sposób nadpisana, to wywołując ją np. w klasie Car tak naprawdę wywołamy ją w klasie Vehicle, która została odziedziczona przez klasę samochodu. Trzeba jednak pamiętać, że jeśli wykonamy rzutowanie do bazowej klasy instancji klasy samochodu, to zostanie wywołana bazowa metoda, nawet jeżeli została ona nadpisana w pochodnej klasie. Rzutowanie jest operacją, która pozwala interpretować obiekt jakby był instancją innej klasy – czasem dodatkowe metody/pola nie są potrzebne. Omawiane tutaj nadpisywanie jest również ściśle związane z polimorfizmem, o którym dowiecie się więcej w następnych wpisach.
Zapobieganie dziedziczeniu
Czasem zdarza się, że nie chcemy, aby dana klasa była jakkolwiek dziedziczona. W takiej sytuacji należy dodać odpowiedni modyfikator przy deklaracji klasy. Modyfikator ten ma różną postać – jest ona zależna od danego języka programowania. W przypadku C++, na którym opieramy ten kurs, wystarczy dodać słówko final po nazwie klasy. W karierze IT możesz się spotkać również z frazą sealed. Niezależnie jednak od postaci modyfikatora, ma on ten sam rezultat: nie można utworzyć klas pochodnych na podstawie takiej klasy – kompilator wypluje wtedy błąd.
Przykład
Zastosujmy nowo nabytą wiedzę w praktyce. Poniżej znajdziecie deklarację bazowej klasy pojazdu: Vehicle.
Vehicle.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#ifndef VEHICLE_H #define VEHICLE_H #include <string> #include <iostream> class Vehicle { public: Vehicle(std::string vehicleName, int engineCapacity, int horsePower); //tworzymy konstruktor, ktory przyjmuje podstawowe cechy pojazdu jako parametry std::string getName(); //tworzymy metody, ktore zwroca wartosci prywatnych pol int getEngineCapacity(); int getHorsePower(); void moveForward(float speed); //tworzymy funkcje, ktora ma za zadanie napedzac pojazd w przod void moveBackward(float speed); //tworzymy funkcje, ktora ma za zadanie napedzac pojazd w tyl void brake(); //tworzymy funkcje do hamowania protected: void toot(); //tworzymy protected metode - bedzie ona widoczna tylko dla klas dziedziczacych private: std::string _name; //tworzymy prywatne pola, ktore przechowuja podstawowe cechy pojazdu int _engineCapacity; int _horsePower; }; #endif // VEHICLE_H |
Vehicle.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
#include "vehicle.h" Vehicle::Vehicle(std::string vehicleName, int engineCapacity, int horsePower) : //konstruktor klasy naszego pojazdu _name(vehicleName), //nadajemy wartosci wczesniej utworzonym polom _engineCapacity(engineCapacity), //bazujac na parametrach przekazanych konstruktorowi _horsePower(horsePower) { } std::string Vehicle::getName() { return _name; //zwracamy wartosc prywatnego pola _name } int Vehicle::getEngineCapacity() { return _engineCapacity; //zwracamy wartosc prywatnego pola _engineCapacity } int Vehicle::getHorsePower() { return _horsePower; //zwracamy wartosc prywatnego pola _horsePower } void Vehicle::moveForward(float speed) { std::cout << _name << " wlasnie jedzie w przod z predkoscia " << speed << "." << std::endl; //implementacja jazdy do przodu } void Vehicle::moveBackward(float speed) { std::cout << _name << " wlasnie jedzie w tyl z predkoscia " << speed << "." << std::endl; //implementacja jazdy w tyl } void Vehicle::brake() { std::cout << _name << " wlasnie hamuje." << std::endl; //implementacja hamowania } void Vehicle::toot() { std::cout << _name << " wlasnie trabi." << std::endl; } |
Jak widzicie stworzyliśmy klasę Vehicle. Jak na razie posiada ona jedynie bazowe metody i pola. Konstruktor pozwala na szybkie nadanie wartości polom klasy według podanych parametrów. Zwróć uwagę, że pola klasy stworzyliśmy w sekcji private, a ich wartości zwracamy w publicznych metodach. Jest to często stosowana praktyka, mająca na celu ochronę danych przed niekontrolowaną modyfikacją. Jest to element hermetyzacji, ale o dowiesz się o niej więcej w innym wpisie. Zauważ też, że pola prywatne zaczynają się znakiem _ – ma to na celu zwiększenie przejrzystości kodu i zmniejszenie ryzyka pomyłki.
Mamy już więc bazową klasę. Pora więc stworzyć klasy pochodne:
Motorbike.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#ifndef MOTORBIKE_H #define MOTORBIKE_H #include "vehicle.h" class Motorbike : public Vehicle //rozszerzamy klase Vehicle { public: Motorbike(std::string motorbikeName, int engineCapacity, int horsePower, bool isScooter); //tworzymy konstruktor klasy Motorbike - dodajemy parametry charakterystyczne dla motocykla bool isScooter(); //tworzymy metode, ktora zwrocu wartosc prywatnego pola void doTricks(); //tworzymy metode charakterystyczna dla klasy Tractor protected: void toot(); //nadpisujemy protected metode private: bool _isScooter; //tworzymy prywatne pole }; #endif // MOTORBIKE_H |
Motorbike.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include "motorbike.h" Motorbike::Motorbike(std::string motorbikeName, int engineCapacity, int horsePower, bool isScooter): Vehicle(motorbikeName, engineCapacity, horsePower), //wywolujemy konstruktor klasy bazowej wg. podanych parametrow _isScooter(isScooter) { this->toot(); //wywolujemy metode toot od razu po stworzeniu obiektu } bool Motorbike::isScooter() { return _isScooter; //zwracamy wartosc prywatnego pola } void Motorbike::doTricks() { std::cout << getName() << " wykonuje wlasnie sztuczki!" << std::endl; //dalsza implementacja trikow } void Motorbike::toot() //nadpisujemy protected metode toot - teraz ta metoda bedzie wywolywana zamiast bazowej { std::cout << getName() << " wlasnie dal mocno po gazie!" << std::endl; //dalsza implementacja toot } |
Stworzyliśmy klasę motocykla. Zauważ, że nadpisaliśmy metodę trąbienia z klasy bazowej. Dodatkowo wywołujemy ją w konstruktorze klasy – wywoła się ona od razu po utworzeniu instancji obiektu.
Car.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#ifndef CAR_H #define CAR_H #include "vehicle.h" class Car : public Vehicle { public: Car(std::string carName, int engineCapacity, int horsePower, int seatsAmount, bool automaticGearbox); //tworzymy konstruktor klasy Car - dodajemy parametry charakterystyczne dla auta int getSeatsAmount(); //tworzymy metody, ktore zwroca wartosci prywatnych pol bool hasAutomaticGearbox(); void doDrift(); //tworzymy metody charakterystyczne dla klasy Car void openTrunk(); void closeTrunk(); private: int _seatsAmount; //tworzymy prywatne pola, ktore przechowuja cechy charakterystyczne dla klasy Car bool _hasAutomaticGearbox; }; #endif // CAR_H |
Car.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
#include "car.h" Car::Car(std::string carName, int engineCapacity, int horsePower, int seatsAmount, bool automaticGearbox) : Vehicle(carName, engineCapacity, horsePower), //wywolujemy konstruktor klasy bazowej wg. podanych parametrow _seatsAmount(seatsAmount), //nadajemy wartosci prywatnym polom wg. parametrow _hasAutomaticGearbox(automaticGearbox) { } int Car::getSeatsAmount() { return _seatsAmount; //zwracamy wartosc prywatnego pola } bool Car::hasAutomaticGearbox() { return _hasAutomaticGearbox; //zwracamy wartosc prywatnego pola } void Car::doDrift() { //cout << _name << " wlasnie driftuje"; <- to by zwrocilo blad - nie mamy dostepu do prywatnego pola _name z poziomu klasy dziedziczacej std::cout << getName() << " wlasnie driftuje" << std::endl; //implementacja funkcji do driftu } void Car::openTrunk() { //cout << _name << " wlasnie otwiera bagaznik"; <- to by zwrocilo blad - nie mamy dostepu do prywatnego pola _name z poziomu klasy dziedziczacej std::cout << getName() << " wlasnie otwiera bagaznik" << std::endl; //implementacja otwierania bagaznika } void Car::closeTrunk() { //cout << _name << " wlasnie zamyka bagaznik"; <- to by zwrocilo blad - nie mamy dostepu do prywatnego pola _name z poziomu klasy dziedziczacej std::cout << getName() << " wlasnie zamyka bagaznik" << std::endl; //implementacja zamykania bagaznika } |
Teraz stworzyliśmy klasę samochodu. Zawiera ona kilka, charakterystycznych dla siebie metod. Zauważ, że z poziomu klasy pochodnej nie mamy dostępu do prywatnego pola _name – musimy się do niego dostać poprzez publiczną metodę getName().
Tractor.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#ifndef TRACTOR_H #define TRACTOR_H #include "vehicle.h" class Tractor : public Vehicle { public: Tractor(std::string tractorName, int engineCapacity, int horsePower, bool hasACSystem); //tworzymy konstruktor klasy Tractor - dodajemy parametry charakterystyczne dla traktora bool hasACSystem(); //tworzymy metode, ktora zwrocu wartosc prywatnego pola void attachSeeder(); //tworzymy metody charakterystyczne dla klasy Tractor void brake(); //nadpisujemy dziedziczona metode: teraz ta funkcja zostanie wywolana, zamiast bazowej private: bool _hasACSystem; //tworzymy prywatne pole, przechowujace ceche naszego traktora }; #endif // TRACTOR_H |
Tractor.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include "tractor.h" Tractor::Tractor(std::string tractorName, int engineCapacity, int horsePower, bool hasACSystem) : Vehicle(tractorName, engineCapacity, horsePower), //wywolujemy konstruktor klasy bazowej wg. podanych parametrow _hasACSystem(hasACSystem) { } bool Tractor::hasACSystem() { return _hasACSystem; //zwracamy wartosc prywantego pola } void Tractor::attachSeeder() { std::cout << getName() << " wlasnie dolacza siewnik." << std::endl; //implementacja dolaczenia siewnika } void Tractor::brake() //nadpisujemy dziedziczona metode: teraz ta funkcja zostanie wywolana, zamiast bazowej { std::cout << getName() << " wlasnie gwaltownie hamuje." << std::endl; this->toot(); //implementacja gwaltownego hamowania } |
Kolejną pochodną jest klasa ciągnika. W klasie tej nadpisaliśmy funkcję hamowania: teraz ciągnik trąbi, zanim zahamuje.
Pora zebrać wszystko do kupy i sprawdzić działanie naszych klas w akcji:
Main.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <iostream> using namespace std; #include "car.h" #include "tractor.h" #include "vehicle.h" #include "tractor.h" #include "motorbike.h" int main() { Car *c = new Car("Ford", 1500, 100, 5, false); Tractor *t = new Tractor("Zetor", 1600, 120, true); Motorbike *m = new Motorbike("Honda", 125, 28, false); //Mozemy teraz wywolac metody typowe dla pojazdu: c->moveForward(15.2); t->brake(); m->moveBackward(12.2); //Mozemy tez wywolac metody z danego typu: c->doDrift(); t->attachSeeder(); m->doTricks(); return 0; } |
Zwróć uwagę, że możemy wywoływać metody charakterystyczne dla klasy Vehicle z każdej instancji jej klas pochodnych. Działanie jednak może się różnić, zależnie od tego jak określiliśmy je w konstruktorze i/lub nadpisanych metodach
A oto efekt uruchomienia powyższego programu:
Jak widzisz klasa Motorbike, zgodnie z zamiarami, w momencie stworzenia wywołała zmodyfikowaną metodę trąbienia. Klasa Tractor zaś wywołała metodę trąbienia podczas hamowania – dokładnie tak jak to planowaliśmy. Poniżej widzimy również efekty działania metod charakterystycznych dla każdej z klas.
Co ciekawe, klasy możemy dziedziczyć do momentu, aż któraś będzie oznaczona jako final. Oznacza to, że moglibyśmy stworzyć klasy pochodne od Car, np. klasy samochodów terenowych, wyścigowych, dostawczych itd. Dziedzicząc wiele klas trzeba jednak uważać, bo możemy doprowadzić do problemu diamentu. Występuje on w sytuacji, gdy dana klasa dziedziczy kilka różnych klas, spośród których co najmniej dwie mają wspólną klasę bazową. W takiej sytuacji ich własności i zachowania się pokrywają, przez co program nie wie z której dziedziczonej klasy ma korzystać, w przypadku np. wywołania wspólnej dla nich metody.
Podsumowanie
W dzisiejszym wpisie dowiedzieliście się czym jest dziedziczenie, a także bliżej poznaliście się z jego zasadami. Wspólnie sprawdziliśmy na czym ono dokładnie polega przy użyciu przykładowych klas. W następnym wpisie dowiecie się dokładniej czym jest polimorfizm (którego elementy mogliście zauważyć już dzisiaj). Udanej środy 😀