Добавить в избранное

Классы и наследование в C++: виртуальные функции, override и полиморфизм

Чтение ≈ 5 мин

ООП в C++ без воды: конструкторы и деструкторы, наследование, virtual/override/final, виртуальные деструкторы, абстрактные классы и когда полиморфизм вообще нужен, а когда лучше шаблоны.

Класс: инкапсуляция по умолчанию

Класс в C++ объединяет данные и операции над ними. По умолчанию члены class приватны, struct — публичны; во всём остальном они идентичны. Договорённость простая: struct — для простых «мешков данных», class — для типов с инвариантами.

class Temperature {
    double celsius_ = 0;
public:
    void setCelsius(double c) {
        if (c < -273.15) throw std::invalid_argument("ниже абсолютного нуля");
        celsius_ = c;
    }
    double celsius() const { return celsius_; }
    double fahrenheit() const { return celsius_ * 9.0 / 5.0 + 32; }
};

Инвариант класса — «температура не ниже абсолютного нуля» — защищён приватным полем и проверкой в сеттере. В этом и есть смысл инкапсуляции: никто не сможет положить мусор в объект.

Конструкторы: от default до explicit

class Point {
    int x_ = 0, y_ = 0;
public:
    Point() = default;                       // конструктор по умолчанию
    Point(int x, int y) : x_(x), y_(y) {}    // с параметрами
    explicit Point(int x) : x_(x) {}         // запрет неявного int -> Point
    Point(const Point&) = default;           // копирующий
    ~Point() = default;                      // деструктор
};
  • = default — просим компилятор сгенерировать стандартную реализацию.
  • explicit — запрещает неявные преобразования. Point p = 5; не скомпилируется; Point p{5}; — будет.
  • Список инициализации : x_(x) — правильный способ инициализировать поля (в теле конструктора — это уже присваивание, что дороже и иногда невозможно для const/ссылок).

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

Наследование в C++ — инструмент для «is-a» отношений с полиморфизмом через указатели/ссылки на базовый класс:

class Shape {
public:
    virtual double area() const = 0;   // чисто виртуальная — класс абстрактный
    virtual ~Shape() = default;        // ВИРТУАЛЬНЫЙ деструктор — обязателен!
};

class Circle : public Shape {
    double r_;
public:
    explicit Circle(double r) : r_(r) {}
    double area() const override { return 3.14159 * r_ * r_; }
};

class Rect : public Shape {
    double w_, h_;
public:
    Rect(double w, double h) : w_(w), h_(h) {}
    double area() const override { return w_ * h_; }
};

Используем через базовый указатель:

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2));
shapes.push_back(std::make_unique<Rect>(3, 4));

for (const auto& s : shapes)
    std::cout << s->area() << "\n";   // вызывается нужная area — динамический полиморфизм

override, final и виртуальный деструктор

override — защита от опечаток. Без него неправильная сигнатура просто создаст новую виртуальную функцию, а ошибку вы узнаете в рантайме:

class Bad : public Shape {
    double area() { return 0; }          // забыли const — НЕ переопределение!
    double area() const override {}      // так компилятор проверит
};

final class Sealed : public Shape { ... };  // запретить дальнейшее наследование
double area() const final { ... }           // запретить переопределение

Виртуальный деструктор — правило выживания: если удаляете объект через указатель на базовый класс, деструктор базового класса обязан быть виртуальным. Иначе — неопределённое поведение и утечки:

Shape* s = new Circle(1);
delete s;   // корректно только потому, что ~Shape() virtual

Абстрактные классы и интерфейсы

Класс с чисто виртуальной функцией (= 0) — абстрактный, его нельзя инстанцировать. Полностью абстрактный класс без данных — аналог интерфейса из других языков:

class ILogger {
public:
    virtual void log(std::string_view msg) = 0;
    virtual ~ILogger() = default;
};

class FileLogger : public ILogger {
    std::ofstream f_;
public:
    explicit FileLogger(const char* path) : f_(path) {}
    void log(std::string_view msg) override { f_ << msg << "\n"; }
};

void app(ILogger& logger) {   // не знаем, файловый или консольный
    logger.log("событие");
}

Срезка объекта — подводный камень

Передача по значению «отрезает» наследника до базового класса — теряются и поля, и полиморфизм:

void bad(Shape s);              // срезка! из Circle останется Shape — вообще не скомпилируется для абстрактного
void good(const Shape& s);      // ссылка/указатель — полиморфизм работает

Если класс не абстрактный, срезка скомпилируется молча — и вызов area() вернёт базовую реализацию. Поэтому полиморфные объекты всегда передают по ссылке или указателю.

Когда виртуальные функции — а когда шаблоны

Виртуальные функции — динамический полиморфизм: выбор реализации в рантайме, лёгкое расширение новыми наследниками без перекомпиляции вызывающего кода. Цена — vtable, косвенный вызов, запрет инлайна.

Шаблоны и std::variant — статический полиморфизм: всё решается при компиляции, быстрее, но типы фиксированы. Если набор реализаций известен заранее и их немного — часто лучше std::variant + std::visit, чем иерархия классов.

Чек-лист корректного класса-наследника

  • В базе виртуальный деструктор (virtual ~Base() = default;).
  • Каждое переопределение помечено override.
  • Полиморфные объекты — только через ссылки/указатели (unique_ptr<Base>).
  • Наследование — только для «is-a»; «has-a» — это композиция (поле), она предпочтительнее.
  • Не вызывайте виртуальные функции в конструкторе/деструкторе — они разрешаются в текущий, а не конечный класс.

Итог

Минимум ООП в C++: инкапсуляция через приватные поля и инварианты, полиморфизм через virtual+override, обязательный виртуальный деструктор у базы, передача по ссылке/указателю против срезки. А для известного набора типов рассмотрите std::variant — часто это проще и быстрее.

Композиция важнее наследования

Правило «is-a vs has-a»: если объект содержит что-то — это поле, не наследование. Машина не является двигателем — машина имеет двигатель:

class Car {
    Engine engine_;       // композиция — правильно
    // а не class Car : public Engine — наследование «is-a» — ошибка
};

Наследование оправдано только для полиморфизма через базовый указатель/ссылку. Всё остальное — композиция: проще, гибче, без vtable.

Конструкторы: explicit и делегирование

class Config {
    int timeout_ = 30;
    std::string host_ = "localhost";
public:
    Config() = default;
    Config(int t) : timeout_(t) {}                        // explicit!
    Config(int t, std::string h) : Config(t) { host_ = std::move(h); }  // делегирование
    explicit Config(std::string h) : host_(std::move(h)) {}
};

Делегирующий конструктор вызывает другой — избегает дублирования инициализации.

Полиморфизм без наследования: std::variant

Когда типы известны и их немного, иерархия классов с vtable — перебор:

using Shape = std::variant<Circle, Rect, Triangle>;

double area(const Shape& s) {
    return std::visit([](const auto& shape) { return shape.area(); }, s);
}

Без указателей, без кучи, быстрее — и типы проверяются компилятором.

Инкапсуляция реального примера

class BankAccount {
    double balance_ = 0;
public:
    void deposit(double sum) {
        if (sum <= 0) throw std::invalid_argument("сумма <= 0");
        balance_ += sum;
    }
    bool withdraw(double sum) {
        if (sum > balance_ || sum <= 0) return false;
        balance_ -= sum;
        return true;
    }
    double balance() const { return balance_; }
};

Баланс нельзя сделать отрицательным извне — инвариант держится внутри. Это и есть смысл приватности.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий