Наследование. Еще несколько важных вопросов о наследовании и виртуальных функциях / FAQ C++

Добавлено 15 декабря 2020 в 06:11

Как я могу настроить свой класс так, чтобы от него не наследовались?

Просто объявите класс конечным с помощью ключевого слова final.

Но также спросите себя, зачем вам это? Есть два распространенных ответа:

  1. для эффективности: чтобы ваши вызовы функций не были виртуальными;
  2. в целях безопасности: чтобы убедиться, что ваш класс не используется в качестве базового класса (например, чтобы быть уверенным, что вы можете копировать объекты, не опасаясь слайсинга).

В сегодняшних обычных реализациях вызов виртуальной функции влечет за собой выборку "vptr" (то есть указателя на виртуальную таблицу) из объекта, определение в ней местоположения через константу и косвенный вызов функции через указатель на функцию, найденный в этом месте. Обычный вызов – это чаще всего прямой вызов через точный адрес. Хотя вызов виртуальной функции кажется намного более трудоемким, правильный способ оценки затрат – это сравнение с работой, фактически выполняемой функцией. Если эта работа значительна, стоимость вызова пренебрежимо мала в сравнении с ней и часто не может быть измерена. Однако если тело функции простое (например, средство доступа или переадресация), стоимость виртуального вызова может быть соизмеримой, а иногда и значительной.

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


Как настроить функцию-член, чтобы она не переопределялась в производном классе?

Просто объявите функцию конечной с помощью ключевого слова final.

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


Можно ли, чтобы невиртуальная функция базового класса вызывала виртуальную функцию?

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

#include "Shape.h"

void Shape::print() const
{
    float a = this->area();  // area() - чисто виртуальная функция
    // ...
}

Последний ответ FAQ меня смущает. Отличается ли эта стратегия от других способов использования виртуальных функций? Что происходит?

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

  1. Предположим, у вас есть ситуация, описанная в предыдущем ответе FAQ: у вас есть функция-член, общая структура которой одинакова для каждого производного класса, но есть небольшие части, которые различны в каждом производном классе. Итак, алгоритм тот же, но детали разные. В этом случае вы должны написать общий алгоритм в базовом классе как общедоступную функцию-член (иногда не виртуальную), а небольшие фрагменты – в производных классах. Маленькие кусочки будут объявлены в базовом классе (они часто защищены, они часто являются чисто виртуальными, и они определенно являются виртуальными), и в конечном итоге они будут определены в каждом производном классе. Самый важный вопрос в этой ситуации – должна ли открытая функция-член, содержащая общий алгоритм, быть виртуальной. Ответ состоит в том, чтобы сделать ее виртуальной, если вы думаете, что какому-то производному классу может потребоваться ее переопределение.
  2. Предположим, у вас прямо противоположная ситуация из предыдущего ответа FAQ, где у вас есть функция-член, общая структура которой различается в каждом производном классе, но в ней есть небольшие части, которые одинаковы в большинстве (если не во всех) производных классах. В этом случае вы поместите общий алгоритм в общедоступную виртуальную функцию, которая в конечном итоге определяется в производных классах, а небольшие фрагменты общего кода можно записать один раз (чтобы избежать дублирования кода) и спрятать где-нибудь (где угодно!). Обычно маленькие кусочки прячут в защищенной части базового класса, но в этом нет необходимости и, возможно, это даже не лучший вариант. Просто найдите, где их спрятать, и всё будет в порядке. Обратите внимание, что если вы всё же храните их в базовом классе, обычно вы должны делать их защищенными, поскольку обычно они делают то, что публичным пользователям не нужно и они не хотят этого делать. Предполагая, что они защищены, они, вероятно, не должны быть виртуальными: если производному классу не нравится поведение в одном из них, ему не нужно вызывать эту функцию-член.

Уточню, приведенный выше список представляет собой ситуацию «и/и», а не ситуацию «или/или». Другими словами, вам не нужно выбирать между этими двумя стратегиями в каком-либо конкретном классе. Совершенно нормально, если функция-член f() соответствует стратегии № 1, а функция-член g() соответствует стратегии № 2. Другими словами, совершенно нормально, когда обе стратегии работают в одном классе.


Должен ли я использовать защищенные виртуальные функции вместо общедоступных виртуальных функций?

Иногда да, иногда нет.

Во-первых, держитесь подальше от правил «всегда/никогда» и вместо этого используйте тот подход, который лучше всего подходит для ситуации. Есть, по крайней мере, две веские причины для использования защищенных виртуальных функций (смотрите ниже), но то, что вам иногда лучше подходят защищенные виртуальные функции, не означает, что вы всегда должны их использовать. Последовательность и симметрия хороши до определенного момента, но в конечном итоге наиболее важными показателями являются затраты + график + риск, и если идея существенно не улучшает затраты и/или график и/или риск, это просто симметрия ради симметрии (или последовательность ради последовательности и т.д.).

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

Последнее заслуживает дополнительных комментариев. Представьте, что у вас есть базовый класс с набором перегруженных виртуальных функций. Чтобы упростить пример, представьте, что их всего две: virtual void f(int) и virtual void f(double). Идея идиомы «общедоступные перегруженные невиртуальные функции вызывают защищенные неперегруженные виртуальные функции» (Public Overloaded Non-Virtuals Call Protected Non-Overloaded Virtuals) состоит в том, чтобы изменить общедоступные перегруженные функции-члены на невиртуальные и заставить их вызывать защищенные неперегруженные виртуальные функции-члены.

Код с использованием общедоступных перегруженных виртуальных функций:

class Base {
public:
  virtual void f(int x);    // может быть или не быть чисто виртуальной
  virtual void f(double x); // может быть или не быть чисто виртуальной
};

Улучшение этого кода с помощью идиомы «общедоступные перегруженные невиртуальные функции вызывают защищенные неперегруженные виртуальные функции» (Public Overloaded Non-Virtuals Call Protected Non-Overloaded Virtuals):

class Base {
public:
  void f(int x)    { f_int(x); }  // невиртуальная
  void f(double x) { f_dbl(x); }  // невиртуальная
protected:
  virtual void f_int(int);
  virtual void f_dbl(double);
};

Ниже приведен обзор изначального кода:

Функция-членОбщедоступная (public)?Встраиваемая (inline)?Виртуальная (virtual)?Перегруженная?
f(int) и f(double)ДаНетДаДа

А ниже приведен обзор улучшенного кода, который использует идиому «общедоступные перегруженные невиртуальные функции вызывают защищенные неперегруженные виртуальные функции» (Public Overloaded Non-Virtuals Call Protected Non-Overloaded Virtuals):

Функция-членОбщедоступная (public)?Встраиваемая (inline)?Виртуальная (virtual)?Перегруженная?
f(int) и f(double)ДаДаНетДа
f_int(int) и f_dbl(double)НетНетДаНет

Причина, по которой я и другие использую эту идиому, состоит в том, чтобы упростить жизнь и снизить вероятность ошибок для разработчиков производных классов. Помните сформулированные выше цели: график + стоимость + риск? Давайте оценим эту идиому в свете этих целей. С точки зрения стоимости/графика базовый класс (единственное число) немного больше, но производные классы (множественное число) немного меньше, для чистого (небольшого) улучшения графика и стоимости. Более существенное улучшение касается рисков: идиома объединяет сложность правильного управления правилом скрытия в базовый класс (единственное число). Это означает, что производные классы (во множественном числе) более или менее автоматически обрабатывают правило скрытия, поэтому различные разработчики, которые создают эти производные классы, могут почти полностью сосредоточиться на деталях самих производных классов – им не нужно беспокоиться о (хитром и часто неправильно понимаемом) правиле скрытия. Это значительно снижает вероятность того, что авторы производных классов испортят правило скрытия.

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

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


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

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

