专题 · 争议与对照Topic

在 AI 写代码的时代读这本书

Reading A Philosophy of Software Design when code is cheap

这本书第 2 版出版于 2021 年,书里没有任何关于大语言模型(LLM)或 AI 编程助手的内容。本页分成两种性质完全不同的材料,请注意区分:

此外还有第三种:书的读者讨论组里读者自己的经历,单独放在一节里,既不是作者观点,也不是本站看法。

Ousterhout 本人说过什么

截至本页写作时(2026 年 10 月),本站能找到并核实的、Ousterhout 关于 AI 编程工具的公开表态,有两处:2025 年的一次访谈,和 2026 年在读者讨论组里的一条回帖。先说访谈:Gergely Orosz 主持的播客 The Pragmatic Engineer:The Philosophy of Software Design – with John Ousterhout(2025-04-09,YouTube 视频)。下面的转述依据节目页面提供的自动转写稿,时间点取自转写稿(与节目页面的章节标注基本对齐)。

约 11:51 起 主持人问:AI 工具生成代码又快又多,有点像书里的"战术龙卷风",长期看会怎样改变软件工程?Ousterhout 的回答要点:

  1. 坦言自己不知道答案,只能猜,未来五到十年会有大变化,方向很难预测。
  2. 比较清楚的一点:AI 工具会让写底层代码更容易,自动补全会越来越好;并特别补充,这不一定是战术龙卷风式的东西,产出的也可能是质量相当不错的代码。
  3. 大问题是 AI 能在多大程度上取代更高层的设计工作。回答是不知道,到目前为止在现有工具里没看到能说明会这样的迹象,但也说"永远不要低估可能发生的事"。
  4. 如果 AI 承担越来越多的底层编程,开发者的工作里设计所占的比例会越来越大,软件设计会变得更重要;而大学几乎不教软件设计,现在教给学生的技能反倒可能正是会被 AI 取代的那些。
So I think software design is going to become more and more important.— John Ousterhout,The Pragmatic Engineer 访谈,约 13:49(据节目自动转写稿)

约 1:08:22 起 谈到注释时,主持人说 AI 助手生成代码时常顺手加上解释性注释,这让自己更容易核对 AI 的产出。Ousterhout 的回答要点:

  1. 先说"有点犹豫说这个,怕是在给坏习惯找理由":发现 AI 工具在一定程度上能弥补注释的缺失。
  2. Ousterhout 当时在 Linux 内核里实现一个新的网络传输协议(Homa),认为 Linux 内核注释明显不足,大量时间花在弄清接口和挂载点上;ChatGPT 成了"最好的朋友",能回答许多用搜索引擎答不了的内核问题。
  3. 它并不总是对,有时会幻觉,但常常是对的;即使错了,通常也能把人带到正确的位置附近。
  4. 但接着又说:如果内核开发者当初花一点点时间写注释,会比用 ChatGPT 去猜容易得多。所以不认为注释的必要性会完全消失。
And we shouldn't use AI tools as an excuse for people not to write comments.— John Ousterhout,同一访谈,约 1:10:33(据自动转写稿;紧接着还补了一句"至少现在还不行")

该节目页面自己的文字总结把"AI 编程工具像战术龙卷风"列为要点之一,却没有标明是谁的说法,很容易被读成"Ousterhout 认为 AI 编程工具就像战术龙卷风"。根据转写稿,这个类比是主持人提出的;Ousterhout 的回应恰恰是"不一定是战术龙卷风式的东西"。引用时请别把这句话安到 Ousterhout 头上。

第二处是书中给出的读者讨论组。2026 年 7 月,一位每天使用 AI 编程工具的读者问:书里说战略式投资划算,是因为以后有人要承受后果;如果以后和代码长期相处的是 AI,这个论证还成立吗?(原帖)

