Skip to content

设计准则:原则如何落到 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;
}

演示用概念约束的模板在编译期完成分派,避免运行期虚调用与间接跳转。

回链:零开销抽象、concepts

面试怎么问:静态多态与动态多态各自的代价? → 静态多态无运行期开销但代码膨胀、编译变慢;动态多态代码小但每次调用有一次间接跳转且难内联。

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);              // 轻量回调:单行为注入

演示三种机制各有适用面:按变化时机与性能要求来选,而非默认某一种。

回链:多态、CRTP

面试怎么问:std::function 适合什么场景、不适合什么场景? → 适合类型擦除的轻量回调;不适合高频热路径(有间接调用与潜在堆分配),热路径优先用模板或函数指针。

相关 ​

糌粑的面经笔记 · VitePress 版