Один из случаев, когда появляются закрытые виртуальные функции, – это реализация шаблона проектирования «шаблонный метод». Некоторые эксперты, например, статья Херба Саттера «Virtuality» в «C/C++ Users Journal» рекомендуют всегда определять виртуальные функции как закрытые, если нет веских причин сделать их защищенными. Виртуальные функции, по его мнению, никогда не должны быть общедоступными, потому что они определяют интерфейс класса, который должен оставаться согласованным во всех производных классах. Защищенные и закрытые виртуальные функции определяют настраиваемое поведение класса, и нет необходимости делать их общедоступными. Открытая виртуальная функция будет определять как интерфейс, так и точку настройки, двойственность, которая может отражать слабое проектирование.

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

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

Вспомните фильм «Назад в будущее». Предположим, что базовый класс был написан в прошлом году, а сегодня вы собираетесь создать новый производный класс. Функции-члены базового класса, которые, возможно, были скомпилированы и вставлены в библиотеку несколько месяцев назад, будут вызывать частную (или защищенную) виртуальную функцию, и это будет эффективно «обращаться в будущее» – код, который был скомпилирован несколько месяцев назад, будет вызывать код, который еще даже не существует – код, который вы собираетесь написать в ближайшие несколько минут. У вас нет доступа к закрытым членам базового класса – вы не можете проникнуть в прошлое, но прошлое может пойти в будущее и вызывать ваши функции-члены, которые вы еще даже не написали.

Вот как выглядит этот паттерн «шаблонный метод»:

class MyBaseClass {
public:
  void myOp();

private:
  virtual void myOp_step1() = 0;
  virtual void myOp_step2();
};

void MyBaseClass::myOp()
{
  // предобработка...

  myOp_step1();  // вызов в будущее - вызов функции производного класса
  myOp_step2();  // возможно, будущее - эта функция не чисто виртуальная

  // постобработка...
}

void MyBaseClass::myOp_step2()
{
  // это код "по умолчанию" - он может быть опционально настроен производным классом
}

В этом примере общедоступная функция-член MyBaseClass::myOp() реализует интерфейс и базовый алгоритм для выполнения какой-то операции. Предобработка и постобработка, а также последовательность шагов 1 и 2 намеренно фиксированы и не могут быть настроены производным классом. Если бы функция MyBaseClass::myOp() была виртуальной, целостность этого алгоритма была бы серьезно нарушена. Вместо этого настройка ограничивается конкретными «частями» алгоритма, реализованными в двух закрытых виртуальных функциях. Это обеспечивает лучшее соответствие производных классов первоначальному замыслу, воплощенному в базовом классе, а также упрощает настройку – автору производного класса нужно писать меньше кода.

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

В этот момент я почти могу прочитать ваши мысли: «Что? Скопируйте код?! Вы ШУТИТЕ?! Это увеличило бы стоимость обслуживания и дублировало бы ошибки!! Вы с ума сошли?!» Сошел ли я с ума, еще предстоит выяснить, но у меня достаточно опыта, чтобы понять, что жизнь иногда загоняет нас в угол. Если базовый класс не может быть изменен, иногда «наименее худшая» из плохих альтернатив – это копирование кода. Помните, не существует единственно правильного решения. Поэтому зажмите нос и сделайте то, что является наименьшим злом. Затем примите душ. Дважды. Но если вы рискуете успехом команды, потому что ждете какую-то третью сторону, чтобы изменить их базовый класс, или если вы используете #define для изменения значения private, возможно, вы выбрали худшее зло. И о да, если вы скопируете код, отметьте его большим жирным комментарием, чтобы кто-то не пришел и не подумал, что вы сошли с ума!

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


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

Потому что это было бы очень опасно, а C++ защищает вас от этой опасности.

Остальная часть этого ответа FAQ дает объяснение того, почему C++ должен защищать вас от этой опасности, но прежде чем мы начнем, имейте в виду, что вы можете получить этот эффект, как если бы динамическое связывание работало с объектом this даже во время выполнения конструктора, с помощью идиомы «динамическое связывание во время инициализации» (Dynamic Binding During Initialization Idiom).

Вы можете вызвать виртуальную функцию в конструкторе, но будьте осторожны. Это может не сделать то, что вы ожидаете. В конструкторе механизм вызова виртуальных функций отключен, поскольку переопределения из производных классов еще не произошло. Объекты конструируются снизу вверх, «базовый класс перед производным классом».

Рассмотрим:

