gadget59 · C++
1 / 22
Лекция

ООП на C++ простыми словами

Сегодня разберём, что такое объектно-ориентированное программирование. Без страшных слов и без спешки: сначала на бытовых примерах, потом на коде. К концу лекции ты поймёшь, зачем нужны классы и почему все о них говорят.

🏛 классы 🔒 инкапсуляция 🧬 наследование 🎭 полиморфизм 🧩 абстракция
Читай медленно. Если что-то непонятно — не листай дальше, вернись на слайд назад. Все примеры можно запустить и потрогать руками.
Управление: ← → или пробел · gadget59.ru
Цели

Что ты будешь понимать после лекции

Я не буду требовать, чтобы ты запомнил все слова. Главное — чтобы после лекции ты мог своими словами объяснить:

Если после лекции ты сможешь нарисовать на бумажке класс Cat с полями и методами и объяснить, что происходит при создании объекта — цель достигнута.
Идея

Зачем вообще придумали ООП?

Представь, что ты пишешь программу для школы. У тебя есть ученики, учителя, классы, оценки, расписание. В процедурном стиле ты бы держал всё отдельно:

// Данные отдельно:
std::string studentName = "Ира";
int studentAge = 16;
double studentAvg = 4.7;

// Функции отдельно:
void printStudent(std::string name, int age, double avg) {
    std::cout << name << ", " << age << ", средний балл " << avg;
}

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

Аналогия. Это как держать все инструменты в разных ящиках: молоток в одном, гвозди в другом, инструкция — в третьем. ООП — это один ящик с инструментами, где всё нужное лежит рядом и подписано.
ООП собирает данные и функции в одну коробку — объект. И даёт этой коробке имя: «Ученик», «Машина», «Игра».
Основы

Класс и объект: в чём разница?

Это самое важное понятие, без него дальше не двинуться. Разберём на примере.

Аналогия. Класс — это чертёж дома. На бумаге нарисовано: двери, окна, крыша. Объект — это конкретный дом, построенный по этому чертежу. По одному чертежу можно построить сто домов, и каждый будет жить своей жизнью: у кого-то красная крыша, у кого-то синяя.
class Cat {          // ЧЕРТЁЖ: «кот вообще»
public:
    std::string name;     // поле: имя
    int age;              // поле: возраст

    void meow() {        // метод: умеет мяукать
        std::cout << name << ": мяу!\n";
    }
};

int main() {
    Cat barsik;            // ОБЪЕКТ №1: кот Барсик
    barsik.name = "Барсик";
    barsik.age = 3;

    Cat murka;             // ОБЪЕКТ №2: кошка Мурка
    murka.name = "Мурка";
    murka.age = 5;

    barsik.meow();        // Барсик: мяу!
    murka.meow();         // Мурка: мяу!
}
Обрати внимание: Cat — это тип (как int), а barsik — переменная этого типа. Чертёж один — котов много.
Столп 1

Инкапсуляция: почему поля прячут?

В прошлом примере мы свободно писали barsik.age = 3. Но что, если кто-то напишет barsik.age = -10? Или barsik.name = ""? Программа не сломается сразу — но данные станут бессмысленными.

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

Как это делается в C++: поля помечаем словом private, доступ — через public-методы. В методе можно проверить данные.

class Cat {
private:                    // ЗАКРЫТО: снаружи не видно
    std::string name;
    int age;

public:                     // ОТКРЫТО: доступно всем
    Cat(std::string n, int a) : name(n), age(a >= 0 ? a : 0) {}

    int getAge() const { return age; }   // только прочитать

    void setAge(int a) {                 // записать — с проверкой
        if (a >= 0) age = a;
    }
};
Теперь barsik.age = -10 просто не скомпилируется. А barsik.setAge(-10) проигнорирует плохое значение. Данные защищены.
Столп 2

Наследование: не повторяй одно и то же

Представь, что тебе нужны классы Cat, Dog, Hamster. У всех есть имя, возраст, и все умеют есть и спать. Писать это в каждом классе заново — скучно и чревато ошибками.

