ООП на C++ на практике: пишем упрощенный видеоплеер

ООП на C++ на практике: пишем упрощенный видеоплеер Про IT

ООП (объектно-ориентированное программирование) — подход, при котором программа собирается из объектов: каждый объект прячет свои данные и общается через методы. Разберу все четыре принципа ООП — инкапсуляцию, наследование, полиморфизм и абстракцию — на одном сквозном проекте: упрощенном видеоплеере на C++.

Проект намеренно модельный: я не декодирую видеопоток и не подключаю кодеки, методы вроде play() и render() только печатают, что происходит. Настоящий плеер использует библиотеки обработки медиа (GStreamer, FFmpeg), но принципы ООП удобнее показать без этого шума.

Четыре принципа ООП кратко

Задача проекта: собрать плейлист из разных медиафайлов (видео и аудио) и проиграть их одним плеером. Классы и их принципы сведены в таблицу.

Принцип Что делает Где в проекте
Абстракция прячет детали за простым интерфейсом базовый класс MediaFile: только kind/render, без кодека
Инкапсуляция связывает данные с методами и скрывает состояние Player: приватные position_/playing_, наружу — команды
Наследование новый класс переиспользует базовый VideoFile и AudioFile от MediaFile
Полиморфизм один вызов — разное поведение по типу render() через указатель MediaFile*

Два соседних термина стоит развести сразу. Абстракция — это решение, что показать наружу, а что спрятать. Инкапсуляция — механизм, который держит данные и методы вместе и закрывает поля через private. Коротко: абстракция про интерфейс, инкапсуляция про доступ.

Минимальный рабочий пример целиком

Весь проект одним куском (файл player.cpp), разбор — ниже.

#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <utility>

// Абстракция: интерфейс медиафайла
class MediaFile {
public:
    MediaFile(std::string name, int seconds)
        : name_(std::move(name)), seconds_(seconds) {}
    virtual ~MediaFile() = default;          // виртуальный деструктор обязателен

    virtual std::string kind() const = 0;    // чистый виртуальный -> класс абстрактный
    virtual void render() const = 0;         // проиграть (модель, без кодека)

    const std::string& name() const { return name_; }
    int seconds() const { return seconds_; }

protected:
    std::string name_;
    int seconds_;
};

// Наследование: конкретные типы файлов
class VideoFile : public MediaFile {
public:
    VideoFile(std::string name, int seconds, int height)
        : MediaFile(std::move(name), seconds), height_(height) {}

    std::string kind() const override { return "video"; }
    void render() const override {
        std::cout << "  frames " << height_ << "p" << std::endl;
    }
private:
    int height_;
};

class AudioFile : public MediaFile {
public:
    AudioFile(std::string name, int seconds, int kbps)
        : MediaFile(std::move(name), seconds), kbps_(kbps) {}

    std::string kind() const override { return "audio"; }
    void render() const override {
        std::cout << "  sound " << kbps_ << " kbps" << std::endl;
    }
private:
    int kbps_;
};

// Инкапсуляция: Player прячет состояние, наружу - команды
class Player {
public:
    void open(const MediaFile* file) {
        file_ = file;
        position_ = 0;
        playing_ = false;
        std::cout << "open " << file_->kind() << " \"" << file_->name()
                  << "\" (" << file_->seconds() << " s)" << std::endl;
    }
    void play() {
        if (!file_) return;
        playing_ = true;
        std::cout << "play: " << file_->name() << std::endl;
        file_->render();                 // полиморфизм по типу файла
    }
    void pause() {
        playing_ = false;
        std::cout << "pause at " << position_ << " s" << std::endl;
    }
    void stop() {
        playing_ = false;
        position_ = 0;
        std::cout << "stop" << std::endl;
    }
private:
    const MediaFile* file_ = nullptr;    // приватное состояние
    int position_ = 0;
    bool playing_ = false;
};

// Плейлист: коллекция медиафайлов за общим интерфейсом
class Playlist {
public:
    void add(std::unique_ptr<MediaFile> file) {
        items_.push_back(std::move(file));
    }
    void playAll(Player& player) const {
        for (const auto& item : items_) {
            player.open(item.get());
            player.play();
        }
    }
private:
    std::vector<std::unique_ptr<MediaFile>> items_;
};

int main() {
    Playlist list;
    list.add(std::make_unique<VideoFile>("intro.mp4", 30, 1080));
    list.add(std::make_unique<AudioFile>("theme.mp3", 90, 320));

    Player player;
    list.playAll(player);               // разное поведение render()
    player.pause();
    player.stop();
    return 0;
}

Сборка и запуск (g++, C++17):

g++ -std=c++17 player.cpp -o player && ./player

Ожидаемый вывод:

open video "intro.mp4" (30 s)
play: intro.mp4
  frames 1080p
open audio "theme.mp3" (90 s)
play: theme.mp3
  sound 320 kbps
pause at 0 s
stop

playAll вызывает у обоих файлов один и тот же render(), но у видео печатаются кадры, а у аудио — битрейт. Это полиморфизм в действии.

Абстракция: интерфейс медиафайла

