Программирование на основе классов и шаблонов

Наследование

Аладин Дмитрий Владимирович

iu5edu.ru/wiki/cpp2

План

  1. Ассоциация, композиция и агрегация.
  2. Понятие наследования. Виды и дерево наследования.
  3. Ключи наследования и правило доступа к членам производного класса.
  4. Правила наследования и примеры реализации методов классов при наследовании.
  5. Иерархия классов.
  6. Виртуальные функции. Ключевые слова override и final.
  7. Абстрактные классы и интерфейсы.
  8. Виртуальные конструкторы и деструкторы.
  9. Стратегии наследования.

Очередная классификация - три кита ООП

  1. Инкапсуляция — способ спрятать сложную логику внутри класса, предоставив программисту лаконичный и понятный интерфейс для взаимодействия с сущностью.
    <--Вы находитесь здесь-->
  2. Наследование — способ легко и просто расширить существующий класс, дополнив его функциональностью.
  3. Полиморфизм — принцип "один интерфейс — множество реализаций". Например, метод print может вывести текст на экран, распечатать его на бумаге или вовсе записать в файл.

Эпичная цитата для придания важности лекции

В ответ от 2007 года на то, почему C++ не использовался в Git вместо C:

C++ - ужасный язык. Это становится еще более ужасным из-за того, что многие некачественные программисты используют его до такой степени, что с его помощью гораздо проще генерировать полное [продукт жизнедеятельности, и это не код]. Откровенно говоря, даже если бы выбор C ничего не делал, кроме как не подпускал программистов на C++, это само по себе было бы огромной причиной использовать C.

Линус Торвальдс, создатель Linux

Аргумент Линуса Торвальдса о том, почему C++ не так хорош

C++ приводит к действительно плохому выбору дизайна. Вы неизменно начинаете использовать "приятные" библиотечные функции языка, такие как STL, Boost и прочую полную чушь, которая может "помочь" вам программировать, но вызывает:

...

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

Еще одна цитата по поводу того, что "киты" ООП не так хороши, как кажутся на первый взгляд

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

Джо Армстронг, один из соавторов Erlang

P.S. Джозеф Лесли Армстронг (27 декабря 1950 – 20 апреля 2019) был специалистом по информатике, работавшим в области отказоустойчивых распределенных систем.

Если речь пошла про языки...

Изображение создано и опубликовано, при поддержке сообщества C#.

Конкретная критика наследования ООП

ООП-наследование не отражает наследование реального мира.

Родительский объект не может изменить поведение дочерних объектов во время выполнения. Даже если вы наследуете свою ДНК от родителей, они не могут вносить изменения в вашу ДНК по своему усмотрению. Вы не наследуете поведение от своих родителей. Вы развиваете своё поведение. И вы не можете переопределить поведение своих родителей.

Илья Суздальницкий, senior full-stack-разработчик

Рекомендация "банды четырех"

[Второе правило] объектно-ориентированного проектирования:

предпочитайте композицию наследованию класса.

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

P.S. Некоторые современные языки программирования вообще его избегают [например, Go].

Композиция??? Мы только про агрегацию и наследование говорили!

Сейчас проясним!

Что насчет ассоциации?

Ассоциация – это когда один класс включает в себя другой класс в качестве одного из полей. Ассоциация описывается словом "имеет". Автомобиль имеет двигатель. Вполне естественно, что он не будет являться наследником двигателя (хотя такая архитектура тоже возможна в некоторых ситуациях).

center

Два частных случая ассоциации: композиция и агрегация

Что это за Покемон?

class Engine {
  int _power;

 public:
  explicit Engine(int p) { _power = p; }
};

class Car {
  string _model = "Porshe";
  Engine* _engine;

 public:
  Car() : _engine{new Engine(360)} {}
};

Композиция

Композиция – это когда двигатель не существует отдельно от автомобиля. Он создается при создании автомобиля и полностью управляется автомобилем.

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

Агрегация

Агрегация – это когда экземпляр двигателя создается где-то в другом месте кода, и передается в конструктор автомобиля в качестве параметра.

class Engine {
  int _power;
 public:
  explicit Engine(int p) { _power = p; }
};

class Car {
  string _model = "Porshe";
  Engine* _engine;
 public:
  explicit Car(Engine* engine) { _engine = engine; }
};

