Класс: инкапсуляция по умолчанию
Класс в 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_; }
};
Баланс нельзя сделать отрицательным извне — инвариант держится внутри. Это и есть смысл приватности.