Класс MediaFile объявляет, что умеет любой файл, но не говорит как. Два метода помечены = 0 — это чистые виртуальные методы. Класс хотя бы с одним таким методом становится абстрактным: создать его объект напрямую нельзя, только унаследоваться и реализовать методы. В C++ нет отдельного слова interface: роль интерфейса играет абстрактный класс (у нашего MediaFile есть и общие поля name_/seconds_, поэтому это абстрактный базовый класс, а не чистый интерфейс).

Попытка создать объект абстрактного класса — типовая ошибка новичка (неверный код, результат, исправление):

MediaFile file("intro.mp4", 30);   // попытка создать объект интерфейса

Компилятор g++ откажется собирать:

error: cannot declare variable 'file' to be of abstract type 'MediaFile'
note:   because the following virtual functions are pure within 'MediaFile'

Исправление: создавать конкретный тип и хранить его через указатель на базовый класс.

std::unique_ptr<MediaFile> file = std::make_unique<VideoFile>("intro.mp4", 30, 1080);

Наследование: видео и аудио

VideoFile и AudioFile объявлены как : public MediaFile и переиспользуют конструктор и поля базы, а свои kind() и render() помечают словом override. Оно не обязательно, но полезно: при несовпадении сигнатуры с методом базы компилятор поймает ошибку сразу. Каждый наследник добавляет свои приватные данные — высоту кадра или битрейт.

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

Полиморфизм работает через виртуальные методы и указатель (или ссылку) на базовый класс. Когда Player::play() вызывает file_->render(), статический тип file_ — это const MediaFile*, но реальный объект за ним — VideoFile или AudioFile, и C++ на этапе выполнения выбирает реализацию по фактическому типу (динамическая диспетчеризация).

Важная граница: диспетчеризация работает только при вызове через указатель или ссылку на базовый тип. Если положить наследника в контейнер по значению, произойдет срезка (object slicing) — производная часть потеряется. Поэтому Playlist хранит std::vector<std::unique_ptr<MediaFile>>, а не std::vector<MediaFile>; unique_ptr заодно сам освобождает память.

Здесь же ясно, зачем деструктор базового класса помечен virtual. Без этого удаление наследника через указатель на базу — неопределенное поведение: вызвался бы только деструктор базовой части. Виртуальный деструктор гарантирует вызов деструктора фактического типа.

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

Player хранит три приватных поля: текущий файл, позицию и флаг воспроизведения. Снаружи к ним не добраться — доступны только команды open, play, pause, stop. Это и есть инкапсуляция: пользователь класса нажимает кнопки, но не может испортить состояние напрямую. Такой контракт дает свободу менять реализацию: сегодня position_ — просто число, завтра за ним реальный таймер, а внешний код не изменится.

Границы модели

Это учебная модель, и ее пределы стоит назвать явно. render() ничего не декодирует — он печатает строку вместо работы с реальным потоком байтов. В настоящем плеере тут стоит конвейер обработки медиа: демультиплексор контейнера, декодер кодека (H.264, AAC и подобные), синхронизация аудио и видео. Но сами классы, наследование и виртуальные методы устроены так же, как в примере выше.

Выводы

  • Четыре принципа удобно закрепить на одном проекте: абстракция задает интерфейс MediaFile, наследование дает VideoFile/AudioFile, полиморфизм включается при вызове render() через указатель, инкапсуляция прячет состояние Player.
  • Абстрактный класс в C++ — это класс хотя бы с одним чистым виртуальным методом (= 0); объект такого класса создать нельзя, отдельного слова interface в языке нет.
  • Полиморфизм работает только через указатель или ссылку на базовый тип; хранение наследников по значению приводит к срезке, поэтому в контейнере держат unique_ptr.
  • Виртуальный деструктор в базе обязателен, если объектами управляют через указатель на базу.

Где применяется / связь с практикой

ООП — основа промышленного кода на C++: движки, драйверы, GUI, обработка медиа. Разобранный видеоплеер показывает тот же скелет, что и в настоящих плеерах, только без слоя кодеков. Уверенное владение классами, виртуальными методами и умными указателями — база, с которой начинается серьезная работа на C++.

Освойте тему на практике

Системно пройти путь от синтаксиса до проектов на классах помогает курс C++ Basic. Посмотреть, как устроены занятия, можно на открытых уроках Otus — вход бесплатный.

FAQ

Чем абстрактный класс отличается от интерфейса в C++?
В C++ нет ключевого слова interface; интерфейс моделируют абстрактным классом с одними чистыми виртуальными методами и без данных. Абстрактный класс, в отличие от чистого интерфейса, может хранить общие поля и реализованные методы, как MediaFile в примере.

Зачем деструктор базового класса делать виртуальным?
Чтобы при удалении объекта через указатель на базу вызвался деструктор фактического класса, а не только базовой части. Без virtual это неопределенное поведение и потенциальная утечка ресурсов наследника.

Чем override отличается от перегрузки (overload)?
override — это переопределение: метод наследника с той же сигнатурой заменяет виртуальный метод базы. Перегрузка (overload) — несколько методов с одним именем, но разными параметрами в одном классе; к наследованию она отношения не имеет.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)