组合、接口与设计
掌握组合优于继承、纯抽象接口、设计原则(ISP、DIP)、std::variant 作为继承的替代方案以及类型擦除。
为什么组合优于继承?
「优先使用组合而非继承」是面向对象设计中最重要的原则之一。继承在基类和派生类之间创建紧密耦合 ── 对基类的更改会波及整个层次结构。组合(拥有另一种类型的成员)更加灵活:你可以在运行时更改包含的类型,自由组合行为,并避免脆弱基类问题。
当你需要多态(通过公共接口统一处理不同类型)时,继承是合适的。仅仅为了代码复用,组合几乎总是更好的选择。
组合的实践
不要继承行为,而是包含它。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 的类型擦除
类型擦除让你可以将任何可调用对象 ── 函数、lambda、仿函数 ── 以兼容的签名存储在单个 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 层,这几乎肯定是设计问题的信号。
- 组合比继承更灵活 ── 用于代码复用(has-a 关系)
- 纯抽象接口定义契约并支持依赖倒置原则
std::variant与std::visit为封闭类型集提供编译时多态std::function提供类型擦除 ── 存储任何具有兼容签名的可调用对象- 保持继承层次结构扁平(1-2 层),优先使用接口而非具体基类
- 应用 ISP(小接口)和 DIP(依赖抽象)以获得可维护、可测试的设计
测验 — 检验你的知识
(15 经验值)1. 何时应该优先使用组合而非继承?
2. `std::variant` 相比传统继承层次结构有什么优势?
3. 依赖倒置原则(DIP)的内容是什么?