int main() {
  auto engine = new Engine(360);
  Car car(engine);
  return 0;
}

На этом закончим лекцию про наследование?

center

В любой непонятной ситуации ссылайся на Роберта Мартина

Когда наследование предпочтительнее композиции?

Сначала мы должны признать, что наследование - это всего лишь особая форма композиции. Производные классы состоят из своих базовых классов.

Однако связь производного с базовым гораздо более тесная, чем связь композита с компонентом.

Тред Роберта Мартина

Open Source - это сила*

При подготовке данной лекции использовался Open Source материал 😎

Основано на материале от @ashtanyuk. Адаптированный материал публикуется с сохранением условий распространения.

*Акция действует только при наличии паспорта "хорошего программиста". Представителям "неправильных" компаний просьба не беспокоить.

Понятие наследования

Наследование - механизм передачи свойств одних классов другим классам в процессе проектирования иерархии классов. Исходные классы называют:

  • базовыми (БК) (родителями);
  • производными (ПК) (потомками).

Виды наследования

Наследование может быть одиночным и множественным, в зависимости от числа непосредственных родителей у конкретного класса.

center

Источник картинки: статья.

Дерево наследования

Для удобного изображения отношений классов при наследовании строят специальный граф: дерево наследования:

center

Базовые классы изображают над производными.

Пример с наследованием

class Box  // тип ``коробка``
{
 protected:
  int _width, _height;

 public:
  void SetWidth(int w) { _width = w; }
  void SetHeight(int h) { _height = h; }
};
class ColoredBox : public Box  // ``цветная коробка``
{
  int _color;

 public:
  void SetColor(int c) { _color = c; }
};

Ключи наследования

При описании производного класса в его заголовке перечисляются все базовые для него классы. Доступ к элементам этих классов регулируется с помощью ключей доступа:

  • private;
  • protected;
  • public.

Если базовых классов несколько, они перечисляются через запятую.

Правило доступа к членам производного класса

Ключ доступа Раздел БК Раздел ПК
private private нет
private protected private
private public private
protected private нет
protected protected protected
protected public protected
public private нет
public protected protected
public public public

Читаем таблицу правила доступа к членам ПК

  • Если в БК некоторая переменная располагалась в разделе public, а ПК был объявлен с ключом private, то в ПК к данной переменной можно будет обращаться только членам ПК или его друзьям (эта переменная перейдет в раздел private ПК).
  • Если наследование без явного указания спецификатора, все имена базового класса в производном классе автоматически становятся приватными (или можно указать private).
  • Если наследовать с ключевым словом public - все общедоступные имена базового класса будут общедоступными в производном классе и все защищенные имена будут защищенными в производном классе.

Правила доступа к членам ПК в виде схемы

Схема взята из книги C++ под рукой: Пер. с англ. - Киев: «ДиаСофт», 1993. - 176 с

  • Пунктирная линия означает, что хотя закрытые поля базового класса и становятся частью производного класса, но к ним нельзя получить доступ через производный класс.
  • Если наследование без явного указания спецификатора, то подразумевается ключ private.

Правила наследования

Я ВАМ ЗАПРЕЩАЮ
Нельзя наследовать:

  • конструкторы;
  • деструкторы;
  • перегруженные new;
  • перегруженные =;
  • отношения дружественности.

Правила для специальных методов

  • ПК должен иметь собственные конструкторы.
  • Если в конструкторе производного класса явный вызов конструктора базового класса отсутствует, автоматически вызывается конструктор БК.
  • Для иерархии, состоящей из нескольких классов, конструкторы БК вызываются начиная с самого верхнего уровня. После этого выполняются конструкторы элементов класса, являющихся объектами, а затем - конструктор класса.
  • В случае нескольких БК их конструкторы вызываются в порядке объявления.
  • Для деструкторов эти правила справедливы, но порядок вызова обратный - сначала ПК, затем БК. Не требуется явно вызывать деструкторы, поскольку они будут вызваны автоматически.

Передача параметра в конструктор базового класса

class X {
  int _a, _b, _c;

 public:
  X(int x, int y, int z) : _a{x}, _b{y}, _c{z} {}
};
class Y : public X {
  int _val;

