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

5.06. ООП

Разработчику Архитектору

ООП

Объектно-ориентированное программирование (ООП) — это парадигма, в которой программа строится вокруг объектов, объединяющих данные и поведение, с которыми эти данные связаны. Эта модель позволяет проектировать программы в терминах предметной области, повышая читаемость, сопровождаемость и масштабируемость кода.

C++ — один из немногих языков, в которых ООП не навязано в ущерб другим парадигмам, а встроено органично в многоуровневую систему абстракций. Он поддерживает объектную, процедурную, обобщённую, функциональную и даже метапрограммную парадигмы. Такой подход позволяет применять ООП там, где он действительно оправдан: для моделирования сложных сущностей, их взаимодействий и иерархий, — и при этом не платить избыточной стоимостью абстракций в критичных по производительности участках кода.

В языках вроде Java или C# объектная модель доминирует: почти всё — объект, даже примитивы обёрнуты в классы-оболочки. C++ же начинается с базовых типов и позволяет постепенно и избирательно повышать уровень абстракции: от свободных функций и структур до полноценных иерархий классов с виртуальными диспетчеризациями, шаблонными метаклассами и полиморфными контейнерами.

Тем не менее, при грамотном применении ООП в C++ достигается та же инкапсуляция, модульность и повторное использование кода, что и в «чисто» объектных языках — но с дополнительными преимуществами: предсказуемой производительностью, отсутствием накладных расходов сборщика мусора и возможностью точной настройки поведения на уровне деталей реализации.

Четыре столпа ООП в C++

C++ реализует классическую четвёрку принципов, лежащих в основе объектно-ориентированного проектирования:

  • Инкапсуляция — объединение данных и методов, работающих с этими данными, внутри одной сущности (класса), и управление доступом к внутреннему состоянию;
  • Наследование — возможность создания новых классов на основе существующих с расширением или изменением их поведения;
  • Полиморфизм — способность объектов различных типов отвечать на один и тот же запрос по-разному, при этом вызов выглядит единообразно;
  • Абстракция — выделение существенных характеристик объекта, скрытие несущественных деталей реализации и представление объекта через его интерфейс.

Эти принципы — проектировочные идеи, которые можно применять даже без использования всех возможностей языка. Например, инкапсуляцию можно достичь и через структуры с закрытыми полями, а полиморфизм — через функциональные объекты или указатели на функции. Однако C++ предоставляет языковые средства, которые делают реализацию этих идей более строгой, безопасной и удобной.

Рассмотрим каждый принцип подробно, начиная с базовой единицы — класса.


Класс

В 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, о которой будет сказано отдельно.


Модификаторы доступа

Контроль доступа в C++ осуществляется на уровне секций внутри класса. Это означает, что после ключевого слова private:, protected: или public: все последующие объявления (до следующего модификатора или конца класса) наследуют этот уровень доступа.

Уровень доступаДоступен из…
privateтолько методов того же класса
protectedметодов того же класса и производных классов
publicлюбого кода

Важное отличие от Java и C#: в C++ нет аналога package-private (или internal) уровня. Однако эту роль частично выполняют анонимные пространства имён (namespace { … }) и friend-объявления, которые позволяют открыть доступ к закрытым членам конкретным функциям или классам.

class Sensor {
private:
double raw_value;

protected:
void calibrate() { /* внутренняя логика калибровки */ }

public:
double getValue() const { return raw_value * 0.98; }

friend void diagnosticsTool(const Sensor& s); // разрешаем доступ в диагностике
};

void diagnosticsTool(const Sensor& s) {
std::cout << "Raw: " << s.raw_value << "\n"; // допустимо благодаря friend
}

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


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

Наследование в 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 наследование.


Полиморфизм

Полиморфизм — это способность различных объектов реагировать на один и тот же запрос по-разному, сохраняя при этом единообразие интерфейса. В 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#.

Чисто виртуальные функции и абстрактные классы

Если виртуальная функция не имеет реализации в базовом классе и должна быть обязательно переопределена в производных, она объявляется как чисто виртуальная с помощью синтаксиса = 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() — и все ресурсы освободятся корректно.

Правило: если класс предназначен для наследования и в нём есть хотя бы одна виртуальная функция, его деструктор должен быть виртуальным. Если класс не предназначен для наследования — наследование можно запретить явно через final, и тогда виртуальность не требуется.

Множественное наследование и виртуальное наследование

В отличие от Java и C#, C++ допускает множественное наследование: класс может наследовать сразу от нескольких базовых:

class Flyable {
public:
virtual void fly() const = 0;
};

class Swimmable {
public:
virtual void swim() const = 0;
};

class Duck : public Animal, public Flyable, public Swimmable {
public:
void speak() const override { std::cout << "Quack!\n"; }
void fly() const override { std::cout << "Duck is flying\n"; }
void swim() const override { std::cout << "Duck is swimming\n"; }
};

Это мощный, но потенциально опасный инструмент. Основная проблема — алмазная проблема (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), а не в теле:

class FileHandler {
std::string filename;
FILE* handle;

public:
FileHandler(const std::string& name)
: filename(name), // инициализация std::string
handle(std::fopen(name.c_str(), "r")) // инициализация handle
{
if (!handle)
throw std::runtime_error("Failed to open file: " + name);
// тело конструктора: логика после инициализации
}

~FileHandler() {
if (handle)
std::fclose(handle); // освобождение ресурса
}
};

Почему это важно?

  • Поля инициализируются до входа в тело конструктора — в порядке объявления в классе, а не в порядке записи в списке.
  • Для const-полей и ссылок инициализация обязательна и возможна только в списке.
  • Для объектов классов без конструктора по умолчанию инициализация в списке — единственный способ.
  • Инициализация эффективнее присваивания: при присваивании в теле сначала вызывается конструктор по умолчанию (если есть), затем оператор присваивания — это избыточно.

Деструктор

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

Деструктор не наследуется, но вызывается автоматически для всех подобъектов: сначала — деструктор производного класса, затем — базовых (в порядке, обратном конструированию).

Правило пяти (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() — падение

Исправление — явное определение копирования и перемещения:

class GoodBuffer {
int* data = nullptr;
size_t size = 0;

public:
GoodBuffer(size_t n) : size(n), data(new int[n]) {}

// Конструктор копирования
GoodBuffer(const GoodBuffer& other)
: size(other.size), data(new int[other.size]) {
std::copy(other.data, other.data + size, data);
}

// Оператор присваивания копированием
GoodBuffer& operator=(const GoodBuffer& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}

// Конструктор перемещения
GoodBuffer(GoodBuffer&& other) noexcept
: size(other.size), data(other.data) {
other.size = 0;
other.data = nullptr;
}

// Оператор присваивания перемещением
GoodBuffer& operator=(GoodBuffer&& other) noexcept {
if (this != &other) {
delete[] data;
size = other.size;
data = other.data;
other.size = 0;
other.data = nullptr;
}
return *this;
}

~GoodBuffer() { delete[] data; }
};

Однако на практике — особенно после 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’ами.

Когда НЕ стоит перегружать операторы

Перегрузка уместна, только если:

  • семантика оператора интуитивно понятна;
  • оператор сохраняет естественные свойства (например, + — коммутативен, = — присваивает);
  • не нарушается принцип наименьшего удивления.

Неприемлемые примеры:

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 предпочтительнее make_unique(new T) — он выделяет память одним блоком для объекта и управляющего блока (control block), что уменьшает фрагментацию и ускоряет работу.

Циклические ссылки и 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, без приведения;
  • специализировать поведение для отдельных типов;
  • использовать шаблоны в качестве основы для метапрограммирования.

Специализации

Иногда требуется изменить поведение шаблона для определённого типа. Это делается через специализацию.

Полная специализация
// Общая версия
template<typename T>
struct Printer {
static void print(const T& x) {
std::cout << x << '\n';
}
};

// Специализация для bool
template<>
struct Printer<bool> {
static void print(bool x) {
std::cout << (x ? "true" : "false") << '\n';
}
};

Printer<int>::print(42); // 42
Printer<bool>::print(true); // true

Специализация — это отдельное определение, которое компилятор выбирает, если аргумент шаблона точно совпадает.

Частичная специализация (только для классов)

Позволяет зафиксировать часть параметров, оставив другие общими:

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):

#include <type_traits>

// Вызывается, только если T — целочисленный тип
template<typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
abs(T x) {
return x < 0 ? -x : x;
}

// Вызывается для остальных типов (например, float, double)
template<typename T>
typename std::enable_if<!std::is_integral_v<T>, T>::type
abs(T x) {
return x < 0 ? -x : x;
}

Здесь std::enable_if<Cond, T>::type определён, только если Cond == true. Если условие ложно — тип не существует → подстановка не удаётся → кандидат отбрасывается → выбирается другая перегрузка.

SFINAE — мощный, но сложный инструмент. Ошибки в нём трудны для диагностики.

Концепции (C++20): читаемость вместо SFINAE

C++20 вводит концепции (concepts) — именованные ограничения на параметры шаблонов. Это делает код на порядки понятнее:

#include <concepts>

template<std::integral T>
T abs(T x) {
return x < 0 ? -x : x;
}

template<std::floating_point T>
T abs(T x) {
return x < 0 ? -x : x;
}

// Или обобщённо:
template<typename T>
requires std::integral<T> || std::floating_point<T>
T abs(T x) { /* ... */ }

Концепции проверяются на этапе компиляции и дают ясные сообщения об ошибках:
«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)

Пример: замена цикла на алгоритм:

// Плохо: ручной цикл
int sum = 0;
for (int x : v) {
if (x > 0) sum += x;
}

// Лучше: алгоритмы
auto positive = [](int x) { return x > 0; };
int sum = std::accumulate(
std::begin(v), std::end(v), 0,
[&](int acc, int x) { return acc + (positive(x) ? x : 0); }
);

// Или: сначала фильтрация (C++20 ranges)
#include <ranges>
auto sum = v | std::views::filter(positive)
| std::views::transform([](int x) { return x; })
| std::ranges::accumulate(0);

Алгоритмы:

  • чище выразительно;
  • менее подвержены ошибкам (например, 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 под капотом).