Как стать автором
Обновить

Наследование в C++: beginner, intermediate, advanced

Программирование *C++ *ООП *

В этой статье наследование описано на трех уровнях: beginner, intermediate и advanced. Expert нет. И ни слова про SOLID. Честно.


Beginner


Что такое наследование?


Наследование является одним из основополагающих принципов ООП. В соответствии с ним, класс может использовать переменные и методы другого класса как свои собственные.


Класс, который наследует данные, называется подклассом (subclass), производным классом (derived class) или дочерним классом (child). Класс, от которого наследуются данные или методы, называется суперклассом (super class), базовым классом (base class) или родительским классом (parent). Термины “родительский” и “дочерний” чрезвычайно полезны для понимания наследования. Как ребенок получает характеристики своих родителей, производный класс получает методы и переменные базового класса.


Наследование полезно, поскольку оно позволяет структурировать и повторно использовать код, что, в свою очередь, может значительно ускорить процесс разработки. Несмотря на это, наследование следует использовать с осторожностью, поскольку большинство изменений в суперклассе затронут все подклассы, что может привести к непредвиденным последствиям.


В этом примере, метод turn_on() и переменная serial_number не были объявлены или определены в подклассе Computer. Однако их можно использовать, поскольку они унаследованы от базового класса.


Важное примечание: приватные переменные и методы не могут быть унаследованы.


#include <iostream>
using namespace std;

class Device {
    public:
        int serial_number = 12345678;

        void turn_on() {
            cout << "Device is on" << endl;
        }
    private:
        int pincode = 87654321;
};

class Computer: public Device {};

int main() {
    Computer Computer_instance;

    Computer_instance.turn_on();
    cout << "Serial number is: " << Computer_instance.serial_number << endl;
    // cout << "Pin code is: " << Computer_instance.pincode << endl;
    // will cause compile time error
    return 0;
}

Типы наследования


В C ++ есть несколько типов наследования:


  • публичный (public)- публичные (public) и защищенные (protected) данные наследуются без изменения уровня доступа к ним;
  • защищенный (protected) — все унаследованные данные становятся защищенными;
  • приватный (private) — все унаследованные данные становятся приватными.

Для базового класса Device, уровень доступа к данным не изменяется, но поскольку производный класс Computer наследует данные как приватные, данные становятся приватными для класса Computer.


#include <iostream>
using namespace std;

class Device {
    public:
        int serial_number = 12345678;

        void turn_on() {
            cout << "Device is on" << endl;
        }
};

class Computer: private Device {
    public:
        void say_hello() {
            turn_on();
            cout << "Welcome to Windows 95!" << endl;
        }
};

int main() {
    Device Device_instance;
    Computer Computer_instance;

    cout << "\t Device" << endl;
    cout << "Serial number is: "<< Device_instance.serial_number << endl;
    Device_instance.turn_on();

    // cout << "Serial number is: " << Computer_instance.serial_number << endl;
    // Computer_instance.turn_on();
    // will cause compile time error

    cout << "\t Computer" << endl;
    Computer_instance.say_hello();
    return 0;
}

Класс Computer теперь использует метод turn_on() как и любой приватный метод: turn_on() может быть вызван изнутри класса, но попытка вызвать его напрямую из main приведет к ошибке во время компиляции. Для базового класса Device, метод turn_on() остался публичным, и может быть вызван из main.


Конструкторы и деструкторы


В C ++ конструкторы и деструкторы не наследуются. Однако они вызываются, когда дочерний класс инициализирует свой объект. Конструкторы вызываются один за другим иерархически, начиная с базового класса и заканчивая последним производным классом. Деструкторы вызываются в обратном порядке.


Важное примечание: в этой статье не освещены виртуальные десктрукторы. Дополнительный материал на эту тему можно найти к примеру в этой статье на хабре.


#include <iostream>
using namespace std;

class Device {
    public:
        // constructor
        Device() {
            cout << "Device constructor called" << endl;
        }
        // destructor
        ~Device() {
            cout << "Device destructor called" << endl;
        }
};

class Computer: public Device {
    public:
        Computer() {
            cout << "Computer constructor called" << endl;
        }
        ~Computer() {
            cout << "Computer destructor called" << endl;
        }
};

class Laptop: public Computer {
    public:
        Laptop() {
            cout << "Laptop constructor called" << endl;
        }
        ~Laptop() {
            cout << "Laptop destructor called" << endl;
        }
};

int main() {
    cout << "\tConstructors" << endl;
    Laptop Laptop_instance;
    cout << "\tDestructors" << endl;
    return 0;
}

Конструкторы: Device -> Computer -> Laptop.
Деструкторы: Laptop -> Computer -> Device.


Множественное наследование


Множественное наследование происходит, когда подкласс имеет два или более суперкласса. В этом примере, класс Laptop наследует и Monitor и Computer одновременно.


#include <iostream>
using namespace std;

class Computer {
    public:
        void turn_on() {
            cout << "Welcome to Windows 95" << endl;
        }
};