 public:
  Y(int d) : X(d, d + 1, d + 5) { _val = d; }
  Y(int d, int e);
};

Y::Y(int d, int e) : X(d, e, 12) { _val = d + e; }

Пример работы с производными классами (Employee)

struct Date {
  int dd, mm, yy;
};

class Employee {
  string _name, _surname;
  Date _hire_date, _fire_date;

 public:
  Employee(string name, string surname);
  ~Employee();
  void hire(Date d);
  void fire(Date d);
  string name() const;
  string surname() const;
  void print() const;
};

Пример работы с производными классами (Programmer)

class Programmer : public Employee {
  string _team;

 public:
  Programmer(string name, string surname, string team);
  ~Programmer();
  void set_team(string team);
  string team() const;
  void print() const;
};

Пример доступных методов экземпляров классов Employee и Programmer

Employee::Employee()
Employee::~Employee()
Employee::hire()
Employee::fire()
Employee::name()
Employee::surname()
Employee::print()

Programmer::Programmer()
Programmer::~Programmer()
Programmer::set_team()
Programmer::team()
Programmer::print()

Programmer::Employee::hire()
Programmer::Employee::fire()
Programmer::Employee::name()
Programmer::Employee::surname()
Programmer::Employee::print()

Пример использования методов экземпляров классов Employee и Programmer

Date start_date(1, 1, 2004), end_date(31, 12, 2007);

Employee emp("Vasya", "Pupkin");
emp.hire(start_date);
cout << emp.name() << " " << cout << emp.surname() << endl;
emp.print();
emp.fire(end_date);

Programmer prog("Petr", "Petrov", "GM00");
prog.hire(start_date);
prog.set_team("GM12");
cout << prog.name()  << " " << prog.surname() << " " <<  prog.team() << endl;
prog.print();
prog.Employee::print();
prog.fire(end_date);

Производные классы и указатели

С объектом производного класса можно обращаться как с объектом базового класса при обращении к нему при помощи указателей и ссылок.

Programmer *prog1 = new Programmer("Petr", "Petrov", "GM12");
Employee *emp1 = prog1;  // хорошо
Employee *emp2 = new Employee("vasya", "Pupkin");
Programmer *prog2 = emp2;  // ошибка. Значение типа "Employee *" не
// может быть использовано для инициализации объекта типа "Programmer *"
prog2->set_team("GM00");   // с чего бы это заработало, если ошибка выше?)

void test_function(Employee & emp);

Programmer prog3("Ivan", "Ivanov", "GM00");
test_function(prog3);  // хорошо

Пример функций-членов класса (объявление классов)

class Employee {
  string _name, _surname;
  //...
 public:
  void print() const;
  string surname() const;
  //...
};

class Programmer : public Employee {
  string _team;
  //...
 public:
  void print_surname() const;
  void print_with_team() const;
  //...
};

Пример функций-членов класса (реализация и использование классов)

void Employee::print() const { cout << _surname << endl; }
void Programmer::print_surname() const { cout << surname() << endl; }
void Programmer::print_with_team() const {
  Employee::print();
  cout << _team << endl;
}

int main() {
  Employee emp("Vasya", "Ivanov");
  Programmer prog("Petr", "Petrov", "GM12");

  emp.print();  // Ivanov

  prog.print_surname();  // Petrov
  prog.print_with_team();  // "Petrov\nGM12"
}

Пример реализации конструктора копирования

class Employee {
 protected:
  string _name, _surname;
 public:
  Employee(const Employee&);
  Employee& operator=(const Employee&);
  //...
};
class Programmer : public Employee {
  string _team;
 public:
  Programmer(const Programmer&);
  Programmer& operator=(const Programmer&);
  //...
};
Programmer::Programmer(const Programmer& rp) : Employee{rp}, _team{rp._team} {}

Programmer& Programmer::operator=(const Programmer& rp) {
  Employee::operator=(rp); // вызываем перегрузку базового класса
  _team = rp._team;
  return *this;
}

Пример иерархии классов (в виде дерева)

Рассмотрим небольшую иерархию должностей в софтверной компании:

center

Пример иерархии классов (объявление)

