速查 · CHEATSHEET

红旗信号与设计原则

A Philosophy of Software Design, 2nd ed. · 14 Red Flags & 16 Design Principles

原书书末有两份清单:14 个红旗信号(red flags)和 16 条设计原则。这一页把它们整理成写代码、做评审时能直接翻的形式。所有定义、例子和代码都由本站重写,不是原书译文;原书的论证、作者自己的例子和边界讨论,请回到对应章节去读。

写代码时:觉得哪里别扭,先在下面的导航里找一个最像的红旗,对照"怎么察觉"确认,再看"常见改法"。做评审时:用页尾的设计评审清单逐项过一遍,每一项都链接到对应的红旗或原则。想打印:清单可以单独打印成一页贴在工位上,整页打印时改前/改后两个面板都会印出来。

每个红旗都附了"别过度":红旗只是症状,提示"这里可能有设计问题",不是必须执行的规则。作者在书里反复提醒,任何原则推到极端都会出问题;判断的最终标准是系统整体的复杂性有没有下降。

14 个红旗信号

每一项的结构相同:一句话定义 → 评审时你会看到的症状 → 一对原创 C++ 例子(点标签切换改前/改后)→ 常见改法 → 什么时候不算问题。

01

浅模块 Shallow Module

第 4 章 · 4.5 节

接口几乎和实现一样复杂:学会怎么调用它,花的力气跟自己把那几行写出来差不多。它没替使用者藏住任何东西。

怎么察觉

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 节)。

02

信息泄漏 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 节)。

03

按时间顺序分解 Temporal Decomposition

第 5 章 · 5.3 节

按"先做什么、后做什么"来切模块,而不是按"谁掌握哪部分知识"来切;结果同一份知识被拆散到好几个阶段的模块里。它是信息泄漏最常见的成因之一。

怎么察觉

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 节)。

04

过度暴露 Overexposure

第 5 章 · 5.7 节

只想用最常见的功能,却被迫先弄懂很少用到的功能或参数。

怎么察觉

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 节)。

05

透传方法 Pass-Through Method

第 7 章 · 7.1 节

方法几乎什么都不做,只是把参数原样交给另一个签名相近的方法。它让所在的类变浅,也说明两个类之间的职责划分不清楚。

怎么察觉

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 节)。

06

重复 Repetition

第 9 章 · 9.3 节

同一段(或几乎一样的)不算简单的代码一再出现,说明还没找到合适的抽象。

怎么察觉

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 节)。

07

通用与专用混杂 Special-General Mixture

第 9 章 · 9.4~9.5 节

通用机制里掺进了只服务某个具体用途的代码,两者没有干净地分开。通用部分因此变复杂,机制和用途之间也开始互相泄漏信息。

怎么察觉

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 节末尾的附注)。

08

连体方法 Conjoined Methods

第 9 章 · 9.7 节

两段代码互相依赖得太紧:不读懂其中一段的实现,就看不懂另一段。不只是方法,任何物理上分开、却必须对照着读的代码都算。

怎么察觉

// 前置条件:先调用 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 节)。

09

注释重复代码 Comment Repeats Code

第 13 章 · 13.2 节

注释里的所有信息,看一眼旁边的代码就已经知道了。这样的注释占地方、要维护,却不提供任何新东西。

怎么察觉

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 节)。

10

实现细节污染接口注释 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 节)。

11

名字含糊 Vague Name

第 14 章 · 14.3 节

名字宽泛得可以指很多东西,读者从中得不到多少信息,甚至会猜错它的含义,进而用错。

怎么察觉

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 节)。

12

名字难起 Hard to Pick Name

第 14 章 · 14.3 节

怎么也想不出一个精确、直观、又不太长的名字。这往往说明被命名的东西本身设计得不干净,而不是你词汇量不够。

怎么察觉

// 该叫什么?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 节)。本站补充:拆分是为了让每个部分概念清楚,别为了好起名把一个完整的概念切碎。

13

难以描述 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 节)。深类的类注释带上用法示例、因而偏长,也是正常的。

14

代码不明显 Nonobvious Code

第 18 章 · 18.2 节

快速读一遍看不出代码在做什么、为什么这样做。通常是有重要信息没放在读者看得见的地方。

怎么察觉

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 条设计原则

红旗告诉你"哪里可能有问题",原则告诉你"往哪个方向改"。每条都写了它为什么能降低复杂性,以及作者自己承认的限度。编号与原书书末清单一致,措辞是本站的转述。

1

复杂性是一点点累积的:小事也要较真

第 2 章 · 2.4 节

为什么系统变复杂,很少是因为某个大错误,而是上百个"就这一次"的小依赖、小含糊叠起来的。每一个单独看都无伤大雅,所以只有每一个都不放过,才挡得住。

注意较真不等于一次性大重写,作者同样反对前期一次性做完设计;它要和原则 3 的"持续小投资"配合使用。

2

能跑的代码还不够

第 3 章 · 3.2 节

为什么能跑只说明今天的功能做对了;结构有没有让下一次改动更容易,才决定长期的开发速度。只求"先跑起来"的战术式编程,会把复杂性一笔一笔记在以后的账上。

注意作者建议把大约 10%~20% 的时间投在设计上,但也说明这是经验判断,没有数据支撑(3.3 节)。

3

持续地做小投资来改善设计

第 3 章 · 第 16 章

为什么每次改动都顺手把设计改好一点,复杂性就不会利滚利;"等忙完这阵子再整理"通常意味着永远不整理,因为忙完之后还有下一阵子。

注意作者承认现实约束(死线、改动会影响很多团队)有时逼人妥协(16.1 节)。这时先找一个几乎同样干净、但只需几天的方案,或者事后争取时间补上。