Аналогия. Есть общий шаблон «Животное». В нём уже написано: у животного есть имя, возраст, оно ест и спит. А потом ты создаёшь «Кошку» — и говоришь: «кошка — это животное, плюс она умеет мяукать». Всё общее достанется ей автоматически.
class Animal {
protected:                  // доступно наследникам
    std::string name;
public:
    Animal(std::string n) : name(n) {}
    void eat() const { std::cout << name << " ест\n"; }
};

class Cat : public Animal {     // Cat наследует Animal
public:
    Cat(std::string n) : Animal(n) {}  // передаём имя базе
    void meow() const { std::cout << "мяу\n"; }
};

int main() {
    Cat c("Барсик");
    c.eat();    // унаследованный метод
    c.meow();   // собственный метод
}
Важно. public перед Animal означает «Cat — это разновидность Animal». Если написать private Animal, то снаружи никто не узнает, что кот — животное. В 99% случаев нужно именно public.
Столп 3

Полиморфизм: одна команда — разное поведение

Вот представь: у тебя есть список животных, и ты хочешь, чтобы каждое «подало голос». У кошки — «мяу», у собаки — «гав», у хомяка — «пи-пи». Как написать один цикл, который сработает для всех?

Аналогия. Ты говоришь пульту «включи». Телевизор включается, кондиционер включается, колонка включается — но по-разному. Команда одна, поведение зависит от того, на что она направлена. Это и есть полиморфизм.
class Animal {
public:
    virtual void speak() const {   // virtual = «решай на лету»
        std::cout << "...\n";
    }
};

class Cat : public Animal {
public:
    void speak() const override { std::cout << "мяу\n"; }
};

class Dog : public Animal {
public:
    void speak() const override { std::cout << "гав\n"; }
};

int main() {
    Cat c;
    Dog d;
    Animal* zoo[] = { &c, &d };   // указатели на базу

    for (auto* a : zoo)
        a->speak();              // мяу, гав — по факт. типу
}
Без virtual программа вызвала бы Animal::speak дважды. С virtual — каждого по-своему. Именно это «включает магию».
Столп 4

Абстракция: контракт без реализации

Иногда хочется сказать: «у любой фигуры есть площадь», но не писать, как её считать — это должны решить наследники. Для этого есть чисто виртуальные функции.

Аналогия. Водительские права — это контракт: «человек умеет водить». Как именно он водит — на легковой, на грузовике — неважно, права подтверждают умение. Класс-интерфейс — это такие «права» для программистов.
class Shape {
public:
    // = 0 — «у меня нет реализации, наследник обязан дать»
    virtual double area() const = 0;

    // виртуальный деструктор: чтобы delete через базу был безопасным
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double r;
public:
    Circle(double r) : r(r) {}
    double area() const override { return 3.14159 * r * r; }
};

// Shape s; // ОШИБКА: нельзя создать объект абстрактного класса
Класс с хотя бы одной = 0 функцией называется абстрактным. Создавать его объекты нельзя — только наследников, где все «нули» реализованы.
Классы

struct vs class: в чём разница?

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

struct

По умолчанию всё открыто (public). Удобно для маленьких «наборов данных»: точка, цвет, координаты.

class

По умолчанию всё закрыто (private). Удобно для объектов с защитой данных и логикой.

struct Point {           // public по умолчанию
    int x;
    int y;
};

class Person {          // private по умолчанию
    std::string name;       // закрыто, нужно public ниже
public:
    Person(std::string n) : name(n) {}
};
Договорённость в индустрии: struct — для простых данных без методов, class — для полноценных объектов. Технически они почти одинаковы.
Доступ

Три уровня доступа: public, private, protected

Эти три слова решают, кто может видеть поле или метод. Представь, что у тебя есть комната, сейф и общий двор.

public — общий двор

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

private — сейф

Видно только внутри класса. Даже наследники не увидят. Здесь хранят поля, которые нельзя менять снаружи.

protected — комната семьи

Видно внутри класса и в наследниках. Снаружи — нет. Удобно, когда наследнику нужно поле базы.

