Введение: SOLID в контексте C++
SOLID — это акроним пяти фундаментальных принципов объектно-ориентированного проектирования, сформулированных Робертом Мартином. Для C++, как языка с мощной статической типизацией, множественным наследованием и RAII, эти принципы приобретают особое значение. На продвинутом уровне понимание SOLID позволяет создавать гибкие, тестируемые и расширяемые системы, избегая антипаттернов, таких как «божественный класс» или жёсткое связывание.
В этой статье мы разберём каждый принцип на практических примерах кода C++ (17/20), акцентируя внимание на нюансах реализации: использование std::unique_ptr, std::function, CRTP, виртуальных деструкторов и современных идиом.
1. Single Responsibility Principle (SRP) — принцип единственной ответственности
Формулировка: Класс должен иметь только одну причину для изменения.
На практике это означает, что класс отвечает за одну бизнес-сущность или одну операцию. Нарушение SRP приводит к «сцеплению» ответственностей, что усложняет тестирование и модификацию.
Пример нарушения
class Report {
public:
void generate() { /* создание данных */ }
void saveToFile(const std::string& path) { /* запись в файл */ }
void sendEmail(const std::string& to) { /* отправка */ }
};
Здесь Report отвечает и за генерацию, и за сохранение, и за отправку — три причины для изменения.
Рефакторинг с SRP
class Report {
public:
void generate() { /* создание данных */ }
};
class ReportSaver {
public:
void save(const Report& report, const std::string& path);
};
class EmailSender {
public:
void send(const Report& report, const std::string& to);
};
Теперь каждый класс имеет единственную ответственность. Это упрощает модульное тестирование и переиспользование.
2. Open/Closed Principle (OCP) — принцип открытости/закрытости
Формулировка: Программные сущности должны быть открыты для расширения, но закрыты для модификации.
В C++ это часто реализуется через полиморфизм (виртуальные функции) или через шаблоны (CRTP). Для продвинутого уровня рассмотрим обе техники.
Полиморфный подход (виртуальные функции)
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double r;
public:
Circle(double r) : r(r) {}
double area() const override { return 3.14 * r * r; }
};
class Rectangle : public Shape {
double w, h;
public:
Rectangle(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
Добавление новой фигуры (например, Triangle) не требует изменения существующих классов — только создание нового наследника.
Шаблонный подход (CRTP — Curiously Recurring Template Pattern)
template<typename Derived>
class ShapeBase {
public:
double area() const { return static_cast<const Derived*>(this)->area_impl(); }
};
class Circle : public ShapeBase<Circle> {
double r;
public:
Circle(double r) : r(r) {}
double area_impl() const { return 3.14 * r * r; }
};
CRTP даёт статический полиморфизм без накладных расходов на виртуальные вызовы, что критично для high-performance C++.
3. Liskov Substitution Principle (LSP) — принцип подстановки Барбары Лисков
Формулировка: Объекты базового класса должны заменяться объектами производных классов без нарушения корректности программы.
Классическое нарушение LSP в C++ — переопределение виртуального метода с ослаблением предусловий или усилением постусловий. Рассмотрим пример с иерархией «Прямоугольник — Квадрат».
Антипаттерн
class Rectangle {
protected:
double w, h;
public:
virtual void setWidth(double w) { this->w = w; }
virtual void setHeight(double h) { this->h = h; }
double area() const { return w * h; }
};
class Square : public Rectangle {
public:
void setWidth(double w) override { this->w = w; this->h = w; }
void setHeight(double h) override { this->w = h; this->h = h; }
};
Если клиент ожидает, что Rectangle может менять ширину и высоту независимо, то Square нарушает это ожидание. Решение — отказаться от наследования, либо использовать интерфейс Shape без мутаторов.
Правильная реализация
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
double w, h;
public:
Rectangle(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
class Square : public Shape {
double side;
public:
Square(double side) : side(side) {}
double area() const override { return side * side; }
};
Теперь оба класса корректно подставляются без нарушения контракта.
4. Interface Segregation Principle (ISP) — принцип разделения интерфейсов
Формулировка: Клиенты не должны зависеть от интерфейсов, которые они не используют.
В C++ это означает, что интерфейс (абстрактный класс) должен быть узким и специфичным. Вместо одного «толстого» интерфейса — несколько мелких.
Пример нарушения
class Worker {
public:
virtual void work() = 0;
virtual void eat() = 0;
virtual void sleep() = 0;
};
Роботу (Robot) не нужно есть и спать, но он вынужден реализовывать эти методы.
Рефакторинг
class Workable {
public:
virtual void work() = 0;
virtual ~Workable() = default;
};
class Eatable {
public:
virtual void eat() = 0;
virtual ~Eatable() = default;
};
class Human : public Workable, public Eatable { /* ... */ };
class Robot : public Workable { /* ... */ };
Использование множественного наследования от узких интерфейсов — стандартный подход в C++.