SOLID принципы в C++: от теории к практике для чистого кода

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему SOLID важен для C++ разработчика?

SOLID — это акроним пяти фундаментальных принципов объектно-ориентированного программирования, сформулированных Робертом Мартином (Дядей Бобом). Для C++ разработчика эти принципы — не просто абстрактная теория, а практический инструмент, который помогает создавать гибкие, поддерживаемые и тестируемые приложения. В мире C++, где управление памятью и производительность критичны, следование SOLID позволяет избежать "спагетти-кода" и упрощает рефакторинг.

В этой статье мы разберем каждый принцип SOLID на конкретных примерах на C++. Вы увидите, как нарушение принципов приводит к проблемам, и как их исправить. Мы будем использовать современный C++ (C++11/14/17/20), чтобы примеры были актуальными.

1. Single Responsibility Principle (SRP) — Принцип единственной ответственности

Суть принципа

Класс должен иметь только одну причину для изменения. Другими словами, каждый класс отвечает только за одну функциональность или аспект системы.

Нарушение SRP в C++

Рассмотрим класс, который нарушает SRP: он одновременно управляет данными отчета и его форматированием.

class Report {
public:
    void generateReport(const std::vector<Data>& data) {
        // Логика обработки данных
        m_data = data;
    }
    
    void saveToFile(const std::string& filename) {
        // Логика сохранения в файл
        std::ofstream file(filename);
        for (const auto& item : m_data) {
            file << item.toString() << "\n";
        }
    }
    
    void printToConsole() {
        // Логика вывода на консоль
        for (const auto& item : m_data) {
            std::cout << item.toString() << std::endl;
        }
    }
    
private:
    std::vector<Data> m_data;
};

Проблема: если изменится формат вывода, придется менять класс Report. Если изменится способ хранения данных — тоже. Это нарушает SRP.

Исправляем: разделяем ответственность

// Отвечает только за данные отчета
class ReportData {
public:
    void addData(const Data& item) {
        m_data.push_back(item);
    }
    
    const std::vector<Data>& getData() const {
        return m_data;
    }
    
private:
    std::vector<Data> m_data;
};

// Отвечает только за сохранение
class FileSaver {
public:
    void save(const std::string& filename, const ReportData& report) {
        std::ofstream file(filename);
        for (const auto& item : report.getData()) {
            file << item.toString() << "\n";
        }
    }
};

// Отвечает только за вывод на консоль
class ConsolePrinter {
public:
    void print(const ReportData& report) {
        for (const auto& item : report.getData()) {
            std::cout << item.toString() << std::endl;
        }
    }
};

Теперь каждый класс имеет одну причину для изменения, что делает код более гибким и легким для тестирования.

2. Open-Closed Principle (OCP) — Принцип открытости/закрытости

Суть принципа

Программные сущности (классы, модули, функции) должны быть открыты для расширения, но закрыты для модификации. Это означает, что вы должны иметь возможность добавлять новую функциональность, не изменяя существующий код.

Нарушение OCP в C++

Представьте систему расчета скидок, где для каждого типа клиента используется if-else.

class DiscountCalculator {
public:
    double calculateDiscount(const std::string& customerType, double amount) {
        if (customerType == "regular") {
            return amount * 0.1; // 10%
        } else if (customerType == "vip") {
            return amount * 0.2; // 20%
        } else if (customerType == "premium") {
            return amount * 0.3; // 30%
        }
        // При добавлении нового типа придется менять этот метод
        return 0.0;
    }
};

Чтобы добавить новый тип клиента, нужно модифицировать существующий метод — нарушение OCP.

Исправляем: используем полиморфизм

// Абстрактный базовый класс для стратегии скидки
class DiscountStrategy {
public:
    virtual double calculate(double amount) const = 0;
    virtual ~DiscountStrategy() = default;
};

class RegularDiscount : public DiscountStrategy {
public:
    double calculate(double amount) const override {
        return amount * 0.1;
    }
};

class VipDiscount : public DiscountStrategy {
public:
    double calculate(double amount) const override {
        return amount * 0.2;
    }
};

class PremiumDiscount : public DiscountStrategy {
public:
    double calculate(double amount) const override {
        return amount * 0.3;
    }
};

// Теперь калькулятор принимает любую стратегию
class DiscountCalculator {
public:
    explicit DiscountCalculator(std::unique_ptr<DiscountStrategy> strategy)
        : m_strategy(std::move(strategy)) {}
    
    double calculateDiscount(double amount) const {
        return m_strategy->calculate(amount);
    }
    
private:
    std::unique_ptr<DiscountStrategy> m_strategy;
};

Теперь для добавления нового типа скидки достаточно создать новый класс, наследующий от DiscountStrategy, без изменения существующего кода.

3. Liskov Substitution Principle (LSP) — Принцип подстановки Барбары Лисков

Суть принципа

Объекты базового класса должны быть заменяемы объектами его производных классов без нарушения корректности программы. Это значит, что наследники не должны изменять ожидаемое поведение базового класса.

Нарушение LSP в C++

Классический пример — наследование Square от Rectangle.

class Rectangle {
public:
    virtual void setWidth(double w) { width = w; }
    virtual void setHeight(double h) { height = h; }
    double getArea() const { return width * height; }
    
protected:
    double width = 0;
    double height = 0;
};

class Square : public Rectangle {
public:
    void setWidth(double w) override {
        Rectangle::setWidth(w);
        Rectangle::setHeight(w); // Нарушение: меняем обе стороны
    }
    
    void setHeight(double h) override {
        Rectangle::setHeight(h);
        Rectangle::setWidth(h); // То же самое
    }
};

Если клиентский код ожидает прямоугольник, но получает квадрат, поведение ломается:

void resize(Rectangle& rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    // Ожидаем площадь = 50, но для квадрата получим 100
    assert(rect.getArea() == 50); // Ошибка для Square!
}

Блоги

Рекомендаций по книгам