#include<string>
#include<iostream>
using namespace std;

class B {
public:
  B(const string& ss) { cout << "B constructor\n"; f(ss); }
  virtual void f(const string&) { cout << "B::f\n";}
};

class D : public B {
public:
  D(const string & ss) :B(ss) { cout << "D constructor\n";}
  void f(const string& ss) { cout << "D::f\n"; s = ss; }
private:
  string s;
};

int main()
{
  D d("Hello");
}

программа компилируется и выводит

B constructor
B::f
D constructor

Обратите внимание, не D::f. Подумайте, что произошло бы, если бы правило было другим, чтобы из B::B() вызывалась бы D::f() : поскольку конструктор D::D() еще не был запущен, D::f() будет пытаться присвоить свой аргумент неинициализированной строке s. Результатом, скорее всего, будет немедленный сбой. Поэтому, к счастью, язык C++ не позволяет этому случиться: он гарантирует, что любой вызов this->f(), который происходит, когда управление проходит через конструктор B, в конечном итоге вызовет B::f(), а не переопределение D::f().

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

Подробности смотрите в D&E 13.2.4.2 или TC++PL3 15.4.3.

Было высказано предположение, что это правило является артефактом реализации. Это не так. Фактически, было бы заметно проще реализовать небезопасное правило вызова виртуальных функций из конструкторов точно так же, как из других функций. Однако это означало бы, что никакая виртуальная функция не может быть написана, полагаясь на инварианты, установленные базовыми классами. Это был бы ужасный беспорядок.


Хорошо, но есть ли способ имитировать такое поведение, как если бы динамическое связывание работало с объектом this в конструкторе моего базового класса?

Да: идиома «динамическое связывание во время инициализации» (Dynamic Binding During Initialization) (также известная как «вызов виртуальных функций во время инициализации»).

Для пояснения, мы говорим о ситуации, когда конструктор Base вызывает виртуальные функции для своего объекта this:

class Base {
public:
  Base();
  // ...
  virtual void foo(int n) const; // часто чисто виртуальная
  virtual double bar() const;    // часто чисто виртуальная
  // если не хотите, чтобы они вызывались "снаружи", сделайте их защищенными
};

Base::Base()
{
  // ...
  foo(42);  // Предупреждение: динамически НЕ привязывается к производному классу
  bar();    // (то же самое)
  // ...
}

class Derived : public Base {
public:
  // ...
  virtual void foo(int n) const;
  virtual double bar() const;
};

В этом ответе FAQ показаны некоторые способы имитации динамической привязки, как если бы вызовы, сделанные в конструкторе Base, динамически привязывались к производному классу объекта this. Способы, которые мы покажем, требуют компромиссов, поэтому выберите тот, который лучше всего соответствует вашим потребностям, или придумайте другой.

Первый подход – двухэтапная инициализация. На этапе 1 кто-то вызывает реальный конструктор; на этапе 2 кто-то вызывает функцию "init" для объекта. Динамическое связывание объекта this отлично работает во время этапа 2, а этап 2 концептуально является частью процесса создания, поэтому мы просто перемещаем некоторый код из исходного Base::Base() в Base::init().

class Base {
public:
  void init();  // может быть или не быть виртуальной
  // ...
  virtual void foo(int n) const; // часто чисто виртуальная
  virtual double bar() const;    // часто чисто виртуальная
};

void Base::init()
{
  //  Практически идентично телу оригинального Base::Base()
  // ...
  foo(42);
  bar();
  // ...
}

class Derived : public Base {
public:
  // ...
  virtual void foo(int n) const;
  virtual double bar() const;
};

Единственные оставшиеся вопросы – это определить, где вызывать этап 1, а где – этап 2. Вариантов того, где могут жить эти вызовы, есть множество; мы рассмотрим два.

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

В этом варианте код, создающий объект, явно выполняет оба этапа. При выполнении этапа 1 код, создающий объект, либо знает точный класс объекта (например, new Derived() или, возможно, локальный объект Derived), либо не знает точного класса объекта (например, идиома виртуального конструктора или какая-либо другая фабрика). Случай «не знает» настоятельно рекомендуется, если вы хотите упростить подключаемые модули новых производных классов.