2026-07-13 Ousterhout 的回帖要点:

  1. 先声明自己还没有足够的经验和数据来有把握地谈这个问题,而且局面还在变。
  2. 转述了听来的两类说法:不少人反映 AI 工具会产出浅而复杂的代码;也有人称 AI 工具处理结构良好的代码比处理结构差的代码更有效。Ousterhout 自己把这些称为轶事,不是确证。
  3. 推荐了 Matt Pocock 的一个演讲,说其中主张用 AI 工具时软件设计比以往更重要,并且引用了 APOSD。(本站没有核对演讲内容,这里只转述 Ousterhout 的概括。)
  4. 不把现状当定论:即使今天的工具写得草率,半年一年后会怎样没人知道。
  5. 给了一个预测:设计很可能是编码工作里 AI 最难取代的部分;就算这些工具最终在编码的每个方面都做得很好,设计大概也是它们最后接手的那一项。

和 2025 年的访谈相比,有一处变化值得注意:访谈里 Ousterhout 说 AI 的产出"不一定是战术龙卷风式的东西";一年多以后,转述的是"浅而复杂"的反馈。但两次的结论没有变:自己不知道答案,设计会变得更重要。提问的读者列了三种可能(设计更重要、不那么重要、只是换了受众),Ousterhout 没有明确选其中一个。

另外几条"没有"的事实,同样有用:

讨论组里读者的一手经验

下面两条来自讨论组里的读者,是个人经历,不是数据,也不是作者的观点。之所以收进来,是因为它们恰好用书里的词描述了自己看到的现象。

第一条经历里,同一个工具、同一个模块、同一次对话,一个 bug 修得很好,另一个越修越乱。这和下一节的判断一致:问题不在单次产出的质量,而在没有人踩刹车时复杂性累积的速度。第二个 bug 的走向正是书中 第 3 章描述的战术式编程:每一步都在让眼前的问题消失,没有哪一步回头问"这段代码该不该存在"。

第二条经历提出了一个书里没有的问题:如果连作者都不读代码,"为读者而设计"是为谁?本站的回答见下文"读者永远是人"一条,但要承认这是押注,不是定论;Ousterhout 本人的说法也只是"不知道"。

代码变便宜之后,什么变稀缺

以下全部是本站看法。出发点很简单:书里几乎所有原则针对的都不是"打字",而是决定。当打字的成本趋近于零,决定的分量就相对变重了,这也和 Ousterhout 在访谈里"设计比例会变大"的判断一致。

判断什么是重要的

生成器会忠实地实现你说出口的一切,包括不重要的。区分重要与不重要(第 21 章)是提示词之前的工作,模型替不了你。

接口

实现可以重新生成,接口一旦被几十个调用点依赖就很难改。把精力花在接口上(第 4 章),是在投资最难重来的那部分。

认得出红旗

审阅者面对的代码量成倍增长。能在几秒内认出浅模块、信息泄漏、透传方法的人,才审得过来。

注释即规格

"先写注释"(第 15 章)写下的接口注释,恰好也是最好的提示词;而且它会留在代码里,不像对话记录那样消失。

设计两次变便宜了

以前比较三种接口方案要花一两个小时,现在几分钟就能让模型各写一份草图(第 11 章)。挑选和综合仍然是你的事。

读者永远是人

"为读而设计"(第 18 章)在 AI 时代变成了字面意思:写的是机器,读、审、改、出事时排查的仍然是人。

机器速度的战术龙卷风

书中第 3 章把"战术龙卷风"(tactical tornado)描述为写代码极快、但完全战术式工作的程序员,留下的烂摊子要别人收拾。前面说过,把 AI 比作战术龙卷风的是访谈主持人,Ousterhout 在访谈里并不同意代码一定差;讨论组里则有读者用这个词描述自己的亲身经历。

本站认为两种说法可以同时成立,因为它们说的是不同层面:单个函数的质量可以很好,而系统层面的复杂性照样会累积。书中 2.4 节说复杂性是一点点累积起来的,每一处小妥协单看都无伤大雅。AI 辅助开发恰好放大了这种累积的速度:

