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

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

Введение в классы и объекты: конструкторы, деструкторы и указатель this

Введение в классы и объекты: конструкторы, деструкторы и указатель this

Добро пожаловать в курс «Основы объектно-ориентированного программирования на C++». Это первая статья нашего цикла, и мы начнем с фундамента, на котором строится вся парадигма ООП. До этого момента, вероятно, вы писали программы в процедурном стиле: данные хранились в переменных, а логика — в функциях. Сегодня мы изменим этот подход и научимся объединять данные и поведение в единые сущности.

От процедурного мышления к объектному

В процедурном программировании мы думаем о действиях: «прочитать файл», «вычислить сумму», «вывести результат». В объектно-ориентированном программировании (ООП) мы думаем о сущностях: «Пользователь», «Кнопка», «Банковский счет».

Чтобы понять разницу, представьте завод по производству автомобилей.

Здесь есть два ключевых понятия:

  1. Класс — это чертеж, шаблон или описание. Он определяет, какие характеристики (колеса, двигатель, цвет) и возможности (ехать, тормозить) будут у автомобиля. Сам по себе класс не занимает места в памяти как физический объект, это лишь инструкция.
  2. Объект (или экземпляр класса) — это конкретный автомобиль, созданный по этому чертежу. У него есть конкретный цвет и уникальный номер двигателя.

Анатомия класса в C++

В языке C++ класс определяется ключевым словом class. Внутри него мы описываем:

  • Поля (атрибуты) — переменные, хранящие состояние объекта.
  • Методы — функции, описывающие поведение объекта.

Рассмотрим простой пример класса, описывающего точку в двумерном пространстве.

class Point {
public:
    // Поля класса (данные)
    int x;
    int y;

    // Метод класса (поведение)
    void printCoordinates() {
        // Мы можем обращаться к полям x и y напрямую
        std::cout << "Координаты: (" << x << ", " << y << ")" << std::endl;
    }
};

Обратите внимание на спецификатор доступа public:. Он говорит о том, что все, что написано ниже, доступно для использования извне класса. О защите данных (инкапсуляции) мы поговорим в следующих статьях, а пока будем делать наши поля открытыми для простоты.

Чтобы создать объект этого класса (инстанцировать его), мы используем имя класса как тип данных:

int main() {
    Point p1; // Создание объекта p1 класса Point
    p1.x = 10; // Доступ к полю x
    p1.y = 20; // Доступ к полю y

    p1.printCoordinates(); // Вызов метода

    return 0;
}

Конструкторы: Рождение объекта

В примере выше мы вручную задавали значения x и y. Но что, если мы забудем это сделать? В C++ переменные, не инициализированные явно, могут содержать «мусор» — случайные значения, оставшиеся в памяти.

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

Конструктор — это специальный метод, который:

  • Имеет то же имя, что и класс.
  • Не имеет возвращаемого типа (даже void).
  • Вызывается автоматически в момент создания объекта.

Конструктор по умолчанию и с параметрами

class Point {
public:
    int x;
    int y;

    // 1. Конструктор по умолчанию (без параметров)
    Point() {
        x = 0;
        y = 0;
        std::cout << "Создана точка в начале координат" << std::endl;
    }

    // 2. Конструктор с параметрами
    Point(int coordX, int coordY) {
        x = coordX;
        y = coordY;
        std::cout << "Создана точка с заданными координатами" << std::endl;
    }
};

Теперь создание объектов выглядит так:

Point p1;             // Вызовется Point(), x=0, y=0
Point p2(5, 10);      // Вызовется Point(int, int), x=5, y=10

Списки инициализации

В C++ существует профессиональный способ инициализации полей — через список инициализации. Это происходит до входа в тело конструктора и часто является более эффективным.

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

// Тот же конструктор, но с использованием списка инициализации
Point(int coordX, int coordY) : x(coordX), y(coordY) {
    // Тело конструктора может остаться пустым
}

Деструкторы: Завершение жизненного цикла

Если конструктор — это «рождение» объекта, то деструктор — это его «смерть». Деструктор вызывается автоматически, когда объект уничтожается (например, когда программа выходит из функции, где был создан объект).

Особенности деструктора:

  • Имя совпадает с именем класса, но перед ним ставится тильда ~.
  • Не принимает аргументов и не возвращает значения.
  • У класса может быть только один деструктор.

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

class Point {
public:
    // ... поля и конструкторы ...

    ~Point() {
        std::cout << "Точка уничтожена" << std::endl;
    }
};

Указатель this: Самоидентификация

Внутри любого метода класса существует скрытый указатель под названием this. Он указывает на текущий объект, для которого был вызван метод.

Представьте, что вы пишете инструкцию для группы людей: «Поднимите правую руку». Каждый человек (объект), выполняющий эту инструкцию, поднимает свою руку. В контексте объекта this — это способ сказать «я» или «мое».

Зачем нужен this?

