Производные классы. Наследование
Производные классы дают простой, гибкий и эффективный аппарат задания для класса альтернативного интерфейса и определения класса посредством добавления возможностей к уже имеющемуся классу без перепрограммирования или перекомпиляции. С помощью производных классов можно также обеспечить общий интерфейс для нескольких различных классов так, чтобы другие части программы могли работать с объектами этих классов одинаковым образом. При этом обычно в каждый объект помещается информация о типе, чтобы эти объекты могли обрабатываться соответствующим образом в ситуациях, когда их тип нельзя узнать во время компиляции. Для элегантной и надежной обработки таких динамических зависимостей типов имеется понятие виртуальной функции. По своей сути производные классы существуют для того, чтобы облегчить программисту формулировку общности.
Наследование данных
Начнём с простого примера: объявим структуру, хранящую информацию об одном человеке (например, фамилию и имя). Будем использовать для хранения строк тип std::string (#include <string>), он весьма похож на тот класс строк, который вы реализовывали в первом задании. Итак, имеем простую структуру (можно было бы назвать её классом, но она слишком проста для этого):
struct Person /* структура "человек" */
{
std::string firstname, lastname; /* фамилия, имя */
};
Представим, что нам нужно описать структуру для хранения информации о сотруднике организации: например, его имя, фамилию и отдел, в котором он работает. Мы можем описать структуру с тремя полями (имя, фамилия, отдел), но вместо этого мы расширим определение структуры Person следующим образом:
struct Employee: Person /* структура "сотрудник"; расширяет структуру "человек" */
{
std::string department; /* отдел */
};
Теперь любой экземпляр структуры Employee будет содержать не только поле department, но также и поля firstname и lastname, унаследованные от структуры Person. Мы можем обратиться к ним так же, как если бы они были членами структуры Employee:
Employee e;
e.firstname = "Ivan";
e.lastname = "Petrov";
e.department = "sales";
std::cout << e.firstname << ", " << e.lastname << ", " <<
e.department << "\n";
Объект e обладает всеми свойствами типа Person и добавляет к ним новые свойства, указанные в описании типа Employee. Структуру, от которой производится наследование (в нашем примере это Person), мы будем называть базовой (base), а наследующую структуру (Employee) производной (derived). Аналогичная терминология будет использоваться и для классов: базовый класс, производный класс. Теперь сделаем из базовой структуры эквивалентный ей класс:
class Person
{
public:
std::string firstname, lastname;
};
В языке C++ существует правило, по которому при наследовании классов все поля и методы базового класса в производном классе приобретают уровень доступа private. Соответственно, находясь вне класса Employee, обратиться к полям базового класса (firstname и lastname будет нельзя. Эта проблема решается путем явного указания уровня доступа к базовому классу при наследовании:
class Employee: public Person
{
public:
std::string department;
};
Ниже мы чуть подробнее поговорим про уровни доступа к базовому классу, пока же советую всегда при наследовании указывать уровень доступа public.
Наследование методов
Наши классы Person и Employee описаны не очень красиво: обычно не принято выносить данные (поля) в секцию public (это подробно обсуждалось в первой части). Место данных в приватной части класса, а для доступа к ним должны быть описаны соответствующие методы. Перепишем класс Person, добавив к нему конструктор для инициализации членов firstname и lastname и методы для получения их значений:
class Person
{
std::string firstname, lastname; /* теперь приватные */
public:
Person(std::string f, std::string l): firstname(f),
lastname(l)
{ }
std::string getFirstname() { return firstname; }
std::string getLastname() { return lastname; }
};
Напомним, что синтаксис Person(std::string f, std::string l): firstname(f), lastname(l) { } используется для инициализации полей класса при конструировании. Фактически, таким образом указываются аргументы для конструкторов firstname и lastname. Тело конструктора Person оставлено пустым (об этом говорят пустые фигурные скобки { }). Обратите внимание, что это не эквивалентно присваиванию полям значений в конструкторе: Person(std::string f, std::string l) { firstname = f; lastname = l; } В этом случае сначала firstname и lastname будут созданы при помощи конструктора по умолчанию, затем им будет присвоено значение при помощи оператора присваивания. Мы описали конструктор и два метода класса Person, теперь можно создать экземпляр этого класса и получить значения его полей следующим образом:
Person p("Ivan", "Petrov");
std::cout << p.getFirstname() << ", " <<
p.getLastname() << "\n";
Что же происходит при наследовании? Вполне ожидаемо, что методы getFirstname() и getLastname() становятся доступными для класса Employee, т. к. они объявлены как public и при наследовании указан такой же уровень доступа к базовому классу. Но непонятно, как можно инициализировать поля firstname и lastname при конструировании объекта класса Employee, ведь они не являются членами класса Employee и обычная инициализация типа : firstname(f) здесь не сработает. Оказывается, при конструировании производного класса можно указывать аргументы конструктора базового класса, то есть явно указать, что для вызова конструктора базового класса Person необходимо использовать следующие параметры:
class Employee: public Person
{
std::string department;
public:
Employee(std::string f, std::string l, std::string d):
Person(f, l), department(d)
{ }
std::string getDepartment() { return department; }
};
Теперь ничто не мешает написать нам такой код:
Employee e("Ivan", "Petrov", "sales");
std::cout << e.getFirstname() << ", " <<
e.getLastname() << ", " <<
e.getDepartment() << "\n";
При этом сначала будет сконструирован объект базового класса Person (полям firstname и lastname будут присвоены значения Ivan и Petrov соответственно), а уже затем будет выполнен конструктор Employee. Деструкторы будут вызываться в обратном порядке: сначала деструктор производного, затем базового класса.
11. Деструкторы. Зачем они нужны?
Деструктор метод, который вызывается при удалении объекта из памяти (например, при достижении конца блока, в котором была введена переменная). Деструктор имеет имя, совпадающее с именем класса, но с префиксом ~. Деструктор не имеет параметров.
class Rational
{
...
public:
~Rational()
{
}
};
Для простых классов (как Rational) деструктор объявлять не обязательно. В более сложных классах он может, например, освобождать ранее выделенную память.
Виртуальный деструктор
Практически всегда деструктор делается виртуальным. Делается это для того, чтобы корректно (без утечек памяти) уничтожались объекты не только заданного класса, а и любого производного от него. Например: в игре уровни, звуки и спрайты могут создаваться загрузчиком, а уничтожаться — менеджером памяти, для которого нет разницы между уровнем и спрайтом.
Друзья
Обычное объявление компонентной функции гарантирует три логически различные вещи:
1) функция имеет доступ к скрытой части объявления класса;
2) функция находится в области видимости класса;
3) функция должна вызываться для объекта (имеется указатель this).
Объявив функцию как static, мы придаем ей только первые два свойства. В С++, объявив функцию как friend, мы наделяем ее только первым свойством. Таким образом, друг класса – это функция, которая не является компонентом класса, но которой разрешается использовать его защищенные и скрытые компоненты. Друга класса нельзя вызвать посредством операции доступа к компоненту класса, кроме случая, когда друг является компонентом другого класса. Пример:
class X {
int a;
friend void friend_set(X*, int);
public void member_set(int);
};
void friend_set(X* p, int i) {p->a = i;};
void X::member_set(int i) {a = i};
void f {
X obj;
friend_set(&obj, 10);
obj.member_set(10);
};
Механизм друзей важен потому, что функция может быть другом двух классов и потому – гораздо эффективнее обычной функции, оперирующей объектами обоих классов. Например, мы могли бы захотеть определить функцию, которая умножает матрицу на вектор. Естественно, что классы matrix и vector скрывают свое представление и обеспечивают полный набор операций для манипулирования объектами своих типов. Однако наша функция не может быть членом обоих классов. Если ее описать как обычную функцию с использованием компонентных функций обоих классов, мы можем получить не очень эффективный код. Выход – в объявлении этой функции другом обоих классов:
class matrix; // объявили класс матриц
class vector {
float v[4];
// . . .
friend vector multiply(const matrix&, const vector&);
};
class matrix {
vector v[4];
// . . .
friend vector multiply(const matrix&, const vector&);
};
Мы можем теперь написать функцию умножения, которая использует элементы векторов и матриц непосредственно:
vector multiply(const matrix& m, const vector& v) {
vector r;
for (int i = 0; i < 4; i++) { // r[i] = m[i] * v
r.v[i] = 0;
for (int j = 0; j < 4; j++) r.v[i] += m.v[i][j] * v.v[j];
};
return r;
}
Объявление друзей можно поместить как в открытой, так и закрытой части класса – не имеет значения, где именно. Так же, как и компонентные функции, друзья явно указываются в объявлении класса, Поэтому они являются часть интерфейса класса в той же мере, что и компонентные функции. В то же время дружественная функция вызывается как обычная функция. Она не получает указателя this и конкретный объект может передаваться ей только через механизм параметров.
Компонентная функция одного класса может быть другом другого:
class X {
// . . .
void f();
};
class Y {
// . . .
friend void X::f();
};
Если сразу все компонентные функции класса Х должны быть объявлены друзьями класса Y, то можно применить более короткую форму записи:
class Y {
// . . .
friend class X;
};
Функция, первое объявление которой содержит спецификатор friend, считается также внешней. Из сказанного следует:
static void f() { /* . . . */};
class X { friend g(); };// подразумевает и extern g()
class Y {
friend void f();// верно: f() имеет теперь внутренне связывание
};
static g() { /* . . . */};// ошибка: несовместимое связывание