class Base {
private:
    int secret = 1;       // никто, кроме Base
protected:
    int shared = 2;       // Base и наследники
public:
    int open = 3;         // все
};

class Derived : public Base {
public:
    int sum() const { return shared + open; } // secret недоступен
};
Жизненный цикл

Конструктор: как объект появляется на свет

Когда ты пишешь Cat barsik;, объект создаётся — но с какими значениями? Если поля не заданы, там будет «мусор». Чтобы объект сразу был в порядке, придумали конструктор.

Аналогия. Конструктор — это сборка мебели из IKEA: ты достаёшь коробку (объект), а инструкция говорит, куда что прикрутить, чтобы получился нормальный стул, а не груда деталей.
class Cat {
    std::string name;
    int age;
public:
    // Конструктор №1: без параметров
    Cat() : name("без имени"), age(0) {}

    // Конструктор №2: с параметрами
    Cat(std::string n, int a) : name(n), age(a) {}

    void show() const {
        std::cout << name << ", " << age << "\n";
    }
};

int main() {
    Cat a;                 // сработал конструктор №1
    Cat b("Барсик", 3);  // сработал конструктор №2
    a.show();             // без имени, 0
    b.show();             // Барсик, 3
}
Запись : name(n), age(a) называется списком инициализации. Это правильный способ задать поля до выполнения тела конструктора. Пока просто запомни: «так принято».
Жизненный цикл

Деструктор: как объект уходит

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

Аналогия. Ты взял книгу в библиотеке — обязан вернуть. Деструктор — это «вернуть книгу»: автоматически, когда уходишь.
class Buffer {
    int* data;              // память, которую надо вернуть
public:
    Buffer(int n) : data(new int[n]) {}

    // Деструктор: тильда + имя класса
    ~Buffer() {
        delete[] data;       // вернули память системе
    }
};

void f() {
    Buffer b(100);         // создали
} // здесь автоматически вызовется ~Buffer()
Если забыть освободить память — она останется занятой до конца работы программы. Это называется утечка памяти. Деструктор спасает.
Копирование

Что происходит, когда объект копируют?

В C++ можно написать Cat b = a; — и b станет копией a. По умолчанию копируются все поля. Обычно это хорошо, но есть подвох.

Аналогия. Если у кошки есть ошейник, то при копировании кошки получается две кошки с одним ошейником на двоих. Если одна кошка «снимет» ошейник — вторая останется без него. С указателями на память — та же беда.
class Buffer {
    int* data;
public:
    // Конструктор копирования: создаём НОВЫЙ массив
    Buffer(const Buffer& other) : data(new int) {
        *data = *other.data;   // глубокое копирование
    }
};
Правило: если класс владеет памятью через new — пиши конструктор копирования сам. Иначе два объекта будут думать, что память их, и попытаются освободить её дважды.
Правило

Правило трёх (и пяти)

Это простое правило-подсказка: если классу нужен хотя бы один из трёх особых методов — скорее всего, нужны все три.

class Buffer {
public:
    ~Buffer();                          // 1
    Buffer(const Buffer&);             // 2
    Buffer& operator=(const Buffer&);   // 3
};
В C++11 добавили ещё перемещение — тогда говорят про «правило пяти». Но для начала хватит и правила трёх: главное — не забывать про все три, если класс владеет памятью.
Виртуальность

virtual, override, final: три слова, которые спасают

Мы уже видели virtual: он включает выбор метода на лету. Два других слова делают код надёжнее.

