🧠ООП на примере C++ и Qt: класс, объект (экземпляр), наследование и полиморфизм
Я чот смотрю иногда объявления на hh.ru, там везде требуется понимание принципов ООП. А что это за хуйня такая? Как будто бы где-то есть золотая рамка и вот, они там выписаны как заповеди на скрижалях Моисея. Уже третий год занимаюсь программированием и до сих пор понимаю такие вещи чисто-интуитивно. Вроде сам пользуюсь, но не знаю что как называется. Это типа как я пришел к буддизму, когда еще не знал что это такое... Наконец, решил разобраться.
"Принципы ООП" — это основополагающие концепции Объектно-Ориентированного Программирования (Object-Oriented Programming, OOP). OOP — это парадигма программирования, где программы строятся вокруг объектов, которые сочетают данные и методы работы с ними. Это помогает делать код более модульным, переиспользуемым и понятным.
Классически выделяют три основных принципа ООП, но иногда добавляют четвёртый (абстракцию). Вот они с краткими объяснениями:
- Инкапсуляция (Encapsulation): Это механизм, который объединяет данные (поля) и методы (функции) в одном объекте (классе), скрывая внутреннюю реализацию от внешнего мира. Например, вы можете иметь класс "Автомобиль" с приватными полями (типа "топливо") и публичными методами (типа "запустить двигатель"). Это защищает данные от несанкционированного доступа и упрощает использование.
- Наследование (Inheritance): Позволяет создавать новые классы на основе существующих, наследуя их свойства и методы. Это способствует переиспользованию кода. Например, класс "Грузовик" может наследовать от класса "Автомобиль", добавляя свои уникальные особенности (типа "грузоподъёмность").
- Полиморфизм (Polymorphism): Означает "много форм" — один и тот же метод может вести себя по-разному в зависимости от объекта. Есть перегрузка (overloading) методов и переопределение (overriding) в наследуемых классах. Например, метод "двигаться" у "Автомобиля" и "Велосипеда" будет реализован по-разному, но вызываться одинаково.
- Абстракция (Abstraction) (иногда считается дополнительным принципом): Это фокус на существенных характеристиках объекта, скрывая ненужные детали. Используется через абстрактные классы или интерфейсы. Например, вы определяете интерфейс "Транспорт" с методом "двигаться", а конкретные классы реализуют его по-своему.
🧭 Содержание
- Базовые понятия: класс, объект, метод
- Инкапсуляция: скрытие данных и доступ через методы
- Экземпляр класса: где живёт и как умирает
- Наследование: «берём старое и добавляем своё»
- Полиморфизм: базовая ручка, но крутит реальный механизм
- Под капотом: vptr и vtable
- Абстракция: интерфейсы как договор
- Qt и почему
show()
— не про полиморфизм - Готовый каркас (.hpp/.cpp + main)
- Короткая шпаргалка
Чтобы сделать объяснения нагляднее, я сначала приведу полный демонстрационный код на примере классов Car и ElectricCar. Этот код иллюстрирует все ключевые принципы ООП. Далее в каждом разделе мы будем ссылаться на него, разбирая, как там применяются концепции. Это поможет лучше понять, как всё работает на практике, а не в абстрактных примерах.
🛠️ Готовый каркас: Car
и ElectricCar
Car.hpp
#ifndef CAR_HPP #define CAR_HPP #include <string> // Базовый класс class Car { protected: std::string brand; int speed; public: Car(const std::string& brand); virtual void drive(); // метод (виртуальный, чтобы переопределять в наследниках) void stop(); }; #endif
Car.cpp
#include "Car.hpp" #include <iostream> Car::Car(const std::string& brand) : brand(brand), speed(0) {} void Car::drive() { speed = 60; std::cout << brand << " едет со скоростью " << speed << " км/ч\n"; } void Car::stop() { speed = 0; std::cout << brand << " остановился\n"; }
ElectricCar.hpp
#ifndef ELECTRIC_CAR_HPP #define ELECTRIC_CAR_HPP #include "Car.hpp" // Класс-наследник class ElectricCar : public Car { private: int battery; public: ElectricCar(const std::string& brand, int battery); void drive() override; // переопределяем метод drive() void charge(); }; #endif
ElectricCar.cpp
#include "ElectricCar.hpp" #include <iostream> ElectricCar::ElectricCar(const std::string& brand, int battery) : Car(brand), battery(battery) {} void ElectricCar::drive() { if (battery > 0) { battery -= 10; speed = 80; std::cout << brand << " (электро) едет, батарея: " << battery << "%\n"; } else { std::cout << brand << " не может ехать, батарея разряжена!\n"; } } void ElectricCar::charge() { battery = 100; std::cout << brand << " зарядился до 100%\n"; }
main.cpp
#include "Car.hpp" #include "ElectricCar.hpp" int main() { Car lada("Lada"); lada.drive(); lada.stop(); ElectricCar tesla("Tesla", 50); tesla.drive(); tesla.charge(); tesla.drive(); return 0; }
Этот код — основа для всех объяснений ниже. В нём вы увидите классы, объекты, наследование, полиморфизм и инкапсуляцию в действии. Давайте разберём по частям. Как читать этот код:
- Класс — общий план (Car).
- Экземпляр — конкретная машина (lada, tesla).
- Наследование — ElectricCar «как Car, но с батареей».
- Полиморфизм —
Car*
указывает наElectricCar
, вызывается версия наследника.
1) 🧱 Базовые понятия
Класс — чертёж. В нём написано, какие будут поля (данные) и кнопки управления (методы). В нашем коде класс Car — это чертёж обычной машины с полями brand и speed, методами drive() и stop().
Объект (экземпляр) — вещь, сделанная по чертежу. У каждого — свои значения полей. Например, в main.cpp: Car lada("Lada"); — это объект lada с брендом "Lada".
Метод — функция внутри класса, которая управляет его данными. Например, drive() меняет speed и выводит сообщение.
Ещё раз, но проще: Класс — рецепт, объект — готовое блюдо, методы — «пожарить», «посолить», «подать».
2)🔒 Инкапсуляция: скрытие данных и доступ через методы
Инкапсуляция — это принцип, по которому данные (поля) класса скрываются от внешнего доступа, а взаимодействие с ними происходит только через методы. Это как чёрный ящик: вы знаете, что машина может ехать (метод drive()), но не лезете напрямую в двигатель (поле speed).
В нашем коде:
- Поля brand и speed в Car объявлены protected — они доступны только внутри класса и наследников, но не снаружи.
- В ElectricCar поле battery private — доступно только внутри ElectricCar.
- Доступ к данным: через конструктор (установка brand) и методы (drive() меняет speed, charge() — battery).
Преимущества: Защита от ошибок (нельзя случайно установить speed в -100), упрощение API (пользователь вызывает drive(), не думая о деталях).
Пример нарушения: Если бы speed был public, то в main.cpp можно было бы написать lada.speed = 1000; — и машина "разогналась" до абсурда.
Инкапсуляция предотвращает это.
Еще разок для закрепления:
Ты работаешь только через публичный интерфейс (public
методы).
В Car
:
protected: std::string brand; int speed;
Поля brand
и speed
скрыты от внешнего мира. Снаружи (main.cpp
) нельзя написать:
lada.speed = 200; // Ошибка! Доступ закрыт.
Зато можно вызвать lada.drive()
, и машина сама решит, что сделать со скоростью.
👉 Инкапсуляция даёт надёжность: объект нельзя «сломать» напрямую, а работать с ним можно только через методы.
3) 🧪 Экземпляр класса: память и жизнь объекта
Экземпляр создаётся из класса и хранит свои данные отдельно от других экземпляров:
Car a("Lada");
Car b("Volvo"); // a и b — разные объекты с разными полями
Где он живёт:
- 🧷 Стек — создаёшь просто как переменную, деструктор вызовется сам при выходе из блока.
Car lada("Lada"); // автоматическое время жизни
- 📦 Куча (heap) — через
new
/delete
или «умные» указатели.
Car* p = new Car("Lada"); p->drive(); delete p; // не забыть!
4) 🧬 Наследование
Создаём новый класс на базе старого: получаем всё старое и добавляем своё.
class Car { /* базовый класс */ }; class ElectricCar : public Car { /* +battery, +charge() */ };
В конструкторе ElectricCar вызывает Car(brand), чтобы инициализировать базовую часть.
Ещё один ракурс: Наследование — как «расширенная комплектация»: базовая модель + батарея и розетка.
5) 🎭 Полиморфизм
«Один интерфейс — много форм поведения».
Если в базовом классе метод помечен virtual
, наследники могут его переопределять.
В Car
:
virtual void drive();
В ElectricCar
:
void drive() override;
Теперь, если у тебя есть указатель/ссылка на Car
, но в нём лежит ElectricCar
, вызов drive()
выполнит именно версию из ElectricCar
.
Car* car = new ElectricCar("Tesla", 50); car->drive(); // вызовется ElectricCar::drive()
👉 Это и есть полиморфизм: одна команда (drive
) → разное поведение в зависимости от того, какой объект.
Вот еще пример:
#include <iostream> class Base { public: virtual void hello() { std::cout << "Base\n"; } }; class Child : public Base { public: void hello() override { std::cout << "Child\n"; } }; int main() { Base* b = new Child(); b->hello(); // вывод: Child }
Структурно это значит:
-
b
указывает на объектChild
. -
Child
хранитvptr
→ таблица, гдеhello
указывает наChild::hello()
. -
Вызов идёт через vtable → на экран попадает "Child".
🔑 Итого:
Полиморфизм = вызов методов через «виртуальный механизм».
Ты работаешь через базовый интерфейс, а реально исполняется та версия, которая соответствует настоящему типу объекта.
6) ⚙️ Под капотом: vptr и vtable
Если в классе есть virtual
-метод, компилятор кладёт в объект скрытый указатель vptr на таблицу функций vtable его реального типа. Вызов идёт: объект → vptr → vtable → адрес нужной функции.
Base* p = new Child(); /* Объект Child в куче ┌───────────────────────────────┐ │ поля Base/Child ... │ │ vptr ────────────────────┐ │ └──────────────────────────┼───┘ ▼ [vtable для Child] ├─ &Child::hello ├─ &Child::~Child └─ ... другие virtual */ p->hello(); // берём vptr → таблица Child → слот hello → Child::hello()
Тогда можно работать с IVehicle* без знания деталей: IVehicle* v = new ElectricCar(...); v->drive();
Простое объяснение vptr и vtable
Когда ты используешь virtual в C++ (например, virtual void drive() = 0 в IVehicle или virtual void drive() в Car), компилятор включает механизм динамического полиморфизма. Это значит, что при вызове метода через указатель или ссылку (типа IVehicle* v = new ElectricCar(); v->drive();) программа должна в момент выполнения понять, какую именно версию метода вызвать (Car::drive() или ElectricCar::drive()). Вот тут и появляются vptr и vtable.
- vptr (virtual pointer, указатель на виртуальную таблицу): Это скрытый указатель, который компилятор добавляет в каждый объект класса, где есть хотя бы одна виртуальная функция. Он указывает на таблицу виртуальных функций (vtable) для этого класса.
- vtable (virtual table, таблица виртуальных функций): Это таблица, где хранятся адреса всех виртуальных методов для конкретного класса. Каждый класс с виртуальными функциями имеет свою vtable.
Как это работает?
- Когда ты создаёшь объект, например, ElectricCar, компилятор кладёт в него vptr, который указывает на vtable класса ElectricCar.
- В vtable для ElectricCar лежат адреса методов, например, &ElectricCar::drive и &ElectricCar::stop.
- Когда ты вызываешь v->drive() через указатель IVehicle*, программа смотрит на vptr объекта, находит его vtable и вызывает нужный метод (в данном случае ElectricCar::drive).
Аналогия для "дураков" (как мы любим): Представь, что объект — это человек, а vptr — это его визитная карточка, на которой написан номер телефона справочной службы (vtable). Когда ты звонишь по номеру (v->drive()), справочная соединяет тебя с нужным человеком (методом ElectricCar::drive), даже если ты звонишь через общий номер (IVehicle*).
7) 🧩 Абстракция: интерфейсы как меню в ресторане
Абстракция — это когда ты говоришь: "Мне пох, как это работает, главное, чтобы работало!" Ты задаёшь список того, что объект должен уметь, но не лезешь в детали, как он это делает. В C++ это называется интерфейсом — списком методов, которые класс обязан реализовать.
Похоже на прототипы функций в заголовочном файле (типа void drive();
в начале программы)? Отчасти да, но интерфейс — это не просто прототипы, а контракт для классов. Он говорит: "Если ты хочешь быть транспортом, ты должен уметь drive()
и stop()
, а как — твоё дело".
Аналогия: Представь, что ты в ресторане. Меню — это интерфейс, где написано: "Можно заказать суп и десерт". Повар (класс) готовит их по-своему: один делает борщ и тирамису, другой — щи и мороженое. Ты просто заказываешь "суп" и не думаешь, как его готовят. Абстракция — это и есть такое меню.
Вернёмся к нашему коду. Мы можем сделать интерфейс для транспорта:
#include <iostream> #include <string> struct IVehicle { virtual void drive() = 0; // "Ты обязан уметь ехать!" virtual void stop() = 0; // "Ты обязан уметь останавливаться!" virtual ~IVehicle() = default; // Для корректной очистки памяти }; class Car : public IVehicle { public: Car(const std::string& brand) : brand(brand), speed(0) {} void drive() override { std::cout << brand << " едет со скоростью 60 км/ч\n"; } void stop() override { std::cout << brand << " остановился\n"; } private: std::string brand; int speed; }; class ElectricCar : public IVehicle { public: ElectricCar(const std::string& brand, int battery) : brand(brand), battery(battery) {} void drive() override { if (battery > 0) { battery -= 10; std::cout << brand << " (электро) едет, батарея: " << battery << "%\n"; } else { std::cout << brand << " не может ехать, батарея разряжена!\n"; } } void stop() override { std::cout << brand << " остановился\n"; } private: std::string brand; int battery; };
Чем это отличается от прототипов функций?
- Прототипы (типа
void drive();
в .h файле) — это просто объявления функций, чтобы компилятор знал, что они где-то есть. Они не связаны с классами и не дают гибкости ООП. - Интерфейс — это контракт для классов.
IVehicle
говорит: "Все, кто наследуется от меня, должны иметьdrive()
иstop()
". Плюс он позволяет полиморфизм: ты вызываешьv->drive()
, и код сам решает, какую версию метода выполнить.
Что тут происходит?
IVehicle
— это как меню: "Все транспортные средства должны уметьdrive()
иstop()
".Car
иElectricCar
— это повара, которые готовят эти "блюда" по-своему.- Ты работаешь через указатель
IVehicle*
, но не знаешь, что там внутри — машина, электромобиль или трактор. И тебе это не важно!
Почему это круто?
- Можно добавить новый класс, например
Bicycle
, унаследовать отIVehicle
, и код будет работать без изменений. - Ты не ломаешь голову о том, как именно
drive()
реализован — главное, что он есть. - Это позволяет писать гибкий код, где разные классы могут работать через один интерфейс.
BBC-пересказ: Абстракция — это как заказать пиццу: ты говоришь "хочу пиццу", а как её испекут — не твоя забота. Интерфейс задаёт, что должно быть, а классы решают, как это сделать.
9) 🪟 Qt и почему show() — не про полиморфизм
Ты, наверное, писал код в Qt, где создаёшь окно или виджет и вызываешь show()
, чтобы оно появилось на экране. Например:
About *m_about = nullptr; m_about = new About; m_about->show();
Здесь About
— это, скорее всего, класс, унаследованный от QDialog
или QWidget
. Метод show()
говорит: "Покажи это окно на экране". Но в чём подвох с полиморфизмом? Давай разберёмся.
Что такое show()
? Это метод из класса QWidget
, который отображает виджет (кнопку, окно, форму и т.д.) на экране. Например, QPushButton
или твой About
наследуются от QWidget
, поэтому могут использовать show()
.
Почему show()
не про полиморфизм? В C++ полиморфизм работает, когда метод помечен как virtual
. Это значит, что если ты вызываешь метод через указатель базового класса (например, QWidget*
), C++ выберет версию метода из реального класса объекта (например, QPushButton
). Но QWidget::show()
— не виртуальный. Это значит, что даже если ты пишешь:
QWidget* w = new QPushButton("Нажми меня"); w->show();
вызовется QWidget::show()
, а не какая-то особая версия show()
из QPushButton
. Почему? Потому что Qt не ожидает, что ты будешь переопределять show()
— он просто делает окно видимым, и это поведение одинаково для всех виджетов.
Где в Qt есть полиморфизм? Полиморфизм в Qt появляется, когда ты работаешь с виртуальными методами, такими как paintEvent()
, mousePressEvent()
или resizeEvent()
. Эти методы помечены как virtual
, и ты можешь переопределить их, чтобы задать кастомное поведение.
Пример: Допустим, ты хочешь сделать кнопку с кастомной отрисовкой. Ты создаёшь класс MyButton
:
#include #include #include class MyButton : public QPushButton { Q_OBJECT public: using QPushButton::QPushButton; protected: void paintEvent(QPaintEvent* e) override { QPainter painter(this); painter.setBrush(Qt::red); // Красим кнопку в красный painter.drawRect(rect()); // Рисуем прямоугольник QPushButton::paintEvent(e); // Вызываем стандартную отрисовку } };
Теперь, если ты создашь:
QWidget* w = new MyButton("Нажми меня"); w->show(); // Вызовет QWidget::show() — окно появится
и Qt будет отрисовывать кнопку, он вызовет MyButton::paintEvent()
, а не QWidget::paintEvent()
. Это и есть полиморфизм: ты работаешь через указатель QWidget*
, но выполняется кастомная версия метода из MyButton
.
Аналогия: Представь, что show()
— это кнопка "включить свет" в комнате. Она всегда просто включает свет, и нет смысла менять её поведение. А paintEvent()
— это как выбор, чем покрасить стены: один красит в красный, другой в синий, но ты просто говоришь "покрась", и C++ (через vptr
и vtable
) выбирает нужный цвет.
Связь с нашим кодом про машины: Вспомни наш пример с Car
и ElectricCar
. Там метод drive()
был виртуальным, и вызов через Car* vehicle = new ElectricCar()
запускал ElectricCar::drive()
. Это полиморфизм. В Qt то же самое: paintEvent()
виртуальный, поэтому твой кастомный код срабатывает, а show()
— нет, и он всегда работает одинаково.
Почему это важно?
- Когда ты пишешь
m_about->show()
, ты используешь стандартное поведениеQWidget::show()
. Это нормально, но не полиморфизм. - Если хочешь кастомное поведение (например, особую отрисовку окна
About
), переопределяй виртуальные методы вродеpaintEvent()
. Тогда Qt вызовет твою версию, даже если работает черезQWidget*
.
BBC-пересказ: show()
— это как кнопка "вкл" на телевизоре: она просто включает экран, и всё. А paintEvent()
— это как выбор канала: ты можешь настроить, что именно показывать, и Qt выберет твой вариант, если ты его задал.
🎯 Итого
На примере:
-
Инкапсуляция — поля
brand
,speed
,battery
закрыты, работа только через методы. -
Наследование —
ElectricCar
расширяетCar
. -
Полиморфизм —
drive()
работает по-разному уCar
иElectricCar
. -
Абстракция — можно вынести общий интерфейс
IVehicle
, чтобы работать с машинами вообще.
10) 📝 Шпаргалка (повторим ещё раз)
- Класс = чертёж. Объект = изделие. Метод = кнопка управления.
- Экземпляр живёт либо на стеке (сам «уберётся»), либо в куче (убираем сами/«умно»).
- Наследование = базовая модель + свои фишки.
- Полиморфизм = одна команда, разные исполнения у разных типов — благодаря
virtual
и vtable. - В Qt полиморфизм виден на виртуальных событиях (
paintEvent
,mousePressEvent
, ...), а не наshow()
.