Чаще всего this используется в двух случаях:

  1. Устранение неоднозначности имен. Если имя параметра метода совпадает с именем поля класса, параметр «затеняет» поле. Чтобы обратиться именно к полю, используют this->.
class Point {
    int x;
    int y;
public:
    // Параметры называются так же, как поля: x и y
    Point(int x, int y) {
        // x = x; // Ошибка логики: мы присвоим параметру его же значение

        this->x = x; // В поле текущего объекта (this->x) записываем параметр (x)
        this->y = y;
    }
};
  1. Цепочки вызовов (Method Chaining). Методы могут возвращать ссылку на текущий объект (*this), что позволяет вызывать их друг за другом.
class Calculator {
    int value;
public:
    Calculator() : value(0) {}

    Calculator& add(int v) {
        value += v;
        return *this; // Возвращаем сам объект
    }

    Calculator& subtract(int v) {
        value -= v;
        return *this;
    }

    void show() {
        std::cout << "Result: " << value << std::endl;
    }
};

int main() {
    Calculator calc;
    // Цепочка вызовов
    calc.add(10).subtract(3).add(5).show();
    // Результат: 12
}

Итоговый пример

Давайте соберем все знания в одном примере. Создадим класс Book (Книга).

#include <iostream>
#include <string>

class Book {
private:
    std::string title;
    int pages;

public:
    // Конструктор с параметрами и списком инициализации
    Book(std::string title, int pages) : title(title), pages(pages) {
        std::cout << "Книга \"" << this->title << "\" создана." << std::endl;
    }

    // Метод для чтения
    void read() {
        std::cout << "Я читаю \"" << title << "\". В ней " << pages << " страниц." << std::endl;
    }

    // Деструктор
    ~Book() {
        std::cout << "Книга \"" << title << "\" закрыта и убрана на полку." << std::endl;
    }
};

int main() {
    std::cout << "--- Начало программы ---" << std::endl;

    {
        // Создаем объект внутри блока кода (области видимости)
        Book myBook("Мастер и Маргарита", 480);
        myBook.read();
    } // Здесь myBook выходит из области видимости, вызывается деструктор

    std::cout << "--- Конец программы ---" << std::endl;
    return 0;
}

Вывод программы:

--- Начало программы ---
Книга "Мастер и Маргарита" создана.
Я читаю "Мастер и Маргарита". В ней 480 страниц.
Книга "Мастер и Маргарита" закрыта и убрана на полку.
--- Конец программы ---

Заключение

Сегодня мы сделали первый шаг в мир ООП. Мы узнали, что класс — это чертеж, а объект — это реализация этого чертежа. Мы научились управлять рождением объекта с помощью конструкторов и его уничтожением с помощью деструкторов, а также поняли, как объект может ссылаться на самого себя через указатель this.

В следующей статье мы углубимся в тему инкапсуляции и узнаем, почему делать все поля public — плохая идея, и как модификаторы доступа private и protected помогают писать надежный код.

Инкапсуляция и сокрытие данных: модификаторы доступа, геттеры, сеттеры и статические члены

Инкапсуляция и сокрытие данных: модификаторы доступа, геттеры, сеттеры и статические члены

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

Вспомните наш класс Book или Point. Мы делали все поля public. Это значит, что в функции main кто угодно мог написать book.pages = -500;. С точки зрения синтаксиса C++ это верно (число есть число), но с точки зрения логики — это абсурд. Книга не может иметь отрицательное количество страниц.

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

Что такое инкапсуляция?

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

Представьте себе микроволновую печь.

У микроволновки есть интерфейс (кнопки, таймер, ручка дверцы) — это то, с чем вы взаимодействуете. И есть реализация (магнетрон, трансформатор, провода) — это то, что скрыто внутри корпуса. Корпус защищает вас от удара током, а детали — от поломки из-за вашего любопытства. В программировании роль такого «корпуса» играют модификаторы доступа.

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

В C++ существует три уровня доступа к членам класса:

  1. public (публичный) — доступ открыт всем. Поля и методы видны из любой части программы.
  2. private (приватный) — доступ есть только у самого класса (его методов). Из функции main или других классов эти данные не видны.
  3. protected (защищенный) — похож на private, но разрешает доступ классам-наследникам (об этом мы поговорим в статье про наследование).

Пример проблемы без инкапсуляции

struct BankAccount {
    // В структурах (struct) по умолчанию все public
    int balance;
};

int main() {
    BankAccount account;
    account.balance = 1000;

    // Ошибка логики: прямой доступ позволяет установить некорректное значение
    account.balance = -5000000;

    return 0;
}

Решение с использованием private

Давайте перепишем этот пример, используя класс и модификаторы доступа.

class BankAccount {
private:
    // Переменная balance теперь скрыта внутри объекта
    int balance;

public:
    // Конструктор доступен всем, чтобы мы могли создать объект
    BankAccount(int initialBalance) {
        if (initialBalance >= 0) {
            balance = initialBalance;
        } else {
            balance = 0;
        }
    }
};