所以本站的结论不是"别用 AI",而是:第 3 章建议的那 10%~20% 设计投资,比例不该因为写得快而降低。产出翻倍,用于重构和审阅的绝对时间也该跟着涨。

用红旗审 AI 生成的代码:清单

下表把书末的 14 个红旗翻译成审阅 AI 生成代码时的具体症状和提问(本站整理)。点击红旗名称可跳到速查页看定义。

红旗在生成代码里常见的样子审阅时问自己
浅模块一个需求生成了 XxxManager、XxxHelper、XxxUtil 好几个类,每个只包一层去掉它,调用方会多写几行?如果几乎不变,它就不该存在
信息泄漏同一个文件格式、键名约定、魔数出现在几个分别生成的文件里如果这个格式要改,要改几个地方?
按时间顺序分解函数按提示词的叙述顺序切成 step1Load、step2Parse、step3Validate这些步骤是否共享同一份知识?调用者是否必须按顺序调用?
过度暴露一个塞满开关的 Config 结构;每个调用都要传的输出参数多数调用者需要知道这些吗?默认值能不能直接替他们决定?
透传方法按"分层架构"模板生成的 Controller → Service → Manager → Repository,每层只转手这一层提供了什么新的抽象?
重复仓库里已有的工具函数被重新实现一遍,或几段近似的代码在不同文件里搜一下:这个逻辑是不是已经存在?
通用与专用混杂提示词里提到一个特例("VIP 用户不限流"),它就被写进了通用机制内部这个特例属于这个模块,还是属于调用它的业务层?
连体方法辅助函数只在主函数特定的调用顺序下才正确,依赖成员变量的中间状态能否不看调用方就确信它正确?
注释重复代码// 检查会话是否过期 写在 if (isExpired(s)) 上面删掉这条注释,读者会少知道什么?
实现细节污染接口注释函数头注释逐步复述算法步骤,却没说前置条件、错误和副作用调用者读完能正确使用它吗?
名字含糊data、info、result、handle()、process()只看名字,能猜对它是什么吗?
名字难起getFromCacheOrLoadAndUpdate 这类把实现步骤串起来的长名字名字长是因为这个函数做了几件不相干的事吗?
难以描述你试着给它补接口注释,发现要写一大段接口是不是本身就不干净?
代码不明显巧妙的一行写法、隐式转换、来历不明的常数换一个没参与对话的同事,能一遍读懂吗?

还有两条书里有、但不在 14 个红旗之列的检查,在生成代码里特别常见:到处 try/catch 再打日志(第 10 章:异常应该被定义掉、屏蔽或聚合,而不是在每个调用点各处理一遍),以及巫毒常数(这个叫法出自 OSTEP 对 Ousterhout 的引用,书中没有用;对应第 8 章 8.2 节:重试次数、超时、阈值被做成参数,却没有人知道该设多少)。

完整示例:一段"AI 写的"会话缓存

下面是本站原创的示例,模拟一段很常见的生成结果。提示词大意是:"用 C++ 写一个会话缓存,命中时直接返回,未命中从数据库加载;会话 30 分钟过期;管理员的会话不过期;数据库失败要重试。"代码能编译、能通过简单测试,单看每个函数也不算差。先自己找找红旗,再看分析。

// SessionCacheManager: manages session caching with database fallback.
// 会话缓存管理器
struct SessionCacheConfig {
    int ttlSeconds = 1800;
    int maxEntries = 10000;
    double cleanupThreshold = 0.8;
    int cleanupBatchSize = 500;
    bool enableAdminBypass = true;
    int dbRetryCount = 3;
    int dbRetryDelayMs = 100;
};

