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

Наследование - это механизм создания нового класса на основе уже существующего. При этом к существующему классу могут быть добавлены новые элементы (данные и функции), либо существующие функции могут быть изменены. Наследование содействует повторному использованию атрибутов и методов класса, а значит, делает процесс разработки ПО более эффективным. Возникающие между классами A и B отношения наследования позволяют, например говорить, что:

  • Класс А является базовым (base) или родительским классом, классом-предком, суперклассом (superclass)
  • Класс B является производным (derived) или дочерним классом, классом-потомком, подклассом (subclass)

Отношения наследования связывают классы в иерархию наследования, вид которой зависит от числа базовых классов у каждого производного:

  • При одиночном наследовании иерархия имеет вид дерева
    classDiagram Class1 <|-- Class2 Class1 <|-- Class3 Class2 <|-- Class4 Class4 <|-- Class5 Class4 <|-- Class6
  • При множественном наследовании – вид направленного ациклического графа произвольного вида.
    classDiagram Class2 <|-- Class11 Class9 <|-- Class11 Class11 <|-- Class7 Class11 <|-- Class5 Class8 <|-- Class7 Class8 <|-- Class3 Class10 <|-- Class11 Class10 <|-- Class3

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

class Account { /* ... */ }
class Deposit : public Account {
    // ...
}

То есть мы создаем класс счет (account). Это достаточно общий финансовый инструмент. Счета бывают привязанные к карте, накопительные, депозит. В данном примере наследуем новую сущность “депозит” путем наследование от “счета”. Наследование объявлется через :, далее идет модификатор доступа наследование и класс(ы), функциональность которых должна быть унаследована. Здесь как тоже может быть 3 типа модификаторов:

  • public - открытое наследование - все публичные (открытые) объекты базового класса доступны как публичные функции у наследника. Все защищенные объекты доступны для вызова внутри наследника. Все закрытые объекты не доступны для вызова внутри наследника.
  • protected - зашщищенное наследование - все публичные (открытые) и защищенные объекты базового класса доступны только для вызова внутри наследника. Все закрытые объекты не доступны для вызова внутри наследника.
  • protected - закрытое наследование. Все объекты базового класса не доступны для вызова в наследнике.

Защищенныe и закрытые члены класса

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

  • Доступны производному классу (прямому потомку)
  • Недоступны классам вне рассматриваемой иерархии Если наличие прямого доступа к объектам базового класса со стороны производных классов нежелательно, он вводится как закрытый (private).

В общем случае наследование с определенным типом модификатора и сами объекта можно соотнести к следующей диаграмме: Процесс итерпритации

Еще о наследовании классов/структур

По умолчанию наследование классов закрытое, наследование структур отрытое. Следующий пример демонстриует равносильные объявления:

struct A { };
struct B : A { };		 // равносильно
struct B : public A { }; 	// равносильно
class A { };
class B : A { };		 // равносильно
class B : private A { }; 	// равносильно

Перегрузка и перекрытие функций класса

Рассмотрим следующий пример

struct A {
    void f(int n) { cout << "A"; }
};
struct B : A {
    void f(long n) { cout << "B"; }
};

//...

B b;
b.f(1); // что будет выведено?

В рассматриваемом примере в структуре A имеется функция, принимающая int и отнаследованная структура B c одноименной феунцией, принмающая long. Далее создается экземпляр класса B И вызываются функция с параметром 1 (то есть int). Может возникнуть предположение, что вызовется функция f из структуры A, но, в реальности C++ это не так и будет выведено “B”. Элементы (функции и поля) базового класса могут перекрываться одноименными членами данных производного класс, при этом их типы не должны обязательно совпадать. (Для доступа к объектам базового класса его имя должно быть квалифицировано - подробнее в примере ниже).

Методы базового и производного классов не образуют множество перегруженных функций. В этом случае методы производного класса не перегружают (overload), а перекрывают (override) методы базового.

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

struct A {
    void f(int n) { cout << "A"; }
};
struct B : A {
    void f(long n) { cout << "B"; }
    using A::f;
};

//...

B b;
b.f(1); // "A"

Порядок вызова конструкторов производных классов

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

  1. Конструктор базового класса (если таковых несколько, конструкторы вызываются в порядке перечисления имен классов в списке базовых классов)
  2. Конструктор производного класса.

Порядок вызова деструкторов при уничтожении объекта производного класса прямо противоположен порядку вызова конструкторов и является следующим:

  1. Деструктор производного класса;
  2. Деструктор базового класса (или несколько)

Полиморфизм классов

В том случае, если базовый и производный классы имеют общий открытый интерфейс, говорят, что производный класс представляет собой подкласс базового. Отношение между классом и подклассом, позволяющее указателю или ссылке на базовый класс без вмешательства программиста адресовать объект производного класса, возникает в С++ благодаря поддержке полиморфизма. Рассмотри следующую иерархию:

struct A {};
struct B : A {}

//...

B b;
A a = b; // возможно так как B отнаследовано от A

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

A a;
B b = a; // ошибка

Рассмотрим следующую простую иерархию классов:

class Base {
public:
    void hi() { cout << "Hi, I'm base class" << endl; }
};

class Derived : public Base {
public:
    void hi() { cout << "Hi, I'm derived class" << endl; }
};

И следющий порядок использования:

Derived d;
Base b = d; // создается новый экземпляр Base присвоенный из Derived
d.hi(); // "Hi, I'm derived class"
b.hi(); // "Hi, I'm base class"

Здесь акцентрирую внимание на то, что несмотря на то, что переменный b было присвоен объект типа Derived функция hi будет вызывана из типа Base, а не из типа Derived, хотя сам объект является этим типом. Не помогут и ссылки и присовение по указателю:

Derived d;
Base& b = d; // ссылка типа Base на экземпляр Derived
d.hi(); // "Hi, I'm derived class"
b.hi(); // "Hi, I'm base class"
Derived d;
Base* b = &d; // указатель типа Base на экземпляр Derived
d.hi(); // "Hi, I'm derived class"
b->hi(); // "Hi, I'm base class"

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

Скрытие функции

В случаях когда нам необходимо скрыть открытую функцию из наследников (что бывает крайне редко) существует ключевое слово delete. Пример:

class Base
{
	int _value;
public:
	Base(int value): _value(value){ }
	int getValue() { return _value; }
};
 
class Derived : public Base
{
public:
	Derived(int value): Base(value)	{ }
	int getValue() = delete; // делаем этот метод недоступным
};
 
int main()
{
    Derived d(9);	
	cout << d.getValue(); // не сработает, поскольку getValue() удален
	return 0;
}