Наследование и переопределение
Внимание: наследование классов иногда влечет за собой использование виртуального деструктора. Рекомендуется к прочтению ниже.
Представим, что у нас есть такой код:
struct vehicle {
void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
int main() {
bus b;
b.beep();
vehicle& v = b;
v.beep();
}
Люди, пишущие код на Java, привыкли к тому, что переопределение метода сохраняется при приведении класса-потомка к более базовому классу. Поэтому они могут ожидать, что код дважды вернет bus does beep-beep
, но по умолчанию в С++ это не так.
Правильный ответ такой:
bus does beep-beep
vehicle does beep-bepp
Добавление в потомке метода с сигнатурой, уже определенной ранее в классе-родителе, не переопределяет методы базового класса. Можно в каждом потомке писать метод void foo(int, int)
, и полученный объект действительно будет все их содержать.
Как вызвать определенный метод, если таких в цепочке наследования было несколько?
- через приведение к базовому типу
- через квалифицированный поиск (смотри
namespaces, aliases, adl
)
Пример квалифицированного поиска:
struct vehicle {
void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
struct liaz : bus {
void beep() {
std::cout << "liaz does beep-beep\n";
}
};
int main() {
liaz l;
l.beep();
l.bus::beep();
l.vehicle::beep();
}
Вывод:
liaz does beep-beep
bus does beep-beep
vehicle does beep-beep
Компилятор будет искать вызываемый метод начиная с того класса в цепочке наследования, на котором он был вызван, переходя при неудаче к предкам. То есть необязательно вызывать метод непосредственно на том классе, в котором он объявлен - можно запускать поиск и с потомков. Но есть нюанс:
Множественное наследование
В С++ классам разрешено иметь более одного родителя:
struct platform {
void f() {}
};
struct body {
void f() {}
};
struct vehicle : platform, body {};
Но что будет, если теперь запустить поиск метода void f()
из класса vehicle?
int main() {
vehicle v;
v.f(); // ???
}
Мы получим ошибку компиляции, так как в overload resolution придут оба кандидата, как глубоко бы не был вложен метод f()
в цепочке наследования, поэтому здесь не обойтись без приведения к нужному классу-родителю или без квалифицированного поиска.
Внимание: даже если один из родителей будет наследоваться приватно, все равно будет получена ошибка компиляции.
Все те же правила применимы к полям классов.
Виртуальные методы (virtual и override)
Внимание: использование виртуальных функций зачастую требует знания dynamic_cast
. Рекомендуется к прочтению ниже.
Добиться похожего в Java переопределения методов можно с помощью ключевого слова virtual
рядом с нужным методом.
struct vehicle {
virtual void beep() {
std::cout << "vehicle does beep-beep\n";
}
};
struct bus : vehicle {
void beep() {
std::cout << "bus does beep-beep\n";
}
};
int main() {
bus b;
b.beep();
vehicle& v = b;
v.beep();
}
Теперь начиная с класса vehicle
и далее по потомкам метод beep
всегда будет вызывать последнее переопределение:
bus does beep-beep
bus does beep-beep
Для классов, являющихся предками vehicle
сохраняется обычное поведение.
Было бы неудобно писать один раз где-то далеко virtual
и никак о нем не вспоминать при дальнейшей разработке, поэтому можно либо писать его и во всех последующих переопределениях, либо писать ключевое слово override
, которое ничего не делает, кроме проверки компилятора на то, что данный метод действительно является виртуальным (зато сразу понятно, что следует от него ожидать).
struct vehicle {
virtual void beep() {}
};
struct bus : vehicle {
void beep() override {}
};
Квалифицированный поиск при точном указании класса-родителя, у которого нужно вызывать метод вызывает именно этот метод и не обращает внимание на виртуальность. Если в указанном родителе метода не было, то компилятор так же, как и раньше, будет идти по предкам и искать, кого бы вызвать. При нахождении такого метода виртуальность тоже будет проигнорирована.
Использование виртуальных функций не дается за бесплатно. Каждый их вызов - потенциальные прыжки по указателям, поскольку для поддержания такого поведения необходима дополнительная структура, де-факто являющаяся так называемой таблицей виртуальных функций (vtable).
Отсюда мемы, что в Java все функции - виртуальные, и что "этот господин за все заплатит".
Виртуальный деструктор, ошибка разрушения базового класса
Рассмотрим следующий код:
struct vehicle {
~vehicle() {
std::cout << "vehicle destroyed\n";
}
};
struct bus : vehicle {
~bus() {
std::cout << "bus destroyed\n";
}
};
int main() {
bus b;
}
Он является корректным: мы создали машину, затем автобус, а достигнув конца блока main
разрушили сначала автобус, затем машину. Но что будет со следующим кодом?
vehicle* v = new bus();
delete v;
Будет вызван только деструктор класса vehicle
, а память класса bus
утечет. Так происходит, поскольку компилятор не знает, что по указателю лежит не просто класс vehicle
. Может быть еще хуже, если класс bus
будет иметь в себе указатель на условный гигабайт данных, который мы тоже не удалим.
Для решения этой проблемы деструктор базового класса предлагается делать виртуальным:
struct vehicle {
virtual ~vehicle() {/* ... */}
};
struct bus : vehicle {
~bus() {/* ... */}
};
В таком случае удалению базового класса будет всегда приводить сначала к удалению производного, затем базового, что правильно.
Виртуальное наследование (virtual)
Рассмотрим следующий код:
struct base {
void f() {}
}
struct left : base {}
struct right : base {}
struct top : left, right {}
int main() {
top t;
t.f();
}
Здесь есть две проблемы:
- Классы
left
иright
оба имеют методvoid f()
, поэтому мы получим ошибку компиляции (но ведь для нас этот метод все равно один, в каком-то смысле!) - Класс
top
будет содержать в себе два независимых подклассаbase
. Это подразумевает, что у них будет отдельная память, можно отдельно изменять их поля и так далее.
Если нам не хочется иметь такое поведение, то мы можем дать команду компилятору на склеивание одинаковых базовых классов, которые были когда-то унаследованы виртуально, в один экземпляр:
struct base {
void f() {}
}
struct left : virtual base {}
struct right : virtual base {}
struct top : left, right {}
int main() {
top t;
t.f(); // no compile errors
}
При этом данная техника тоже не бесплатная: при обращении к полям/методам виртуально унаследованного класса будет совершаться прыжок по указателю.
Приведение типов
В С++ есть 5 способов приведения типов
- C-style cast
static_cast<T>
dynamic_cast<T>
reinterpret_cast<T>
const_cast<T>
C-style cast
Может приводить практически любой тип к любому (но, например, пользовательские типы без правил приведения не удастся кастануть). Его минусом является отсутствие проверки типов на совместимость, например, так можно привести указатель на int
к указателю на std::iostream
. Чаще всего не рекомендуется к использованию.
int *p = new int(5);
std::iostream *i = (std::iostream*) p;
static_cast
Предоставляет типобезопасное приведение на уровне компиляции. Умеет кастовать числа друг к другу, а также указатели и ссылки на классы вверх или вниз по цепочке наследования. Любой указатель может быть приведен к указателю на void
и обратно.
dynamic_cast
Необходим для приведения полиморфных типов (классов, которые имеют виртуальные функции, либо наследуют их от предков). Если приведение по указателю было неудачным, возвращает nullptr
. Если приведение по ссылке было неудачным, бросает std::bad_cast
. Так как этот вид каста работает в реалтайме, то его использование в качестве замены static_cast
является нецелесообразным из-за медленности, но, конечно, возможно.
reinterpret_cast
Используется для приведения несовместимых типов друг к другу. Например, для приведения указателя к числу (или наоборот), либо указателю к другому указателю или ссылке к другой ссылке. В отличие от C-style cast не умеет изменить cv-квалификаторы (const
и volatile
), как и предыдущие два каста.
const_cast
Единственный C++ каст, который умеет модифицировать cv-квалификаторы. А больше он ничего и не умеет. Зачастую нужен, чтобы просто снять const
.