Перейти к основному содержимому

Объектно-ориентированное программирование в C++

Разработчику Архитектору
Сначала — общие понятия (раздел 4 "Код")
Как устроена статья

В начале — короткие примеры с высоты птичьего полёта.

Ниже — развёрнутый разбор (инкапсуляция, 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++ — не строго ООП-язык. Это мультипарадигмальный язык, где ООП — один из инструментов. Главные отличия:

  1. Нет сборщика мусора — владение ресурсами через RAII (std::unique_ptr, контейнеры, деструкторы). Сырой new/delete нужен для понимания модели и низкоуровневого кода; в прикладных проектах — редко (память, идиомы).
  2. Множественное наследование классов (алмазная проблема — решается виртуальным наследованием)
  3. Деструкторы (вызываются автоматически при уничтожении объекта — основа RAII)
  4. Нет ключевого слова interface — роль контракта выполняют чисто виртуальные базовые классы без полей
  5. Константность (const везде — параметры, методы, возвращаемые значения)
  6. Перегрузка операторов (можно определить свой +, -, [], () для классов)
КЛАСС Кот
поля: имя, возраст
метод мяукнуть()
КОНЕЦ

объект 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, поэтому даже производный класс не имеет к нему доступа. Для решения есть два пути:

  1. Объявить name как protected в Person (не всегда безопасно).
  2. Предоставить 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). При вызове виртуальной функции:

  1. из объекта извлекается vptr;
  2. по индексу функции (определённому на этапе компиляции) из таблицы читается адрес реализации;
  3. вызывается функция по этому адресу.

Это добавляет небольшую накладную стоимость (одно разыменование указателя), но позволяет добиться гибкости, сопоставимой с интерфейсами в 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-полей и ссылок инициализация обязательна и возможна только в списке.
  • Для объектов классов без конструктора по умолчанию инициализация в списке — единственный способ.
  • Инициализация эффективнее присваивания: при присваивании в теле сначала вызывается конструктор по умолчанию (если есть), затем оператор присваивания — это избыточно.

Свойства конструкторов — краткая шпаргалка. Подробнее про список инициализации и конструктор копирования.

  1. Нет возвращаемого значения — даже void писать нельзя; взять адрес конструктора как функции нельзя.
  2. Перегрузка — в одном классе может быть несколько конструкторов с разными параметрами.
  3. Конструктор по умолчанию — вызывается без аргументов (все параметры имеют значения по умолчанию или список пуст).
  4. Параметры — любого типа, кроме типа самого класса; значения по умолчанию для аргументов задают только у одного из конструкторов.
  5. Генерация компилятором — если вы не написали ни одного конструктора, компилятор создаст конструктор по умолчанию (кроме случаев с неинициализированными const-полями и ссылками).
  6. Не наследуются — у Derived свой набор конструкторов; базовую часть инициализируют в списке инициализации (Base(args…)). См. наследование.
  7. Запрещённые модификаторы — конструктор не может быть virtual, static или const (объекта ещё нет, "константность" метода бессмысленна).
  8. Момент вызова
    • глобальные и статические объекты — до входа в main (порядок между файлами не гарантирован — см. C++ — углублённые темы)
    • локальные — при входе в блок { … }
    • временные — при вычислении выражения, например при передаче аргумента

Если конструктор бросает исключение, объект не создан — ~Class() не вызовется. Уже инициализированные члены и базовые подобъекты разрушаются при раскрутке стека. Поэтому в конструкторе предпочитают RAII-поля (unique_ptr, контейнеры), а не сырой new.


Деструктор​

Деструктор вызывается автоматически, когда объект покидает область видимости (для стека) или при delete (для кучи). Он не принимает параметров и не возвращает значение. Его нельзя вызвать явно (кроме случая placement-new), но можно вызвать неявно через obj.~T() — это редкость и требует осторожности.

Деструктор не наследуется, но вызывается автоматически для всех подобъектов — сначала — деструктор производного класса, затем — базовых (в порядке, обратном конструированию). Конструкторы тоже не наследуются — у каждого класса свой набор; базовую часть инициализируют в списке инициализации (Base(...)).


Правило пяти (Rule of Five)​