class Employee { /* (•‿•) */ };
class Programmer : public Employee { /* (シ_ _)シ */ };
class Team_leader : public Programmer { /* (-_-) zzZ */ };
class Proj_manager : public Employee { /* …ᘛ⁐̤ᕐᐷ */ };
class Senior_Manager : public Proj_manager { /* (,,◕ ⋏ ◕,,) */ };
class HR_manager : public Employee { /* ( ˘▽˘)っ♨ */ };

Пример иерархии классов (конкретная реализация)

class Team_leader : public Programmer {
  list<Programmer*> _team_list;
 public:
  Team_leader(string n, string fn, string t);
  bool add_designer(Programmer*);
  bool rm_designer(string fn, string n);
  Programmer* get_designer(string fn, string n) const;
};

Team_leader::Team_leader(string n, string fn, string t)
    : Programmer(n, fn, t), _team_list() {}

Пример иерархии классов (использование)

Team_leader tm("Igor", "Kotov", "GM12");

tm.hire(Date(20, 3, 2008));
cout << tm.name();
tm.set_team("GM18");
tm.add_designer(p);
tm.fire(Date());

Статическое связывание aka раннее связывание

class A {
 public:
  A() = default;
  void print() const { std::cout << "print() from A" << std::endl; }
};

class B : public A {
 public:
  B() : A() {}
  void print() const { std::cout << "print() from B" << std::endl; }
};

int main() {
  A* ent = new B();
  ent->print();  // print() from A
  delete ent;
}

Класс B наследуется от класса A, но оба этих класса определяют функцию print(), которая выводит данные об объекте.

В функции main создаем объект и присваиваем его указателю на тип A и вызываем через этот указатель функцию print. Однако даже если этому указателю присваивается адрес объекта B, то все равно вызывает реализация функции из класса A.

Класс B скрыл (затенил) реализацию функции print() из A.

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

Объяснение взято из статьи.

Динамическое связывание aka позднее связывание

class A {
 public:
  A() = default;
  virtual void print() const { std::cout << "print() from A" << std::endl; }
};

class B : public A {
 public:
  B() : A() {}
  void print() const { std::cout << "print() from B" << std::endl; }
};

int main() {
  A* ent = new B();
  ent->print();  // print() from B
  delete ent;
}

Базовый класс A определяет виртуальную функцию print, а производный класс B переопределяет её.

При вызове функции print для объекта B через указатель A* будет вызываться реализация функции именно класса B.

При динамическом связывании на этапе выполнения решается то, какую функцию какого типа вызвать.

Класс, который определяет или наследует виртуальную функцию, еще называется полиморфным. То есть в данном случае A и B являются полиморфными классами.

Объяснение взято из статьи.

Ограничения определения виртуальных функций

  • Чтобы функция попадала под динамическое связывание, в производном классе она должна иметь тот же самый набор параметров и возвращаемый тип, что и в базовом классе.
  • Если же функция имеет разный набор параметров или несоответствие по константности, то мы будем иметь дело со скрытием функций, а не переопределением. И тогда будет применяться статическое связывание.

P.S. Имеется еще ряд проблем, с которыми можно встретиться при переопределении. Но об этом поговорим при обсуждении ключевого слова override.

Пример виртуальных функций (объявление)

class Employee {
 protected:
  string _name, _surname;
  // ....

 public:
  Employee(string name, string surname);
  virtual void print() const;
};

class Programmer : public Employee {
  string _team;

 public:
  Programmer(string name, string surname, string team);
  virtual void print() const override;
};

Пример виртуальных функций (реализация)

Employee::Employee(string name, string surname)
    : _name{name}, _surname{surname} {}

void Employee::print() const { cout << _name << " " << _surname << endl; }

Programmer::Programmer(string name, string surname, string team)
    : Employee(name, surname), _team{team} {}

void Programmer::print() const {
  Employee::print(); // вызываем метод базового класса
  cout << "team: " << _team << endl;
}

Пример виртуальных функций (использование)

void print_emp(const Employee* pEmp) {
  cout << "Employee info:" << endl;
  pEmp->print();
}

int main() {
  Employee emp("Vassya", "Pupkin");
  Programmer prog("Ivan", "Sidorov", "GM12");
  print_emp(&emp);
  print_emp(&prog);
  return 0;
}
// Employee info:
// Vassya Pupkin
// Employee info:
// Ivan Sidorov
// team: GM12