class Monitor {
    public:
        void show_image() {
            cout << "Imagine image here" << endl;
        }
};

class Laptop: public Computer, public Monitor {};

int main() {
    Laptop Laptop_instance;
    Laptop_instance.turn_on();
    Laptop_instance.show_image();
    return 0;
}

Проблематика множественного наследования


Множественное наследование требует тщательного проектирования, так как может привести к непредвиденным последствиям. Большинство таких последствий вызваны неоднозначностью в наследовании. В данном примере Laptop наследует метод turn_on() от обоих родителей и неясно какой метод должен быть вызван.


#include <iostream>
using namespace std;

class Computer {
    private:
        void turn_on() {
            cout << "Computer is on." << endl;
        }
};

class Monitor {
    public:
        void turn_on() {
            cout << "Monitor is on." << endl;
        }
};

class Laptop: public Computer, public Monitor {};

int main() {
    Laptop Laptop_instance;
    // Laptop_instance.turn_on();
    // will cause compile time error
    return 0;
}

Несмотря на то, что приватные данные не наследуются, разрешить неоднозначное наследование изменением уровня доступа к данным на приватный невозможно. При компиляции, сначала происходит поиск метода или переменной, а уже после — проверка уровня доступа к ним.


Intermediate


Проблема ромба



Проблема ромба (Diamond problem)- классическая проблема в языках, которые поддерживают возможность множественного наследования. Эта проблема возникает когда классы B и C наследуют A, а класс D наследует B и C.


К примеру, классы A, B и C определяют метод print_letter(). Если print_letter() будет вызываться классом D, неясно какой метод должен быть вызван — метод класса A, B или C. Разные языки по-разному подходят к решению ромбовидной проблем. В C ++ решение проблемы оставлено на усмотрение программиста.

Ромбовидная проблема — прежде всего проблема дизайна, и она должна быть предусмотрена на этапе проектирования. На этапе разработки ее можно разрешить следующим образом:


  • вызвать метод конкретного суперкласса;
  • обратиться к объекту подкласса как к объекту определенного суперкласса;
  • переопределить проблематичный метод в последнем дочернем классе (в коде — turn_on() в подклассе Laptop).

#include <iostream>
using namespace std;

class Device {
    public:
        void turn_on() {
            cout << "Device is on." << endl;
        }
};

class Computer: public Device {};

class Monitor: public Device {};

class Laptop: public Computer, public Monitor {
    /*
    public:
        void turn_on() {
            cout << "Laptop is on." << endl;
        }
    // uncommenting this function will resolve diamond problem
    */
};

int main() {
    Laptop Laptop_instance;

    // Laptop_instance.turn_on();
    // will produce compile time error
    // if Laptop.turn_on function is commented out

    // calling method of specific superclass
    Laptop_instance.Monitor::turn_on();

    // treating Laptop instance as Monitor instance via static cast
    static_cast<Monitor&>( Laptop_instance ).turn_on();
    return 0;
}

Если метод turn_on() не был переопределен в Laptop, вызов Laptop_instance.turn_on(), приведет к ошибке при компиляции. Объект Laptop может получить доступ к двум определениям метода turn_on() одновременно: Device:Computer:Laptop.turn_on() и Device:Monitor:Laptop.turn_on().


Проблема ромба: Конструкторы и деструкторы


Поскольку в С++ при инициализации объекта дочернего класса вызываются конструкторы всех родительских классов, возникает и другая проблема: конструктор базового класса Device будет вызван дважды.


#include <iostream>
using namespace std;

class Device {
    public:
        Device() {
            cout << "Device constructor called" << endl;
        }
};

class Computer: public Device {
    public:
        Computer() {
            cout << "Computer constructor called" << endl;
        }
};

class Monitor: public Device {
    public:
        Monitor() {
            cout << "Monitor constructor called" << endl;
        }
};

class Laptop: public Computer, public Monitor {};

int main() {
    Laptop Laptop_instance;
    return 0;
}

Виртуальное наследование


Виртуальное наследование (virtual inheritance) предотвращает появление множественных объектов базового класса в иерархии наследования. Таким образом, конструктор базового класса Device будет вызван только единожды, а обращение к методу turn_on() без его переопределения в дочернем классе не будет вызывать ошибку при компиляции.


#include <iostream>
using namespace std;

class Device {
    public:
        Device() {
            cout << "Device constructor called" << endl;
        }
        void turn_on() {
            cout << "Device is on." << endl;
        }
};

class Computer: virtual public Device {
    public:
        Computer() {
            cout << "Computer constructor called" << endl;
        }
};

class Monitor: virtual public Device {
    public:
        Monitor() {
            cout << "Monitor constructor called" << endl;
        }
};

class Laptop: public Computer, public Monitor {};

int main() {
    Laptop Laptop_instance;
    Laptop_instance.turn_on();
    return 0;
}

