红旗信号与设计原则
A Philosophy of Software Design, 2nd ed. · 14 Red Flags & 16 Design Principles
原书书末有两份清单:14 个红旗信号(red flags)和 16 条设计原则。这一页把它们整理成写代码、做评审时能直接翻的形式。所有定义、例子和代码都由本站重写,不是原书译文;原书的论证、作者自己的例子和边界讨论,请回到对应章节去读。
写代码时:觉得哪里别扭,先在下面的导航里找一个最像的红旗,对照"怎么察觉"确认,再看"常见改法"。做评审时:用页尾的设计评审清单逐项过一遍,每一项都链接到对应的红旗或原则。想打印:清单可以单独打印成一页贴在工位上,整页打印时改前/改后两个面板都会印出来。
每个红旗都附了"别过度":红旗只是症状,提示"这里可能有设计问题",不是必须执行的规则。作者在书里反复提醒,任何原则推到极端都会出问题;判断的最终标准是系统整体的复杂性有没有下降。
14 个红旗信号
每一项的结构相同:一句话定义 → 评审时你会看到的症状 → 一对原创 C++ 例子(点标签切换改前/改后)→ 常见改法 → 什么时候不算问题。
浅模块 Shallow Module
第 4 章 · 4.5 节接口几乎和实现一样复杂:学会怎么调用它,花的力气跟自己把那几行写出来差不多。它没替使用者藏住任何东西。
怎么察觉
- 方法体只有一两行,名字和参数基本就是实现的复述;
- 类主要由 getter/setter 组成,真正的逻辑要调用者自己拼;
- 把接口注释写完整,发现比实现还长;
- 直接写底层代码反而比调用它更短。
class TokenBucket { // 只是把几个字段包了一层
public:
double tokens() const;
void setTokens(double t);
double ratePerSec() const;
Clock::time_point lastRefill() const;
void setLastRefill(Clock::time_point t);
};
// 每个调用点都要重复这套算法:
auto now = Clock::now();
double t = b.tokens() + b.ratePerSec() * secondsBetween(b.lastRefill(), now);
b.setTokens(std::min(t, kBurst)); b.setLastRefill(now);
if (b.tokens() >= 1) { b.setTokens(b.tokens() - 1); handle(req); }
// 令牌桶限流器:每秒补充 ratePerSec 个令牌,最多攒 burst 个。线程安全。
class RateLimiter {
public:
RateLimiter(double ratePerSec, double burst);
// 有令牌就扣掉一个并返回 true;否则立即返回 false(不阻塞)。
bool tryAcquire();
};
if (limiter.tryAcquire()) handle(req);
else reply429(req);
补充算法、时钟、并发保护都藏进了类里,调用者只需要知道一个方法。
常见改法
- 把调用者那边拼装的逻辑收进模块,提供一个更高层的操作;
- 合并几个总是一起用的浅类或浅方法;
- 实在没有价值的包装就删掉,让调用者直接用底层。
别过度
深度是比值,不是行数。作者承认链表这类数据结构天生就浅,接口没法比实现简单多少,但它们仍然有用(4.5 节)。别为了"变深"硬塞功能,也别走向另一个极端:把整个应用塞进一个类(5.5 节、8.3 节)。
信息泄漏 Information Leakage
第 5 章 · 5.2 节同一个设计决定(文件格式、协议细节、某个常量的含义)同时被好几个模块"知道";它一变,这几处就必须一起改。
怎么察觉
- 改一个格式、协议或常量,要在多个文件里同步修改,漏一处就出错;
- 两个模块之间没有调用关系,却都在拼装或解析同一种字符串、字节布局("后门泄漏",最隐蔽);
- 接口直接暴露内部表示:返回内部容器的引用、要求调用者传入格式串。
// audit_writer.cpp
void AuditWriter::append(const Event& e) {
out_ << e.ts << '|' << e.user << '|' << e.action << '\n';
}
// audit_report.cpp(另一个人写的)
Event parseLine(const std::string& line) {
auto f = split(line, '|'); // 也得知道分隔符和字段顺序
return Event{std::stoll(f[0]), f[1], f[2]};
}
// action 里哪天出现了 '|',或者要加一个字段:两边都得改
// 审计日志。磁盘上的格式(分隔、转义、字段顺序、版本)只有这个类知道。
class AuditLog {
public:
explicit AuditLog(const std::string& path);
void append(const Event& e);
// 按写入顺序回放每条事件;损坏的行跳过,并计入 corruptLines()。
void forEach(const std::function<void(const Event&)>& fn) const;
size_t corruptLines() const;
};
// 报表模块只和 Event 打交道
常见改法
- 问自己:怎样重组,才能让这份知识只影响一个模块?
- 几个小类都紧贴这份知识时,把它们合并;
- 或者把这份知识抽成一个新类,由它独占格式细节。
别过度
抽出来的新类如果在接口上把细节又全暴露出来,只是把"后门泄漏"换成了"接口泄漏"(5.2 节)。另外,有些信息本来就该让外部知道:不同场景需要不同取值的性能参数就必须放在接口里(5.9 节)。
按时间顺序分解 Temporal Decomposition
第 5 章 · 5.3 节按"先做什么、后做什么"来切模块,而不是按"谁掌握哪部分知识"来切;结果同一份知识被拆散到好几个阶段的模块里。它是信息泄漏最常见的成因之一。
怎么察觉
- 模块名就是步骤:
Step1…、Loader / Processor / Writer、…Phase; - 第一步和最后一步都要懂同一种格式或协议;
- 调用者必须按固定顺序调用几个方法,中间还要传递半成品数据。
class FetchStep { // 第 1 步:拉取远端变更
// 要懂:URL 拼法、鉴权头、分页游标、JSON 字段名
std::vector<Record> run(const std::string& sinceCursor);
};
class MergeStep { // 第 2 步:和本地数据合并
std::vector<Record> run(std::vector<Record> remote, LocalDb& db);
};
class UploadStep { // 第 3 步:推送本地变更
// 又要懂:URL 拼法、鉴权头、JSON 字段名、每批最多 500 条
void run(const std::vector<Record>& changes);
};
// 远端存储:协议细节(URL、鉴权、分页、批量上限、JSON)全在这里。
class RemoteStore {
public:
std::vector<Record> changesSince(Cursor& cursor); // 内部自动翻页
void push(const std::vector<Record>& changes); // 内部自动分批
};
// 顺序仍然存在,只是变成了一个函数里的三行
void sync(RemoteStore& remote, LocalDb& db) {
auto incoming = remote.changesSince(db.cursor());
auto outgoing = db.merge(incoming);
remote.push(outgoing);
}
常见改法
- 先问"完成这件事需要哪些知识",按知识划分模块;
- 多个阶段都用到的知识(格式、协议)收进一个模块,各阶段都调用它;
- 执行顺序留在一个高层函数里,几行就能看清。
别过度
顺序本身是真实的,总要在某处体现。如果各个阶段用到的信息确实完全不同,按阶段切分并不违反信息隐藏,这样切没有问题(5.3 节)。
过度暴露 Overexposure
第 5 章 · 5.7 节只想用最常见的功能,却被迫先弄懂很少用到的功能或参数。
怎么察觉
- 最简单的用法也要填一长串参数,而大多数调用点填的都是同样的值;
- 调用点上到处是
/*xxx=*/注释和0、false、nullptr占位; - "快速开始"文档就得先解释罕用选项。
auto log = Logger::create(
"app.log",
/*maxBytes=*/64 << 20,
/*maxFiles=*/5,
/*flushEveryN=*/1,
/*async=*/false,
/*queueCapacity=*/0,
Encoding::Utf8);
log.write(Level::Info, "server started", /*tags=*/{});
// 默认:按 64 MB 轮转、保留 5 份、同步写。大多数程序只需要这两行。
auto log = Logger::open("app.log");
log.info("server started");
// 少数人才需要的调优,放进单独的选项结构
LoggerOptions opt;
opt.async = true;
opt.queueCapacity = 1 << 16;
auto trace = Logger::open("trace.log", opt);
常见改法
- 默认值就是大多数人想要的行为,常见用法零配置;
- 罕用选项放进选项结构、重载或单独的方法;
- 模块能自己算出合适取值的,就别做成参数(第 8 章)。
别过度
不是要砍掉罕用功能。给使用者留选择是好的,只要罕用的选择不挡在常用路径上(4.7 节)。真正需要调用者决定的参数也必须暴露(5.9 节)。
透传方法 Pass-Through Method
第 7 章 · 7.1 节方法几乎什么都不做,只是把参数原样交给另一个签名相近的方法。它让所在的类变浅,也说明两个类之间的职责划分不清楚。
怎么察觉
- 方法体只有一行:
return other_.sameName(args…); - 一个类的大多数公开方法都在转发给同一个成员;
- 加一个功能,要在上下两层各加一个同名方法。
class UserService {
public:
std::optional<User> findById(UserId id) { return repo_.findById(id); }
std::vector<User> findByEmail(const std::string& e) { return repo_.findByEmail(e); }
void save(const User& u) { repo_.save(u); }
void remove(UserId id) { repo_.remove(id); }
// ……还有 9 个,长得都一样
private:
UserRepository repo_;
};
// 没有额外价值时:调用者直接用 UserRepository,删掉这一层。
// 需要这一层时:让它承担存储层不该管的事,接口自然就不同了。
class AccountService {
public:
// 注册:校验邮箱格式与唯一性、哈希密码、写库、发欢迎邮件。
// 邮箱已被占用时返回 RegisterError::EmailTaken。
Result<UserId, RegisterError> registerUser(const SignupForm& form);
// 注销:删除用户并吊销其全部会话;用户不存在时什么也不做。
void closeAccount(UserId id);
};
常见改法
- 让调用者直接使用下层类;
- 重新分配两个类的职责,让每一层提供不同的东西;
- 两个类实在分不开,就合并成一个。
别过度
签名相同本身不是问题,关键看有没有贡献实质功能:分派器(根据参数决定交给谁)、同一接口的多种实现都合理(7.2 节);为了适配一个你改不了的外部类而写的包装,也属于少数合理的情况(7.3 节)。
重复 Repetition
第 9 章 · 9.3 节同一段(或几乎一样的)不算简单的代码一再出现,说明还没找到合适的抽象。
怎么察觉
- 修一个 bug 要在好几处改同样的几行,而且其中一份已经悄悄走样;
- 复制粘贴后只改了一两个变量名;
- 好几个函数的出错路径上都有同一套清理、日志或回滚。
for (int i = 0; i < 3; ++i) { // 拉取配置
if (auto r = http.get(cfgUrl); r.ok()) { cfg = r.body(); break; }
std::this_thread::sleep_for(100ms * (1 << i));
}
for (int i = 0; i < 3; ++i) { // 上报指标
if (http.post(metricsUrl, m).ok()) break;
std::this_thread::sleep_for(100ms * (1 << i));
}
// 下单、发通知……又各抄一遍;其中一份写成了 10ms * i
// 反复调用 op,直到它返回 true 或已试满 attempts 次;两次之间按指数退避
// (100ms、200ms、400ms……)。返回最后一次 op 的结果。
bool retryWithBackoff(int attempts, const std::function<bool()>& op);
retryWithBackoff(3, [&] {
auto r = http.get(cfgUrl);
if (r.ok()) cfg = r.body();
return r.ok();
});
retryWithBackoff(3, [&] { return http.post(metricsUrl, m).ok(); });
常见改法
- 提取成一个签名简单的函数,把变化的部分作为参数(C++ 里常用 lambda);
- 重组控制流,让这段代码只需要在一个地方执行(如统一出口、RAII 清理);
- 重复的是一组概念时,可能缺的是一个类而不是一个函数。
别过度
只有一两行的片段,换成函数调用收益不大;片段和周围很多局部变量纠缠在一起时,提取出的函数签名会很复杂(一堆引用参数),反而不划算(9.3 节)。
通用与专用混杂 Special-General Mixture
第 9 章 · 9.4~9.5 节通用机制里掺进了只服务某个具体用途的代码,两者没有干净地分开。通用部分因此变复杂,机制和用途之间也开始互相泄漏信息。
怎么察觉
- 通用容器、框架或工具类里出现了具体业务的类型名、
if (kind == …); - 每多一个使用者,就要改一次通用模块;
- 给通用模块写单元测试,得先构造一堆业务对象。
template <class K, class V>
class LruCache {
public:
void put(const K& key, V value) {
if constexpr (std::is_same_v<V, Thumbnail>) { // 缩略图特判
if (value.width > 2048) return; // 太大的不缓存
metrics::thumbnailsCached.inc();
}
insertFront(key, std::move(value));
if (size_ > capacity_) evictOldest();
}
};
template <class K, class V>
class LruCache { // 通用:满了就淘汰最久没被访问的条目
public:
void put(const K& key, V value);
std::optional<V> get(const K& key);
};
class ThumbnailStore { // 专用:缩略图的规矩放在缩略图模块
LruCache<std::string, Thumbnail> cache_;
public:
void remember(const std::string& url, Thumbnail t) {
if (t.width > kMaxCachedWidth) return;
cache_.put(url, std::move(t));
metrics::thumbnailsCached.inc();
}
};
常见改法
- 通用模块只提供机制(存取、淘汰、调度……);
- 专用规则移到使用它的模块,或通过回调、子类注入;
- 专用化往上推到应用层,或往下推到各个具体实现里(6.6 节)。
别过度
这条说的是同一个机制的通用部分和专用部分。作者明确说,某个机制的专用代码,和另一个与它密切相关的机制的通用代码放在一起是可以的(6.7 节末尾的附注)。
连体方法 Conjoined Methods
第 9 章 · 9.7 节两段代码互相依赖得太紧:不读懂其中一段的实现,就看不懂另一段。不只是方法,任何物理上分开、却必须对照着读的代码都算。
怎么察觉
- 注释写着"调用前必须先调 X",或某个成员变量只是在两个方法之间偷偷传数据;
- 读一个函数时要不停跳到另一个函数,去看它留下了什么状态;
- 两个方法总在同一处、按固定顺序一起被调用。
// 前置条件:先调用 prepareRow()
void Importer::prepareRow(const std::string& line) {
fields_ = split(line, ',');
if (fields_.size() == 3) // 旧格式没有 region 列
fields_.insert(fields_.begin() + 2, "");
pos_ = 0;
}
Order Importer::buildOrder() {
Order o;
o.id = fields_[pos_++];
o.user = fields_[pos_++];
o.region = fields_[pos_++]; // 为什么会是空串?得回去看 prepareRow
o.amount = parseAmount(fields_[pos_++]);
return o;
}
// 把一行 CSV 解析成订单。新格式 4 列:id,user,region,amount;
// 也接受旧格式 3 列(没有 region,此时 region 为空串)。
// 列数不对或金额不是数字时抛 ImportError,消息里带原始行。
Order parseOrderLine(const std::string& line) {
auto f = split(line, ',');
if (f.size() == 3) f.insert(f.begin() + 2, "");
if (f.size() != 4) throw ImportError("bad column count: " + line);
return Order{f[0], f[1], f[2], parseAmount(f[3])};
}
常见改法
- 合并成一个把事情做完整的方法;
- 用参数和返回值显式传递数据,代替隐式共享的成员状态;
- 确实要拆时,拆出一个能独立理解的子任务。
别过度
不是说函数越长越好。拆出一个父、子都能单独读懂的子任务,是作者认可的拆法(9.7 节)。反过来,这个红旗常常正是"函数越短越好"拆过了头造成的(9.8 节)。
注释重复代码 Comment Repeats Code
第 13 章 · 13.2 节注释里的所有信息,看一眼旁边的代码就已经知道了。这样的注释占地方、要维护,却不提供任何新东西。
怎么察觉
- 注释用的词和名字几乎一样("获取连接"写在
acquire上面); - 注释逐行把代码翻译成中文("i 加一");
- 测试:一个从没见过这段代码的人,只看旁边那行,能不能写出同样的注释?能,就是重复。
class ConnPool {
int maxIdle_; // 最大空闲数
std::chrono::seconds idleTimeout_; // 空闲超时
// 获取连接
Conn acquire();
// 归还连接
void release(Conn c);
};
class ConnPool {
// 归还时若空闲连接已达此数,直接关闭归还的连接;0 表示不保留空闲连接。
int maxIdle_;
// 空闲超过此时长的连接,会在下次 acquire 时被关闭而不是交出去,
// 以免调用者拿到已被服务端断开的连接。
std::chrono::seconds idleTimeout_;
// 返回一个可用连接:优先复用空闲连接,否则新建。活跃连接数已达上限时
// 阻塞等待,最多等 acquireTimeout_,超时抛 PoolExhausted。
Conn acquire();
// 把 c 交还给池。c 之后不得再使用;归还已断开的连接是安全的。
void release(Conn c);
};
常见改法
- 补上代码表达不了的东西:单位、边界是否包含、特殊值的含义、谁负责释放、调用前提、为什么这样做;
- 换用和名字不同的词来描述;
- 写在和代码不同的层次上:更低(精确语义)或更高(意图)。
别过度
作者的结论不是"少写注释",而是"写有用的"。书中主张每个类、成员变量、方法都写接口注释,显然到无话可说的(如某些 getter/setter)很少(13.1 节)。短方法内部通常不需要实现注释(13.6 节)。
实现细节污染接口注释 Implementation Documentation Contaminates Interface
第 13 章 · 13.5 节接口注释写了调用者根本不需要知道的实现细节。读者被迫了解内部结构,实现一换,注释也得重写。
怎么察觉
- 接口注释里出现内部数据结构、私有成员名、锁、内部常量;
- 注释在讲"怎么做",而不是"做什么、返回什么、有什么副作用";
- 换一种实现方式,接口注释就得跟着重写。
// 内部用 unordered_map<string, list::iterator> 加一条双向链表维护
// 访问顺序。get 时把节点 splice 到表头;put 超容量时从表尾 pop_back。
// mu_ 保护上述两个结构。返回 key 对应的值。
std::optional<std::string> get(const std::string& key);
// 返回 key 当前缓存的值;不存在(从未写入或已被淘汰)时返回 nullopt。
// 命中会让该条目成为"最近使用",因而最晚被淘汰。线程安全,O(1)。
std::optional<std::string> get(const std::string& key);
// 链表与 splice 的说明放进 .cpp 里函数体内的实现注释。
常见改法
- 接口注释只写调用者视角:行为、参数与返回值、副作用、异常、前置条件;
- 实现细节挪进函数体内的实现注释;
- 不提实现就说不清接口时,说明模块是浅的,回头重新考虑设计。
别过度
调用者在乎的性能特征可以写进接口注释,比如是否线程安全、能否并发、是否阻塞,只要用高层的话说(13.5 节、13.9 节)。对用法不显然的深类,在类注释里放一个使用示例也有帮助(13.5 节)。
名字含糊 Vague Name
第 14 章 · 14.3 节名字宽泛得可以指很多东西,读者从中得不到多少信息,甚至会猜错它的含义,进而用错。
怎么察觉
data、info、tmp、flag、status、count、time、process、Manager单独出现;- 要看声明或注释才知道单位、"什么的个数"、true 代表什么;
- 布尔变量不是谓词形式。
struct Session {
int64_t time; // 创建时间?最后活跃?过期时刻?秒还是毫秒?
bool flag;
int count;
std::string data;
};
void process(Session& s);
if (s.flag && now - s.time > limit) process(s);
struct Session {
int64_t lastActiveMs; // 最后一次收到请求的时刻,Unix 毫秒
bool loggedIn;
int failedLogins; // 连续登录失败次数,成功后清零
std::string csrfToken;
};
void expire(Session& s);
if (s.loggedIn && nowMs - s.lastActiveMs > idleLimitMs) expire(s);
常见改法
- 自问:别人单独看到这个名字,能猜对它指什么、不指什么吗?
- 补上最关键的信息:对象、单位、状态;
- 布尔量写成谓词(
isX、hasX、xEnabled)。
别过度
循环只有几行、整个使用范围一眼看得见时,i、j 这样的短名字没问题;result 在它确实就是返回值时也可以(14.3 节)。名字也可能过于具体:一个能处理任意区间的参数不该叫 selection(14.3 节)。
名字难起 Hard to Pick Name
第 14 章 · 14.3 节怎么也想不出一个精确、直观、又不太长的名字。这往往说明被命名的东西本身设计得不干净,而不是你词汇量不够。
怎么察觉
- 候选名字里带
And、Or、Maybe、Misc、Helper; - 团队里对它该叫什么争论不休,每个名字都只说对了一半;
- 一个变量用特殊值编码了几种不同的状态。
// 该叫什么?pending?sendState?offsetOrStatus?……
// -1:还没连上;0:连上了但还没发数据;>0:已发送的字节数
int64_t sendInfo = -1;
void onConnected() { sendInfo = 0; }
void onSent(size_t n) { sendInfo += n; }
bool canSend() const { return sendInfo >= 0; }
bool connected = false;
uint64_t bytesSent = 0; // 本连接累计发送的字节数
void onConnected() { connected = true; }
void onSent(size_t n) { bytesSent += n; }
bool canSend() const { return connected; }
常见改法
- 先怀疑设计,而不是去找一个更长的名字;
- 一个变量表示了几件事,就拆成几个;一个方法做了几件不相关的事,就拆开;
- 列出几个候选词,挑信息量最大的那个(21.1 节)。
别过度
"难"指的是找不到能让人一眼看明白的名字,不是"想了五分钟"。在名字上多花一点时间本来就是值得的投资,熟练以后几乎不额外花时间(14.7 节)。本站补充:拆分是为了让每个部分概念清楚,别为了好起名把一个完整的概念切碎。
难以描述 Hard to Describe
第 15 章 · 15.3 节想把一个方法或变量完整地说清楚,注释就得写很长。注释是复杂度的早期探测器:说不简单,往往是因为抽象本身不简单。
怎么察觉
- 接口注释里满是"如果……则……但当……时";
- 参数之间有组合约束:某些组合无效,或者一个会覆盖另一个;
- 返回值要用一段话解释几种特殊编码;
- 先写注释时写不下去。
// 把 buf 写入 path。append 为 true 时追加,否则覆盖;但 atomic 为 true 时
// 忽略 append,改为先写临时文件再 rename。fsync 为 true 时写完刷盘,但在
// atomic 模式下刷的是临时文件,rename 后目录项不刷。返回值:>=0 为写入
// 字节数;-1 打开失败(看 errno);-2 部分写入,文件可能处于半写状态
// (atomic 模式下不会出现 -2)。
int writeFile(const std::string& path, std::string_view buf,
bool append, bool atomic, bool fsync);
// 用 data 原子地替换 path 的全部内容:要么完整写入新内容,要么保留旧内容,
// 崩溃或断电后不会出现半个文件。失败时抛 IoError。
void replaceFileAtomically(const std::string& path, std::string_view data);
// 把 data 追加到 path 末尾,文件不存在则创建。失败时抛 IoError。
void appendToFile(const std::string& path, std::string_view data);
常见改法
- 先写接口注释、再写代码(第 15 章),趁还没投入实现时改设计;
- 按"调用者真正想做的事"拆成几个各自好描述的操作;
- 去掉模式开关,让每个函数只有一种语义;用异常或类型代替魔法返回值。
别过度
只有完整而清楚的注释才能拿来衡量复杂度:漏了信息或写得晦涩的注释,短也不说明设计好(15.3 节)。深类的类注释带上用法示例、因而偏长,也是正常的。
代码不明显 Nonobvious Code
第 18 章 · 18.2 节快速读一遍看不出代码在做什么、为什么这样做。通常是有重要信息没放在读者看得见的地方。
怎么察觉
- 调用点上的魔法数字和裸
true/false; - 看名字以为只是读一下,实际却有副作用;
- 用
std::pair、std::tuple之类的通用容器装着有具体含义的数据; - 评审时有人问"这里为什么要……"。
schedule(job, 3, true, false); // 3?true?false?
if ((job.state & 0x4) && !(job.state & 0x10))
retry(job);
// 看起来只是读一下长度
size_t n = queue.size(); // 实际上会先把缓冲的批次刷到磁盘
schedule(job, Priority::High, Placement::AnyWorker, Retry::Never);
if (job.failed() && !job.cancelled())
retry(job);
// 有副作用的操作在名字上说出来;查询就只查询
queue.flushPending();
size_t n = queue.size();
常见改法
- 用枚举或具名类型代替裸布尔和魔法数字;用有字段名的 struct 代替 pair;
- 有副作用就在名字上说出来,别违背读者的预期;
- 不得不用的间接手段(回调、事件驱动),在注释里写清何时被调用;
- 用空行和块注释把代码分段。
别过度
"明显"由读者判断,而不是作者:评审说看不懂,就改代码或补注释,不要争辩(13.8 节、第 18 章)。有些天然不那么明显的技术(如事件驱动)仍然值得用,用注释补偿即可(18.2 节)。
16 条设计原则
红旗告诉你"哪里可能有问题",原则告诉你"往哪个方向改"。每条都写了它为什么能降低复杂性,以及作者自己承认的限度。编号与原书书末清单一致,措辞是本站的转述。
复杂性是一点点累积的:小事也要较真
第 2 章 · 2.4 节为什么系统变复杂,很少是因为某个大错误,而是上百个"就这一次"的小依赖、小含糊叠起来的。每一个单独看都无伤大雅,所以只有每一个都不放过,才挡得住。
注意较真不等于一次性大重写,作者同样反对前期一次性做完设计;它要和原则 3 的"持续小投资"配合使用。
能跑的代码还不够
第 3 章 · 3.2 节为什么能跑只说明今天的功能做对了;结构有没有让下一次改动更容易,才决定长期的开发速度。只求"先跑起来"的战术式编程,会把复杂性一笔一笔记在以后的账上。
注意作者建议把大约 10%~20% 的时间投在设计上,但也说明这是经验判断,没有数据支撑(3.3 节)。
持续地做小投资来改善设计
第 3 章 · 第 16 章为什么每次改动都顺手把设计改好一点,复杂性就不会利滚利;"等忙完这阵子再整理"通常意味着永远不整理,因为忙完之后还有下一阵子。
注意作者承认现实约束(死线、改动会影响很多团队)有时逼人妥协(16.1 节)。这时先找一个几乎同样干净、但只需几天的方案,或者事后争取时间补上。
模块应该深
第 4 章 · 4.4 节为什么使用者只为接口付出学习成本,却能得到实现的全部功能。接口越窄、背后的功能越多,系统其他部分需要知道的就越少。
注意深度是比值不是规模;有些模块天生就浅(4.5 节);也别把整个应用塞进一个类(8.3 节)。
接口要让最常见的用法尽可能简单
第 4 章 · 4.7 节为什么大多数人只用到常用的那几项,接口的"有效复杂度"就由它们决定。默认值做对了,多数使用者根本不必知道其余选项存在。
注意不是砍掉罕用功能:罕用选项可以有,放在单独的入口即可(4.7 节);真正需要使用者决定的参数仍要暴露(5.9 节)。
模块的接口简单,比实现简单更重要
第 8 章 · 第 9 章为什么模块的使用者远多于开发者。复杂性留在实现里,只由开发者承受一次;放到接口上,会被每个调用者各承受一次。
注意只有当这份复杂性和模块已有功能密切相关、能简化别处的代码、并能让模块接口更简单时,才值得收进来(8.3 节)。否则只是把无关的东西塞进一个类。
通用的模块更深
第 6 章为什么不绑定某个具体用途的接口,往往方法更少、每个更有力,也自然藏住了用途相关的信息。作者发现即使最后没被复用,这样做也更划算(6.1 节)。
注意关键词是"稍微通用":功能按今天的需要做,接口别只为今天的一个调用者写。过度通用(例如只提供逐字符操作)会让今天的调用者很难用(6.5 节)。
把通用代码和专用代码分开
第 6 章 · 第 9 章为什么通用机制只管机制,专用规则放在使用它的地方。两边都能被单独理解、单独修改,新增一种用途也不必改动机制。
注意说的是同一个机制的两部分。一个机制的专用代码和另一个相关机制的通用代码放在一起,是可以的(6.7 节附注)。
不同的层应该提供不同的抽象
第 7 章为什么每穿过一层,抽象就应该变一次。相邻两层的抽象差不多,说明这一层没有替上层藏住什么,只多了一套要学的接口。
注意签名相同不一定是问题:分派器、同一接口的多种实现都合理(7.2 节)。消除透传变量的常用办法是 context 对象,但作者也承认它远非理想(7.5 节)。
把复杂性往下拉
第 8 章为什么无法消除的复杂性,由模块自己消化,而不是通过抛异常、加配置参数推给每一个调用者。一个能自动算出合适取值的模块,胜过一个让人去猜参数的模块。
注意判断条件同原则 6(8.3 节)。使用者确实比模块更懂的东西(例如请求优先级),做成参数是正当的(8.2 节)。
把错误定义为不存在
第 10 章 · 10.3 节为什么异常是接口的一部分。每多一种,就多一批调用者要写、却很少被执行(因而很少被测到)的处理代码。调整语义让它不再是错误,这些处理代码就整片消失。
注意只有调用者确实不需要这个信息时,才能把它定义掉或屏蔽掉;外部必须知道的失败(如网络故障)要暴露(10.9 节)。"直接崩溃"是否可以接受,取决于应用(10.8 节)。
设计两次
第 11 章为什么第一个想法很少是最好的。做出两三个差别很大的草案再比较,各自的代价才看得清,还常常能合成出比任何一个都好的方案。
注意草图就够,只需列出最重要的几个方法,不必实现。小模块花不了多少时间;越大的模块越值得多探索。
注释应该描述代码里看不出来的东西
第 13 章为什么设计意图、单位、边界、前置条件、"为什么这样做",代码表达不出来,只能靠注释。有了好的接口注释,使用者不读实现也能用对,抽象才真正成立。
注意短方法内部通常不需要实现注释(13.6 节);但作者主张每个类、成员变量和方法都写接口注释,"太显然不用写"的情况很少(13.1 节)。
软件应该为易读而设计,而不是为易写而设计
第 18 章 · 18.2 节为什么代码被读的次数远多于被写的次数。为写的人省下几秒钟的捷径(通用容器、裸布尔参数、隐藏的副作用),会让之后每个读的人多花时间,甚至读错。
注意是否明显由读者判断,作者自己说了不算;评审者说看不懂就是看不懂(13.8 节)。不那么明显但有用的技术可以用,用注释补偿(18.2 节)。
开发的增量应该是抽象,而不是功能
第 19 章 · 19.2 节为什么按功能一点点往上堆,抽象就永远是"顺手凑出来的"。当某个功能需要一个新抽象时,把这个抽象完整地、稍微通用地设计好,后面的功能才有可靠的地基。
注意作者认可敏捷的迭代思想,也认为等到真正需要时再做抽象是可以的;作者反对的是把一个抽象拆成碎片、随功能零敲碎打地长出来(19.2 节)。
把重要的和不重要的分开,并突出重要的
第 21 章为什么抽象、命名、性能设计其实都在做同一件事:把注意力引向少数关键因素,把其余的藏起来或减少。重要的东西应该出现在显眼处,并决定周围代码的结构。
注意两个方向都会出错:把太多东西当成重要的(接口臃肿、类变浅),或者没认出真正重要的东西(该暴露的被藏起来)(21.4 节)。拿不准时,先提出一个假设,做完再回头反思(21.1 节)。
设计评审清单
按"你正在做什么"分组。每一项都是一个可以在几秒内回答的问题;回答"否"或"说不准"时,点旁边的徽章去看对应的红旗或原则。勾选只是方便当场使用,不会被保存。