Композиция, интерфейсы и дизайн
Мастер-композиция над наследованием, чистые абстрактные интерфейсы, принципы проектирования (ISP, DIP), std::вариант как альтернатива наследованию и стирание типов.
Почему композиция выше наследования?
«Любовная композиция над наследованием» — один из важнейших принципов в дизайне ООП. Наследование создает тесную связь между базовыми и производными классами — изменения в базовой ряби через всю иерархию. Композиция (имеющая член другого типа) более гибкая: вы можете изменять содержащийся тип во время выполнения, свободно комбинировать поведение и избегать хрупкой проблемы базового класса.
Наследование уместно, когда вам нужен полиморфизм (однородное лечение различных типов через общий интерфейс). Для повторного использования кода состав почти всегда превосходит.
Состав на практике
Вместо того, чтобы наследовать поведение, сдержите его. Car не является Engine — он имеет Engine. Это дает вам свободу менять двигатели, тестировать компоненты самостоятельно и развивать каждый класс, не нарушая другой.
#include <iostream>
#include <string>
#include <memory>
class Engine {
int horsepower_;
std::string type_;
public:
Engine(int hp, const std::string& type)
: horsepower_{hp}, type_{type} {}
void start() const {
std::cout << type_ << " engine (" << horsepower_ << "hp) started\n";
}
int horsepower() const { return horsepower_; }
};
class Transmission {
int gears_;
public:
explicit Transmission(int gears) : gears_{gears} {}
void shift(int gear) const {
if (gear >= 1 && gear <= gears_) {
std::cout << "Shifted to gear " << gear << '\n';
}
}
};
// Car COMPOSES Engine and Transmission (has-a, not is-a)
class Car {
std::string model_;
Engine engine_;
Transmission trans_;
public:
Car(const std::string& model, Engine engine, Transmission trans)
: model_{model}
, engine_{std::move(engine)}
, trans_{std::move(trans)} {}
void drive() const {
std::cout << model_ << ": ";
engine_.start();
trans_.shift(1);
}
};
int main() {
Engine v8{450, "V8"};
Transmission auto6{6};
Car mustang{"Mustang", std::move(v8), std::move(auto6)};
mustang.drive();
return 0;
}Чистые абстрактные интерфейсы
Чистый абстрактный интерфейс — это класс, в котором есть только чистые виртуальные функции и виртуальный деструктор — никаких данных, никакой реализации. Это определяет контракт, который должны выполнять производные классы. Принцип инверсии зависимости (DIP): зависит от абстракций, а не от конкретных классов.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// Pure abstract interface — the contract
class IRepository {
public:
virtual ~IRepository() = default;
virtual void save(const std::string& key, const std::string& value) = 0;
virtual std::string load(const std::string& key) const = 0;
virtual bool exists(const std::string& key) const = 0;
};
// Concrete implementation: in-memory storage
class MemoryRepository : public IRepository {
std::vector<std::pair<std::string, std::string>> data_;
public:
void save(const std::string& key, const std::string& value) override {
for (auto& [k, v] : data_) {
if (k == key) { v = value; return; }
}
data_.emplace_back(key, value);
}
std::string load(const std::string& key) const override {
for (const auto& [k, v] : data_) {
if (k == key) return v;
}
return "";
}
bool exists(const std::string& key) const override {
for (const auto& [k, v] : data_) {
if (k == key) return true;
}
return false;
}
};
// Service depends on the INTERFACE, not the concrete class (DIP)
class UserService {
std::unique_ptr<IRepository> repo_;
public:
explicit UserService(std::unique_ptr<IRepository> repo)
: repo_{std::move(repo)} {}
void register_user(const std::string& username) {
if (repo_->exists(username)) {
std::cout << username << " already exists\n";
return;
}
repo_->save(username, "active");
std::cout << username << " registered\n";
}
};
int main() {
auto repo = std::make_unique<MemoryRepository>();
UserService service{std::move(repo)};
service.register_user("alice"); // alice registered
service.register_user("bob"); // bob registered
service.register_user("alice"); // alice already exists
return 0;
}std:variant — Альтернатива наследственности
std::variant - это типобезопасный союз, который может содержать один из нескольких типов. В сочетании с std::visit он обеспечивает компилятивный полиморфизм — альтернативу виртуальным функциям, когда набор типов известен во время компиляции. Это позволяет избежать распределения кучи и виртуальной отправки накладных расходов.
#include <iostream>
#include <variant>
#include <vector>
#include <cmath>
#include <numbers>
struct Circle { double radius; };
struct Square { double side; };
struct Triangle { double base, height; };
// Type alias for the closed set of shapes
using Shape = std::variant<Circle, Square, Triangle>;
// Visitor: a callable that handles each type
struct AreaVisitor {
double operator()(const Circle& c) const {
return std::numbers::pi * c.radius * c.radius;
}
double operator()(const Square& s) const {
return s.side * s.side;
}
double operator()(const Triangle& t) const {
return 0.5 * t.base * t.height;
}
};
struct NameVisitor {
std::string operator()(const Circle&) const { return "Circle"; }
std::string operator()(const Square&) const { return "Square"; }
std::string operator()(const Triangle&) const { return "Triangle"; }
};
int main() {
std::vector<Shape> shapes{
Circle{5.0},
Square{4.0},
Triangle{3.0, 6.0}
};
for (const auto& shape : shapes) {
std::string name = std::visit(NameVisitor{}, shape);
double area = std::visit(AreaVisitor{}, shape);
std::cout << name << ": area = " << area << '\n';
}
return 0;
}Стирание типа с помощью std::function
Type erasure позволяет хранить любую вызывающую функцию, лямбду, функтор с совместимой подписью в одном объекте std::function. Это форма полиморфизма во время выполнения без явного наследования.
#include <iostream>
#include <functional>
#include <vector>
#include <string>
// std::function erases the concrete callable type
using Callback = std::function<void(const std::string&)>;
class EventEmitter {
std::vector<Callback> listeners_;
public:
void on(Callback cb) {
listeners_.push_back(std::move(cb));
}
void emit(const std::string& event) const {
for (const auto& cb : listeners_) {
cb(event);
}
}
};
// A functor
struct FileLogger {
void operator()(const std::string& msg) const {
std::cout << "[File] " << msg << '\n';
}
};
void console_log(const std::string& msg) {
std::cout << "[Console] " << msg << '\n';
}
int main() {
EventEmitter emitter;
// Register different callable types — all stored as std::function
emitter.on(console_log); // function pointer
emitter.on(FileLogger{}); // functor
emitter.on([](const std::string& m) { // lambda
std::cout << "[Lambda] " << m << '\n';
});
emitter.emit("user_login");
return 0;
}Принцип сегрегации интерфейсов (ISP): предпочитайте небольшие, сфокусированные интерфейсы большим, монолитным. Класс не должен принуждаться к внедрению методов, которые он не использует. Разделите IAnimal на IFlyable и ISwimmable, если не все животные летают и плавают.
Принцип инверсии зависимости (DIP): модули высокого уровня должны зависеть от абстракций (интерфейсов), а не от конкретных реализаций. Перейдите IRepository& вместо MySQLDatabase& — это позволяет вам обмениваться реализациями для тестирования (мок-репозитории) и различных сред.
Глубокие наследственные иерархии (4+ уровня) становятся хрупкими и трудными для понимания. Изменение базового класса может иметь неожиданные последствия для отдаленных потомков. Проблема хрупкого базового класса делает обслуживание кошмаром. Предпочитают плоские иерархии (1-2 уровня), используют интерфейсы для полиморфизма и составляют поведение с объектами-членами или std::variant. Если глубина иерархии превышает 3 уровня, это почти наверняка запах дизайна.
- Композиция более гибкая, чем наследование — используйте ее для повторного использования кода (имеет отношения).
- Чистые абстрактные интерфейсы определяют контракты и включают принцип инверсии зависимостей
-
std::variantсstd::visitобеспечивает полиморфизм времени компиляции для закрытых наборов типов std::functionобеспечивает стирание типа - храните любой вызывающий с совместимой подписью- Держите иерархии наследования плоскими (1-2 уровня) и предпочитайте интерфейсы конкретным базовым классам.
- Применять ISP (небольшие интерфейсы) и DIP (в зависимости от абстракций) для поддерживаемых, тестируемых проектов.
Тест — проверьте свои знания
(15 XP)1. Когда вы предпочитаете наследование?
2. Какое преимущество имеет `std::variant` перед традиционной иерархией наследования?
3. Что такое принцип инверсии зависимостей (DIP)?