class SessionCacheManager {
public:
    SessionCacheManager(Database* db, SessionCacheConfig config);
    // 获取会话数据
    std::optional<SessionData> getSessionData(const std::string& sessionId,
                                              bool& fromCache, ErrorCode& err);
    // 从数据库加载会话
    std::optional<SessionData> loadSessionFromDatabase(const std::string& sessionId, ErrorCode& err);
    void putSessionInCache(const std::string& sessionId, const SessionData& data);
    bool isSessionExpired(const CacheEntry& entry);
    void cleanupExpiredSessions();
    std::unordered_map<std::string, CacheEntry>& getCache() { return cache_; }
    void setConfig(const SessionCacheConfig& c) { config_ = c; }
private:
    Database* db_;
    SessionCacheConfig config_;
    std::unordered_map<std::string, CacheEntry> cache_;
};

std::optional<SessionData> SessionCacheManager::getSessionData(
        const std::string& sessionId, bool& fromCache, ErrorCode& err) {
    // 检查是否需要清理
    if (cache_.size() > config_.maxEntries * config_.cleanupThreshold) cleanupExpiredSessions();
    // 在缓存中查找会话
    auto it = cache_.find(sessionId);
    if (it != cache_.end()) {
        // 检查是否为管理员
        if (config_.enableAdminBypass && it->second.data.role == "admin") {
            fromCache = true;
            return it->second.data;
        }
        // 检查会话是否过期
        auto now = std::chrono::system_clock::now();
        if (now - it->second.insertedAt < std::chrono::seconds(config_.ttlSeconds)) {
            fromCache = true;
            return it->second.data;
        }
    }
    fromCache = false;
    auto data = loadSessionFromDatabase(sessionId, err);
    if (data) putSessionInCache(sessionId, *data);
    return data;
}

bool SessionCacheManager::isSessionExpired(const CacheEntry& entry) {
    return std::chrono::system_clock::now() - entry.insertedAt > std::chrono::seconds(config_.ttlSeconds);
}

// SessionService.cpp
std::optional<SessionData> SessionService::getSession(const std::string& id, bool& fromCache, ErrorCode& err) {
    return cacheManager_->getSessionData(id, fromCache, err);
}

逐条分析

  1. 巫毒常数 + 过度暴露(第 4~10 行):七个参数,其中 cleanupThreshold、cleanupBatchSize、dbRetryDelayMs 没有任何调用者能比模块自己定得更好(第 8 章 8.2 节的检验问题)。enableAdminBypass 根本不是性能参数,而是一条安全策略。
  2. 注释重复代码(第 16、19、34、36、39、44 行一类的注释):每条都在复述下一行。真正需要说明的事却一条没写:fromCache 有什么用、err 何时被设置、失效会话返回什么、线程安全吗。
  3. 过度暴露(输出参数)(第 17~18 行):每个调用者都必须准备 bool fromCache 和 ErrorCode err,而关心"是否命中缓存"的大概只有监控代码。
  4. 浅方法 + 按时间顺序分解(第 20~23 行):loadSessionFromDatabase、putSessionInCache、cleanupExpiredSessions 是 getSessionData 内部的步骤,却被公开了,等于邀请调用者自己拼流程(拼错顺序就会读到过期数据)。
  5. 信息泄漏(第 24~25 行):getCache() 把内部的 unordered_map 以可修改引用交出去,缓存的表示方式从此成了接口的一部分;setConfig 允许运行中改 TTL,已缓存条目的语义随之变得模糊。
  6. 通用与专用混杂(第 39~43 行):提示词里的"管理员不过期"被写进了通用缓存的命中路径。后果不只是复杂:管理员被撤权或登出后,缓存里的旧会话依然永远有效。这是业务策略,不属于缓存。
  7. 重复(第 46 行与第 58 行):过期判断写了两遍,一处用 < 判断"未过期",一处用 > 判断"已过期",恰好在边界上两者都不成立。重复的逻辑迟早会这样悄悄分叉。
  8. 代码不明显(第 35、45 行):在 get 里顺手触发全表清理,会让某些请求的延迟突然变高,读代码的人很难从名字想到;用 system_clock 计算过期,系统时间被回拨时会话会"返老还童"。
  9. 透传方法(第 62~64 行):SessionService::getSession 签名和下层完全一样,只是转手。
  10. 名字:SessionCacheManager、SessionData 里的 Manager、Data 不提供信息 ;而公开方法一多,getSessionData 和 getSession 哪个该用也成了要记的东西。