Примечание: виртуальное наследование в классах Computer и Monitor не разрешит ромбовидное наследование если дочерний класс Laptop будет наследовать класс Device не виртуально ( class Laptop: public Computer, public Monitor, public Device {}; ).


Абстрактный класс


В С++, класс в котором существует хотя бы один чистый виртуальный метод (pure virtual) принято считать абстрактным. Если виртуальный метод не переопределен в дочернем классе, код не скомпилируется. Также, в С++ создать объект абстрактного класса невозможно — попытка тоже вызовет ошибку при компиляции.


#include <iostream>
using namespace std;

class Device {
    public:
       void turn_on() {
            cout << "Device is on." << endl;
        }
        virtual void say_hello() = 0;
};

class Laptop: public Device {
    public:
       void say_hello() {
            cout << "Hello world!" << endl;
        }
};

int main() {
    Laptop Laptop_instance;
    Laptop_instance.turn_on();
    Laptop_instance.say_hello();

    // Device Device_instance;
    // will cause compile time error
    return 0;
}

Интерфейс


С++, в отличии от некоторых ООП языков, не предоставляет отдельного ключевого слова для обозначения интерфейса (interface). Тем не менее, реализация интерфейса возможна путем создания чистого абстрактного класса (pure abstract class) — класса в котором присутствуют только декларации методов. Такие классы также часто называют абстрактными базовыми классами (Abstract Base Class — ABC).


#include <iostream>
using namespace std;

class Device {
    public:
        virtual void turn_on() = 0;
};

class Laptop: public Device {
    public:
       void turn_on() {
            cout << "Device is on." << endl;
        }
};

int main() {
    Laptop Laptop_instance;
    Laptop_instance.turn_on();

    // Device Device_instance;
    // will cause compile time error
    return 0;
}

Advanced


Несмотря на то, что наследование — фундаментальный принцип ООП, его стоит использовать с осторожностью. Важно думать о том, что любой код который будет использоваться скорее всего будет изменен и может быть использован неочевидным для разработчика путем.


Наследование от реализованного или частично реализованного класса


Если наследование происходит не от интерфейса (чистого абстрактного класса в контексте С++), а от класса в котором присутствуют какие-либо реализации, стоит учитывать то, что класс наследник связан с родительским классом наиболее тесной из возможных связью. Большинство изменений в классе родителя могут затронуть наследника что может привести к непредвиденному поведению. Такие изменения в поведении наследника не всегда очевидны — ошибка может возникнуть в уже оттестированом и рабочем коде. Данная ситуация усугубляется наличием сложной иерархии классов. Всегда стоит помнить о том, что код может изменяться не только человеком который его написал, и пути наследования очевидные для автора могут быть не учтены его коллегами.


В противовес этому стоит заметить что наследование от частично реализованных классов имеет неоспоримое преимущество. Библиотеки и фреймворки зачастую работают следующим образом: они предоставляют пользователю абстрактный класс с несколькими виртуальными и множеством реализованных методов. Таким образом, наибольшее количество работы уже проделано — сложная логика уже написана, а пользователю остается только кастомизировать готовое решение под свои нужды.


Интерфейс


Наследование от интерфейса (чистого абстрактного класса) преподносит наследование как возможность структурирования кода и защиту пользователя. Так как интерфейс описывает какую работу будет выполнять класс-реализация, но не описывает как именно, любой пользователь интерфейса огражден от изменений в классе который реализует этот интерфейс.


Интерфейс: Пример использования


Прежде всего стоит заметить, что пример тесно связан с понятием полиморфизма, но будет рассмотрен в контексте наследования от чистого абстрактного класса.


Приложение выполняющее абстрактную бизнес логику должно настраиваться из отдельного конфигурационного файла. На раннем этапе разработки, форматирование данного конфигурационного файла до конца сформировано не было. Вынесение парсинга файла за интерфейс предоставляет несколько преимуществ.


Отсутствие однозначности касательно форматирования конфигурационного файла не тормозит процесс разработки основной программы. Два разработчика могут работать параллельно — один над бизнес логикой, а другой над парсером. Поскольку они взаимодействуют через этот интерфейс, каждый из них может работать независимо. Данный подход облегчает покрытие кода юнит тестами, так как необходимые тесты могут быть написаны с использованием мока (mock) для этого интерфейса.


Также, при изменении формата конфигурационного файла, бизнес логика приложения не затрагивается. Единственное чего требует полный переход от одного форматирования к другому — написания новой реализации уже существующего абстрактного класса (класса-парсера). В дальнейшем, возврат к изначальному формату файла требует минимальной работы — подмены одного уже существующего парсера другим.


Заключение


Наследование предоставляет множество преимуществ, но должно быть тщательно спроектировано во избежание проблем, возможность для которых оно открывает. В контексте наследования, С++ предоставляет широкий спектр инструментов который открывает массу возможностей для программиста.


А SOLID это хорошо.

Теги:
Хабы:
Всего голосов 15: ↑11 и ↓4 +7
Просмотры 171K
Комментарии Комментарии 62