Примечание. На этапе 1 объект часто, но не всегда, выделяется из кучи. Когда это произойдет, вы должны сохранить указатель в каком-либо управляемом указателе, таком как std::unique_ptr, указателе с подсчетом ссылок или какой-либо другом объекте, деструктор которого удаляет выделение памяти. Это лучший способ предотвратить утечку памяти, когда на этапе 2 могут возникать исключения. В следующем примере предполагается, что на этапе 1 объект выделяется из кучи.

#include <memory>

void joe_user()
{
  std::unique_ptr<Base> p( /*...как-то создает объект Derived с помощью new...*/ );
  p->init();
  // ...
}

Второй вариант – объединить первые две строки функции joe_user в некоторую функцию create. Это почти всегда правильно, когда есть много функций, подобных joe_user. Например, если вы используете какую-то фабрику, такую ​​как реестр и идиому виртуального конструктора, вы можете переместить эти две строки в статическую функцию-член с именем Base::create():

#include <memory>

class Base {
public:
  // ...
  using Ptr = std::unique_ptr<Base>;  // псевдонимы типов упрощают код
  static Ptr create();
  // ...
};

Base::Ptr Base::create()
{
  Ptr p( /*...использует фабрику для создания объекта Derived с помощью new...*/ );
  p->init();
  return p;
}

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

void joe_user()
{
  Base::Ptr p = Base::create();
  // ...
}

Если вы достаточно умны и мотивированы, вы даже можете исключить вероятность того, что кто-то сможет создать объект Derived, не вызывая для него init(). Важный шаг в достижении этой цели – сделать конструкторы Derived, включая конструктор копирования, защищенными или закрытыми.

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

Давайте назовем базовый класс этой второй иерархии Helper и его производные классы Helper1, Helper2 и т.д. Первый шаг – переместить foo() и bar() во эту вторую иерархию:

class Helper {
public:
  virtual void foo(int n) const = 0;
  virtual double bar() const = 0;
};

class Helper1 : public Helper {
public:
  virtual void foo(int n) const;
  virtual double bar() const;
};

class Helper2 : public Helper {
public:
  virtual void foo(int n) const;
  virtual double bar() const;
};

Затем удаляем init() из Base (поскольку мы больше не используем двухэтапный подход), удаляем foo() и bar() из Base и Derived (foo() и bar() теперь находятся в иерархии Helper) , и изменяем сигнатуру конструктора Base, чтобы он принимал Helper по ссылке:

class Base {
public:
  Base(const Helper& h);
  // Удаляем init(), поскольку теперь не используем двухэтапную инициализацию
  // Удаляем foo() и bar(), поскольку они теперь в Helper
};

class Derived : public Base {
public:
  // Удаляем foo() и bar(), поскольку они теперь в Helper
};

Затем определяем Base::Base(const Helper&), чтобы он вызывал h.foo(42) и h.bar() именно в тех местах, которые init() использовал для вызова this->foo(42) и this->bar():

Base::Base(const Helper& h)
{
  // Почти идентично телу исходного Base::Base(),
  // кроме вставки h.
  // ...
  h.foo(42);
  h.bar();
  ↑↑ // h. - это новое
  // ...
}

Наконец, мы изменяем конструктор Derived для передачи (возможно, временного) объекта соответствующего производного класса Helper конструктору Base (используя синтаксис списка инициализации). Например, Derived передаст экземпляр Helper2, если он будет содержать поведение, которое требуется Derived для функций foo() и bar():

Derived::Derived()
  : Base(Helper2())   // ← волшебство происходит здесь
{
  // ...
}

Обратите внимание, что Derived может передавать значения в конструктор производного класса Helper, но он не должен передавать какие-либо элементы данных, которые на самом деле находятся внутри объекта this. Раз уж мы заговорили об этом, давайте прямо скажем, что Helper::foo() и Helper::bar() не должны обращаться к членам данных объекта this, особенно к членам данных, объявленным в Derived. (Вспомните, когда инициализируются эти элементы данных, и вы поймете почему.)