4

模块应该深

第 4 章 · 4.4 节

为什么使用者只为接口付出学习成本,却能得到实现的全部功能。接口越窄、背后的功能越多,系统其他部分需要知道的就越少。

注意深度是比值不是规模;有些模块天生就浅(4.5 节);也别把整个应用塞进一个类(8.3 节)。

5

接口要让最常见的用法尽可能简单

第 4 章 · 4.7 节

为什么大多数人只用到常用的那几项,接口的"有效复杂度"就由它们决定。默认值做对了,多数使用者根本不必知道其余选项存在。

注意不是砍掉罕用功能:罕用选项可以有,放在单独的入口即可(4.7 节);真正需要使用者决定的参数仍要暴露(5.9 节)。

6

模块的接口简单,比实现简单更重要

第 8 章 · 第 9 章

为什么模块的使用者远多于开发者。复杂性留在实现里,只由开发者承受一次;放到接口上,会被每个调用者各承受一次。

注意只有当这份复杂性和模块已有功能密切相关、能简化别处的代码、并能让模块接口更简单时,才值得收进来(8.3 节)。否则只是把无关的东西塞进一个类。

7

通用的模块更深

第 6 章

为什么不绑定某个具体用途的接口,往往方法更少、每个更有力,也自然藏住了用途相关的信息。作者发现即使最后没被复用,这样做也更划算(6.1 节)。

注意关键词是"稍微通用":功能按今天的需要做,接口别只为今天的一个调用者写。过度通用(例如只提供逐字符操作)会让今天的调用者很难用(6.5 节)。

8

把通用代码和专用代码分开

第 6 章 · 第 9 章

为什么通用机制只管机制,专用规则放在使用它的地方。两边都能被单独理解、单独修改,新增一种用途也不必改动机制。

注意说的是同一个机制的两部分。一个机制的专用代码和另一个相关机制的通用代码放在一起,是可以的(6.7 节附注)。

9

不同的层应该提供不同的抽象

第 7 章

为什么每穿过一层,抽象就应该变一次。相邻两层的抽象差不多,说明这一层没有替上层藏住什么,只多了一套要学的接口。

注意签名相同不一定是问题:分派器、同一接口的多种实现都合理(7.2 节)。消除透传变量的常用办法是 context 对象,但作者也承认它远非理想(7.5 节)。

10

把复杂性往下拉

第 8 章

为什么无法消除的复杂性,由模块自己消化,而不是通过抛异常、加配置参数推给每一个调用者。一个能自动算出合适取值的模块,胜过一个让人去猜参数的模块。

注意判断条件同原则 6(8.3 节)。使用者确实比模块更懂的东西(例如请求优先级),做成参数是正当的(8.2 节)。

11

把错误定义为不存在

第 10 章 · 10.3 节

为什么异常是接口的一部分。每多一种,就多一批调用者要写、却很少被执行(因而很少被测到)的处理代码。调整语义让它不再是错误,这些处理代码就整片消失。

注意只有调用者确实不需要这个信息时,才能把它定义掉或屏蔽掉;外部必须知道的失败(如网络故障)要暴露(10.9 节)。"直接崩溃"是否可以接受,取决于应用(10.8 节)。

12

设计两次

第 11 章

为什么第一个想法很少是最好的。做出两三个差别很大的草案再比较,各自的代价才看得清,还常常能合成出比任何一个都好的方案。

注意草图就够,只需列出最重要的几个方法,不必实现。小模块花不了多少时间;越大的模块越值得多探索。

13

注释应该描述代码里看不出来的东西

第 13 章

为什么设计意图、单位、边界、前置条件、"为什么这样做",代码表达不出来,只能靠注释。有了好的接口注释,使用者不读实现也能用对,抽象才真正成立。

注意短方法内部通常不需要实现注释(13.6 节);但作者主张每个类、成员变量和方法都写接口注释,"太显然不用写"的情况很少(13.1 节)。

14

软件应该为易读而设计,而不是为易写而设计

第 18 章 · 18.2 节

为什么代码被读的次数远多于被写的次数。为写的人省下几秒钟的捷径(通用容器、裸布尔参数、隐藏的副作用),会让之后每个读的人多花时间,甚至读错。

注意是否明显由读者判断,作者自己说了不算;评审者说看不懂就是看不懂(13.8 节)。不那么明显但有用的技术可以用,用注释补偿(18.2 节)。

15

开发的增量应该是抽象,而不是功能

第 19 章 · 19.2 节

为什么按功能一点点往上堆,抽象就永远是"顺手凑出来的"。当某个功能需要一个新抽象时,把这个抽象完整地、稍微通用地设计好,后面的功能才有可靠的地基。

注意作者认可敏捷的迭代思想,也认为等到真正需要时再做抽象是可以的;作者反对的是把一个抽象拆成碎片、随功能零敲碎打地长出来(19.2 节)。

16

把重要的和不重要的分开,并突出重要的

第 21 章

为什么抽象、命名、性能设计其实都在做同一件事:把注意力引向少数关键因素,把其余的藏起来或减少。重要的东西应该出现在显眼处,并决定周围代码的结构。

注意两个方向都会出错:把太多东西当成重要的(接口臃肿、类变浅),或者没认出真正重要的东西(该暴露的被藏起来)(21.4 节)。拿不准时,先提出一个假设,做完再回头反思(21.1 节)。

设计评审清单

按"你正在做什么"分组。每一项都是一个可以在几秒内回答的问题;回答"否"或"说不准"时,点旁边的徽章去看对应的红旗或原则。勾选只是方便当场使用,不会被保存。

设计接口时

写实现时

写注释与命名时

修改已有代码时

代码评审时