Отступление об инициализации (не имеет отношения к теме лекции).

Пусть есть класс Foo

class Foo {

public:
  Foo (int);

private:
  Foo(const &Foo);
};

и следующий код:

Foo f0(1);
Foo f1=1;

Скомпилируются они почти наверняка в одно и то же, но, если первая строчка всегда вызывает конструктор foo(int), то вторая формально представляет собой Foo(Foo(int)). Однако, компилятору разрешено оптимизировать код в вызов первого конструктора, что он, как правило и делает. Но современный компилятор увидит ошибку во второй записи, так как формально вызывается приватный конструктор Foo(const &Foo);, однако, после разрешения использовать второй конструктор, пользоваться им всё равно не будет. Вот такая история.

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

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

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

class Translator {

public:
  std::string translate(const std::string&);

};

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

Translator t;

text = t.translate("...");

Здесь и возникает проблема: переводчик должен переводить с любого языка на любой, но, если узнать с какого языка переводить ещё как-нибудь можно, то узнать на какой весьма проблематично.
Какие есть идеи?

Вариант 1. Передавать больше параметров, то есть функция теперь выглядит так:

std::string translate(const std::string&, const Language& from, const Language& to);

Однако переводчики с разных языков могут существенно различаться, и в функции translate для каждой пары языков должен быть свой кусок кода, то есть в ней сначала должен быть switch по from, а потом по to, который, во-первых, будет очень большим, а, во-вторых, для добавления нового языка его надо будет прописать во все ветки, что может очень легко привести к ошибкам. То есть, этот способ плох.

Вариант 2. Пусть теперь выбор языков происходит вне функции. Для этого нужно заготовить много классов, например TranslatorEnFr – для перевода с английского на французский, что удобно тем, что каждый переводчик может писать отдельный специально обученный человек. Тогда большой switch будет в той части программы, которая с окошком, и в каждой ветке будет написано что-нибудь вроде

TranslatorEnFr t;
text = t.translate("...");
break;

Тут можно заметить, что вторая строчка во всех случаях одинакова, и её так и хочется вытащить из switch. Можно сказать, что у всех переводчиков одинаковый интерфейс, что хотелось бы использовать. Итак –

Вариант 3.

class Translator {

public:
  virtual std::string translate(const std::string&);

};

Этот класс сам ничего не делает и в других языках называется интерфейсом. На его основе создаются уже частные случаи:

class TranslatorKrJp: public Translator {

public:
  std::string translate(const std::string&);

};

Класс "Переводчик с корейского на японский" наследуется от класса "переводчик". Также говорят, что "Переводчик с корейского на японский" называется наследником или сыном, а "переводчик" – предком, отцом или базовым классом. Тогда можно писать так:


Translator* t = new TranslatorKrJp();
text = t->translate("...");


При этом вызовется функция translate класса TranslatorKrJp, а не Translator. Почему же?

Из чего состоят объекты при наследовании.

Раньше было сказано, что вызов функции компилируется в следующие действия:
1. положить на стек параметры и адрес возврата
2. перейти по адресу этой функции
При этом функция-член класса на самом деле функция с объектом этого класса в качестве параметра. Всё это верно пока функция не virtual. Вызов виртуальной функции компилируется в другое. Во что же?

Пусть есть простенький класс:

class Foo {

private:
  int myF1, myF2;
};

Когда создаётся объект класса foo, он представляет собой в памяти просто int, и его размер – 2 размера int.
Усложним историю:

class Base {

private:
  int myF1;
};

class Derived: public Base {

private:
  int myF2;
};

Base представляет собою просто int, а Derived содержит все поля из Base и ещё свои. Поэтому синоним наследования – расширение. Если же есть виртуальная функция, то объект становится больше на один указатель – указатель на таблицу виртуальных функций.

Таблица виртуальных функций.


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

class Translator {

public:
  virtual std::string translate(const std::string&);
  virtual const language &from();
  virtual const language &to();

};

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

Translator::translate
Translator::from
Translator::to

Такая же таблица существует и для класса - наследника:

TranslatorKrJp::translate
TranslatorKrJp::from
TranslatorKrJp::to

В каждом объекте невидимое первое поле – указатель на таблицу виртуальных функций. При компиляции вызов виртуальной функции превращается в следующие действия:
1. положить на стек параметры и адрес возврата.
2. перейти в таблицу виртуальных функций.
3. найти указатель на функцию с нужным номерам и вызвать её.
Здесь важно то, что то, какая функция будет вызвана, определяется не в момент компиляции, а в момент выполнения. Теперь можно подумать о том, как могут работать функции from и to. В первом варианте можно добавить в Translator два поля: myFrom и myTo. Функции, соответвенно, будут возвращать эти поля. Тогда их можно реализовать прямо в классе Translator, и, если функции в классах-наследниках переопределены не будут, то в таблице виртуальных функций будет указатель на функцию родителя. Впрочем, в таком случае, эти функции можно сделать невиртуальными. Можно предположить, что все переводчики, например с английского, будут иметь общую часть. Тогда можно устроить наследование более хитрым образом: от класса Translator наследуется класс TranslatorEn, а от него, в свою очередь, классы TranslatorEnRu, TranslatorEnFr и т.д..

Тогда функцию from можно определить в классе TranslatorEn, а функции to и translate уже в последних. То есть существуют 2 решения:
1. Функция возвращает поле, и она не виртуальна.
2. Функция виртуальна.
Первый способ выгоднее по времени, так как при вызове виртуальной функции происходт три перехода (по адресу объекта, затем в таблицу и оттуда в функцию) вместо одного для обычной; второй – по памяти. В нашем случае экономии несущественны, так как объектов- переводчиков мало и функции их вызываются редко.

Чисто виртуальные функции.

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

  virtual std::string translate(const std::string&)=0;

Тогда объектов этого класса существовать не может.

Отступление о терминологии.

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

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

То, что было написано до сих пор, всё ещё не совсем верно. Рассмотрим следующий код:


Translator* t = new TranslatorRuEn();
text = t->translate("...");
delete t;


Что происходит при его выполнении? В первой строчке отводится место и вызывается конструктор. Во второй - вызов виртуальной функции. В третьей же – вызов деструктора. Но при компиляции неизвестно, какой именно деструктор вызывать, так что вызван будет деструктор класса Translator, что, скорее всего, не то, что нужно. Итак, при использовании виртуальных функций деструктор тоже должен быть виртуальным.

virtual ~Translator();