Принцип выполнения виртуальных функций

center

Объяснение таблицы виртуальных функций

Любой класс, который использует виртуальные функции (или дочерний класс, родительский класс которого использует виртуальные функции), имеет свою собственную виртуальную таблицу. Это обычный статический массив, который создается компилятором во время компиляции.

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

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

Компилятор также генерирует "скрытый" код в конструкторе каждого класса для инициализации указателей его объектов адресами соответствующей vtable.

Если виртуальная функция не переопределена в производном классе, vtable производного класса хранит адрес функции в родительском классе. Таблица vtable используется для получения доступа к адресу при вызове виртуальной функции.

Механизм vtable позволяет реализовать динамическое связывание в C++. [Оп-оп, это мы к полиморфизму подобрались?]

Когда мы связываем объект производного класса с указателем базового класса, переменная vtbl указывает на vtable производного класса. Это присвоение гарантирует, что будет вызвана нужная виртуальная функция.

Как использовать виртуальные функции?

Механизм виртуальности используется, когда виртуальная функция вызывается через указатель или ссылку на базовый класс:

Employee emp("Vasya", "Pupkin");
Programmer prog("Ivan", "Sidorov", "GM12");
emp.print();   // нет, Employee::print()
prog.print();  // нет, Programmer::print()

void fn1(Employee *p) {
  p->print();  // да
}

void fn2(Employee &r) {
  r.print();  // да
}

Пример работы вызова виртуальной функции

class Employee {
  // ...
  virtual void bonus() const;
};

// массив указателей на Employee, размер
void give_a_bonus(Employee *list[], int size) {
  for (int i = 0; (i < size && list[i]); ++i) list[i]->bonus();
}
void create_lucky_list_and_give_bonus() {
  Employee **list = new (Employee *)[10];
  for (int i = 0; i < 10; ++i) list[i] = next_lucky_man();
  give_a_bonus(list);
}

Другой пример использования виртуальных функций

class Unit {
 public:
  virtual bool action() { return false; };
};
class Soldier : public Unit { /*...*/ };
class Tank : public Unit { /*...*/ };
class Mine : public Unit { /*...*/ };

class Field {
  int _unit_number;
  Unit **_units;
 public:
  Field();
  ~Field();
  void refresh_field();
  void turn();
  void move_to_end(Unit *);
  //...
};

void Field::turn() {
  for (int i = 0; i < _unit_number; ++i)
    if (_units[i]->action() != true) move_to_end(_units[i]);
}

Еще один пример виртуальных функций с подвохом

class A {
 public:
  virtual std::string_view getName1(int x) { return "A"; }
  virtual std::string_view getName2(int x) { return "A"; }
};

class B : public A {
 public:
  // обратите внимание: параметр имеет тип short int
  virtual std::string_view getName1(short int x) { return "B"; }
  // обратите внимание: функция - константная
  virtual std::string_view getName2(int x) const { return "B"; }
};

int main() {
  B b{};
  A& rBase{b};
  std::cout << rBase.getName1(1) << std::endl;                          // A
  std::cout << rBase.getName2(2) << std::endl;                          // A
  std::cout << rBase.getName1(static_cast<short int>(1)) << std::endl;  // A
}

Чиним пример

class A {
 public:
  virtual std::string_view getName1(int x) { return "A"; }
  virtual std::string_view getName2(int x) const { return "A"; }
};

class B : public A {
 public:
  virtual std::string_view getName1(int x) { return "B"; }
  virtual std::string_view getName2(int x) const { return "B"; }
};

int main() {
  B b{};
  A& rBase{b};
  std::cout << rBase.getName1(1) << std::endl;                          // B
  std::cout << rBase.getName2(2) << std::endl;                          // B
  std::cout << rBase.getName1(static_cast<short int>(1)) << std::endl;  // B
}

Ключевое слово override

Ключевое слово override служит двум целям:

  1. Это показывает начало кода, что "это виртуальный метод, который переопределяет виртуальный метод базового класса".
  2. Компилятор также знает, что это переопределение, поэтому он может "проверить", что вы не изменяете/не добавляете новые методы, которые, по вашему мнению, являются переопределениями.

Пример использования override