Конечно, выбор производного класса Helper может быть сделан в функции, подобной joe_user, и в этом случае он будет передан в конструктор Derived, а затем в конструктор Base:

Derived::Derived(const Helper& h)
  : Base(h)
{
  // ...
}

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

class Base {
public:
  using FooFn = void (*)(int);  // псевдонимы упрощают
  using BarFn = double (*)();   // остальной код
  Base(FooFn foo, BarFn bar);
  // ...
};

Base::Base(FooFn foo, BarFn bar)
{
  // Почти идентично телу исходного Base::Base(),
  // кроме вызовов, выполненных через указатели на функции.
  // ...
  foo(42);
  bar();
  // ...
}

Класс Derived также легко реализовать:

class Derived : public Base {
public:
  Derived();
  static void foo(int n); // статическая - это важно!
  static double bar();    // статическая - это важно!
  // ...
};
Derived::Derived()
  : Base(foo, bar)  // ← передает указатели на функции в конструктор Base
{
  // ...
}

Как и раньше, функциональность foo() и/или bar() может быть передана из функций, подобных joe_user. В этом случае конструктор Derived просто принимает их и передает в конструктор Base:

Derived::Derived(FooFn foo, BarFn bar)
  : Base(foo, bar)
{
  // ...
}

Последний подход – использовать шаблоны для «передачи» функциональности производным классам. Это похоже на случай, когда функции, подобные joe_user, выбирают функцию-инициализатор или производный класс Helper, но вместо использования указателей на функции или динамической привязки они связывают код с классами через шаблоны.


Я получаю то же самое и с деструкторами: вызов виртуальной функции для объекта this из деструктора базового класса приводит к игнорированию переопределения в производном классе; что происходит?

C++ защищает вас от самих себя. То, что вы пытаетесь сделать, очень опасно, и если бы компилятор делал то, что вы хотели, вы оказались бы в худшей ситуации.

Чтобы понять, почему C++ должен защищать вас от этой опасности, убедитесь, что вы понимаете, что происходит, когда конструктор вызывает виртуальные функции для своего объекта this. Ситуация во время выполнения деструктора аналогична ситуации во время выполнения конструктора. В частности, в {теле} деструктора Base::~Base() объект, изначально принадлежавший типу Derived, уже был понижен (передан, если хотите) до объекта типа Base. Если вы вызываете виртуальную функцию, которая была переопределена в классе Derived, вызов будет разрешен в пользу вызова Base::virt(), а не в пользу вызова переопределения Derived::virt(). То же самое касается использования typeid для объекта this: объект this действительно был понижен до типа Base; это больше не объект типа Derived.

Прочтите это.


Может ли производный класс переопределить («перегрузить») функцию-член, которая не является виртуальной в базовом классе?

Это легально, но не этично.

Опытные программисты C++ иногда переопределяют невиртуальную функцию для повышения эффективности (например, если реализация производного класса может лучше использовать ресурсы производного класса) или для обхода правила скрытия. Однако видимые для клиента эффекты должны быть идентичными, поскольку невиртуальные функции выполняются на основе статического типа указателя/ссылки, а не динамического типа объекта, на который указывают/ссылаются.


Что означает «Предупреждение: Derived::f(char) скрывает Base::f(double)»?

Вот в чем дело: если Base объявляет функцию-член f(double x), а Derived объявляет функцию-член f(char c) (то же имя, но разные типы параметров и/или их константность), то f(double x) в Base «скрывается», а не «перегружается» или «переопределяется» (даже если f(double x) в Base является виртуальной).

class Base {
public:
  void f(double x);  // Не имеет значения, виртуальная она или нет
};

class Derived : public Base {
public:
  void f(char c);  // Не имеет значения, виртуальная она или нет
};

int main()
{
  Derived* d = new Derived();
  Base* b = d;
  b->f(65.3);  // Хорошо: передает 65.3 в f(double x)
  d->f(65.3);  // Странно: преобразует 65.3 в char ('A', если ASCII) и передает его в f(char c); НЕ вызывает f(double x)!!
  delete d;
  return 0;
}

