Сегодня разберём, что такое объектно-ориентированное программирование. Без страшных слов и без спешки: сначала на бытовых примерах, потом на коде. К концу лекции ты поймёшь, зачем нужны классы и почему все о них говорят.
Я не буду требовать, чтобы ты запомнил все слова. Главное — чтобы после лекции ты мог своими словами объяснить:
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 — переменная этого типа. Чертёж один — котов много.В прошлом примере мы свободно писали 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) проигнорирует плохое значение. Данные защищены.Представь, что тебе нужны классы 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.Вот представь: у тебя есть список животных, и ты хочешь, чтобы каждое «подало голос». У кошки — «мяу», у собаки — «гав», у хомяка — «пи-пи». Как написать один цикл, который сработает для всех?
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 — каждого по-своему. Именно это «включает магию».Иногда хочется сказать: «у любой фигуры есть площадь», но не писать, как её считать — это должны решить наследники. Для этого есть чисто виртуальные функции.
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++ есть два ключевых слова для классов. Новички часто путаются. На самом деле разница всего одна.
По умолчанию всё открыто (public). Удобно для маленьких «наборов данных»: точка, цвет, координаты.
По умолчанию всё закрыто (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Эти три слова решают, кто может видеть поле или метод. Представь, что у тебя есть комната, сейф и общий двор.
Видно всем: и внутри класса, и снаружи, и наследникам. Используй для того, что должно быть доступно пользователю класса.
Видно только внутри класса. Даже наследники не увидят. Здесь хранят поля, которые нельзя менять снаружи.
Видно внутри класса и в наследниках. Снаружи — нет. Удобно, когда наследнику нужно поле базы.
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;, объект создаётся — но с какими значениями? Если поля не заданы, там будет «мусор». Чтобы объект сразу был в порядке, придумали конструктор.
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
};
virtual, override, final: три слова, которые спасаютМы уже видели virtual: он включает выбор метода на лету. Два других слова делают код надёжнее.
override — «проверь, что я реально переопределяю функцию базы». Опечатка в имени или параметрах — ошибка компиляции, а не тихий баг.final — «дальше переопределять нельзя». Запрещает наследникам менять функцию.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. Но есть три вида наследования, и они отличаются тем, что видно снаружи.
«Это разновидность». Cat — это Animal. Снаружи можно обращаться к Cat как к Animal. Используется в 99% случаев.
«Это деталь реализации». Снаружи никто не знает, что внутри есть 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()
Это не «страшные слова», а ситуации, в которые попадают почти все. Прочитай — и не наступи на грабли.
override — опечатался в имени, компилятор не заметил, метод не переопределился.public — любой код может испортить данные.Объединим всё, что узнали. У нас будет абстрактный класс 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() — а работают разные фигуры. Добавь третью (например, треугольник) — старый код менять не нужно.virtual включает выбор метода на лету.Совет на будущее: пробуй писать маленькие классы — «Кот», «Студент», «Корзина покупок». Чем больше практики, тем проще.