class A {
 public:
  virtual std::string_view getName1(int x) { return "A"; }
  virtual std::string_view getName2(int x) { return "A"; }
  virtual std::string_view getName3(int x) { return "A"; }
};

class B : public A {
 public:
  // ошибка: 'getName1' помечен как 'override', 
  // но не переопределяет какие-либо функции-члены
  virtual std::string_view getName1(short int x) override { return "B"; }
  // ошибка: аналогично 'getName1'
  virtual std::string_view getName2(int x) const override { return "B"; }
  // ок: функция является переопределением A::getName3(int)
  virtual std::string_view getName3(int x) override { return "B"; }
};

Объяснение примера использования override

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

Если же override не указать, то компилятор будет считать, что речь идет о скрытии функции, и никаких ошибок не будет генерировать.

Объяснение взято из статьи.

А нужен ли virtual в переопределении?

[...] дочерний метод автоматически наследует virtual функциональность родительского метода.

Люди повторяют virtual в дочерних классах как соглашение просто для того, чтобы явно показать, что дочерний элемент намеревается переопределить виртуальный метод (хотя у нас есть override в C++ 11).

Основано на ответе.

P.S. Возможно, это недоработка стандарта.

Ключевое слово final

При использовании в объявлении или определении виртуальной функции final спецификатор гарантирует, что функция является виртуальной, и указывает, что она не может быть переопределена производными классами. В противном случае программа неправильно сформирована (генерируется ошибка времени компиляции).

P.S. Отмечайте виртуальные функции или как virtual, или как override, но не то и другое одновременно.

Пример использования final

#include <string_view>

class A {
 public:
  virtual std::string_view getName() { return "A"; }
};

class B : public A {
 public:
  // обратите внимание на использование спецификатора final в следующей строке
  // - это делает эту функцию больше не переопределяемой
  // ok, переопределяет A::getName()
  std::string_view getName() override final { return "B"; }
};

class C : public B {
 public:
  // ошибка компиляции: переопределяет B::getName(),
  // которая объявлена конечной
  std::string_view getName() override { return "C"; }
};

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

Существуют классы, в которых виртуальные функции не имеют реализации. Такие функции называются чисто виртуальными, а классы, в которых они находятся, - абстрактными.

Я ВАМ ЗАПРЕЩАЮ
Нельзя создать экземпляр абстрактного класса.

А может все-таки можно создать экземпляр абстрактного класса?

class Cosmetics {
 public:
  virtual void make_up() = 0; // =0 - спецификатор чисто виртуальной функции pure
  virtual void touch_up() = 0;
  virtual void remove() = 0;
};
class Lipstick : public Cosmetics {
 public:
  void make_up() override final{};
  void touch_up() override final{};
  void remove() override final{};
};

int main() {
  Lipstick lips;    // ок
  Cosmetics cosmo;  // ошибка
  return 0;
}

Ограничения на использование абстрактных классов

Абстрактные классы нельзя использовать для:

  • переменных и данных членов;
  • типов аргументов;
  • типов возвращаемых функциями значений;
  • типов явных преобразований.

Если конструктор абстрактного класса вызывает чистую виртуальную функцию прямо или косвенно, результат не определен. Однако конструкторы и деструкторы абстрактных классов могут вызывать другие функции-члены.

Интерфейсы

Под интерфейсом, в узком смысле, понимается абстрактный класс, в котором:

  • все методы чисто виртуальные;
  • нет полей с данными;
  • все методы открытые (public).

В случае наследования от интерфейса, класс обязан реализовать его методы, иначе он остается абстрактным.

Виртуальный деструктор

class base {
 public:
  base() = default;
  virtual ~base() = 0;  // класс стал абстрактным
};
// в то же время, определить деструктор мы можем
base::~base() { std::cout << "~base()" << std::endl; }

class derived : public base {
 public:
  derived() {}
  ~derived() { std::cout << "~derived()" << std::endl; }
};

int main() {
  derived aDerived;  // деструктор вызывается, когда он выходит за пределы
                     // области видимости
}
// ~derived()
// ~base()

Основное правило использования виртуального деструктора

Если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным.

Деструктор по умолчанию виртуальным не будет, поэтому следует объявить его явно. Если этого не сделать, у вас в программе почти наверняка будут утечки памяти.