Если теперь мы попытаемся написать account.balance = 100; в main, компилятор выдаст ошибку: «member BankAccount::balance is inaccessible». Мы успешно спрятали данные.

Геттеры и Сеттеры: Управляемый доступ

Но если данные скрыты (private), как нам с ними работать? Как узнать баланс или изменить его? Для этого создаются специальные публичные методы, которые называют аксессорами (accessors).

Их делят на два типа:

  • Геттеры (Getters) — методы для чтения данных (обычно начинаются с get).
  • Сеттеры (Setters) — методы для записи данных (обычно начинаются с set).

Зачем они нужны?

Почему бы просто не сделать переменную public? Сеттеры позволяют нам добавить логику валидации (проверки).

Доработаем наш банковский счет:

#include <iostream>

class BankAccount {
private:
    int balance;

public:
    BankAccount(int initialBalance) {
        if (initialBalance >= 0) balance = initialBalance;
        else balance = 0;
    }

    // Геттер: позволяет только узнать баланс
    int getBalance() {
        return balance;
    }

    // Сеттер (в данном случае метод deposit): позволяет добавить деньги
    void deposit(int amount) {
        if (amount > 0) {
            balance += amount;
            std::cout << "Внесено: " << amount << std::endl;
        } else {
            std::cout << "Ошибка: сумма должна быть положительной!" << std::endl;
        }
    }

    // Еще один метод для изменения состояния
    void withdraw(int amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            std::cout << "Снято: " << amount << std::endl;
        } else {
            std::cout << "Ошибка: недостаточно средств или неверная сумма!" << std::endl;
        }
    }
};

int main() {
    BankAccount myAccount(100);

    // myAccount.balance = 1000; // ОШИБКА КОМПИЛЯЦИИ

    myAccount.deposit(50);
    std::cout << "Текущий баланс: " << myAccount.getBalance() << std::endl;

    myAccount.withdraw(200); // Сработает защита внутри метода

    return 0;
}

Преимущества такого подхода:

  1. Контроль: Мы не позволяем установить отрицательный баланс.
  2. Только чтение: Если мы уберем методы deposit и withdraw и оставим только getBalance, переменная станет доступной только для чтения.
  3. Гибкость: Если в будущем мы захотим сохранять историю всех операций в базу данных, мы изменим код внутри методов deposit/withdraw, и это не сломает код в main.

Статические члены класса (static)

Иногда нам нужны данные, которые принадлежат не конкретному объекту (например, конкретному автомобилю), а всему классу в целом (заводу).

В C++ ключевое слово static используется для объявления членов класса, которые являются общими для всех экземпляров этого класса.

Статические поля

Представьте, что мы хотим подсчитать, сколько всего объектов класса User было создано в нашей программе. Если мы создадим обычную переменную int count внутри класса, то у каждого объекта будет своя копия этой переменной, и они не будут знать друг о друге.

Здесь на помощь приходит static.

class User {
private:
    static int userCount; // Объявление статического поля
    int id;

public:
    User() {
        userCount++;      // Увеличиваем общий счетчик
        id = userCount;   // Присваиваем уникальный ID
    }

    ~User() {
        userCount--;      // Уменьшаем счетчик при удалении
    }

    int getId() {
        return id;
    }

    // Статический метод
    static int getUserCount() {
        return userCount;
    }
};

// ВАЖНО: Инициализация статического поля происходит ВНЕ класса
int User::userCount = 0;

Особенности статических полей:

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

Статические методы

В примере выше мы написали static int getUserCount(). Статические методы:

  1. Могут быть вызваны без создания объекта.
  2. Имеют доступ только к статическим полям класса. Они не могут использовать обычные поля (например, id), потому что не знают, к какому конкретно объекту обращаться (у них нет указателя this).

Пример вызова:

int main() {
    // Вызов статического метода через имя класса
    std::cout << "Пользователей сейчас: " << User::getUserCount() << std::endl;

    User u1;
    User u2;

    std::cout << "Пользователей сейчас: " << User::getUserCount() << std::endl;
    std::cout << "ID первого: " << u1.getId() << std::endl;
    std::cout << "ID второго: " << u2.getId() << std::endl;

    return 0;
}

Вывод:

Пользователей сейчас: 0
Пользователей сейчас: 2
ID первого: 1
ID второго: 2

Хорошие практики инкапсуляции

Чтобы писать качественный код на C++, следуйте этим правилам:

  1. Все поля делайте private. Если полю нужен доступ извне, напишите геттер. Если его нужно менять — напишите сеттер.
  2. Минимизируйте интерфейс. Делайте public только те методы, которые действительно нужны пользователю класса. Вспомогательные методы, которые используются только внутри класса, должны быть private.
  3. Используйте const. Если геттер не меняет состояние объекта (а он и не должен), помечайте его как const. Например: int getBalance() const { ... }. Это гарантирует, что метод случайно не изменит данные.

Заключение