Вот как можно выбраться из этой путаницы: объект Derived должен иметь объявление using скрытой функции-члена. Например,

class Base {
public:
  void f(double x);
};

class Derived : public Base {
public:
  using Base::f;  // это обратно "открывает" Base::f(double x)
  void f(char c);
};

Если ваш компилятор не поддерживает синтаксис using, переопределите скрытые функции-члены Base, даже если они не виртуальные. Обычно это переопределение просто вызывает скрытую функцию-член Base с использованием синтаксиса ::. Например,

class Derived : public Base {
public:
  void f(double x) { Base::f(x); }  // это переопределение просто вызывает Base::f(double x)
  void f(char c);
};

Примечание: проблема скрытия также возникает, если класс Base объявляет функцию-член f(char).

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

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


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

Этот вопрос (во многих вариантах) обычно задается таким примером:

#include<iostream>
using namespace std;

class B {
public:
  int f(int i) { cout << "f(int): "; return i+1; }
  // ...
};

class D : public B {
public:
  double f(double d) { cout << "f(double): "; return d+1.3; }
  // ...
};

int main()
{
  D* pd = new D;
  cout << pd->f(2) << '\n';
  cout << pd->f(2.3) << '\n';
  delete pd;
}

который будет выводить:

f(double): 3.3
f(double): 3.6

а не

f(int): 3
f(double): 3.6

как некоторые люди (ошибочно) думали, должно быть.

Другими словами, нет разрешения перегрузки междуD и B. Разрешение перегрузки концептуально происходит в одной области за раз: компилятор просматривает область действия D, находит единственную функцию double f(double) и вызывает ее. Поскольку он нашел совпадение, он никогда не утруждает себя дальнейшим изучением (включающим) область видимости B. В C++ отсутствует перегрузка между областями видимости – области видимости производных классов не являются исключением из этого общего правила (подробности смотрите в D&E или TC++PL4).

Но что, если я хочу создать набор перегрузки всех моих функций f() из базового и производного классов? Это легко сделать с помощью объявления using, которое просит включить функции в область видимости:

class D : public B {
public:
  using B::f; // делает доступными все f из B
  double f(double d) { cout << "f(double): "; return d+1.3; }
  // ...
};

Учитывая эту модификацию, вывод программы будет следующим:

f(int): 3
f(double): 3.6

То есть разрешение перегрузки было применено к функциям f() из B и f() из D, чтобы выбрать для вызова наиболее подходящую функцию f().


Что означает, что «виртуальная таблица» является неразрешенной внешней?

Если вы получаете сообщение об ошибке в форме «Ошибка: обнаружены неразрешенные или неопределенные символы: виртуальная таблица для класса Fred» («Error: Unresolved or undefined symbols detected: virtual table for class Fred»), вероятно, у вас есть неопределенная виртуальная функция-член в классе Fred.

Компилятор обычно создает волшебную структуру данных, называемую «виртуальной таблицей» для классов, которые имеют виртуальные функции (так он обрабатывает динамическое связывание). Обычно вам совсем не нужно об этом знать. Но если вы забудете определить виртуальную функцию для класса Fred, тогда вы получите эту ошибку компоновщика.

Вот немного подробностей: многие компиляторы помещают эту волшебную «виртуальную таблицу» в блок компиляции, который определяет первую не встраиваемую виртуальную функцию в классе. Таким образом, если первая не встраиваемая виртуальная функция в Fred – это wilma(), компилятор поместит виртуальную таблицу Fred в тот же блок компиляции, где он увидит Fred::wilma(). К сожалению, если вы случайно забудете определить Fred::wilma(), вместо того, чтобы получить ошибку «Fred::wilma() is undefined» («метод Fred::wilma() не определен»), вы можете получить сообщение «Fred’s virtual table is undefined» («виртуальная таблица Fred не определена»). Печально, но факт.

Теги

C++ / CppВиртуальная функцияВысокоуровневые языки программированияДеструктор / Destructor / dtor (программирование)Динамическое связываниеКонструктор / Constructor / ctor (программирование)НаследованиеПерегрузка (программирование)Правило скрытияПрограммированиеЯзыки программирования

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.