Зачем нужны виртуальные деструкторы?

Виртуальные деструкторы обеспечивают корректное освобождение ресурсов при применении delete к указателю на базовый класс.

Пустая реализация чистой виртуальной функции ~base гарантирует, что для функции существует по крайней мере некоторая реализация. Без этого компоновщик создает неразрешенную ошибку внешнего символа для неявного вызова.

Пример использования виртуального деструктора

class A {
 public:
  A() { std::cout << "A()" << std::endl; }
  ~A() { std::cout << "~A()" << std::endl; }
};

class B : public A {
 public:
  B() { std::cout << "B()" << std::endl; }
  ~B() { std::cout << "~B()" << std::endl; }
};

int main() { B b; }
// A()
// B()
// ~B()
// ~A()

Пример использования виртуального деструктора (проблема)

class A {
 public:
  A() { std::cout << "A()" << std::endl; }
  ~A() { std::cout << "~A()" << std::endl; }
};

class B : public A {
 public:
  B() { std::cout << "B()" << std::endl; }
  ~B() { std::cout << "~B()" << std::endl; }
};

int main() {
  A* pA = new B;
  delete pA;
}
// A()
// B()
// ~A()

Пример использования виртуального деструктора (причина проблемы)

Происходит это потому, что удаление производится через указатель на базовый класс и для вызова деструктора компилятор использует раннее связывание. Деструктор базового класса не может вызвать деструктор производного, потому что он о нем ничего не знает. В итоге часть памяти, выделенная под производный класс, безвозвратно теряется.

Чтобы этого избежать, деструктор в базовом классе должен быть объявлен как виртуальный.

Пример использования виртуального деструктора (решение)

class A {
 public:
  A() { std::cout << "A()" << std::endl; }
  virtual ~A() { std::cout << "~A()" << std::endl; }
};

class B : public A {
 public:
  B() { std::cout << "B()" << std::endl; }
  ~B() { std::cout << "~B()" << std::endl; }
};

int main() {
  A* pA = new B;
  delete pA;
}
// A()
// B()
// ~B()
// ~A()

Комментарий к примеру использования виртуального деструктора

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

Итог: объект разрушен, память освобождена.

Комментарий взят из статьи.

Использование виртуальных функций в деструкторах

class A {
 public:
  A() { std::cout << "A(); "; }
  virtual void say_bye() { std::cout << "~A(); "; }
  // Виртуальная функция 'say_bye' вызывается из деструктора '~A()'.
  // Динамическое связывание не используется.
  virtual ~A() { say_bye(); }
};

class B : public A {
 public:
  B() { std::cout << "B(); "; }
  void say_bye() { std::cout << "~B(); "; }
  ~B() { say_bye(); }
};

int main() {
  A* pA = new B;
  delete pA;
}
// A(); B(); ~B(); ~A();

Комментарий к использованию виртуальных функций в деструкторах

При вызове виртуальных методов из деструктора компилятор использует не позднее, а раннее связывание. Если подумать, зачем он делает именно так, всё становится очевидным: нужно просто рассмотреть порядок конструирования и разрушения объектов. Конструирование объекта происходит, начиная с базового класса, а разрушение идет в строго обратном порядке.

Если же мы захотим внутри деструктора БК совершить виртуальный вызов метода ПК, то фактически попытаемся обратиться к той части объекта, которая уже была разрушена.

Комментарий взят из статьи.

Виртуальные конструкторы

На самом деле, таких не существует! Но если очень нужно:

class Employee {
 public:
  virtual Employee* new_employee() { return new Employee(); }
  virtual Employee* clone() { return new Employee(*this); }
};
class Programmer {
 public:
  virtual Programmer* new_employee() { return new Programmer(); }
  virtual Programmer* clone() { return new Programmer(*this); }
};

Стратегии наследования

  • Производный класс имеет доступ к защищенным членам базового (но только для объектов собственного типа);
  • Защищенные данные приводят к проблемам сопровождения;
  • Защищенные функции - хороший способ задания операций для использования в производных классах;
  • Открытое наследование делает производный класс подтипом базового;
  • Защищенное и закрытое наследование используются для выражения деталей реализации;
  • Защищенные базовые классы полезны в иерархиях с дальнейшим наследованием;
  • Закрытые базовые классы полезны для "ужесточения интерфейса".

