Объектно-ориентированное программирование в C++
Если ООП для вас новое, сначала: парадигмы и уровни абстракции, ООП — о разделе (зачем объекты, введение, абстракция, инкапсуляция, наследование, полиморфизм).
Ниже — ООП в C++; также: композиция и наследование, this / static / friend, исключения, RTTI.
Полный порядок — о разделе C++.
В начале — короткие примеры с высоты птичьего полёта.
Ниже — развёрнутый разбор (инкапсуляция, vtable, Rule of Five, STL, шаблоны). Узкие темы без дублирования — память, идиомы, композиция.
Теория и синтаксис C++
| Понятие ООП | Как выражено в C++ |
|---|---|
| АДТ, класс | class / struct; модель Simula |
| Инкапсуляция | private / protected / public, дружественные функции |
| Наследование | одиночное и множественное, виртуальное наследование |
| Полиморфизм подтипов | virtual, override, чисто виртуальные функции |
| Ad hoc-полиморфизм | перегрузка функций и операторов |
| Параметрический полиморфизм | шаблоны template |
| Сообщения | вызовы методов; RTTI при dynamic_cast |
C++ — мультипарадигменный язык: ООП сочетается с процедурным кодом и шаблонами. Определения — раздел 4-08-oop.
Кратко для новичка:
class/struct— тип с полями; уclassпо умолчаниюprivate, уstruct—public.- Объект на стеке —
Dog d;; в куче —auto p = std::make_unique<Dog>();. virtual— полиморфный вызов через указатель или ссылку на базовый класс.- Чисто виртуальный метод (
= 0) — класс абстрактный, экземпляр создать нельзя. - RAII — ресурс захватывается в конструкторе и освобождается в деструкторе.
Откуда взялся C++ в контексте ООП
C++ появился в AT&T Bell Labs (Бьёрн Страуструп, с 1979 года) как расширение C с механизмом классов по мотивам Simula. Первые коммерческие трансляторы (часто как препроцессор в C) вышли в начале 1980-х; широкое распространение закрепила книга Stroustrup, 1985. Идея "эффективный C + абстракция данных и ООП" объясняет, почему в языке одновременно процедурный код, классы, шаблоны и ручное управление памятью.
| Этап эволюции языков (упрощённо) | Вклад в ООП |
|---|---|
| Simula (1960-е) | классы, абстракция данных |
| C (1970-е) | эффективность, близость к железу |
| Smalltalk-80, "бум ООП" 1980-х | чистая объектная модель |
| C++ | C + классы + позднее STL и шаблоны |
| Java, C#, Python (1990–2000-е) | платформы, GC, скриптовые ООП-стили |
Общая теория без синтаксиса C++ — сложность и декомпозиция, введение в ООП.
ООП в C++
C++ — не строго ООП-язык. Это мультипарадигмальный язык, где ООП — один из инструментов. Главные отличия:
- Нет сборщика мусора — владение ресурсами через RAII (
std::unique_ptr, контейнеры, деструкторы). Сыройnew/deleteнужен для понимания модели и низкоуровневого кода; в прикладных проектах — редко (память, идиомы). - Множественное наследование классов (алмазная проблема — решается виртуальным наследованием)
- Деструкторы (вызываются автоматически при уничтожении объекта — основа RAII)
- Нет ключевого слова
interface— роль контракта выполняют чисто виртуальные базовые классы без полей - Константность (
constвезде — параметры, методы, возвращаемые значения) - Перегрузка операторов (можно определить свой
+,-,[],()для классов)
КЛАСС Кот
поля: имя, возраст
метод мяукнуть()
КОНЕЦ
объект barsik := новый Кот(имя="Барсик", возраст=3)
barsik.мяукнуть()
Справочно на C++
Код ITЗагрузка примера кода…
Разбор:
private-поляnameиlevelскрывают состояние объекта и поддерживают инкапсуляцию.static int countхранит общее количество живых объектов класса и меняется в конструкторе/деструкторе.- Конструктор использует список инициализации
: name(name), level(level)— это базовый эффективный стиль C++. virtual ~Warrior()нужен для корректного удаления наследников через указатель на базовый тип.virtual void specialAbility() const = 0;делает класс абстрактным и задаёт обязательный контракт для наследников.
Наследование множественное:
Код ITЗагрузка примера кода…
Разбор:
Knight : public Warriorпоказывает классическоеpublic-наследование по схемеis-a.overrideвattackиspecialAbilityпроверяет корректность переопределения виртуальных методов.- Вызов
Warrior(name, level)в списке инициализации конструктора наследника инициализирует базовую часть объекта. - Классы
FlyingиSwimmingдемонстрируют множественное наследование как объединение ролей. Duck : public Flying, public Swimmingиллюстрирует риск ромбовидной иерархии при общем предке.
Чисто виртуальные классы — аналог interface (отдельная роль, без дублирования методов базового класса):
Код ITЗагрузка примера кода…
Разбор:
Healableзадаёт узкий интерфейс-роль через единственный чисто виртуальный методheal().- Виртуальный деструктор
= defaultделает интерфейс безопасным для полиморфного удаления. Paladinсочетает наследование реализации (Knight) и контрактной роли (Healable).- Каждый
overrideфиксирует, какие именно полиморфные точки поведения класс реализует. - Такой подход помогает избегать дублирования методов и держать интерфейсы предметно-ориентированными.
Не добавляйте в "интерфейс" метод с той же сигнатурой, что у базового класса, но без const (или с другим cv-квалификатором): получится два разных attack(), и override закроет только один из них. В C++ имена интерфейсов обычно без префикса I (в отличие от C#)
важен узкий контракт, а не венгерская нотация.
Время жизни объектов (в Java/C# память кучи чаще управляется runtime):
#include <memory>
// Предпочтительно: стек или умные указатели
Knight arthur("Артур", "Буран", 10);
auto gawain = std::make_unique<Knight>("Гавейн", "Ветер", 12);
auto shared = std::make_shared<Knight>("Персиваль", "Тень", 8);
// Сырой new/delete — чтобы понимать модель; в учебных и прикладных проектах — исключение
Warrior* raw = new Knight("Ланселот", "Скакун", 15);
raw->attack();
delete raw; // обязательно; иначе утечка. Для полиморфизма нужен virtual ~Warrior()
// unique_ptr — эксклюзивное владение; shared_ptr — разделяемое (счётчик ссылок, не GC)
Разбор:
Knight arthur(...)создаёт объект на стеке с автоматическим временем жизни.std::make_uniqueвозвращаетunique_ptrс эксклюзивным владением объектом.std::make_sharedсоздаёт объект с разделяемым владением и счётчиком ссылок.- Пара
new/deleteпоказана как низкоуровневая модель, требующая строгой дисциплины. - Удаление
rawчерез базовый указатель корректно только при виртуальном деструкторе в базе.
Есть также фишка - константность:
Код ITЗагрузка примера кода…
Разбор:
getValue() constгарантирует, что метод не изменяет состояние объекта.setValueостаётся немодифицированным поconst, поэтому может менять полеlevel.const Warrior&в параметре функции предотвращает копирование и запрещает изменение аргумента.const-корректность формирует более безопасный API и помогает компилятору ловить ошибки.- Разделение
const/non-constметодов делает намерение кода прозрачным.
Можно перегружать операторы:
Код ITЗагрузка примера кода…
Разбор:
operator+создаёт новый объект-сумму и не изменяет исходные векторы (const-метод).operator==реализует структурное сравнение по координатамxиy.operator[]даёт индексный доступ, делая тип похожим на контейнер.- Перегрузки повышают выразительность, если семантика оператора остаётся ожидаемой.
- Использование
v1 + v2иv1 == v2делает клиентский код компактным и читаемым.
И основа управления ресурсами - нет finally, нет try-with-resources, всё через деструкторы.
Код ITЗагрузка примера кода…
Разбор:
- Конструктор
FileHandlerзахватывает ресурс (fopen) в момент создания объекта. - Деструктор проверяет
fileи гарантированно закрывает его черезfclose. - Это классический RAII: жизненный цикл ресурса привязан к жизненному циклу объекта.
- В
processFile()освобождение произойдёт автоматически даже при исключении. - Такой стиль снижает риск утечек и упрощает обработку ошибок.
Модификаторы доступа довольно непривычные:
Код ITЗагрузка примера кода…
Разбор:
- В
classуровень доступа по умолчанию —private, поэтому поля скрыты без явногоpublic. protectedоткрывает доступ наследникам, но не внешнему коду.publicформирует внешний интерфейс класса.- В
structдоступ по умолчанию наоборотpublic, что удобно для простых data-типов. - Отличие
classиstructв C++ в основном синтаксическое (доступ по умолчанию), а не функциональное.
Полиморфизм - через виртуальные функции:
Код ITЗагрузка примера кода…
Разбор:
Animal::sound()объявленvirtual, поэтому вызов выбирается по реальному типу объекта.Dog::sound() overrideподменяет базовую реализацию в полиморфном сценарии.virtual ~Animal() = defaultобязателен для безопасного удаления через базовый указатель.std::unique_ptr<Animal>хранит объектDogи автоматически управляет временем жизни.a->sound()демонстрирует динамическую диспетчеризацию через vtable.
Для полиморфных объектов в рабочем коде предпочтительны std::unique_ptr / std::shared_ptr, а не сырой new/delete (управление памятью).
Пример класса в C++
Ниже — учебный пример с инкапсуляцией: характеристики в private, снаружи — только методы.
Код ITЗагрузка примера кода…
Директивы #include <iostream> и #include <string> подключают стандартные заголовки в начале единицы трансляции.
Ключевое слово class задаёт пользовательский тип. По умолчанию члены приватны; секция public: открывает интерфейс. Поля name_, intel_ и остальные недоступны из main — это инкапсуляция — состояние меняется через setName, setStats, takeDamage, а не произвольной записью в поля.
Список инициализации в конструкторе (: name_("Имя"), …) задаёт начальные значения до входа в тело — предпочтительный стиль в C++.
Метод getDamage помечен const: он не меняет *this. attack принимает ссылку Unit&, чтобы не копировать цель и снижать здоровье через takeDamage.
Функция main создаёт объекты на стеке и настраивает их через публичный интерфейс. Сборка: g++ -std=c++17 unit.cpp -o unit && ./unit.
Объектно-ориентированное программирование
Объектно-ориентированное программирование (ООП) — это парадигма, в которой программа строится вокруг объектов, объединяющих данные и поведение, с которыми эти данные связаны. Эта модель позволяет проектировать программы в терминах предметной области, повышая читаемость, сопровождаемость и масштабируемость кода.
C++ — один из немногих языков, где ООП встроено органично в многоуровневую систему абстракций, не вытесняя другие парадигмы. Он поддерживает объектную, процедурную, обобщённую, функциональную и метапрограммную парадигмы. Такой подход позволяет применять ООП там, где он оправдан — для моделирования сложных сущностей и иерархий, — и при этом не платить избыточной стоимостью абстракций в критичных по производительности участках кода.
В языках вроде Java или C# объектная модель доминирует: почти всё — объект, даже примитивы обёрнуты в классы-оболочки. C++ же начинается с базовых типов и позволяет постепенно и избирательно повышать уровень абстракции: от свободных функций и структур до полноценных иерархий классов с виртуальными диспетчеризациями, шаблонными метаклассами и полиморфными контейнерами.
Тем не менее, при грамотном применении ООП в C++ достигается та же инкапсуляция, модульность и повторное использование кода, что и в "чисто" объектных языках — но с дополнительными преимуществами: предсказуемой производительностью, отсутствием накладных расходов сборщика мусора и возможностью точной настройки поведения на уровне деталей реализации.
Четыре столпа ООП в C++
C++ реализует классическую четвёрку принципов, лежащих в основе объектно-ориентированного проектирования:
- Инкапсуляция — объединение данных и методов, работающих с этими данными, внутри одной сущности (класса), и управление доступом к внутреннему состоянию;
- Наследование — возможность создания новых классов на основе существующих с расширением или изменением их поведения;
- Полиморфизм — способность объектов различных типов отвечать на один и тот же запрос по-разному, при этом вызов выглядит единообразно;
- Абстракция — выделение существенных характеристик объекта, скрытие несущественных деталей реализации и представление объекта через его интерфейс.
Эти принципы — проектировочные идеи, которые можно применять даже без использования всех возможностей языка. Например, инкапсуляцию можно достичь и через структуры с закрытыми полями, а полиморфизм — через функциональные объекты или указатели на функции. Однако C++ предоставляет языковые средства, которые делают реализацию этих идей более строгой, безопасной и удобной.
Интерактивная схема — класс и объект (псевдокод, подходит для любого ООП-языка). Полный разбор принципов: ООП в разделе "Код и разработка".
Play ITЗагрузка интерактивного демо…
Рассмотрим каждый принцип подробно, начиная с базовой единицы — класса.
Класс
В C++ класс — это пользовательский тип, определяющий форму и поведение объектов. Объявление класса задаёт:
- набор членов — переменных (поля, member variables) и функций (методы, member functions);
- правила доступа к этим членам;
- способы создания и уничтожения объектов этого типа (конструкторы и деструкторы);
- поведение при копировании, перемещении, сравнении и т.д.
Пример простого класса:
class Person {
private:
std::string name;
public:
Person(std::string n) : name(std::move(n)) {}
void greet() const {
std::cout << "Hello, my name is " << name << '\n';
}
};
Этот код задаёт тип Person, который хранит имя и умеет его представить. Даже в таком лаконичном виде он иллюстрирует ключевые аспекты:
- Инкапсуляция: поле
nameобъявлено какprivate, то есть доступно только внутри методов самого класса. Извне его напрямую прочитать или изменить нельзя. Это защищает внутреннее состояние от некорректного использования. - Интерфейс: метод
greet()объявлён какpublic, то есть составляет открытый интерфейс класса. Именно через такие методы внешний код взаимодействует с объектом. - Конструктор и инициализация: конструктор
Person(std::string n)принимает параметр и инициализирует полеname. Использована инициализирующая список (— name(std::move(n))), что предпочтительнее присваивания в теле конструктора, особенно для сложных или дорогостоящих объектов. - Константность метода: ключевое слово
constпосле параметров метода означает, что метод не изменяет состояние объекта. Это гарантирует безопасность вызова наconst-объектах и позволяет компилятору проводить дополнительные проверки и оптимизации.
Создание экземпляра класса:
Person p("Alice");
p.greet(); // вывод: Hello, my name is Alice
Здесь p — объект типа Person, созданный на стеке. Его время жизни ограничено областью видимости, и при выходе из этой области автоматически вызовется деструктор (если он определён). Такая модель управления ресурсами — основа идиомы RAII, о которой будет сказано отдельно.
Модификаторы доступа
Интерактивная схема — инкапсуляция (псевдокод). Подробнее: Инкапсуляция.
Play ITЗагрузка интерактивного демо…
Контроль доступа в C++ осуществляется на уровне секций внутри класса. Это означает, что после ключевого слова private:, protected: или public: все последующие объявления (до следующего модификатора или конца класса) наследуют этот уровень доступа.
| Уровень доступа | Доступен из… |
|---|---|
private | только методов того же класса |
protected | методов того же класса и производных классов |
public | любого кода |
Важное отличие от Java и C#: в C++ нет аналога package-private (или internal) уровня. Однако эту роль частично выполняют анонимные пространства имён (namespace { … }) и friend-объявления, которые позволяют открыть доступ к закрытым членам конкретным функциям или классам.
Код ITЗагрузка примера кода…
Использование protected требует осторожности: оно создаёт неявный контракт между базовым и производным классом, который трудно документировать и поддерживать. В современной практике предпочтение отдаётся композиции и интерфейсам, а не расширению через наследование с доступом к защищённым членам.
Наследование
Интерактивная схема — наследование (псевдокод). Подробнее: Наследование.
Play ITЗагрузка интерактивного демо…
Наследование в C++ позволяет определить новый класс — производный (derived, subclass), — основанный на существующем — базовом (base, superclass). Производный класс наследует все члены базового (кроме конструкторов, деструктора и операторов присваивания), но может:
- добавлять новые поля и методы;
- переопределять виртуальные методы (полиморфизм);
- изменять поведение конструкторов (через вызов конструкторов базового класса);
- ограничивать или расширять доступ к унаследованным членам.
Синтаксис:
class Student : public Person {
private:
int student_id;
public:
Student(std::string n, int id)
: Person(std::move(n)), // вызов конструктора базового класса
student_id(id) {}
void study() const {
std::cout << name << " is studying...\n"; // ошибка: name — private в Person!
}
};
В этом примере возникнет ошибка компиляции: name объявлен как private в Person, поэтому даже производный класс не имеет к нему доступа. Для решения есть два пути:
- Объявить
nameкакprotectedвPerson(не всегда безопасно). - Предоставить
protectedилиpublicметод-геттер вPerson, например:
protected:
const std::string& getName() const { return name; }
Тип наследования (public, protected, private) определяет, как изменяется доступ к унаследованным членам внешнего интерфейса:
public— наследование "является" (is-a): интерфейс базового класса остаётся доступен какpublicв производном. Это стандартный случай для полиморфных иерархий.protected— наследование "реализуется через", но скрывает базовый интерфейс от внешнего кода (используется редко).private— наследование "реализуется через" (has-a по форме, но не по смыслу): всеpublicиprotectedчлены базового класса становятсяprivateв производном. Часто применяется для композиции через наследование (например, при реализации CRTP или внутренней делегации).
Для большинства случаев — особенно при использовании полиморфизма — требуется именно public наследование.
Полиморфизм
Интерактивная схема — полиморфизм (псевдокод). Подробнее: Полиморфизм.
Play ITЗагрузка интерактивного демо…
Полиморфизм — это способность различных объектов реагировать на один и тот же запрос по-разному, сохраняя при этом единообразие интерфейса. В C++ он реализуется преимущественно через виртуальные функции и наследование. Это позволяет писать код, который оперирует абстракциями, не зная конкретных типов реализации на этапе компиляции, — тем самым достигается гибкость, расширяемость и устойчивость к изменениям.
Рассмотрим ключевые элементы механизма.
Виртуальные функции и переопределение
Чтобы функция-член могла быть переопределена в производном классе, она должна быть объявлена как virtual в базовом классе:
class Animal {
public:
virtual void speak() const {
std::cout << "Animal sound\n";
}
};
Ключевое слово virtual указывает компилятору, что вызов этой функции должен разрешаться динамически, во время выполнения, на основе реального типа объекта — а не статически, по типу указателя или ссылки.
Производный класс может переопределить такую функцию, предоставив собственную реализацию:
class Dog : public Animal {
public:
void speak() const override { // override — необязательно, но настоятельно рекомендуется
std::cout << "Woof!\n";
}
};
Здесь override — это спецификатор контекста. Он даёт компилятору возможность проверить, действительно ли функция переопределяет виртуальную из базового класса. Если сигнатура не совпадает — будет ошибка компиляции, что исключает скрытые баги.
Теперь поведение зависит от фактического типа объекта:
Animal* a1 = new Animal();
Animal* a2 = new Dog();
a1->speak(); // Animal sound
a2->speak(); // Woof!
delete a1;
delete a2;
Даже несмотря на то, что оба указателя имеют тип Animal*, во втором случае вызывается Dog::speak(). Это и есть динамическая диспетчеризация.
Таблицы виртуальных функций (vtable)
Каждый класс, содержащий хотя бы одну виртуальную функцию, получает таблицу виртуальных функций — статический массив указателей на реализации виртуальных методов. Каждый объект такого класса содержит скрытый указатель на соответствующую vtable (vptr). При вызове виртуальной функции:
- из объекта извлекается
vptr; - по индексу функции (определённому на этапе компиляции) из таблицы читается адрес реализации;
- вызывается функция по этому адресу.
Это добавляет небольшую накладную стоимость (одно разыменование указателя), но позволяет добиться гибкости, сопоставимой с интерфейсами в Java/C#.
Чисто виртуальные функции и абстрактные классы
В C++ нет отдельного ключевого слова interface. Контракт задают классы с чисто виртуальными методами (= 0), без полей данных. Общую иерархию с состоянием строят на абстрактном классе с полями и частичной реализацией. Теория — Абстракция в ООП.
| Критерий | Класс-контракт (только = 0, без полей) | Абстрактный класс с реализацией |
|---|---|---|
| Роль | Способность (Healable, Drawable) | Семейство типов (Shape, Unit) |
| Состояние | Обычно без полей | protected поля, конструктор |
| Наследование | Несколько таких баз (осторожно с ромбом) | Одна ветка is-a |
| Методы | Только объявления = 0 | Часть с телом, часть = 0 |
См. композицию вместо наследования, полиморфизм в C++.
Если виртуальная функция не имеет реализации в базовом классе и должна быть обязательно переопределена в производных, она объявляется как чисто виртуальная с помощью синтаксиса = 0:
class Shape {
public:
virtual double area() const = 0; // чисто виртуальная функция
virtual ~Shape() = default;
};
Класс с хотя бы одной чисто виртуальной функцией называется абстрактным. Создать объект абстрактного класса напрямую невозможно:
Shape s; // ошибка компиляции: нельзя инстанцировать абстрактный класс
Shape* p = nullptr; // OK: указатель — допустим
Абстрактный класс определяет интерфейс или частичную реализацию, которую должны завершить наследники:
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14159 * radius * radius;
}
};
Такой подход позволяет строить иерархии, где базовый класс задаёт контракт, а производные — конкретную семантику. Это аналог интерфейсов (interface) в Java/C#, но с возможностью включения реализации по умолчанию (если не все функции чисто виртуальные).
Виртуальный деструктор
Одна из самых критичных и часто упускаемых деталей — деструктор базового класса должен быть виртуальным, если предполагается удалять объекты производных классов через указатель на базовый.
Рассмотрим проблему:
class Base {
public:
~Base() { std::cout << "~Base\n"; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "~Derived\n"; }
};
Base* p = new Derived();
delete p; // Вывод: только ~Base
Поскольку деструктор в Base не виртуальный, вызов delete p приводит к статической диспетчеризации: вызывается только ~Base(), а ~Derived() — не вызывается. Это означает, что ресурсы, захваченные в Derived (файлы, память, сокеты), не освобождаются — возникает утечка.
Исправление:
class Base {
public:
virtual ~Base() = default; // или { /* ... */ }
};
Теперь при delete p сначала вызовется ~Derived(), затем ~Base() — и все ресурсы освободятся корректно.
Правило. Базовый класс с хотя бы одной
virtual-функцией, из которого удаляют объекты через указатель на базу, должен иметь виртуальный деструктор. Если наследование запрещено (final), виртуальность деструктора обычно не нужна.
Когда виртуальный деструктор не обязателен
Виртуальный деструктор нужен только там, где возможен полиморфный вызов деструктора — то есть delete base_ptr, где реальный тип объекта в куче — наследник. В остальных случаях virtual у ~T() только добавляет служебные данные.
Полиморфное удаление — вы держите Base* p, хотя в памяти лежит Derived, и пишете delete p. Без virtual ~Base() вызовется только деструктор базы.
vptr (указатель на таблицу виртуальных функций, vtable) — скрытое поле в объекте с виртуальными методами. Оно нужно, чтобы во время выполнения выбрать правильную версию virtual-функции, в том числе деструктора. Подробнее — раздел Таблицы виртуальных функций (vtable) ниже в этой статье.
Когда virtual ~T() можно не делать:
- объект никогда не удаляют через указатель на базовый тип (только
Derived d;на стеке илиdeleteпоDerived*) - в классе нет других
virtual-методов и он не задуман как интерфейс — добавлениеvirtualк деструктору создаст vptr "впустую" - класс помечен
final— от него не наследуют, полиморфная иерархия через этот тип не строится
Если виртуальные методы уже есть, virtual ~Base() = default обычно не увеличивает размер объекта — vptr уже присутствует. Делать деструктор виртуальным "на всякий случай" в утилитарном классе без полиморфизма — лишняя память и лишняя запись в vtable.
См. RTTI и dynamic_cast, композиция вместо глубоких иерархий.
Множественное наследование и виртуальное наследование
В отличие от Java и C#, C++ допускает множественное наследование: класс может наследовать сразу от нескольких базовых:
Код ITЗагрузка примера кода…
Это мощный, но потенциально опасный инструмент. Основная проблема — алмазная проблема (diamond problem), возникающая при наличии общего предка:
class A { public: int x; };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D содержит два экземпляра A::x!
Чтобы гарантировать один экземпляр общего базового класса, используется виртуальное наследование:
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // Теперь D содержит один A::x
Виртуальное наследование изменяет схему хранения и инициализации: конструктор A вызывается напрямую из самого производного класса (D), а не через B или C. Это требует внимания при проектировании, но позволяет корректно моделировать сложные иерархии, такие как потоки ввода-вывода (std::iostream наследует и от istream, и от ostream, оба из которых виртуально наследуют ios_base).
Жизненный цикл объекта
Управление ресурсами — одна из центральных задач в C++, особенно в условиях отсутствия сборщика мусора. ООП в C++ тесно связан с идиомой RAII (Resource Acquisition Is Initialization), которая гласит: получение ресурса происходит в конструкторе, а освобождение — в деструкторе.
Это позволяет привязать время жизни ресурса (памяти, файла, мьютекса, сокета) ко времени жизни объекта. Когда объект выходит из области видимости — ресурс автоматически освобождается, даже в случае исключения.
Конструкторы
Конструктор — это специальная функция, вызываемая при создании объекта. Его задача — привести объект в корректное и полностью инициализированное состояние. Важно различать:
- инициализацию — задание начального значения при создании объекта;
- присваивание — изменение уже существующего объекта.
Инициализация в конструкторе должна происходить в списке инициализации (member-initializer-list), а не в теле:
Код ITЗагрузка примера кода…
Почему это важно?
- Поля инициализируются до входа в тело конструктора — в порядке объявления в классе, а не в порядке записи в списке инициализации. Перестановка в списке не меняет порядок — частая ошибка на собеседованиях и в код-ревью.
- Для
const-полей и ссылок инициализация обязательна и возможна только в списке. - Для объектов классов без конструктора по умолчанию инициализация в списке — единственный способ.
- Инициализация эффективнее присваивания: при присваивании в теле сначала вызывается конструктор по умолчанию (если есть), затем оператор присваивания — это избыточно.
Свойства конструкторов — краткая шпаргалка. Подробнее про список инициализации и конструктор копирования.
- Нет возвращаемого значения — даже
voidписать нельзя; взять адрес конструктора как функции нельзя. - Перегрузка — в одном классе может быть несколько конструкторов с разными параметрами.
- Конструктор по умолчанию — вызывается без аргументов (все параметры имеют значения по умолчанию или список пуст).
- Параметры — любого типа, кроме типа самого класса; значения по умолчанию для аргументов задают только у одного из конструкторов.
- Генерация компилятором — если вы не написали ни одного конструктора, компилятор создаст конструктор по умолчанию (кроме случаев с неинициализированными
const-полями и ссылками). - Не наследуются — у
Derivedсвой набор конструкторов; базовую часть инициализируют в списке инициализации (Base(args…)). См. наследование. - Запрещённые модификаторы — конструктор не может быть
virtual,staticилиconst(объекта ещё нет, "константность" метода бессмысленна). - Момент вызова
- глобальные и статические объекты — до входа в
main(порядок между файлами не гарантирован — см. C++ — углублённые темы) - локальные — при входе в блок
{ … } - временные — при вычислении выражения, например при передаче аргумента
- глобальные и статические объекты — до входа в
Если конструктор бросает исключение, объект не создан — ~Class() не вызовется. Уже инициализированные члены и базовые подобъекты разрушаются при раскрутке стека. Поэтому в конструкторе предпочитают RAII-поля (unique_ptr, контейнеры), а не сырой new.
Деструктор
Деструктор вызывается автоматически, когда объект покидает область видимости (для стека) или при delete (для кучи). Он не принимает параметров и не возвращает значение. Его нельзя вызвать явно (кроме случая placement-new), но можно вызвать неявно через obj.~T() — это редкость и требует осторожности.
Деструктор не наследуется, но вызывается автоматически для всех подобъектов — сначала — деструктор производного класса, затем — базовых (в порядке, обратном конструированию). Конструкторы тоже не наследуются — у каждого класса свой набор; базовую часть инициализируют в списке инициализации (Base(...)).
Правило пяти (Rule of Five)
Если класс управляет ресурсами (например, владеет указателем, файлом, сокетом), то, вероятно, потребуется определить пять специальных функций:
- Деструктор (
~T()) - Конструктор копирования (
T(const T&)) - Оператор присваивания копированием (
T& operator=(const T&)) - Конструктор перемещения (
T(T&&)) — C++11 - Оператор присваивания перемещением (
T& operator=(T&&)) — C++11
Если определена хотя бы одна из этих функций, почти всегда нужно определить и остальные — иначе поведение по умолчанию (член-по-члену копирование указателей) приведёт к двойному освобождению или утечкам.
Пример проблемного кода:
class BadBuffer {
int* data;
size_t size;
public:
BadBuffer(size_t n) : size(n), data(new int[n]) {}
~BadBuffer() { delete[] data; }
// остальные — по умолчанию!
};
BadBuffer a(10);
BadBuffer b = a; // копирование по умолчанию: data скопирован как указатель
// теперь a.data == b.data
// при выходе: сначала ~b() освобождает data, затем ~a() — падение
Исправление — явное определение копирования и перемещения:
Код ITЗагрузка примера кода…
Однако на практике — особенно после C++11 — часто лучше делегировать управление ресурсами умным указателям или контейнерам и оставить специальные функции по умолчанию (или удалить их с помощью = default / = delete). Это и есть суть современного C++: не пишите RAII-обёртки вручную, если можно использовать готовые.
Правило нуля (Rule of Zero): стремитесь к тому, чтобы в классе не было пользовательских специальных функций. Достигается это использованием членов, которые сами соблюдают RAII (
std::string,std::vector,std::unique_ptrи т.д.).
Перегрузка функций и операторов
C++ позволяет определять несколько функций с одинаковым именем, но разными сигнатурами — это перегрузка функций (function overloading). Также допускается перегрузка операторов (operator overloading), что позволяет использовать встроенные символы (+, -, <<, (), [] и др.) для пользовательских типов. Оба механизма направлены на повышение читаемости и естественности кода, но требуют ответственного применения: перегрузка должна сохранять семантическую целостность — пользователь не должен удивляться поведению оператора.
Важно не путать:
- Перегрузку (overloading) — несколько функций с одним именем, различающихся параметрами (выбор происходит на этапе компиляции).
- Переопределение (overriding) — замена реализации виртуальной функции в производном классе (выбор — во время выполнения).
Перегрузка функций
Функции считаются перегруженными, если у них одинаковое имя, но различаются:
- количеством параметров;
- типами параметров (включая
const-квалификацию); - порядком параметров.
Возвращаемый тип не участвует в разрешении перегрузки.
Пример:
void log(const char* msg);
void log(const std::string& msg);
void log(int value);
void log(double value, int precision = 2);
Вызов log("Hello") однозначно свяжется с первой версией, log(std::string("Hi")) — со второй, log(42) — с третьей, а log(3.14) — с четвёртой (благодаря параметру по умолчанию).
Компилятор применяет алгоритм разрешения перегрузки (overload resolution), который:
- собирает все кандидаты с данным именем в текущей области видимости;
- отбрасывает те, чьи параметры нельзя связать с аргументами (например, нет неявного преобразования);
- из оставшихся выбирает наилучшее совпадение по числу и "стоимости" преобразований (exact match > promotion > conversion).
Если наилучшее совпадение неоднозначно — ошибка компиляции.
Рекомендация: избегайте избыточной перегрузки. Чем больше вариантов — тем сложнее предсказать, какая функция вызовется. Часто яснее использовать разные имена (
parseInt,parseDouble) или параметризацию через шаблоны.
Перегрузка операторов
C++ позволяет перегружать почти все операторы языка — за исключением:
.(прямой доступ к члену),.*(доступ через указатель на член),::(разрешение области видимости),?:(тернарный условный),sizeof,typeid,alignof,noexcept.
Оператор можно реализовать либо как метод класса, либо как свободную функцию. Выбор влияет на симметрию и доступ к приватным членам.
Операторы как методы класса
Когда оператор реализуется как метод, его левый операнд — это объект (*this), а правый — параметр функции.
class Complex {
double re, im;
public:
Complex(double r = 0, double i = 0) : re(r), im(i) {}
// Оператор += как метод
Complex& operator+=(const Complex& other) {
re += other.re;
im += other.im;
return *this;
}
};
Тогда a += b эквивалентно a.operator+=(b).
Преимущества:
- прямой доступ ко всем членам класса (включая
private); - гарантия, что левый операнд — именно объект данного типа.
Недостатки:
- асимметрия:
Complex(1, 2) + 3.0— возможно, а3.0 + Complex(1, 2)— нет, еслиoperator+метод.
Операторы как свободные функции
Для бинарных операторов, где важна симметрия (например, +, ==, <<), предпочтительнее свободная функция:
Complex operator+(Complex a, const Complex& b) {
a += b;
return a;
}
Теперь оба выражения допустимы:
Complex c1(1, 2), c2(3, 4);
Complex c3 = c1 + c2; // OK
Complex c4 = Complex(1, 0) + 5.0; // OK: неявное приведение 5.0 → Complex(5, 0)
Для доступа к private-членам свободная функция объявляется как friend:
class Complex {
double re, im;
public:
// ...
friend Complex operator+(Complex a, const Complex& b);
};
Complex operator+(Complex a, const Complex& b) {
a.re += b.re;
a.im += b.im;
return a;
}
Типичные шаблоны перегрузки
Ниже — проверенные практикой идиомы для часто перегружаемых операторов.
1. Арифметические операторы (+, -, *, /)
Реализуются как свободные функции, использующие составные присваивания (+=, -= и т.д.) как базу:
Complex& operator+=(Complex& lhs, const Complex& rhs) {
lhs.re += rhs.re;
lhs.im += rhs.im;
return lhs;
}
Complex operator+(Complex lhs, const Complex& rhs) {
lhs += rhs;
return lhs; // возврат по значению — возможен move
}
Обратите внимание: левый параметр в operator+ передаётся по значению — это позволяет использовать move-семантику при возврате, если lhs — временный объект.
2. Операторы сравнения (==, !=, <, <=, >, >=)
Согласно C++20, достаточно реализовать <=> (three-way comparison), и компилятор сгенерирует остальные. Но для обратной совместимости часто пишут вручную:
bool operator==(const Complex& a, const Complex& b) {
return a.re == b.re && a.im == b.im;
}
bool operator!=(const Complex& a, const Complex& b) {
return !(a == b);
}
3. Операторы ввода-вывода (<<, >>)
operator<< для std::ostream (например, std::cout) — всегда свободная функция, возвращающая ссылку на поток:
std::ostream& operator<<(std::ostream& os, const Complex& c) {
os << "(" << c.re << "," << c.im << ")";
return os;
}
Это позволяет строить цепочки: cout << c1 << " and " << c2;.
4. Функциональный вызов operator()
Превращает объект в функциональный объект (functor). Широко используется в алгоритмах STL и для замыканий до появления лямбд:
class Multiplier {
int factor;
public:
Multiplier(int f) : factor(f) {}
int operator()(int x) const { return x * factor; }
};
Multiplier dbl(2);
int result = dbl(5); // 10
5. Операторы индексации operator[] и вызова operator()
Для контейнероподобных типов:
class SafeVector {
std::vector<int> data;
public:
int& operator[](size_t i) {
if (i >= data.size())
throw std::out_of_range("Index out of bounds");
return data[i];
}
const int& operator[](size_t i) const { /* ... */ } // перегрузка для const-объектов
};
Обязательно предоставляйте и const, и non-const версии, чтобы поддерживать работу с константными объектами.
6. Операторы new и delete
Можно перегружать как глобальные, так и члены класса, чтобы контролировать размещение объектов (пулы памяти, выравнивание, логирование):
class PooledObject {
public:
void* operator new(size_t size) {
std::cout << "Allocating " << size << " bytes\n";
return ::operator new(size); // делегирование глобальному new
}
void operator delete(void* ptr) noexcept {
std::cout << "Deallocating\n";
::operator delete(ptr);
}
};
Важно: перегрузка
new/delete— продвинутая техника. Её следует применять только при наличии чёткой потребности (например, в embedded-системах или высоконагруженных серверах). В большинстве случаев лучше использовать аллокаторы (std::allocator_traits) или умные указатели с кастомными deleter’ами.
Унарные операторы
Унарные операторы имеют один операнд. Часто реализуются как методы класса (неявный this):
Код ITЗагрузка примера кода…
Постфиксная форма отличается фиктивным параметром int. Для итераторов обычно достаточно префиксной версии.
Операторы приведения типа
Позволяют объекту явно превращаться в другой тип:
class Temperature {
double celsius_ = 0.0;
public:
explicit Temperature(double c) : celsius_(c) {}
explicit operator double() const { return celsius_; }
};
Temperature t(25.0);
// double x = t; // ошибка: explicit
double x = static_cast<double>(t); // OK
explicit запрещает неожиданные неявные преобразования. Осторожно с operator bool() — лучше explicit operator bool() const.
Подробнее о приведениях: Операторы и выражения в C++, RTTI.
Когда НЕ стоит перегружать операторы
Перегрузка уместна, только если:
- семантика оператора интуитивно понятна;
- оператор сохраняет естественные свойства (например,
+— коммутативен,=— присваивает); - не нарушается принцип наименьшего удивления.
Неприемлемые примеры:
Point operator+(const Point& p, int offset); // что делает? сдвиг по X? по Y? по обеим?
// Лучше: p.offsetX(offset) или p.translate(offset, 0)
Logger& operator<<(Logger& log, const std::string& msg); // OK
Logger& operator<<(Logger& log, int code); // OK, если code — уровень
log << "Error" << 404; // но что означает 404? enum лучше.
// Категорически избегайте:
bool operator&&(const MyType& a, const MyType& b); // нарушает short-circuit!
Операторы &&, ||, , теряют встроенное поведение (ленивые вычисления, порядок), если перегружены — это почти всегда ошибка.
Управление памятью
В C++ нет сборщика мусора, но это не означает, что управление памятью должно быть болезненным. Наоборот: язык предоставляет инструменты для предсказуемого, детерминированного и безопасного управления ресурсами — при условии следования идиомам.
Стек и куча — где живут объекты
- Стек — область памяти с автоматическим управлением временем жизни. Объекты создаются при входе в блок, уничтожаются при выходе (LIFO). Быстро, без фрагментации, без утечек.
{
std::vector<int> v(1000); // память под данные — в куче, но сам объект v — на стеке
} // ~vector() вызывается автоматически → освобождает внутренний буфер
- Куча — динамически выделяемая память. Управление — в руках программиста:
new/delete,malloc/free. Используется, когда:- размер неизвестен на этапе компиляции;
- объект должен "пережить" область видимости;
- требуется полиморфное хранение (
Base* p = new Derived()).
Но: не используйте new напрямую, если нет веской причины.
Умные указатели
Начиная с C++11, стандартная библиотека предоставляет три основных умных указателя, реализующих RAII для динамически выделенной памяти:
| Указатель | Семантика владения | Подсчёт ссылок | Когда использовать |
|---|---|---|---|
std::unique_ptr<T> | эксклюзивное владение | нет | по умолчанию для единственного владельца |
std::shared_ptr<T> | совместное владение | да (атомарный) | когда жизненный цикл неочевиден, циклические структуры |
std::weak_ptr<T> | ненаблюдаемая ссылка | нет (но ссылается на shared-состояние) | для разрыва циклов в shared-структурах |
std::unique_ptr — безопасная замена голого указателя
auto p = std::make_unique<int>(42);
// эквивалентно: std::unique_ptr<int> p(new int(42));
// но make_unique гарантирует исключение-безопасность (no leak при исключении в конструкторе)
// p владеет объектом. При выходе из области — delete вызывается автоматически.
// Передача владения:
auto q = std::move(p); // p становится nullptr, q — владелец
unique_ptr не копируемый, но перемещаемый — это отражает семантику эксклюзивного владения. Он имеет нулевую накладную стоимость: в рантайме — просто указатель.
std::shared_ptr — разделяемое владение
auto p1 = std::make_shared<Widget>();
auto p2 = p1; // счётчик ссылок = 2
// Объект удаляется, когда последний shared_ptr выходит из области видимости.
make_shared<T>() предпочтительнее std::shared_ptr<T>(new T) — объект и control block часто выделяются одним блоком, что уменьшает фрагментацию. Для единственного владельца — std::make_unique<T>(), а не связка unique_ptr + голый new без необходимости.
Циклические ссылки и std::weak_ptr
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak — чтобы не создавать цикл
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // если бы было shared_ptr — утечка: счётчики никогда не опустятся до 0
weak_ptr не увеличивает счётчик владения. Чтобы получить shared_ptr из weak_ptr, нужно вызвать lock():
if (auto sp = b->prev.lock()) {
// sp — shared_ptr, объект жив
} else {
// объект уже удалён
}
Правила работы с памятью в современном C++
- По умолчанию — стек. Используйте локальные объекты, возвращайте по значению (NRVO/RVO + move делают это эффективным).
- Для кучи —
make_unique/make_shared. Избегайте прямогоnew. - Никогда не смешивайте
new/deleteсmalloc/free. Деструкторы не вызовутся приfree. - Не храните "голые" указатели, владеющие ресурсом. Если указатель в классе — он должен быть
unique_ptrилиshared_ptr. - Используйте контейнеры (
vector,string) — они уже реализуют RAII для своих данных.
Такой подход позволяет писать код, в котором утечки памяти невозможны по конструкции — даже в условиях исключений.
Шаблоны и обобщённое программирование
Шаблоны — одна из самых мощных и характерных черт C++. Они позволяют писать алгоритмы и структуры данных, не привязанные к конкретному типу, — и при этом сохранять полную эффективность и типовую безопасность. В отличие от generics в Java или C#, шаблоны C++ — это метапрограммирование на этапе компиляции: для каждого набора аргументов шаблона компилятор генерирует отдельную, конкретную версию кода.
Это даёт два ключевых преимущества:
- Нулевая стоимость абстракции — сгенерированный код ничем не отличается от написанного вручную для конкретного типа.
- Максимальная гибкость — шаблоны могут работать с любыми типами, удовлетворяющими структурным требованиям (например, "умеет
operator<"), а не только с наследниками общего интерфейса.
Но есть и цена — ошибки в шаблонах часто проявляются как многострочные, малопонятные сообщения компилятора, а размер исполняемого файла может расти из-за множественных инстанцирований.
Функции-шаблоны
Самая простая форма — шаблонная функция:
template<typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
Здесь typename T — параметр шаблона. Слово typename можно заменить на class — это синонимы в данном контексте.
При вызове:
int i = max(3, 5); // T = int → генерируется int max(int, int)
double d = max(1.2, 3.4); // T = double → double max(double, double)
Компилятор выводит тип T из аргументов. Вывод возможен, только если все экземпляры параметра в сигнатуре могут быть однозначно сопоставлены. Если нет — нужно указать явно:
auto result = max<int>(3, 5.0); // приведение 5.0 → int, затем сравнение
Классы-шаблоны
Шаблонный класс определяет целое семейство типов:
template<typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value) { data.push_back(value); }
T pop() {
if (data.empty()) throw std::runtime_error("Empty stack");
T val = std::move(data.back());
data.pop_back();
return val;
}
bool empty() const { return data.empty(); }
};
Использование:
Stack<int> int_stack;
Stack<std::string> str_stack;
Каждое из этих объявлений приводит к генерации независимого класса с заменой T на соответствующий тип. В памяти объекты int_stack и str_stack — совершенно разные типы: Stack<int> и Stack<std::string> не имеют общего предка и не могут быть приведены друг к другу.
Это принципиальное отличие от Java/C#, где List<Integer> и List<String> — это один и тот же класс List с разными generic-аргументами, а информация о типе стирается в рантайме (type erasure). В C++ — нет стирания типов. Информация о T сохраняется полностью, что позволяет:
- вызывать методы, специфичные для
T, без приведения; - специализировать поведение для отдельных типов;
- использовать шаблоны в качестве основы для метапрограммирования.
Специализации
Иногда требуется изменить поведение шаблона для определённого типа. Это делается через специализацию.
Полная специализация
Код ITЗагрузка примера кода…
Специализация — это отдельное определение, которое компилятор выбирает, если аргумент шаблона точно совпадает.
Частичная специализация (только для классов)
Позволяет зафиксировать часть параметров, оставив другие общими:
template<typename T, typename Allocator = std::allocator<T>>
class Vector {
// общая реализация
};
// Частичная специализация для bool — как в std::vector<bool>
template<typename Allocator>
class Vector<bool, Allocator> {
// компактное битовое представление
};
Частичная специализация недоступна для функций — вместо неё используют перегрузку или SFINAE.
SFINAE — "Substitution Failure Is Not An Error"
SFINAE — фундаментальный принцип разрешения перегрузки шаблонов — если при подстановке аргументов в сигнатуру шаблонной функции возникает ошибка, эта специализация просто отбрасывается из множества кандидатов, а не приводит к ошибке компиляции.
Это позволяет писать условные шаблоны — например, функцию, которая компилируется только для типов, имеющих определённый метод или оператор.
Пример с std::enable_if (до C++20):
Код ITЗагрузка примера кода…
Здесь std::enable_if<Cond, T>::type определён, только если Cond == true. Если условие ложно — тип не существует → подстановка не удаётся → кандидат отбрасывается → выбирается другая перегрузка.
SFINAE — мощный, но сложный инструмент. Ошибки в нём трудны для диагностики.
Концепции (C++20) — читаемость вместо SFINAE
C++20 вводит концепции (concepts) — именованные ограничения на параметры шаблонов. Это делает код на порядки понятнее:
Код ITЗагрузка примера кода…
Концепции проверяются на этапе компиляции и дают ясные сообщения об ошибках:
"abs не определён для std::string, потому что std::string не удовлетворяет std::integral" — вместо страницы шаблонного бек-трейса.
Можно определять свои концепции:
template<typename T>
concept Printable = requires(T x) {
{ std::cout << x } -> std::same_as<std::ostream&>;
};
template<Printable T>
void log(const T& x) {
std::cout << x << '\n';
}
Требование requires проверяет, что выражение std::cout << x корректно и возвращает std::ostream&.
Шаблоны и generics — ключевые различия
| Критерий | C++ (шаблоны) | Java / C# (generics) |
|---|---|---|
| Время связывания | компиляция (код генерируется для каждого T) | выполнение (стирание типов / реификация) |
| Производительность | нулевая накладная стоимость | возможны боксинг/анбоксинг (Java), виртуальные вызовы |
| Гибкость | работает с любыми типами, удовлетворяющими структуре | только ссылочные типы (Java), примитивы через обёртки |
| Ошибки | на этапе компиляции, но сложные | на этапе компиляции, более понятные |
| Метапрограммирование | полноценное (constexpr, SFINAE, концепции) | ограничено |
| Размер кода | может расти (код дублируется) | один общий байткод / IL |
Пример: в Java невозможно написать List<int> — только List<Integer>. В C++ std::vector<int> — это эффективный контейнер из int напрямую, без обёрток.
Практические рекомендации по использованию шаблонов
- Начинайте с конкретного кода. Не делайте всё шаблонным "на всякий случай". Обобщайте, когда появляется повторяющаяся структура для разных типов.
- Используйте
autoи шаблонные лямбды — они скрывают шаблонную сложность от пользователя:
auto add = [](const auto& a, const auto& b) { return a + b; };
Такая лямбда — это шаблонная функция, но синтаксис проще.
3. Предпочитайте std::vector<T>, std::function<R(Args...)> и другие стандартные шаблоны — они протестированы и оптимизированы.
4. Избегайте глубокой рекурсии шаблонов — это замедляет компиляцию и усложняет отладку.
5. Документируйте требования к типам — даже без концепций:
"Тип T должен поддерживать:
- копирование (или перемещение);
operator<для сортировки;- конструктор по умолчанию (если используется
resize)."
Стандартная библиотека шаблонов (STL)
STL — целостная архитектура, построенная на трёх взаимосвязанных компонентах:
- Контейнеры (
vector,list,map,set, …) — хранят данные. - Итераторы — абстрагируют доступ к элементам контейнера ("умные указатели").
- Алгоритмы (
sort,find,transform,accumulate, …) — работают через итераторы.
Эта декомпозиция позволяет комбинировать любые алгоритмы с любыми контейнерами — при условии, что контейнер предоставляет итераторы нужной категории.
Контейнеры
Неправильно выбирать контейнер "самый быстрый". Правильно — выбрать тот, чья семантика соответствует задаче.
| Контейнер | Семантика | Особенности |
|---|---|---|
std::vector<T> | динамический массив | непрерывная память, произвольный доступ, амортизированно O(1) вставка в конец |
std::deque<T> | двусторонняя очередь | произвольный доступ, O(1) вставка/удаление в начале и конце |
std::list<T> | двусвязный список | O(1) вставка/удаление в любом месте при наличии итератора, нет произвольного доступа |
std::forward_list<T> | односвязный список | минимальные накладные расходы, только forward-итераторы |
std::set<T> / std::map<K,V> | упорядоченное множество / отображение | дерево (обычно красно-чёрное), элементы отсортированы, O(log n) вставка/поиск |
std::unordered_set<T> / std::unordered_map<K,V> | хеш-таблица | средний O(1) поиск/вставка, порядок не гарантируется |
std::array<T, N> | фиксированный массив | стековый, без динамического выделения, поддержка begin/end |
Пример выбора:
- Нужен произвольный доступ и компактность? →
vector. - Частые вставки в начало? →
deque(неlist— он медленнее из-за аллокаций узлов). - Гарантированная сортировка и уникальность? →
set. - Быстрый поиск по ключу, порядок неважен? →
unordered_map.
Итераторы
Итератор — это объект, ведущий себя как указатель — поддерживает *, ->, ++, иногда --, +, -, [].
Категории итераторов (от слабых к сильным):
- Input / Output — однократное чтение / запись (потоки).
- Forward — многократное чтение, только вперёд (
forward_list). - Bidirectional — вперёд и назад (
list,set). - Random Access — произвольный доступ за O(1) (
vector,deque,array). - Contiguous (C++17) — элементы в непрерывной памяти (
vector,array).
Алгоритмы требуют минимум определённую категорию:
std::sort(v.begin(), v.end()); // требует random-access итераторы
std::list<int> lst;
// std::sort(lst.begin(), lst.end()); // ошибка: list — bidirectional, не random-access
lst.sort(); // но у list есть свой метод sort()
Алгоритмы
STL предоставляет более 100 алгоритмов, охватывающих:
- Немодифицирующие (
find,count,equal,for_each) - Модифицирующие (
copy,transform,replace,fill) - Упорядочение (
sort,partial_sort,nth_element) - Поиск по условию (
lower_bound,upper_bound,equal_range) - Численные (
accumulate,inner_product,iota)
Пример: замена цикла на алгоритм:
Код ITЗагрузка примера кода…
Алгоритмы:
- чище выразительно;
- менее подвержены ошибкам (например, off-by-one);
- поддаются оптимизации (например,
std::copyможет использоватьmemmoveдля POD-типов); - позволяют легко распараллеливать (через
std::execution::par).
Адаптеры контейнеров — stack, queue, priority_queue
Это обёртки над другими контейнерами (по умолчанию — deque), предоставляющие ограниченный интерфейс:
std::stack<int> s; // использует deque<int> внутри
s.push(1);
s.push(2);
std::cout << s.top(); // 2
s.pop();
// Можно указать базовый контейнер:
std::stack<int, std::vector<int>> s2; // на основе vector
priority_queue — это бинарная куча (std::make_heap, std::push_heap, std::pop_heap под капотом).
См. также
| Тема | Статья |
|---|---|
| Сложность ПО и зачем ООП | 4-08-oop/7 |
| Композиция и наследование | Композиция и наследование в C++ |
this, static, friend, вложенные классы | Класс в C++ — this, static, friend и вложенные типы |
| Исключения (try/catch) | Обработка исключений в C++ |
| RTTI | RTTI в C++ — typeid и dynamic_cast |
| Паттерны проектирования | design-patterns |
Практика проектирования — как применять ООП в C++ без перегиба
ООП в C++ лучше работает как инженерный инструмент, а не как "обязательная идеология". Практичный ориентир:
- Начинайте с простых структур и функций.
- Добавляйте класс, когда появляется устойчивый инвариант состояния.
- Добавляйте наследование, когда есть стабильный контракт и минимум две реальные реализации.
- Добавляйте виртуальность только там, где она даёт осмысленную точку расширения.
Так архитектура остаётся понятной, а накладные расходы контролируются.
Типичные ошибки в ООП-коде на C++
| Ошибка | Последствие | Что делать |
|---|---|---|
| Базовый класс без виртуального деструктора | утечки и UB при delete через базовый указатель | делать virtual ~Base() = default; |
| Наследование "для повторного использования" вместо композиции | хрупкая иерархия | предпочитать композицию и узкие интерфейсы |
Ручной new в объектной модели | сложный жизненный цикл | использовать RAII, std::unique_ptr, контейнеры |
| Перегрузка операторов без ясной семантики | код трудно читать и поддерживать | оставлять только естественные операторы |
Мини-чеклист перед ревью
- Класс имеет чёткий инвариант и понятные границы ответственности.
- Владелец ресурса определён однозначно.
const-корректность выдержана в публичном API.- Ошибки и исключения обрабатываются без потери ресурсов.
- Для ключевого поведения есть тесты на норму, границу и ошибку.
Кейс из практики — полиморфизм и утечка ресурса
Ситуация: объект удаляли через указатель на базовый класс, а ресурс из наследника не освобождался.
Base* p = new Derived();
delete p;
Причина: у базового класса был невиртуальный деструктор.
Исправление:
class Base {
public:
virtual ~Base() = default;
};
Проверка: добавить тест с ресурсом в Derived и убедиться, что освобождение происходит при delete через Base*.
Вывод: для полиморфной иерархии виртуальный деструктор — обязательная часть контракта.
Справочник по ООП в C++
Консолидированный блок для быстрой ориентации в терминах, создании объектов, модификаторах доступа и сравнении с другими языками. Узкие темы — память, this / static / friend, RTTI. Общая теория — ООП в разделе "Код"; параллель — Java, C#.
Глоссарий — термин C++ и понятие ООП
| Термин C++ | Понятие ООП | Кратко |
|---|---|---|
class / struct | Класс (АДТ) | class — private по умолчанию; struct — public |
| объект / экземпляр | Объект | Переменная пользовательского типа |
поле-член (member) | Состояние | private данные |
| метод-член | Поведение | Функция внутри класса |
| конструктор | Инициализация | Список инициализации : member(val) |
деструктор ~T() | Финализация / RAII | Освобождение ресурсов |
virtual / override | Полиморфизм подтипов | Динамическая диспетчеризация через vtable |
= 0 (pure virtual) | Абстрактный метод | Класс становится абстрактным |
final | Запечатанный класс/метод | Запрет наследования / переопределения |
this | Указатель на текущий объект | Неявный в методах |
static | Член класса | Общее состояние; не привязан к экземпляру |
friend | Доверенный доступ | Обход инкапсуляции для конкретной функции/класса |
public / protected / private | Модификаторы доступа | Секции внутри класса |
explicit | Запрет неявных преобразований | Безопасность конструкторов |
const метод | Инвариантность | Не меняет логическое состояние |
template | Параметрический полиморфизм | Обобщённое программирование (не только ООП) |
std::unique_ptr | Владение объектом | RAII для кучи; память |
Создание экземпляра и запись в переменные
В C++ объект может жить на стеке, в куче или как подобъект внутри другого. Переменная класса на стеке — это сам объект (в Java и C# переменная ссылочного типа хранит адрес в куче).
// Стек: объект создаётся и уничтожается в области видимости
Knight arthur("Артур", 10);
Knight copy = arthur; // копирование (вызывается конструктор копирования)
// Куча: указатель / умный указатель
auto gawain = std::make_unique<Knight>("Гавейн", 12);
Knight* raw = new Knight("Ланселот", 15); // учебно; в проде — unique_ptr
delete raw;
// Ссылка — alias существующего объекта
Knight& ref = arthur;
ref.level = 11; // меняет arthur
// Полиморфизм: указатель/ссылка на базовый тип
std::unique_ptr<Warrior> w = std::make_unique<Knight>("Персиваль", 8);
w->attack(); // virtual → Knight::attack
Порядок при создании Knight(...) на стеке:
- Выделяется память под объект (стек или куча).
- Вызывается конструктор базового класса (
Warrior(...)в списке инициализации). - Инициализируются поля в порядке объявления в классе (не в списке!).
- Выполняется тело конструктора.
При уничтожении: сначала деструктор производного класса, затем базовых (обратный порядок).
Копирование и перемещение (C++11+)
Knight a("А", 1);
Knight b = std::move(a); // перемещение: a в valid-but-unspecified состоянии
Для полиморфных иерархий предпочитайте std::unique_ptr<Base> или std::shared_ptr<Base> вместо сырого new/delete.
Запрещено: создать объект абстрактного класса с чисто виртуальными методами:
// Warrior w("x"); // ошибка, если attack() = 0 и не реализован
Доступность методов и полей (сводная таблица)
Доступ задаётся секциями (public:, protected:, private:). В class по умолчанию — private; в struct — public.
| Модификатор | Тот же класс | Производный класс | Внешний код (friend нет) | friend |
|---|---|---|---|---|
private | да | нет | нет | да* |
protected | да | да | нет | да* |
public | да | да | да | да |
* friend-функция или friend-класс получает доступ к private/protected.
Тип наследования влияет на доступ к унаследованным членам снаружи:
| Наследование | public базы становится | protected базы | private базы |
|---|---|---|---|
public | public | protected | недоступен |
protected | protected | protected | недоступен |
private | private | private | недоступен |
Для полиморфных иерархий почти всегда нужно public наследование.
Дополнительные правила:
| Ситуация | Поведение |
|---|---|
static метод | Нет this; только static члены |
const метод | Не может менять неконстантные поля (кроме mutable) |
virtual деструктор в базе | Обязателен при удалении через Base* |
override | Компилятор проверяет наличие virtual в базе |
final на методе | Дальнейшее переопределение запрещено |
| Анонимное пространство имён | Аналог видимости внутри единицы трансляции (отличается от package в Java) |
Типичные ошибки в C++-ООП
| Ошибка | Почему плохо | Что делать |
|---|---|---|
Базовый класс без virtual ~Base() | UB / утечка при delete через Base* | virtual ~Base() = default; |
Сырой new без парного delete | Утечки, двойное освобождение | unique_ptr, RAII, контейнеры |
| Rule of Five нарушен | Двойной delete при копировании | Определить 5 спецфункций или Rule of Zero |
protected данные в базе | Хрупкий контракт наследникам | private + protected интерфейс |
| Множественное наследование без необходимости | Ромб, сложный layout | Композиция, виртуальное наследование |
Перегрузка && / || | Теряется short-circuit | Не перегружать |
| Вызов virtual из конструктора | Вызовется версия базового класса | Инициализация в конструкторе наследника |
| slicing при присваивании | Base b = derived; — срез объекта | Ссылки, указатели, unique_ptr |
const некорректность | Скрытые мутации через неконстантные ссылки | const методы и параметры |
friend повсюду | Инкапсуляция разрушена | Точечно, для операторов / адаптеров |
Мини-сравнение с другими языками
| Аспект | C++ | C# | Java | Python |
|---|---|---|---|---|
| Управление памятью | RAII, умные указатели | GC | GC | GC |
| Объект на стеке | да (по умолчанию) | нет (class — heap) | нет | нет (всё ссылки) |
| Множественное наследование | да | нет | нет | да |
| Интерфейс | pure virtual ABC | interface | interface | abc / duck |
| Virtual явно | virtual | virtual | неявно | N/A |
| Деструктор | детерминированный | финализатор (ненадёжен) | finalize (deprecated) | __del__ (не гарантирован) |
| Шаблоны / generics | compile-time, zero-cost | reified | erasure | typing hints |
const корректность | строгая | ограниченно | final для ссылок | нет |
| Перегрузка операторов | да | ограниченно (+ для строк) | нет | через dunder |
Учебные примеры ООП
Небольшие самодостаточные программы, которые показывают классы, объекты, инкапсуляцию, наследование и взаимодействие нескольких типов на одной предметной области.
Класс и объект
Чертёж класса Figure и конкретные объекты — круг и квадрат.
Код ITЗагрузка примера кода…
Банковский счёт
Инкапсуляция: скрытое поле баланса и методы deposit/withdraw.
Код ITЗагрузка примера кода…
Наследование
Родитель Animal и дочерние Cat и Dog с общим eat() и своим speak().
Код ITЗагрузка примера кода…
Смартфон
Состояние объекта: заряд батареи, звонки и подзарядка.
Код ITЗагрузка примера кода…
Студент
Список оценок, средний балл и проходной порог.
Код ITЗагрузка примера кода…
Корзина покупок
Взаимодействие Product, Cart и Order при оформлении заказа.
Код ITЗагрузка примера кода…
Автомобиль
Пробег, расход топлива и напоминание о техобслуживании.
Код ITЗагрузка примера кода…
Пользователь
Скрытый пароль, вход в систему и публикация сообщений.
Код ITЗагрузка примера кода…