Виртуальные методы (virtual и override)

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

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

Перекроем метод draw в классах Rectangle, Ellipse и прочих наследниках (Shapes3.mq5), то есть фактически скопируем его и изменим содержимое. Многие называют такое перекрытие переопределением, однако мы будем разделять эти термины: переопределение оставим исключительно за виртуальными методами, о которых речь пойдет чуть позже.

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

class Rectangle : public Shape
{
   ...
   void draw()
   {
      Print("Drawing rectangle");
   }
};

Поскольку мы пока не умеем рисовать на экране, просто выведем сообщение в журнал.

Важно отметить, что предоставляя новую реализацию метода в классе наследнике, мы тем самым получаем 2 версии метода: одна относится к встроенному базовому объекту (внутренняя матрешка, Shape), и другая — к производному (внешняя матрешка, Rectangle).

Первая будет вызываться для переменной типа Shape, вторая — для переменной типа Rectangle.

В более длинной цепочке наследования метод может быть перекрыт и размножен еще большее количество раз.

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

При необходимости программист может вызвать реализацию метода любого из классов-прародителей: для этого применяется специальный оператор разрешения контекста — два двоеточия '::'. В частности, мы могли бы вызвать из метода draw класса Square реализацию draw из класса Rectangle: для этого указываем имя нужного класса, '::' и имя метода, например, Rectangle::draw(). Вызов draw без уточнения контекста подразумевает метод текущего класса, и потому если делать это из самого метода draw — получится бесконечная рекурсия, а в конечном итоге — переполнение стека и падение программы.

class Square : public Rectangle
{
public:
   ...
   void draw()
   {
      Rectangle::draw();
      Print("Drawing square");
   }
};

Тогда вызов draw для объекта Square вывел бы в журнал две строки:

   Square s(10020050clrGreen);
   s.draw(); // Drawing rectangle
             // Drawing square

Привязка метода к классу, в котором он описан, обеспечивает статическую диспетчеризацию (или статическое связывание): компилятор решает, какой метод вызвать, на стадии компиляции и "зашивает" найденное соответствие в двоичный код.

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

В частности, следующий код вызывает метод setColor для объекта Rectangle:

   Rectangle r(1002007550clrBlue);
   r.setColor(clrWhite);

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

Попробуем запустить в функции OnStart отрисовку произвольных фигур из массива (напомним, что мы продублировали и модифицировали метод draw во всех классах-наследниках).

   for(int i = 0i < 10; ++i)
   {
      shapes[i].draw();
   }

Как ни странно, в журнал ничего не выводится. Происходит это потому, что программа вызывает метод draw класса Shape.

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

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

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

Объявим виртуальным метод draw в классе Shape (Shapes4.mq5). Это автоматически сделает виртуальными и все его версии в производных классах.

class Shape
{
   ...
   virtual void draw()
   {
   }
};

После виртуализации метода его модификации в производных классах называют переопределением, а не перекрытием. Переопределение требует соответствия имени, типов параметров и возвращаемого значения метода (с учетом наличия/отсутствия модификаторов const).

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

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

Drawing square
Drawing circle
Drawing triangle
Drawing ellipse
Drawing triangle
Drawing rectangle
Drawing square
Drawing triangle
Drawing square
Drawing triangle

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

class Rectangle : public Shape
{
   ...
   void draw() override
   {
      Print("Drawing rectangle");
   }
};

Это дает знать компилятору, что мы делаем переопределение метода намеренно. Если в будущем вдруг изменится программный интерфейс базового класса, и перекрытый метод перестанет быть виртуальным (или просто будет удален), компилятор выдаст сообщение об ошибке: "метод определен с модификатором 'override', но не переопределяет какой-либо метод в базовом классе" ("method is declared with 'override' specifier, but does not override any base class method"). Учтите, что даже добавление или удаление модификатора const у метода меняет его сигнатуру, и переопределение от этого может "сломаться".

Ключевое слово virtual перед переопределенным методом также допустимо, но не обязательно.

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

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

В связи с этим динамическая диспетчеризация выполняется медленнее, чем статическая.

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

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

Например, если бы мы сделали метод setColor виртуальным в классе Shape:

class Shape
{
   ...
   virtual Shape *setColor(const color c)
   {
      backgroundColor = c;
      return &this;
   }
   ...
};

то могли бы переопределить его в классе Rectangle следующим образом (только в качестве демонстрации технологии):

class Rectangle : public Shape
{
   ...
   virtual Rectangle *setColor(const color coverride
   {
      // вызываем оригинальный метод
      // (выполнив предварительно осветление цвета,
      // зачем бы это ни понадобилось)
      Rectangle::setColor(c | 0x808080);
      return &this;
   }
};

Обратите внимание, что возвращаемый тип — указатель на Rectangle, вместо Shape.

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

Наш пример с рисованием фигур почти готов. Осталось наполнить виртуальные методы draw реальным содержимым. Мы сделаем это в главе Графические объекты (см. пример ObjectShapesDraw.mq5), а усовершенствуем после изучения графических ресурсов.

С учетом наследования, процедура, по которой компилятор подбирает подходящий метод, выглядит слегка запутанной. Руководствуясь именем метода и конкретным перечнем аргументов (их типами) в инструкции вызова, составляется список всех имеющихся методов-кандидатов.
 
Для невиртуальных методов сначала анализируются только методы текущего класса. Если среди них нет подходящего, компилятор продолжит поиск в базовом классе (и затем в более отдаленных предках, пока не найдет соответствия). Если же среди методов текущего класса есть подходящий (даже с учетом, вероятно, необходимой неявной конвертации типов аргументов), будет выбран именно он. Если в базовом классе был метод с более подходящими типами аргументов (без конвертации или с меньшим количеством конвертаций), компилятор до него все равно не "доберется". Иными словами, невиртуальные методы анализируются начиная от класса текущего объекта в сторону предков до первого "рабочего" совпадения.
 
Для виртуальных методов компилятор сначала находит нужный метод по имени в классе указателя и далее выбирает в таблице виртуальных функций реализацию для наиболее конкретизированного класса (наиболее дальнего наследника), в котором этот метод переопределен в цепочке между типом указателя и типом объекта. При этом неявная конвертация аргументов также может быть задействована, если нет точного соответствия по типам аргментов.

Рассмотрим следующий пример (OverrideVsOverload.mq5). Имеется 4 класса, унаследованных по цепочке: Base, Derived, Concrete и Special. Все они содержат методы с аргументами типов int и float. В функции OnStart в качестве аргументов для вызовов всех методов используются целочисленная переменная i и вещественная f.

class Base
{
public:
   void nonvirtual(float v)
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(float v)
   {
      Print(__FUNCSIG__" "v);
   }
};
 
class Derived : public Base
{
public:
   void nonvirtual(int v)
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(int v// override
   // ошибка: 'Derived::process' method is declared with 'override' specifier,
   // but does not override any base class method
   {
      Print(__FUNCSIG__" "v);
   }
};
 
class Concrete : public Derived
{
};
 
class Special : public Concrete
{
public:
   virtual void process(int voverride
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(float voverride
   {
      Print(__FUNCSIG__" "v);
   }
};

В начале мы создаем объект класса Concrete и указатель на него Base *ptr. Затем вызываем невиртуальные и виртуальные методы для них. Во второй части методы объекта Special вызываются через указатели классов Base и Derived.

void OnStart()
{
   float f = 2.0;
   int i = 1;
 
   Concrete c;
   Base *ptr = &c;
   
   // Тесты статического связывания
 
   ptr.nonvirtual(i); // Base::nonvirtual(float), конвертация int -> float
   c.nonvirtual(i);   // Derived::nonvirtual(int)
 
   // предупреждение: deprecated behavior, hidden method calling
   c.nonvirtual(f);   // Base::nonvirtual(float), т.к.
                      // подбор метода завершился в Base,
                      // Derived::nonvirtual(int) не подходит по f
 
   // Тесты динамического связывания
 
   // внимание: нет метода Base::process(int), и плюс к тому
   // нет переопределений process(float) в классах до Concrete (включительно)
   ptr.process(i);    // Base::process(float), конвертация int -> float
   c.process(i);      // Derived::process(int), т.к.
                      // в Concrete нет переопределения,
                      // а переопределение в Special не в счет
 
   Special s;
   ptr = &s;
   // внимание: нет метода Base::process(int) в ptr
   ptr.process(i);    // Special::process(float), конвертация int -> float
   ptr.process(f);    // Special::process(float)
 
   Derived *d = &s;
   d.process(i);      // Special::process(int)
 
   // предупреждение: deprecated behavior, hidden method calling
   d.process(f);      // Special::process(float)
}

Ниже показан вывод в журнал.

void Base::nonvirtual(float) 1.0
void Derived::nonvirtual(int) 1
void Base::nonvirtual(float) 2.0
void Base::process(float) 1.0
void Derived::process(int) 1
void Special::process(float) 1.0
void Special::process(float) 2.0
void Special::process(int) 1
void Special::process(float) 2.0

Вызов ptr.nonvirtual(i) происходит с помощью статического связывания, причем предварительно целое число i приводится к типу параметра — float.

Вызов c.nonvirtual(i) — также статический, и поскольку в классе Concrete метода void nonvirtual(int) нет, компилятор находит такой метод в родительском классе Derived.

Вызов на том же объекте одноименной функции со значением типа float приводит компилятор к методу Base::nonvirtual(float), поскольку Derived::nonvirtual(int) не подходит (конвертация привела бы к потере точности). Попутно компилятор выводит предупреждение "устаревшее поведение, вызов перекрытого метода" ("deprecated behavior, hidden method calling").

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

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

class Derived : public Base
{
public:
   ...
   // это переопределение подавит предупреждение
   // "deprecated behavior, hidden method calling"
   void nonvirtual(float v)
   {
      Base::nonvirtual(v);
      Print(__FUNCSIG__" "v);
   }
...

Вернемся к тестам в OnStart.

Вызов ptr.process(i) демонстрирует описанную выше проблему с путаницей переопределения и перекрытия. В классе Base есть виртуальный метод process(float), а в классе Derived добавляется новый виртуальный метод process(int) — и это не переопределение, так как типы параметров различаются. Компилятор выбирает метод по имени в базовом классе и проверяет в таблице виртуальных функций наличие переопределений в цепочке наследования вплоть до класса Concrete (включительно, это класс объекта по указателю). Поскольку переопределений не найдено, компилятор взял Base::process(float) и применил преобразование типа аргумента к параметру (int во float).

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

class Derived : public Base
{
   ...
   virtual void process(int voverride // ошибка!
   {
      Print(__FUNCSIG__" "v);
   }
};

Компилятор сообщил бы: "Метод 'Derived::process' определен с модификатором 'override', но не переопределяет ни один метод базового класса" ("'Derived::process' method is declared with 'override' specifier, but does not override any base class method"). Это послужило бы подсказкой к устранению проблемы.

Вызов process(i) для объекта Concrete выполняется с помощью Derived::process(int). Хотя у нас имеется еще более "дальнее" переопределение в классе Special, оно не подходит, потому что сделано в цепочке наследования уже после класса Concrete.

Когда указатель ptr устанавливается на объект Special, вызовы process(i) и process(f) решаются компилятором как Special::process(float). Выбор метода с параметром типа float происходит по той же причине, что описана выше, но здесь в ход вступает переопределение в классе Special.

Если же применить указатель d типа Derived, то мы, наконец-то, получим ожидаемый вызов Special::process(int) для строки d.process(i). Дело в том, что process(int) определен в Derived, и попадает в область поиска компилятора.

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

Не стоит вызывать виртуальную функцию из конструктора или деструктора! Хотя технически это возможно, виртуальное поведение в конструкторе и деструкторе полностью утрачивается, и вы можете получить неожиданные результаты. Избегать следует не только явные, но и опосредованные вызовы (например, когда из конструктора вызывается простой метод, а тот в свою очередь вызывает виртуальный).
 
Разберем ситуацию более подробно на примере конструктора. Дело в том, что в момент работы конструктора объект "собран" еще не полностью по всей цепочке наследования, а только вплоть до текущего класса. Все производные части (внешние "матрешки") еще предстоит "досоздать" вокруг имеющегося ядра. Поэтому все более поздние переопределения виртуального метода (если они есть) еще недоступны в этот момент. В результате из конструктора будет вызываться текущая версия метода.