Appearance
设计准则:原则如何落到 C++
设计原则(SOLID 等)谈的是"如何组织依赖与变化",但真正难的是在 C++ 里把它翻译成具体语法与库选择。本页把每条原则落到可写的代码上:给出原则、C++ 里的落地做法、以及反例与代价。核心取向是——依赖抽象而非实现、优先组合而非继承、能用编译期解决就别留到运行期、让非法状态无法表达。
1. 单一职责 SRP:一个类只应有一个变化原因
一句话:把"因不同原因而变化"的职责拆开,类只留一个轴向的变化。
原则 → C++ 落地
- 原则:一个类只承担一个职责,变化原因唯一,避免"上帝类"。
- 做法:按关注点拆成多个类或自由函数;用命名空间而非继承做功能聚合。
- 反例/代价:一个类同时管网络、解析、持久化,任何一处改动都牵动全类;拆分后调用方需自己组合,接口数量增加。
小例子
cpp
// 反例:一个类干了三件互不相干的事
struct Report {
std::string fetch(); // 职责一:取数
std::string format(); // 职责二:格式化
void save(); // 职责三:落盘
};
// 正例:各自单一,用组合拼装
struct DataSource { std::string fetch(); };
struct Formatter { std::string format(const std::string&); };
struct Storage { void save(const std::string&); };演示把"取数/格式化/落盘"三种变化原因拆开后,任一处改动都不波及另外两处。
回链:工程化设计
面试怎么问:怎么判断一个类是否违反 SRP? → 试着用一句话描述它,如果必须用"并且"连接多个不相关的能力,就说明职责过多。
2. 开闭原则 OCP:扩展开放、修改关闭
一句话:新增行为应通过扩展而非改动既有代码完成,多态与模板策略是主要手段。
原则 → C++ 落地
- 原则:面对新需求,尽量加代码而不是改老代码,降低回归风险。
- 做法:运行期变化用虚接口,静态变化用模板策略;回调场景用
std::function注入。 - 反例/代价:
switch(type)一改就得动核心逻辑;抽象层本身是成本,过度抽象会让简单问题变复杂。
小例子
cpp
#include <functional>
#include <vector>
struct Shape { virtual ~Shape() = default; virtual double area() const = 0; };
struct Circle : Shape { double r; double area() const override { return 3.14 * r * r; } };
// 新增图形只需加一个派生类,addArea 无需改动
double total(const std::vector<Shape*>& xs) {
double s = 0;
for (auto* p : xs) s += p->area();
return s;
}演示"加派生类即可扩展、聚合函数保持不动",也提示运行期多态的抽象成本。
回链:多态
面试怎么问:OCP 一定意味着要用虚函数吗? → 不,静态多态(模板/概念)同样满足 OCP,且无运行期开销,适合编译期已知的扩展点。
3. 里氏替换 LSP:派生类不破坏基类契约
一句话:子类型必须能替换基类型而不改变程序正确性,尤其不能加强前置条件或削弱后置条件。
原则 → C++ 落地
- 原则:对外契约不变,调用方无需知道具体子类型。
- 做法:基类声明前置/后置条件与不变量,派生类只收紧实现不动语义;用
override保证签名一致。 - 反例/代价:
Square继承Rectangle后setWidth也改高度,破坏Rectangle的独立可设契约;强行满足会牺牲子类直觉。
小例子
cpp
struct Rectangle {
virtual void setW(int w) { w_ = w; }
virtual void setH(int h) { h_ = h; }
virtual int area() const { return w_ * h_; }
int w_ = 0, h_ = 0;
};
// 反例:Square 令 setW 同时改高度,调用方按 Rectangle 用法会算错
struct Square : Rectangle {
void setW(int w) override { w_ = h_ = w; } // 破坏了基类契约
void setH(int h) override { w_ = h_ = h; }
};演示"数学上 is-a 不代表行为上可替换",LSP 看的是契约而非分类学。
回链:继承的深水区
面试怎么问:为什么违反了 LSP 却仍能编译通过? → LSP 是语义契约,编译器只检查类型;行为不一致只能在运行期或测试中暴露。
4. 接口隔离 ISP:别让客户依赖没用到的接口
一句话:把胖接口拆成小而专的接口,客户只依赖自己真正使用的方法。
原则 → C++ 落地
- 原则:不强迫任何客户依赖它不使用的方法。
- 做法:按使用方视角拆分抽象基类;或用多个
concept描述能力,按需约束。 - 反例/代价:一个万能接口任何实现都得写一堆空方法或抛异常;拆细后接口数量增多,需要合理聚合。
小例子
cpp
// 反例:胖接口,打印机实现被迫支持"扫描/传真"
struct IMachine {
virtual void print() = 0;
virtual void scan() = 0;
virtual void fax() = 0;
};
// 正例:按能力拆分,客户只依赖所需
struct IPrinter { virtual void print() = 0; };
struct IScanner { virtual void scan() = 0; };演示按客户需要拆分接口后,纯打印机不必再实现它根本不会用的扫描方法。
回链:面向对象基本概念
面试怎么问:ISP 和 SRP 有何不同? → SRP 针对实现方的职责划分,ISP 针对使用方的依赖最小化,视角不同但常一起出现。
5. 依赖倒置 DIP:依赖抽象,而非具体实现
一句话:高层模块与底层模块都依赖抽象,抽象不依赖细节,从而可替换、可测试。
原则 → C++ 落地
- 原则:让稳定的抽象成为依赖方向的上游。
- 做法:构造函数注入接口指针/引用,或用
std::function注入行为;不要在高层硬编码new具体类。 - 反例/代价:高层直接依赖数据库实现,测试时无法替换;抽象引入间接层,性能与理解成本上升。
小例子
cpp
#include <functional>
#include <string>
#include <vector>
// 依赖抽象:存储行为以 std::function 注入
struct UserService {
std::function<void(const std::string&)> save;
void register_user(const std::string& u) { save(u); }
};
void use() {
std::vector<std::string> mem;
UserService s{ [&](const std::string& u) { mem.push_back(u); } }; // 测试用内存实现
s.register_user("amy");
}演示把"存储"抽象成可注入的 std::function,生产与测试实现可自由替换。
回链:函数对象与可调用体
面试怎么问:C++ 里实现 DIP 有哪些方式? → 抽象基类 + 注入、模板参数(策略)、std::function 回调,按是否需要运行期替换来选择。
6. 组合优于继承:has-a 优于 is-a
一句话:优先用成员对象组合功能,继承仅用于表达真正的"是一种"与多态替换。
原则 → C++ 落地
- 原则:复用行为时优先持有,而非继承。
- 做法:把可复用能力做成成员或策略对象;需要运行期替换时配合接口/
function。 - 反例/代价:深层继承树导致脆弱基类问题、方法名冲突、钻石继承;组合带来更多转发样板。
小例子
cpp
// 反例:用继承复用日志能力,导致 Car 也是 Logger(is-a 不成立)
struct Logger { void log(const char* s); };
struct Car : Logger { void drive(); }; // Car 竟然"是一种" Logger
// 正例:组合,能力作为成员
struct Car2 {
Logger logger; // has-a
void drive() { logger.log("drive"); }
};演示组合避免了"为了复用而继承"造成的语义扭曲与脆弱基类风险。
回链:工程化设计
面试怎么问:什么是脆弱基类问题? → 基类新增/修改成员函数或数据可能悄悄改变派生类行为,让派生类在基类演进时被破坏。
7. 编译期多态优先:模板与概念能解决就别用虚函数
一句话:若扩展点在编译期已知,用模板/概念实现静态多态,换取零虚调用开销与更好内联。
原则 → C++ 落地
- 原则:不为不加区分的变化付出运行期成本。
- 做法:用模板 +
concept约束接口,或用 CRTP 做静态分派。 - 反例/代价:静态多态导致代码膨胀、编译期变长、类型必须在编译期可见;跨动态库边界通常仍需虚接口。
小例子
cpp
#include <concepts>
template <typename T>
concept Shape = requires(const T& s) { { s.area() } -> std::convertible_to<double>; };
template <Shape S>
double total(const std::vector<S>& xs) { // 编译期绑定,无虚表
double s = 0;
for (const auto& x : xs) s += x.area();
return s;
}演示用概念约束的模板在编译期完成分派,避免运行期虚调用与间接跳转。
面试怎么问:静态多态与动态多态各自的代价? → 静态多态无运行期开销但代码膨胀、编译变慢;动态多态代码小但每次调用有一次间接跳转且难内联。
8. 让非法状态不可表达:强类型 + 不变量 + 工厂校验
一句话:用类型系统和不变量让错误状态根本无法构造出来,而非靠运行期检查。
原则 → C++ 落地
- 原则:把约束前移到类型与构造过程,减少可表示的状态空间。
- 做法:
enum class区分取值、私有构造 + 静态工厂做校验、用不变式保证对象恒有效。 - 反例/代价:裸类型加注释约定最省事但最脆弱;强类型化会带来包装类型与转换胶水代码。
小例子
cpp
#include <optional>
class Port {
public:
static std::optional<Port> make(int p) {
if (p <= 0 || p > 65535) return std::nullopt; // 工厂内校验
return Port{p};
}
int value() const { return p_; }
private:
explicit Port(int p) : p_(p) {} // 只能经工厂构造
int p_;
};演示私有构造 + 工厂校验后,一个非法端口值根本不可能存在于 Port 对象中。
回链:把正确性前置
面试怎么问:为什么"让非法状态不可表达"优于运行期 assert? → 前者在编译期/构造期就杜绝了错误状态,后者只能在运行到那一步才失败,且可能被 NDEBUG 关掉。
9. 值语义优先:优先用值,指针只表达所有权或可空
一句话:默认按值持有与传递,指针仅用于表达所有权、多态或"确实可空"。
原则 → C++ 落地
- 原则:值语义更易推理生命周期,减少共享可变状态。
- 做法:优先按值成员与返回值,
optional表达可空,unique_ptr/shared_ptr明确所有权。 - 反例/代价:到处裸指针/引用导致悬垂与别名分析困难;值传递带来拷贝成本,必要时用移动或视图。
小例子
cpp
#include <optional>
#include <string>
struct User { std::string name; int age; }; // 纯值语义
std::optional<User> find(const std::string& id); // 可空用 optional,不用裸指针
struct Cache {
std::optional<User> current; // 有值/无值语义清晰
};演示值成员 + optional 让"是否存在"与"拥有关系"都由类型表达,避免指针歧义。
回链:生命周期设计
面试怎么问:什么时候必须用指针而非值? → 需要多态(基类指针)、需要共享所有权(shared_ptr)、对象过大且需原地复用,或确实要表达"可空引用"时。
10. 面向接口编程 vs 泛型编程的取舍
一句话:运行期可变用虚接口,编译期已知用模板/概念,轻量回调用 std::function。
原则 → C++ 落地
- 原则:按"变化何时发生、集合是否封闭"来选择抽象机制。
- 做法:开放集合 + 运行期替换 → 虚接口;封闭集合 + 追求性能 → 模板/概念或
variant;单个行为回调 →std::function。 - 反例/代价:为性能硬套虚函数牺牲内联;为通用硬套模板造成编译膨胀与难读的错误信息;
std::function有类型擦除与可能堆分配的开销。
小例子
cpp
#include <functional>
#include <concepts>
struct IShape { virtual double area() const = 0; }; // 运行期多态:开放集合
template <class T> // 编译期多态:追求性能
concept HasArea = requires(const T& t) { t.area(); };
void use_any(std::function<double()> f); // 轻量回调:单行为注入演示三种机制各有适用面:按变化时机与性能要求来选,而非默认某一种。
面试怎么问:std::function 适合什么场景、不适合什么场景? → 适合类型擦除的轻量回调;不适合高频热路径(有间接调用与潜在堆分配),热路径优先用模板或函数指针。