Сегодня мы разобрали важнейший принцип ООП — инкапсуляцию. Мы узнали, что:

  • Инкапсуляция скрывает детали реализации и защищает данные.
  • Модификаторы доступа (private, public) управляют видимостью членов класса.
  • Геттеры и сеттеры обеспечивают безопасный доступ к приватным полям.
  • Статические члены (static) принадлежат всему классу, а не отдельным объектам.

Теперь вы умеете создавать надежные и защищенные классы. В следующей статье мы перейдем ко второму киту ООП — Наследованию, и узнаем, как создавать новые классы на основе уже существующих, экономя время и строки кода.

Наследование: построение иерархий классов, порядок инициализации и множественное наследование

Наследование: построение иерархий классов, порядок инициализации и множественное наследование

Добро пожаловать в третью часть нашего курса «Основы объектно-ориентированного программирования на C++». В предыдущих статьях мы научились создавать отдельные классы, скрывать данные с помощью инкапсуляции и управлять жизненным циклом объектов. Но что делать, если наши классы имеют много общего?

Представьте, что вы разрабатываете игру. У вас есть классы Warrior (Воин), Mage (Маг) и Archer (Лучник). У всех них есть имя, уровень здоровья и координаты на карте. Если писать этот код для каждого класса отдельно, мы нарушим главный принцип программирования — DRY (Don't Repeat Yourself — Не повторяйся).

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

Суть наследования

Наследование позволяет выделить общие характеристики в базовый класс (родительский), а специфические особенности оставить в производных классах (дочерних).

В примере с игрой мы можем создать общий класс Unit (Юнит), в который вынесем здоровье и имя. А классы Warrior, Mage и Archer будут наследоваться от него, добавляя свои уникальные способности (например, castSpell для мага).

Отношение наследования часто называют отношением «является» (Is-A). Воин является Юнитом. Собака является Животным.

Синтаксис наследования в C++

Чтобы унаследовать класс, после имени нового класса ставится двоеточие, спецификатор доступа и имя родительского класса.

class Base {
    // Базовый класс
};

class Derived : public Base {
    // Производный класс
};

Модификаторы доступа при наследовании

Мы уже знакомы с public и private. При наследовании появляется третий важный игрок — protected.

Ключевое слово protected

Члены класса, объявленные как protected (защищенные):

  • Недоступны извне (как private).
  • Доступны внутри самого класса (как private).
  • Доступны внутри классов-наследников (в отличие от private).

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

Типы наследования

В C++ можно наследовать класс тремя способами: public, protected и private. Чаще всего (в 99% случаев) используется public наследование.

Рассмотрим, как меняется доступ к членам базового класса внутри производного при public наследовании:

Доступ в Базовом классе Доступ в Производном классе (при public наследовании)
public public (доступно всем)
protected protected (доступно только наследникам)
private Недоступно (даже наследникам)

Важно: private поля родителя существуют в памяти дочернего объекта, но компилятор запрещает к ним обращаться напрямую. Для работы с ними родитель должен предоставить public или protected методы.

Пример кода

#include <iostream>
#include <string>

// Базовый класс
class Animal {
protected:
    std::string name;

public:
    Animal(std::string n) : name(n) {}

    void eat() {
        std::cout << name << " кушает." << std::endl;
    }
};

// Производный класс
class Dog : public Animal {
public:
    // Мы должны вызвать конструктор родителя
    Dog(std::string n) : Animal(n) {}

    void bark() {
        // Мы имеем доступ к name, так как оно protected
        std::cout << name << " говорит: Гав!" << std::endl;
    }
};

int main() {
    Dog myDog("Шарик");
    myDog.eat();  // Метод унаследован от Animal
    myDog.bark(); // Метод класса Dog

    // myDog.name = "Бобик"; // Ошибка! name защищено (protected)
    return 0;
}

Порядок вызова конструкторов и деструкторов

Когда вы создаете объект производного класса, происходит целая цепочка событий. Понимание этого порядка критически важно для предотвращения ошибок инициализации.

Рождение объекта (Конструкторы)

Строительство объекта всегда начинается с фундамента. Сначала создается базовая часть, затем — надстройка.

  1. Вызывается конструктор Базового класса.
  2. Инициализируются поля Производного класса.
  3. Выполняется тело конструктора Производного класса.

Если у базового класса нет конструктора по умолчанию (без параметров), производный класс обязан явно вызвать нужный конструктор родителя в списке инициализации.

class Parent {
public:
    Parent() { std::cout << "Родитель создан" << std::endl; }
};

class Child : public Parent {
public:
    Child() { std::cout << "Ребенок создан" << std::endl; }
};

// При создании Child вывод будет:
// Родитель создан
// Ребенок создан

Гибель объекта (Деструкторы)

Уничтожение объекта происходит в строго обратном порядке. Сначала мы разбираем крышу, потом стены, и только потом фундамент.

  1. Вызывается деструктор Производного класса.
  2. Вызывается деструктор Базового класса.
// При уничтожении Child вывод будет:
// Ребенок уничтожен
// Родитель уничтожен

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

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

Представьте амфибию: это и машина, и лодка.

class Car {
public:
    void drive() { std::cout << "Едем по дороге" << std::endl; }
};

class Boat {
public:
    void swim() { std::cout << "Плывем по воде" << std::endl; }
};

// Наследуемся от обоих классов через запятую
class Amphibian : public Car, public Boat {
};

int main() {
    Amphibian amphi;
    amphi.drive();
    amphi.swim();
    return 0;
}

Проблема ромба (Diamond Problem)

Множественное наследование — мощный, но опасный инструмент. Самая известная проблема называется «Проблема ромба».

Представьте такую иерархию:

  1. Есть класс Device (Устройство) с полем id.
  2. Классы Scanner и Printer наследуются от Device.
  3. Класс Copier (Ксерокс) наследуется сразу от Scanner и Printer.

В итоге объект Copier будет содержать две копии Device: одну от сканера, другую от принтера. Это приводит к двусмысленности (к какому id мы обращаемся?) и перерасходу памяти.

Решение: Виртуальное наследование

Чтобы избежать дублирования базового класса, используется ключевое слово virtual при наследовании.

class Device { public: int id; };

// Виртуальное наследование гарантирует, что Device будет в единственном экземпляре
class Scanner : virtual public Device {};
class Printer : virtual public Device {};

class Copier : public Scanner, public Printer {};

Теперь Copier содержит только одну копию Device, и проблема решена.

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

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

Существует альтернатива — Композиция (отношение «Имеет» / Has-A).

  • Наследование: Dog является Animal.
  • Композиция: Car имеет Engine.

Не нужно наследовать машину от двигателя. Лучше создать поле типа Engine внутри класса Car.

«Предпочитайте композицию наследованию» — один из ключевых принципов объектно-ориентированного проектирования.

Заключение

Сегодня мы разобрали механизм наследования, который позволяет строить логичные иерархии классов. Мы узнали:

  1. Как создавать производные классы с помощью class Derived : public Base.
  2. Зачем нужен модификатор protected.
  3. Что конструкторы вызываются от родителя к ребенку, а деструкторы — наоборот.
  4. Как работает множественное наследование и как избежать проблемы ромба.

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

Полиморфизм: виртуальные функции, переопределение методов и абстрактные классы

Полиморфизм: виртуальные функции, переопределение методов и абстрактные классы

Приветствую вас в четвертой части нашего курса «Основы объектно-ориентированного программирования на C++». Мы уже научились создавать чертежи объектов (классы), защищать их внутренности (инкапсуляция) и строить иерархии (наследование). Казалось бы, у нас есть все инструменты для создания сложных систем. Но есть одна проблема.

Вспомните наш пример с игрой из прошлой статьи. У нас есть базовый класс Unit и наследники Warrior, Mage, Archer. Представьте, что у вас есть армия, состоящая из разных типов юнитов. Как управлять ими всеми одновременно? Как заставить всю армию атаковать, если у каждого юнита атака выглядит по-разному (меч, магия, лук)?

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

Проблема раннего связывания

Давайте рассмотрим классический пример с животными, чтобы понять суть проблемы.

#include <iostream>

class Animal {
public:
    void voice() {
        std::cout << "Животное издает звук" << std::endl;
    }
};

class Cat : public Animal {
public:
    void voice() {
        std::cout << "Мяу!" << std::endl;
    }
};

class Dog : public Animal {
public:
    void voice() {
        std::cout << "Гав!" << std::endl;
    }
};

int main() {
    Animal* myPet = new Cat(); // Указатель типа Animal хранит адрес объекта Cat
    myPet->voice();

    delete myPet;
    return 0;
}

Если вы запустите этот код, вы ожидаете увидеть «Мяу!», ведь мы создали кошку. Но консоль выдаст:

Животное издает звук

Почему так произошло? Дело в том, что по умолчанию в C++ используется раннее связывание (static binding). Компилятор видит, что указатель myPet имеет тип Animal*. Он не знает, что именно будет лежать по этому адресу во время выполнения программы. Поэтому он «привязывает» вызов метода voice() к реализации из класса Animal еще на этапе компиляции.

Виртуальные функции: магия позднего связывания

Чтобы исправить ситуацию, нам нужно сказать компилятору: «Не спеши. Реши, какую функцию вызывать, прямо в момент выполнения программы, посмотрев на реальный объект».

Для этого используется ключевое слово virtual.

class Animal {
public:
    // Добавляем virtual
    virtual void voice() {
        std::cout << "Животное издает звук" << std::endl;
    }
};

class Cat : public Animal {
public:
    // Здесь virtual писать не обязательно, но желательно для ясности
    void voice() {
        std::cout << "Мяу!" << std::endl;
    }
};

Теперь, запустив тот же main, мы получим заветное:

Мяу!

Это называется поздним связыванием (dynamic binding) или полиморфизмом. Указатель одного типа (Animal*) может вести себя по-разному в зависимости от того, на какой объект он указывает.

Как это работает под капотом?

Когда в классе появляется хотя бы одна виртуальная функция, компилятор скрытно добавляет в каждый объект этого класса специальный указатель — vptr. Он указывает на таблицу виртуальных методов (vtable). В этой таблице хранятся адреса правильных функций для данного конкретного класса. При вызове метода программа смотрит в таблицу и находит нужный адрес.

Ключевое слово override

В современном C++ (начиная со стандарта C++11) появилась отличная практика: явно помечать методы, которые мы переопределяем, словом override.

Зачем это нужно? Представьте, что вы допустили опечатку в наследнике:

class Animal {
public:
    virtual void voice() { ... }
};

class Cat : public Animal {
public:
    // Опечатка: vaice вместо voice
    void vaice() { std::cout << "Мяу!"; }
};

Компилятор посчитает vaice новым методом. Код скомпилируется, но полиморфизм работать не будет. Если же мы напишем override, компилятор проверит, есть ли такой виртуальный метод у родителя.

class Cat : public Animal {
public:
    // Ошибка компиляции: метод vaice не переопределяет ничего из Animal
    void vaice() override { ... }
};

Правило: Всегда используйте override при переопределении виртуальных функций. Это спасет вас от часов отладки.

Виртуальный деструктор

Это одно из самых важных правил в C++. Если в классе есть хоть одна виртуальная функция, деструктор тоже должен быть виртуальным.

Рассмотрим пример утечки памяти:

class Base {
public:
    ~Base() { std::cout << "Деструктор Base" << std::endl; }
};

class Derived : public Base {
    int* array;
public:
    Derived() { array = new int[100]; } // Выделяем память
    ~Derived() {
        delete[] array; // Освобождаем память
        std::cout << "Деструктор Derived" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // Внимание!
    return 0;
}

Если деструктор Base не виртуальный, то при delete ptr вызовется только ~Base(). Деструктор ~Derived() вызван не будет, и память, выделенная под массив array, навсегда утечет.

Исправленный вариант:

class Base {
public:
    virtual ~Base() { std::cout << "Деструктор Base" << std::endl; }
};

Теперь вызовутся оба деструктора в правильном порядке: сначала Derived, потом Base.

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

Иногда базовый класс настолько общий, что создавать его экземпляры не имеет смысла. Например, класс Shape (Фигура). Какая площадь у «просто фигуры»? Как ее нарисовать? Это абстрактное понятие.

В C++ мы можем запретить создание объектов класса, сделав его абстрактным. Для этого нужно объявить хотя бы одну чисто виртуальную функцию (pure virtual function).

Синтаксис выглядит немного странно: = 0 в конце объявления.

class Shape {
public:
    // Чисто виртуальная функция
    // Мы говорим: "У фигуры есть метод getArea, но реализация будет у наследников"
    virtual double getArea() const = 0;

    virtual ~Shape() {}
};

Теперь написать Shape s; нельзя — компилятор выдаст ошибку. Мы обязаны создать наследников и реализовать этот метод.

Пример с математикой

Давайте реализуем систему геометрических фигур. Нам понадобятся формулы площади.

Для круга площадь вычисляется по формуле:

S=π⋅r2S = \pi \cdot r^2

где SS — площадь круга, π\pi — математическая константа (примерно 3.14), а rr — радиус круга.

Для прямоугольника формула проще:

S=a⋅bS = a \cdot b

где SS — площадь, aa — ширина, а bb — высота.

Реализуем это в коде:

#include <iostream>
#include <vector>

// Абстрактный класс
class Shape {
public:
    virtual double getArea() const = 0; // Чисто виртуальный метод
    virtual ~Shape() {} // Виртуальный деструктор
};

class Circle : public Shape {
    double radius;
public:
    Circle(double r) : radius(r) {}

    double getArea() const override {
        return 3.14159 * radius * radius;
    }
};

class Rectangle : public Shape {
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double getArea() const override {
        return width * height;
    }
};

int main() {
    // Полиморфизм в действии: вектор указателей на базовый класс
    std::vector<Shape*> shapes;

    shapes.push_back(new Circle(5.0));
    shapes.push_back(new Rectangle(4.0, 6.0));

    for (const auto* shape : shapes) {
        // Вызывается правильный метод для каждого объекта
        std::cout << "Площадь фигуры: " << shape->getArea() << std::endl;
    }

    // Не забываем очистить память
    for (auto* shape : shapes) {
        delete shape;
    }

    return 0;
}

Зачем это нужно на практике?

Полиморфизм и абстрактные классы позволяют писать расширяемый код. Представьте, что вы пишете графический редактор.

  1. Вы создаете интерфейс Shape с методом draw().
  2. Весь ваш код (перемещение, удаление, сохранение) работает с указателями Shape*.
  3. Завтра заказчик просит добавить «Треугольник».
  4. Вы просто создаете новый класс Triangle : public Shape.
  5. Вам не нужно переписывать основной код программы! Он уже умеет работать с любыми фигурами, даже с теми, которых еще не существовало на момент его написания.

Это соответствует принципу Open/Closed Principle (Принцип открытости/закрытости): программные сущности должны быть открыты для расширения, но закрыты для модификации.

Заключение

Сегодня мы изучили вершину айсберга ООП:

  • Полиморфизм позволяет работать с объектами разных типов через единый интерфейс.
  • Виртуальные функции (virtual) включают механизм позднего связывания.
  • Override защищает от ошибок при переопределении.
  • Виртуальные деструкторы обязательны при наследовании для предотвращения утечек памяти.
  • Абстрактные классы задают контракт (интерфейс), который обязаны реализовать наследники.

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

Перегрузка операторов и основы обобщенного программирования с использованием шаблонов

Перегрузка операторов и основы обобщенного программирования с использованием шаблонов

Добро пожаловать в пятую часть нашего курса «Основы объектно-ориентированного программирования на C++». В предыдущих статьях мы построили прочный фундамент: научились создавать классы, скрывать данные, наследовать поведение и использовать полиморфизм. Наши объекты уже ведут себя как умные сущности.

Однако, есть один нюанс. Когда мы работаем со встроенными типами, такими как int или double, мы привыкли писать a + b, a == b или std::cout << a. Это удобно, интуитивно и читаемо. Но как только мы создаем свой класс, например Point (Точка) или Matrix (Матрица), эта магия исчезает. Мы вынуждены писать громоздкие конструкции вроде p1.add(p2) или matrix.print().

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

Перегрузка операторов: Синтаксический сахар или необходимость?

В C++ операторы (такие как +, -, *, ==, <<) — это, по сути, просто функции с необычными именами. Когда вы пишете 5 + 3, компилятор понимает это как арифметическую операцию. Но C++ позволяет нам переопределить (перегрузить) поведение этих операторов для наших собственных типов данных.

Зачем это нужно?

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

R⃗=A⃗+B⃗\vec{R} = \vec{A} + \vec{B}

где R⃗\vec{R} — результирующий вектор, A⃗\vec{A} — первый вектор-слагаемое, а B⃗\vec{B} — второй вектор-слагаемое.

Без перегрузки операторов код выглядел бы так:

Vector v3 = v1.add(v2);

С перегрузкой операторов мы можем написать гораздо естественнее:

Vector v3 = v1 + v2;

Реализация перегрузки

Чтобы научить класс реагировать на оператор +, нам нужно написать метод со специальным именем operator+. Рассмотрим это на примере класса Vector2D.

class Vector2D {
public:
    double x, y;

    Vector2D(double x, double y) : x(x), y(y) {}

    // Перегрузка оператора сложения (+)
    // Мы принимаем второй вектор как константную ссылку, чтобы не копировать его
    Vector2D operator+(const Vector2D& other) const {
        // Возвращаем новый объект, который является суммой текущего (this) и переданного (other)
        return Vector2D(this->x + other.x, this->y + other.y);
    }
};

int main() {
    Vector2D v1(1.0, 2.0);
    Vector2D v2(3.0, 4.0);

    Vector2D v3 = v1 + v2; // Вызывается v1.operator+(v2)
    // v3.x станет 4.0, v3.y станет 6.0
}

В данном случае выражение v1 + v2 компилятор преобразует в вызов метода v1.operator+(v2).

Дружественные функции и оператор вывода (<<)

Одной из самых частых задач является вывод объекта в консоль. Мы хотим писать std::cout << v1, а не v1.print().

Оператор << (побитовый сдвиг влево, который в контексте потоков означает «поместить в поток») имеет два операнда: слева — поток вывода (std::ostream), справа — наш объект.

Если мы сделаем перегрузку методом класса Vector2D, то левым операндом должен быть сам вектор. Тогда вызов выглядел бы как v1 << std::cout, что крайне нелогично. Чтобы левым операндом был std::cout, функция перегрузки должна быть внешней по отношению к классу.

Но если функция внешняя, она не имеет доступа к private полям класса. Тут на помощь приходит ключевое слово friend (друг). Дружественная функция — это обычная функция, которой класс разрешает доступ к своим приватным членам.

#include <iostream>

class Vector2D {
private:
    double x, y;

public:
    Vector2D(double x, double y) : x(x), y(y) {}

    // Объявляем функцию другом класса
    friend std::ostream& operator<<(std::ostream& os, const Vector2D& v);
};

// Реализация внешней функции
std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
    os << "(" << v.x << ", " << v.y << ")";
    return os; // Возвращаем поток, чтобы можно было делать цепочки вызовов: cout << v1 << v2;
}

int main() {
    Vector2D v(5.5, 1.2);
    std::cout << "Вектор: " << v << std::endl; // Работает!
    return 0;
}

Правила перегрузки

  1. Нельзя менять приоритет. Оператор умножения всегда будет выполняться раньше сложения.
  2. Нельзя менять количество операндов. Бинарный плюс всегда принимает два аргумента.
  3. Нельзя создавать новые операторы. Вы не можете придумать оператор ** или $$.
  4. Нельзя перегружать некоторые операторы. Например: . (доступ к члену), :: (разрешение области видимости), sizeof.
  5. Сохраняйте логику. Не делайте так, чтобы оператор + вычитал числа. Это запутает любого, кто будет читать ваш код.

Обобщенное программирование: Шаблоны (Templates)

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

int max(int a, int b) {
    return (a > b) ? a : b;
}

Потом вам понадобилось то же самое для double. Вы копируете код и меняете типы. Потом для float. Потом для char. Это нарушение принципа DRY (Don't Repeat Yourself). Вы дублируете логику, и если найдете ошибку, придется исправлять её везде.

В C++ есть решение — шаблоны. Шаблоны позволяют написать код, который работает с любым типом данных.

Шаблоны функций

Чтобы превратить обычную функцию в шаблонную, мы используем ключевое слово template и указываем параметры шаблона в угловых скобках < >. Обычно параметр типа называют T (от Type).

template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

Теперь мы можем использовать эту функцию для любых типов, которые поддерживают оператор >.

int main() {
    std::cout << max(10, 20) << std::endl;       // T автоматически выводится как int
    std::cout << max(3.14, 2.71) << std::endl;   // T автоматически выводится как double
    std::cout << max<char>('a', 'z') << std::endl; // Явное указание типа char
    return 0;
}

Как это работает?

Шаблоны — это механизм времени компиляции. Когда компилятор встречает вызов max(10, 20), он видит, что вы используете int. Он берет ваш шаблон, подставляет int вместо T и генерирует реальную функцию:

// Сгенерировано компилятором неявно
int max(int a, int b) {
    return (a > b) ? a : b;
}

Этот процесс называется инстанцированием шаблона. Если вы вызовете функцию для double, компилятор сгенерирует вторую версию функции. Если вы написали шаблон, но ни разу его не использовали, код функции вообще не будет создан.

Шаблоны классов

Шаблоны применяются не только к функциям, но и к классам. Это основа всей стандартной библиотеки C++. Например, std::vector — это шаблонный класс. Вы не можете создать просто «вектор», вы создаете «вектор целых чисел» (std::vector<int>) или «вектор строк» (std::vector<std::string>).

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

template <typename T>
class Box {
private:
    T value;

public:
    Box(T v) : value(v) {}

    T getValue() {
        return value;
    }

    void setValue(T v) {
        value = v;
    }
};

Использование:

int main() {
    Box<int> intBox(123);
    Box<std::string> stringBox("Hello World");

    std::cout << intBox.getValue() << std::endl;
    std::cout << stringBox.getValue() << std::endl;

    return 0;
}

Специализация шаблонов

Иногда общий алгоритм работает для всех типов, кроме одного особенного. Например, сравнение строк в стиле C (char*) нельзя делать через >, так как это сравнит адреса памяти, а не содержимое строк.

Для таких случаев существует специализация шаблона. Мы можем написать отдельную реализацию для конкретного типа.

// Общий шаблон
template <typename T>
class Storage {
public:
    void print() {
        std::cout << "Обычное хранилище" << std::endl;
    }
};

// Специализация для типа bool
template <>
class Storage<bool> {
public:
    void print() {
        std::cout << "Специальное хранилище для bool (оптимизированное)" << std::endl;
    }
};

Связь перегрузки операторов и шаблонов

Эти две концепции часто работают в паре. Представьте, что вы написали шаблонную функцию сортировки. Чтобы она работала с вашим пользовательским классом Person, этот класс должен иметь перегруженный оператор < или >. Если оператора нет, код шаблона просто не скомпилируется при попытке инстанцирования.

Пример математической формулы, которая может быть реализована в шаблоне:

S=a+b2⋅hS = \frac{a + b}{2} \cdot h

где SS — площадь трапеции, aa и bb — длины оснований, а hh — высота.

Если мы напишем шаблонную функцию для вычисления этой площади:

template <typename T>
T trapezoidArea(T a, T b, T h) {
    return (a + b) / 2 * h;
}

То эта функция будет работать и для int, и для double, и даже для нашего класса BigInt (если мы его напишем), при условии, что в этом классе перегружены операторы +, / и *.

Заключение

Сегодня мы значительно расширили арсенал наших возможностей:

  1. Перегрузка операторов позволяет интегрировать пользовательские типы в синтаксис языка, делая код чище и выразительнее.
  2. Шаблоны открывают дверь в мир обобщенного программирования, позволяя писать алгоритмы, независимые от конкретных типов данных.

Эти знания подводят нас к одной из самых мощных частей C++ — Стандартной библиотеке шаблонов (STL). В следующей статье мы узнаем, как не изобретать велосипед, а использовать готовые контейнеры (векторы, списки, словари) и алгоритмы, которые написаны профессионалами с использованием именно тех технологий, которые мы сегодня изучили.