重构后

// 已登录会话的缓存,位于 SessionStore(数据库)之前。
// 会话从加载起 ttl 时间内有效;条目数超过 capacity 时淘汰最久未访问的条目。
// 线程安全。时间一律用 steady_clock,不受系统时间调整影响。
// 数据库的瞬时故障由 SessionStore 内部重试;仍然失败时 find 抛 StoreUnavailable,
// 由请求分发层统一转成 503(不需要每个调用点处理)。
class SessionCache {
public:
    explicit SessionCache(SessionStore& store,
                          std::chrono::seconds ttl = std::chrono::minutes(30),
                          size_t capacity = 10'000);

    // 返回 id 对应的有效会话;会话不存在或已过期时返回 std::nullopt。
    std::optional<Session> find(std::string_view id);

    // 让 id 的缓存条目立即失效。登出、改密码、权限变更后必须调用。
    // id 不在缓存中时什么也不做。
    void invalidate(std::string_view id);

    CacheStats stats() const;   // 命中数、未命中数、淘汰数,供监控使用
};

把书里的方法接进 AI 工作流

以下是本站建议的做法,每一条都对应书中的某个原则:

  1. 接口和接口注释自己写(第 15 章"先写注释")。写下类注释、公开方法的签名和注释,再让模型生成实现。如果注释怎么写都很长,先停下来: 说明设计本身需要再想。
  2. 让模型提供备选方案,而不是直接要答案(第 11 章"设计两次")。例如:"给出三种差异很大的接口设计,各写出公开方法和一段使用示例,不要实现。"然后用书里的标准(调用方是否最简单、接口是否深)来挑。
  3. 按上面的清单审阅每一次生成,尤其盯住公开接口的变化:新增了哪些公开成员?每一个都值得存在吗?
  4. 每次改动都顺手改善一点设计(第 3 章、16.1 节)。生成的代码合入后,别让"以后再整理"变成永远不整理。
  5. 把 AI 给你的解释沉淀成注释。如果你像 Ousterhout 读 Linux 内核那样,靠模型才搞懂了一段代码,核实之后把关键结论写成注释,下一个读者(人或模型)就不用再猜一次。

示例:用接口注释当提示词

下面是第 1 步的一个具体样子(本站示例)。注意提示词的主体就是将来要留在头文件里的接口注释,额外的要求只有几行,而且都在约束不要扩大接口:

// ===== 交给模型的内容:头文件草稿 + 要求 =====
// 按固定窗口统计每个 key 的请求次数,用于限流判断。
// 窗口长度在构造时给定;计数在窗口结束时整体清零(不是滑动窗口)。
// 线程安全;内存占用与"当前窗口内出现过的 key 数"成正比,窗口切换时释放。
class WindowCounter {
public:
    explicit WindowCounter(std::chrono::milliseconds window);

