Наследование и переопределение

Внимание: наследование классов иногда влечет за собой использование виртуального деструктора. Рекомендуется к прочтению ниже.

Представим, что у нас есть такой код:

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();
}

Здесь есть две проблемы:

  1. Классы left и right оба имеют метод void f(), поэтому мы получим ошибку компиляции (но ведь для нас этот метод все равно один, в каком-то смысле!)
  2. Класс 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 способов приведения типов

  1. C-style cast
  2. static_cast<T>
  3. dynamic_cast<T>
  4. reinterpret_cast<T>
  5. 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.