В следующих сериях

Чуть позже обсудим множественное наследование!

center

P.S. Спойлеры здесь.

Вопросы?

center

If not, just clap your hands!

Читаем таблицу правила доступа к членам ПК: - Если в БК некоторая переменная располагалась в разделе `public`, а ПК был объявлен с ключом `private`, то в ПК к данной переменной можно будет обращаться только членам ПК или его друзьям (эта переменная перейдет в раздел `private` ПК). - Если наследование без явного указания спецификатора, все имена базового класса в производном классе автоматически становятся приватными (или можно указать `private`). - Если наследовать с ключевым словом `public` - все общедоступные имена базового класса будут общедоступными в производном классе и все защищенные имена будут защищенными в производном классе.

Класс `B` наследуется от класса `A`, но оба этих класса определяют функцию `print()`, которая выводит данные об объекте. В функции `main` создаем объект и присваиваем его указателю на тип `A` и вызываем через этот указатель функцию `print`. Однако даже если этому указателю присваивается адрес объекта `B`, то все равно вызывает реализация функции из класса `A`. Класс `B` скрыл (затенил) реализацию функции `print()` из `A`. При статическом связывании вызов функции через указатель **определяется исключительно типом указателя**, а не объектом, на который он указывает. При статическом связывании **вызовы функций фиксируются до выполнения программы на этапе компиляции**.

Базовый класс `A` определяет **виртуальную функцию** `print`, а производный класс `B` переопределяет её. При вызове функции `print` для объекта `B` через указатель `A*` будет вызываться реализация функции именно класса `B`. При **динамическом связывании на этапе выполнения решается то, какую функцию какого типа вызвать**. Класс, который определяет или наследует виртуальную функцию, еще называется **полиморфным**. То есть в данном случае `A` и `B` являются полиморфными классами.

Стоит отметить, что виртуальные функции имеют свою цены - объекты классов с виртуальными функциями требуют немного больше памяти и немного больше времени для выполнения. Поскольку при создании объекта полиморфного класса (который имеет виртуальные функции) в объекте создается специальный указатель. Этот указатель используется для вызова любой виртуальной функции в объекте. Специальный указатель указывает на таблицу указателей функций, которая создается для класса. Эта таблица, называемая виртуальной таблицей или vtable, содержит по одной записи для каждой виртуальной функции в классе. Когда функция вызывается через указатель на объект базового класса, происходит следующая последовательность событий Указатель на vtable в объекте используется для поиска адреса vtable для класса. Затем в таблице идет поиск указателя на вызываемую виртуальную функцию. Через найденный указатель функции в vtable вызывается сама функция. В итоге вызов виртуальной функции происходит немного медленнее, чем прямой вызов невиртуальной функции, поэтому каждое объявление и вызов виртуальной функции несет некоторые накладные расходы.**.

`override` явным указывает компилятору на то, что мы хотим переопределить функцию. Если переопределяемая функция в производном классе не соответствует виртуальной функции в базовом классе по списку параметров, возвращаемому типу, константности, или в базовом классе вообще нет функции с таким именем, то **компилятор при компиляции сгенерирует ошибку**. Если же `override` не указать, то компилятор будет считать, что речь идет о скрытии функции, и никаких ошибок не будет генерировать.

Происходит это потому, что удаление производится через указатель на базовый класс и для вызова деструктора компилятор использует раннее связывание. Деструктор базового класса не может вызвать деструктор производного, потому что он о нем ничего не знает. В итоге часть памяти, выделенная под производный класс, безвозвратно теряется. Чтобы этого избежать, деструктор в базовом классе должен быть объявлен как виртуальный.

При вызове виртуальных методов из деструктора компилятор использует не позднее, а **раннее связывание**. Если подумать, зачем он делает именно так, всё становится очевидным: нужно просто рассмотреть порядок конструирования и разрушения объектов. Конструирование объекта происходит, начиная с базового класса, а разрушение идет в строго обратном порядке. Если же мы захотим внутри деструктора БК совершить виртуальный вызов метода ПК, **то фактически попытаемся обратиться к той части объекта, которая уже была разрушена**.