    // 把 key 在当前窗口的计数加 1,返回加 1 之后的值。
    // now 必须来自 steady_clock;早于上次调用的 now 按上次的时间处理。
    uint64_t hit(std::string_view key, std::chrono::steady_clock::time_point now);
};
// 要求:
// 1. 只实现上面的公开接口,不要新增公开方法、配置项或输出参数。
// 2. 不要在实现里写复述代码的注释;只在不明显的地方说明"为什么"。
// 3. 如果你认为接口本身有问题(例如某个约束无法高效实现),先指出来,不要自行修改接口。

第 3 条要求很关键:它把"发现设计问题"留给对话,而不是让模型悄悄绕过去。模型的反馈有时正是书里说的"难以描述""名字难起"一类的信号。

练习

你让 AI 给一个已有的 HttpClient 加"失败重试",它生成了一个新类 RetryingHttpClient,包住原类,公开方法和原类一一对应,每个方法里是同样的重试循环。你会接受吗?如果不接受,打算怎么改?

展开对照

这是书中 7.3 节讨论的装饰器(decorator):签名完全相同的包装层,容易变浅,而且每个方法里的重试循环是重复代码 。先问 7.3 节的问题:重试是不是几乎所有使用者都需要?如果是,就把它做进 HttpClient 内部(只在一个私有的发送函数里实现一次),默认开启,罕见情况可以关掉;如果只有少数调用方需要,再考虑独立的包装,但也只在一个地方实现重试逻辑。顺便检查:重试是否只针对幂等请求(参考 OSTEP 49.7 节)?重试间隔是参数还是自适应的(第 8 章)?

同事说:"AI 能随时帮我解释代码,所以接口注释不重要了。"根据本页,Ousterhout 本人会怎么回应?你自己怎么看?

展开对照

有出处的部分:Ousterhout 承认 AI 在一定程度上能弥补注释缺失(自己读 Linux 内核时就这么用),但认为花一点时间写注释比事后让 ChatGPT 去猜容易得多,AI 不应成为不写注释的理由,"至少现在还不行"。

本站补充:接口注释记录的是契约(哪些行为是承诺、哪些只是当前实现碰巧如此),这一点从代码里推不出来,无论读者是人还是模型。模型能解释代码在做什么,但无法告诉你作者承诺了什么。

讨论组里那位读者问:如果以后和代码长期相处的是 AI 而不是人,战略式编程还值得吗?并列了三种可能:(1) 更重要,因为 AI 没有"当初为什么这样做"的记忆,隐藏的复杂性对它代价更高;(2) 不那么重要,因为 AI 能低成本消化混乱的结构;(3) 取舍没有消失,只是设计要服务的对象换成了干活的 AI 和负责审查的人。你押哪一个?用书里的概念说出理由,再说出什么证据会让你改主意。

展开对照

这题没有标准答案,Ousterhout 自己也说证据不足。可以用来检验自己推理的几个角度:

书里把复杂性的成因归为依赖和模糊性(2.3 节),症状之一是"不知道自己不知道什么"。这些都是关于信息在哪里、要不要去找的,与读者是人还是模型无关:一个约定散落在五个文件里,模型同样要么全部读到、要么漏掉。这支持 (1) 或 (3)。

支持 (2) 的理由是:模型读代码的速度和耐心远超人,"认知负担"这一项对它轻得多。但它的上下文同样有限,而且改动越分散,漏掉一处的概率越高(变更放大)。

会让人改主意的证据应该是可观察的:比如在同一个任务上,对比模型在结构好和结构差的代码库里的成功率;或者一个长期只由模型维护的代码库,缺陷率和改动成本随时间怎么变。目前流传的多是轶事,Ousterhout 转述时也特意这么标注。

在 The Pragmatic Engineer 的访谈里,"AI 编程工具像战术龙卷风"这个说法是怎么出现的?
根据节目转写稿,类比来自主持人;Ousterhout 的回答是 AI 会让写底层代码更容易,而且"不一定是战术龙卷风式的东西"。书(2021 年)和 GitHub 讨论都没有谈 AI。
在本页的会话缓存示例里,为什么把"管理员会话不过期"从缓存里移走,而不是保留开关 enableAdminBypass?
这是"通用与专用混杂":缓存的职责是按过期时间管理条目,至于谁的会话过期时间更长,应在签发会话时由业务层决定并写进 Session。
本站认为"单个函数质量不错"和"系统复杂性仍会快速累积"可以同时成立,主要依据书中的哪个观点?
复杂性来自许多小决定的叠加,而不是某一个糟糕的函数。以功能为单位快速生成代码,正好加快了这种叠加。D 曲解了第 4 章,C 是《Clean Code》的说法(书中反对),A 与第 20 章不符。