🧠ООП на примере 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().
