Appearance
工程与演进:解耦、组合与兼容
面试考「设计」,考的常常不是炫技,而是能不能让代码在半年后还能改。解耦、组合、显式、单一真相源、二进制兼容——这些不是玄学,每一条都对应具体的痛点与代价。这一页把它们讲成可落地的判断准则。
1. 接口与实现分离 / 编译防火墙
一句话:头文件只暴露接口、把实现细节藏进 .cpp,从而切断「改一处、全项目重编译」的依赖链。
展开:C++ 的头文件是文本包含,任何被包含的实现细节(成员的类型、私有函数、第三方头)都会把依赖传染给所有使用者。改一个私有成员,就可能触发整个项目的重新编译。Pimpl(Pointer to Implementation)把私有成员塞进一个前向声明的实现结构体,头文件只剩一个指针。痛点是大项目里「改一行、编十分钟」。代价是多一次堆分配与一次指针间接访问,以及需要手写或默认的析构。
小例子
cpp
// widget.h —— 使用者只看到接口,看不到 Impl 的内容
#include <memory>
class Widget {
public:
Widget();
~Widget(); // 必须在 .cpp 里定义(要看到 Impl 完整类型)
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void draw();
private:
struct Impl; // 前向声明,不暴露细节
std::unique_ptr<Impl> pimpl_;
};cpp
// widget.cpp —— 实现细节与第三方头文件全在这里,不污染使用者
#include "widget.h"
#include <vector>
#include <string>
struct Widget::Impl {
std::vector<std::string> cache; // 私有数据,头文件外不可见
int hidden_state = 0;
};
Widget::Widget() : pimpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // Impl 完整可见处定义析构
void Widget::draw() { /* 使用 pimpl_ */ }头文件里没有 vector、没有 string,改 Impl 不会让 #include "widget.h" 的地方重编译。
2. Pimpl 附带好处
一句话:Pimpl 顺手带来强异常安全与二进制兼容(ABI 稳定),一举多得。
展开:因为私有实现整体藏在一个指针后面,交换两个对象的 pimpl_ 即可完成移动/交换,且天然是 noexcept,于是「赋值先构造副本再交换」的 copy-and-swap 能在改到一半失败时保持原对象不变(强异常保证)。同时,类的大小固定为一个指针,新增/改动私有成员不改变对象布局,对已编译的调用方保持二进制兼容。边界是:仍会引入指针跳转开销,且析构/移动需在 .cpp 内显式定义。
小例子
cpp
// copy-and-swap:借助 noexcept 的 swap 提供强异常保证
class Widget {
public:
Widget& operator=(Widget other) { // 传值:拷贝发生在进入函数前
swap(*this, other); // 交换 pimpl_,只换指针,不抛
return *this;
}
friend void swap(Widget& a, Widget& b) noexcept {
using std::swap;
swap(a.pimpl_, b.pimpl_); // noexcept,失败也不会破坏 a
}
};
// 若赋值中途抛异常,other 是副本,*this 仍保持原状 → 强异常保证失败在前、提交在后(swap),调用方要么看到完整的新值,要么看到完整的旧值,绝不看到半成品。
3. 组合优于继承
一句话:优先用「有一个 / 用这个」(成员、委托)而不是「是一个」(公有继承)来复用行为。
展开:公有继承把子类与父类强耦合在实现细节上:父类改一个虚函数或默认实现,子类可能被悄悄破坏(脆弱的基类问题)。组合只依赖被组合对象的公开接口,耦合更松、更易替换与测试。做法:把「复用哪些行为」抽成接口/策略,用成员持有并委托调用。边界是:确实存在「is-a」且需要运行时多态时,继承仍是合理选择(如抽象基类 + override)。
小例子
cpp
#include <memory>
#include <string>
// 反例:继承复用实现 → 与基类实现细节强耦合
// struct Stack : std::vector<int> { ... }; // 暴露 vector 全部接口,语义错乱
// 正例:组合 + 委托,只暴露需要的接口
class Stack {
public:
void push(int v) { data_.push_back(v); } // 委托给成员
int pop() { int v = data_.back(); data_.pop_back(); return v; }
bool empty() const { return data_.empty(); }
private:
std::vector<int> data_; // "有一个" vector
};组合的 Stack 只暴露「栈该有的接口」,vector 的全套 API 不会泄漏出去,改内部实现也不影响使用者。
4. DRY / 单一真相源
一句话:一个知识/规则只应在一处定义,重复是 bug 的温床——改了一处忘另一处,就出不一致。
展开:重复的代码、常量、格式串不只是「丑」,而是维护风险:一处修复、另一处遗漏,会衍生出难以定位的行为差异。做法:把重复逻辑抽成函数/类;常量集中定义(constexpr/inline constexpr);用模板或泛型消除「仅类型不同」的复制粘贴。边界是:过度抽象(为了 DRY 把毫不相干的东西硬合并)会制造更糟的耦合,DRY 的前提是「语义相同」,而非「长得像」。
小例子
cpp
// 反例:魔法数字散落各处,改口径要满项目搜
double taxA(double p) { return p * 1.06; }
double taxB(double p) { return p * 1.06; } // 税率变了,两处都得改
// 正例:单一真相源
namespace cfg {
inline constexpr double kTaxRate = 0.06; // 只此一处定义
}
double apply_tax(double price) { return price * (1.0 + cfg::kTaxRate); }税率只定义一次,改口径只改一行,不存在「漏改某处」的可能。
5. 显式优于隐式
一句话:别让编译器替你猜意图——用 explicit 挡住危险的隐式转换,让接口调用一眼看得懂。
展开:单参数构造函数默认可被隐式调用,从而产生「意想不到的类型转换」,是历史遗留的经典坑(std::string s = "a"; 尚可,但自定义类被意外构造就危险)。加 explicit 后必须显式构造,语义清晰、避免误用。同类思想还有:禁用默认参数造成的歧义、优先 static_cast 而非 C 风格转换、用 nullptr 替代 0/NULL。边界是:对「语义上就是等价的包装」(如智能指针包装裸指针)允许隐式,此时是刻意设计。
小例子
cpp
#include <vector>
class Buffer {
public:
// 反例:允许隐式构造,600 会被当成 size 传进来
// Buffer(int size);
// 正例:必须显式写清楚,杜绝"整数变 Buffer"的意外
explicit Buffer(int size) : data_(size) {}
private:
std::vector<char> data_;
};
void use(const Buffer& b);
int main() {
Buffer b1(600); // ✅ 显式,意图明确
// Buffer b2 = 600; // ❌ 被 explicit 挡住
// use(600); // ❌ 同样被挡住,避免误传
}explicit 把「意外地构造出对象」变成编译错误,问题在编译期就暴露,而不是运行时诡异行为。
6. Rule of Zero 便于演进
一句话:能用成员自带的资源管理拼装、就不写析构/拷贝/移动——编译器默认生成的最稳,也最易演进。
展开:手写「三/五法则」的函数极易出错(漏处理自赋值、忘记 noexcept、拷贝语义与移动语义不一致)。Rule of Zero 主张把资源交给成员(std::string、std::vector、std::unique_ptr 等 RAII 类型),类的析构/拷贝/移动全部交给编译器默认合成。痛点是「自己管理资源」带来的正确性负担。边界是:一旦需要独占某种底层句柄(且无现成 RAII 包装),就必须自己遵守三/五法则——但先问「能否包一层 RAII 再 Rule of Zero」。
小例子
cpp
#include <string>
#include <vector>
#include <memory>
// Rule of Zero:不写析构/拷贝/移动,全部默认生成
class Session {
public:
Session(std::string name, std::unique_ptr<int> handle)
: name_(std::move(name)), handle_(std::move(handle)) {}
private:
std::string name_; // 自带正确拷贝/移动
std::unique_ptr<int> handle_; // move-only,自动禁拷贝
std::vector<int> trace_;
};
// 无需手写任何特殊成员函数:拷贝/移动/析构语义由成员正确推导类的特殊成员完全由成员类型推导,新增成员也不必回头补析构/拷贝函数,演进成本极低。
回链:RAII、unique_ptr
7. 可测试性与可维护性
一句话:把依赖做成可替换的接口(依赖注入),把大类拆成小职责(接口隔离),代码才测得动、改得动。
展开:如果类自己在构造函数里 new 具体依赖(数据库、网络、时钟),就几乎无法在单测里替换成假实现,只能连真实环境跑。做法:让依赖以抽象接口传入(依赖注入),定义窄接口只暴露调用方真正需要的方法(接口隔离),并用虚函数/模板/std::function 留出替换缝。代价是多一层抽象与少量运行时开销。边界是:过度抽接口会稀释可读性,按「是否真要替换」来决定抽象粒度。
小例子
cpp
#include <functional>
#include <string>
// 抽象时钟接口:生产用真实时钟,测试注入固定时钟
struct Clock {
virtual ~Clock() = default;
virtual long now_ms() const = 0;
};
class RetryPolicy {
public:
explicit RetryPolicy(Clock& clock) : clock_(clock) {} // 依赖注入
bool expired(long deadline) const { return clock_.now_ms() > deadline; }
private:
Clock& clock_; // "用这个",可替换
};
// 测试时传入 FakeClock,返回可控时间,无需真的等待
struct FakeClock : Clock {
long t = 0;
long now_ms() const override { return t; }
};把时钟变成可替换接口,过期逻辑就能在单测里用「假时间」精确验证,不必 sleep。
8. ABI / 兼容性意识
一句话:二进制兼容(ABI)约束的是「已编译的调用方能否继续用新库」,它反过来决定头文件里能放什么。
展开:C++ 没有稳定的 ABI 标准,类布局、vtable 布局、名字修饰、内联展开等随编译器/版本/编译选项而变。若你发布的库要保持二进制兼容(不要求用户重编译),就不能随意改类的大小/布局、删虚函数、改默认参数、改内联函数体——因为这些会改变已编译调用方看到的约定。这正是 Pimpl、稳定导出符号、避免在头文件暴露实现细节的工程理由。边界是:源码兼容(要求重编译)宽松得多,内部项目可放宽。
小例子
cpp
// 破坏二进制兼容的常见改动(要求调用方重编译):
// 1) 在类中间新增/删除成员 → 改变 sizeof 与偏移,旧调用方越界
class Config {
int a;
int b;
// int c; // 新增成员:旧二进制按 2 个 int 布局,读越界
};
// 2) 改变导出函数的默认参数(默认实参在调用点展开成了旧值)
int connect(int timeout = 1000);
// 3) 在头文件里改内联函数体(已内联进旧调用方,无法更新)
inline int scale(int x) { return x * 2; }
// 若改成 x * 3,旧的可执行文件里还是 x * 2
// 保持 ABI 稳定的手法:真正常变的部分藏进 Pimpl / 私有实现改类布局、默认参数、内联实现都会让「不重编译的调用方」看到过时的约定,是 ABI 事故高发区。