🧠ООП на примере C++ и Qt: класс, объект (экземпляр), наследование и полиморфизм

Я чот смотрю иногда объявления на hh.ru, там везде требуется понимание принципов ООП. А что это за хуйня такая? Как будто бы где-то есть золотая рамка и вот, они там выписаны как заповеди на скрижалях Моисея. Уже третий год занимаюсь программированием и до сих пор понимаю такие вещи чисто-интуитивно. Вроде сам пользуюсь, но не знаю что как называется. Это типа как я пришел к буддизму, когда еще не знал что это такое... Наконец, решил разобраться.


"Принципы ООП" — это основополагающие концепции Объектно-Ориентированного Программирования (Object-Oriented Programming, OOP). OOP — это парадигма программирования, где программы строятся вокруг объектов, которые сочетают данные и методы работы с ними. Это помогает делать код более модульным, переиспользуемым и понятным.
              Классически выделяют три основных принципа ООП, но иногда добавляют четвёртый (абстракцию). Вот они с краткими объяснениями:
    1. Инкапсуляция (Encapsulation): Это механизм, который объединяет данные (поля) и методы (функции) в одном объекте (классе), скрывая внутреннюю реализацию от внешнего мира. Например, вы можете иметь класс "Автомобиль" с приватными полями (типа "топливо") и публичными методами (типа "запустить двигатель"). Это защищает данные от несанкционированного доступа и упрощает использование.
    2. Наследование (Inheritance): Позволяет создавать новые классы на основе существующих, наследуя их свойства и методы. Это способствует переиспользованию кода. Например, класс "Грузовик" может наследовать от класса "Автомобиль", добавляя свои уникальные особенности (типа "грузоподъёмность").
    3. Полиморфизм (Polymorphism): Означает "много форм" — один и тот же метод может вести себя по-разному в зависимости от объекта. Есть перегрузка (overloading) методов и переопределение (overriding) в наследуемых классах. Например, метод "двигаться" у "Автомобиля" и "Велосипеда" будет реализован по-разному, но вызываться одинаково.
    4. Абстракция (Abstraction) (иногда считается дополнительным принципом): Это фокус на существенных характеристиках объекта, скрывая ненужные детали. Используется через абстрактные классы или интерфейсы. Например, вы определяете интерфейс "Транспорт" с методом "двигаться", а конкретные классы реализуют его по-своему.

🧭 Содержание

      1. Базовые понятия: класс, объект, метод
      2. Инкапсуляция: скрытие данных и доступ через методы
      3. Экземпляр класса: где живёт и как умирает
      4. Наследование: «берём старое и добавляем своё»
      5. Полиморфизм: базовая ручка, но крутит реальный механизм
      6. Под капотом: vptr и vtable
      7. Абстракция: интерфейсы как договор
      8. Qt и почему show() — не про полиморфизм
      9. Готовый каркас (.hpp/.cpp + main)
      10. Короткая шпаргалка

Чтобы сделать объяснения нагляднее, я сначала приведу полный демонстрационный код на примере классов 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() */ };
Создаём новый класс на базе старого: получаем всё старое и добавляем своё. В коде: class ElectricCar : public Car { ... } — ElectricCar наследует brand, speed, drive(), stop() от 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.

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

  1. Когда ты создаёшь объект, например, ElectricCar, компилятор кладёт в него vptr, который указывает на vtable класса ElectricCar.
  2. В vtable для ElectricCar лежат адреса методов, например, &ElectricCar::drive и &ElectricCar::stop.
  3. Когда ты вызываешь 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().