Если класс управляет ресурсами (например, владеет указателем, файлом, сокетом), то, вероятно, потребуется определить пять специальных функций:

  1. Деструктор (~T())
  2. Конструктор копирования (T(const T&))
  3. Оператор присваивания копированием (T& operator=(const T&))
  4. Конструктор перемещения (T(T&&)) — C++11
  5. Оператор присваивания перемещением (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), который:

  1. собирает все кандидаты с данным именем в текущей области видимости;
  2. отбрасывает те, чьи параметры нельзя связать с аргументами (например, нет неявного преобразования);
  3. из оставшихся выбирает наилучшее совпадение по числу и "стоимости" преобразований (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++​

  1. По умолчанию — стек. Используйте локальные объекты, возвращайте по значению (NRVO/RVO + move делают это эффективным).
  2. Для кучи — make_unique / make_shared. Избегайте прямого new.
  3. Никогда не смешивайте new/delete с malloc/free. Деструкторы не вызовутся при free.
  4. Не храните "голые" указатели, владеющие ресурсом. Если указатель в классе — он должен быть unique_ptr или shared_ptr.
  5. Используйте контейнеры (vector, string) — они уже реализуют RAII для своих данных.

Такой подход позволяет писать код, в котором утечки памяти невозможны по конструкции — даже в условиях исключений.


Шаблоны и обобщённое программирование​

Шаблоны — одна из самых мощных и характерных черт C++. Они позволяют писать алгоритмы и структуры данных, не привязанные к конкретному типу, — и при этом сохранять полную эффективность и типовую безопасность. В отличие от generics в Java или C#, шаблоны C++ — это метапрограммирование на этапе компиляции: для каждого набора аргументов шаблона компилятор генерирует отдельную, конкретную версию кода.

Это даёт два ключевых преимущества:

  1. Нулевая стоимость абстракции — сгенерированный код ничем не отличается от написанного вручную для конкретного типа.
  2. Максимальная гибкость — шаблоны могут работать с любыми типами, удовлетворяющими структурным требованиям (например, "умеет 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 напрямую, без обёрток.


Практические рекомендации по использованию шаблонов​

  1. Начинайте с конкретного кода. Не делайте всё шаблонным "на всякий случай". Обобщайте, когда появляется повторяющаяся структура для разных типов.
  2. Используйте 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 — целостная архитектура, построенная на трёх взаимосвязанных компонентах:

  1. Контейнеры (vector, list, map, set, …) — хранят данные.
  2. Итераторы — абстрагируют доступ к элементам контейнера ("умные указатели").
  3. Алгоритмы (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++
RTTIRTTI в C++ — typeid и dynamic_cast
Паттерны проектированияdesign-patterns

Практика проектирования — как применять ООП в C++ без перегиба​

ООП в C++ лучше работает как инженерный инструмент, а не как "обязательная идеология". Практичный ориентир:

  1. Начинайте с простых структур и функций.
  2. Добавляйте класс, когда появляется устойчивый инвариант состояния.
  3. Добавляйте наследование, когда есть стабильный контракт и минимум две реальные реализации.
  4. Добавляйте виртуальность только там, где она даёт осмысленную точку расширения.

Так архитектура остаётся понятной, а накладные расходы контролируются.


Типичные ошибки в ООП-коде на 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(...) на стеке:

  1. Выделяется память под объект (стек или куча).
  2. Вызывается конструктор базового класса (Warrior(...) в списке инициализации).
  3. Инициализируются поля в порядке объявления в классе (не в списке!).
  4. Выполняется тело конструктора.

При уничтожении: сначала деструктор производного класса, затем базовых (обратный порядок).

Копирование и перемещение (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 базы
publicpublicprotectedнедоступен
protectedprotectedprotectedнедоступен
privateprivateprivateнедоступен

Для полиморфных иерархий почти всегда нужно 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#JavaPython
Управление памятьюRAII, умные указателиGCGCGC
Объект на стекеда (по умолчанию)нет (class — heap)нетнет (всё ссылки)
Множественное наследованиеданетнетда
Интерфейсpure virtual ABCinterfaceinterfaceabc / duck
Virtual явноvirtualvirtualнеявноN/A
Деструктордетерминированныйфинализатор (ненадёжен)finalize (deprecated)__del__ (не гарантирован)
Шаблоны / genericscompile-time, zero-costreifiederasuretyping hints
const корректностьстрогаяограниченноfinal для ссылокнет
Перегрузка операторовдаограниченно (+ для строк)нетчерез dunder

Учебные примеры ООП​

Небольшие самодостаточные программы, которые показывают классы, объекты, инкапсуляцию, наследование и взаимодействие нескольких типов на одной предметной области.

Класс и объект​

Чертёж класса Figure и конкретные объекты — круг и квадрат.

Код ITЗагрузка примера кода…


Банковский счёт​

Инкапсуляция: скрытое поле баланса и методы deposit/withdraw.

Код ITЗагрузка примера кода…


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

Родитель Animal и дочерние Cat и Dog с общим eat() и своим speak().

Код ITЗагрузка примера кода…


Смартфон​

Состояние объекта: заряд батареи, звонки и подзарядка.

Код ITЗагрузка примера кода…


Студент​

Список оценок, средний балл и проходной порог.

Код ITЗагрузка примера кода…


Корзина покупок​

Взаимодействие Product, Cart и Order при оформлении заказа.

Код ITЗагрузка примера кода…


Автомобиль​

Пробег, расход топлива и напоминание о техобслуживании.

Код ITЗагрузка примера кода…


Пользователь​

Скрытый пароль, вход в систему и публикация сообщений.

Код ITЗагрузка примера кода…