class Base {
public:
    virtual void show() const { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
    void show() const override { std::cout << "Derived\n"; } // проверка сигнатуры
};

class Final : public Derived {
public:
    void show() const override final {} // дальше нельзя
};
Если опечатаешься в имени метода без override, компилятор не ругнётся: он просто создаст новую функцию, а старая останется. С override — сразу ошибка.
Виртуальность

Виртуальный деструктор: почему это критично

Вот опасный сценарий. Есть база и наследник. Наследник в конструкторе взял память. Удаляем через указатель на базу — и что?

Аналогия. У тебя в руках пульт «Базовый». Ты жмёшь «выключить». Если пульт не знает, что под ним телевизор (наследник) — он выключит только базовую часть, а телевизор продолжит работать и жечь свет.
class Base {
public:
    virtual ~Base() = default;    // ОБЯЗАТЕЛЬНО virtual
};

class Derived : public Base {
    int* data = new int[10];
public:
    ~Derived() { delete[] data; }
};

Base* p = new Derived();
delete p; // если ~Base НЕ virtual — утечка памяти!
Правило: если у класса есть хотя бы одна виртуальная функция — делай деструктор тоже виртуальным. Вреда не будет, а спасает часто.
Полиморфизм

Как полиморфизм работает на практике

Главная сила полиморфизма: один код — много объектов. Покажем на примере: функция печати площади работает для любой фигуры.

void printArea(const Shape& s) {   // принимает ссылку на базу
    std::cout << s.area() << "\n";    // вызов по факт. типу
}

Circle c(2.0);
Square q(3.0);

printArea(c); // посчитает как круг
printArea(q); // посчитает как квадрат
Обрати внимание: мы написали функцию один раз — а она работает для любой фигуры, даже той, которую ты добавишь завтра. Это и есть главная польза ООП: старый код не нужно трогать, когда добавляешь новый.
Наследование

Виды наследования: не всё так просто

До этого мы писали public Animal. Но есть три вида наследования, и они отличаются тем, что видно снаружи.

public

«Это разновидность». Cat — это Animal. Снаружи можно обращаться к Cat как к Animal. Используется в 99% случаев.

protected / private

«Это деталь реализации». Снаружи никто не знает, что внутри есть Animal. Нужно редко, для специфических задач.

class Base { public: void f() {} };

class A : public Base {};    // A является Base
class B : private Base {};   // Base — деталь B

A a; Base* p = &a; // ок
B b; // Base* p2 = &b; // ошибка
Наследование

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

Когда создаётся наследник, сначала должен быть готов «подобъект» базы. Поэтому порядок строго определён.

Аналогия. Сначала заливаешь фундамент (база), потом стены (наследник). Разбираешь в обратном порядке: сначала стены, потом фундамент.
class Base {
public:
    Base() { std::cout << "Base()\n"; }
    ~Base() { std::cout << "~Base()\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived()\n"; }
    ~Derived() { std::cout << "~Derived()\n"; }
};

int main() {
    Derived d;
}
// Вывод: Base(), Derived(), ~Derived(), ~Base()
Ошибки

Типичные ошибки новичка в ООП

Это не «страшные слова», а ситуации, в которые попадают почти все. Прочитай — и не наступи на грабли.

Если сомневаешься — делай поля закрытыми, деструктор виртуальным, а копирование продумывай отдельно. Это три привычки, которые спасают от 80% проблем.
Мини-проект

Собираем иерархию фигур

Объединим всё, что узнали. У нас будет абстрактный класс Shape, наследники Circle и Square, вектор фигур и общий код, который не знает, какая перед ним фигура.

#include <iostream>
#include <vector>
#include <memory>

// Абстрактный класс: «у любой фигуры есть площадь»
class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

// Конкретная фигура: круг
class Circle : public Shape {
    double r;
public:
    Circle(double r) : r(r) {}
    double area() const override { return 3.14159 * r * r; }
};

// Конкретная фигура: квадрат
class Square : public Shape {
    double a;
public:
    Square(double a) : a(a) {}
    double area() const override { return a * a; }
};

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(2.0));
    shapes.push_back(std::make_unique<Square>(3.0));

    for (const auto& s : shapes) {
        std::cout << s->area() << "\n"; // у каждой — своя площадь
    }
}
Один цикл, один вызов area() — а работают разные фигуры. Добавь третью (например, треугольник) — старый код менять не нужно.
Итоги

Что мы сегодня поняли

Не пытайся запомнить всё сразу. Достаточно, если после лекции ты понимаешь идею: объект — это данные + поведение, и с ним можно работать, не залезая внутрь.

Совет на будущее: пробуй писать маленькие классы — «Кот», «Студент», «Корзина покупок». Чем больше практики, тем проще.