标题
先写骨架,再补细节:用契约拆解算法题与中型程序
副标题 / 摘要
很多人写代码不稳,不是因为不会语法,而是因为一开始就把“公开行为”“内部细节”“副作用边界”揉成了一团。
本文要讲的不是某一种框架,而是一套通用方法:先写外部骨架,先定 helper 的契约,再围绕不变量填实现。它既能用于 LRUCache 这类数据结构题,也能用于 handle_order 这类中型业务流程。
目标读者
- 刷题时经常把
get/put写成一团,改一处坏三处的开发者 - 已经会写函数,但开始接触中型模块设计的初中级工程师
- 想弄清“自顶向下设计”和 DDD 到底是什么关系的人
背景 / 动机
大多数“代码越写越乱”的问题,根源不是缺少模式,而是没有先分层思考。典型症状有三类:
- 公开方法里塞满细节
比如一个put()里同时做哈希表查询、双向链表摘节点、尾部淘汰、头部插入、边界判断。逻辑能跑,但一次修改会牵动 2 个数据结构和 4 到 8 次指针赋值。 - helper 名字存在,契约不存在
比如_sync()、_process()、_handle()这种名字,调用方不知道它改了哪些状态、失败时会怎样、是否有隐藏副作用。 - 一上来就写局部细节,最后回头补结构
结果通常是:越往后越不敢抽象,因为每个局部都已经偷偷依赖了别的局部。
用一个最常见的数字感受一下问题规模。假设你用“数组 + 线性扫描”的方式写 LRU:
- 容量
n = 10^5 - 调用次数
q = 2 * 10^5
最坏情况下,单次查找或调整顺序要 O(n),总成本接近 O(nq) = 2 * 10^10 次比较或搬移。
这时你会发现,真正要先定下来的不是某一行代码,而是结构骨架:必须是“哈希表 + 双向链表”,并且 get / put 都要把复杂度压到 O(1)。
这正是本文方法论的起点:
先决定外部行为和内部状态,再决定 helper;先决定 helper 的契约,再决定它的实现。
快速掌握地图(60-120 秒)
- 问题形状:公开 API 很少,但内部状态变换很多,且多个方法会重复操作同一批状态
- 核心思想一句话:先写主流程骨架,再把可复用的原子状态变换抽成 helper,并为每个 helper 明确前置条件、后置条件和副作用
- 什么时候用:状态型数据结构、应用服务编排、需要维护不变量的中型模块
- 什么时候避免:需求本身还没搞清楚、领域概念变化超过 50% 的探索期原型
- 复杂度头条:像
LRUCache这种结构题,目标通常是get/put = O(1);像业务流程题,目标通常是“主流程 5 到 15 行内能读懂” - 常见失败模式:helper 表面上只“弹出旧节点”,实际上还偷偷删了
map,导致调用方二次删除时报错
深化焦点(PDKH)
本文只深化两个概念,不平行扩散:
- 概念 A:契约先行的 helper 设计
- 概念 B:围绕不变量做状态变换
对这两个概念,本文都会走完 PDKH 的主路径:
- P:重述问题
- D:给最小可运行例子
- K:给出不变量或前后置条件
- H:给出形式化描述、复杂度阈值、反例和工程现实
主心智模型
把一个复杂方法想成三层:
- 公开层(orchestration layer)
决定“这件事要分几步做”,例如put()、get()、handle_order() - 原子变换层(primitive transitions)
决定“每一步到底只改什么状态”,例如_remove(node)、_add_front(node) - 不变量层(invariants)
决定“无论怎么走步骤,哪些事实必须始终成立”
如果用函数组合的写法来表达,公开方法本质上是若干原子变换的组合:
public_method = T_k ∘ T_(k-1) ∘ ... ∘ T_1
其中:
T_i表示第i个原子状态变换- 每个
T_i都应该有清晰契约:Contract(T_i) = (Pre_i, Effect_i, Post_i) - 公开方法的正确性,来自这些变换对不变量的逐步保持
这套模型既适用于算法题,也适用于业务代码:
- 在
LRUCache里,put()是编排层,_remove()/_add_front()是原子变换层 - 在下单系统里,
place_order()是编排层,reserve_inventory()/order.confirm()是原子变换层
这也是为什么“先写骨架”不是空谈。骨架不是 TODO 列表,而是对变换顺序和状态边界的明确建模。
核心概念与术语
- 骨架(skeleton):公开方法的步骤结构,不追求细节,但要能看出控制流和职责边界
- 契约(contract):一个方法对外承诺的输入要求、状态变化、输出结果和失败语义
- helper:被公开方法复用的局部操作,通常应该比公开方法更原子、更窄职责
- 不变量(invariant):在每次状态变化前后都必须成立的事实
- 副作用(side effect):对外部可观察状态的修改,例如改链表、删字典、写数据库、发消息
- 编排(orchestration):按顺序调用多个步骤完成一个完整用例,但不把每一步的细节都摊在主流程里
两个最重要的形式化表达:
helper 契约
Contract(h) = (Pre_h, Effect_h, Post_h)其中:
Pre_h:调用前必须满足的条件Effect_h:helper 会修改哪些状态Post_h:调用后保证成立的条件
结构一致性
|map| = number_of_real_nodes(list)这个公式在 LRU 里尤其重要:
|map|是哈希表里 key 的数量number_of_real_nodes(list)是双向链表里除哨兵外真实节点的数量
如果这两个数字不相等,说明某个 helper 不是漏删了节点,就是漏删了映射。
可行性与下界直觉
为什么不能只靠“先写几个函数名”
如果你只写出:
getput_remove_add_front_pop_lru
但没有决定内部状态结构,那骨架仍然是空的。
例如只用数组来维护最近使用顺序,虽然也能写出这些函数名,但 put 和 get 无法稳定做到 O(1)。
这说明:
骨架不是只写 API 名字,而是先确定“公开行为 + 数据结构 + 不变量”。
反例:骨架定太早,契约定太假
再看一个业务例子。你可能先写出:
def handle_order(user_id: int, item_id: int) -> Receipt:
user = load_user(user_id)
item = load_item(item_id)
validate_order(user, item)
order = create_order(user, item)
return build_receipt(order)
如果后来发现“下单”必须同时满足:
- 锁库存
- 校验优惠券
- 扣余额
- 写订单
- 发异步事件
那这个骨架仍然可以保留,但 helper 的契约必须更新。
真正危险的不是“先写骨架”,而是把骨架误当成真相,不再校正契约。
问题建模
先把本文讨论的问题限定清楚:
场景 A:算法题 / 数据结构题
以 LRUCache 为例:
- 公开接口只有 2 个:
get(key)和put(key, value) - 核心状态有 2 组:哈希表
map和双向链表list - 目标复杂度是:查询、更新、淘汰都要
O(1)
场景 B:中型程序 / 业务流程
以 handle_order() 为例:
- 公开接口可能只有 1 个用例:下单
- 依赖至少 3 类外部对象:用户仓储、商品仓储、订单仓储
- 目标不是极限性能,而是保持流程可读、职责边界清楚、失败路径可解释
共同优化目标
无论场景 A 还是 B,都希望做到三件事:
- 主流程一眼能读懂
- 每个 helper 的副作用范围可预测
- 核心不变量能被逐条验证
基线与瓶颈
朴素写法
很多人第一次写 LRU,会把所有逻辑直接塞进 get 和 put:
- 查字典
- 摘节点
- 接前驱后继
- 插到头部
- 满容量时淘汰尾部
- 维护映射
从渐进复杂度看,最后也许仍然能写到 O(1);
但从正确性维护成本看,它非常脆弱。
原因在于:一次双向链表的删除或插入,至少涉及 4 次指针修改。
例如 _remove(node) 的核心动作是:
p.next = nn.prev = p
而 _add_front(node) 至少还有 4 次引用更新:
node.prev = headnode.next = head.nexthead.next.prev = nodehead.next = node
如果你把这些赋值分散在 3 个分支里复制粘贴,忘掉其中任意 1 次,链表就会断。
这就是瓶颈:不是不会写,而是重复的低层状态操作会污染高层控制流。
业务代码的对应瓶颈
同样的问题会出现在业务代码里。假设 place_order() 里同时出现:
- 查用户
- 查商品
- 余额校验
- 库存校验
- 订单组装
- 仓储持久化
- 事件发送
如果所有逻辑都堆在一个 80 行函数里,任何规则变动都会迫使你重新在一大段流程里寻找边界。
这时即便没有指针错误,也会出现语义耦合错误:一个“查询 helper”突然兼做了“写库”和“发事件”。
关键观察
复杂代码通常不是因为“事情太多”,而是因为相同的状态变换没有被命名。
对 LRUCache 来说,真正难的不是 put() 这 10 来行,而是你是否意识到下面几件事反复出现:
- 从链表中摘掉一个节点
- 把节点插到头部
- 找出尾部旧节点
- 把已有节点标记成最近使用
一旦这些动作被独立命名,主流程会突然变得清晰:
def put(self, key: int, value: int) -> None:
node = self.map.get(key)
if node is not None:
node.val = value
self._move_front(node)
return
if len(self.map) == self.cap:
old = self._pop_lru()
del self.map[old.key]
node = Node(key, value)
self.map[key] = node
self._add_front(node)
这段代码之所以可读,不是因为它短,而是因为它只表达决策顺序,不表达底层指针细节。
也就是说:
- 主流程应该负责“判断分支和拼装步骤”
- helper 应该负责“单一状态变换”
- 不变量应该负责“保证这些变换能安全组合”
算法步骤(实践指南)
下面给出一套通用流程,既能写算法题,也能写中型模块。
先写公开行为
先回答:这个模块从外部看,真正要暴露什么?
例如 LRU 就是get/put;下单服务就是place_order。列出内部状态
不要急着写 helper,先写清楚你要维护哪几组状态。
LRU 是map + list;下单流程可能是user/product/order + transaction/event。定义不变量
每组状态之间应该满足什么关系?
例如:map和链表节点数量一致- 链表顺序必须是 MRU -> LRU
- 一个订单在
confirm()后状态必须从CREATED进入CONFIRMED
把重复动作抽成 helper,并先写契约
不需要一开始就实现,但至少要明确:
- 调用前条件是什么
- 会改哪些状态
- 调用后保证什么
用 helper 契约拼出主流程
此时主流程应该像“步骤清单”,而不是像“寄存器操作手册”。
优先实现最原子的 helper
例如 LRU 里先实现
_remove和_add_front,再实现_move_front和_pop_lru。用具体 trace 验证不变量
至少写一个最小非平凡用例。
LRU 可以用容量2的追踪;业务流程可以用“正常下单 + 库存不足”两条路径。最后才做局部优化
如果某个 helper 太短,不抽也可以;但前提是你已经确认副作用边界没有被破坏。
设计顺序 vs 编码顺序
很多人学到这里会有一个非常具体的困惑:
你不是在第 5 步才推出“需要双向链表”吗?
为什么真正写代码时,第 1 步却让我先写Node?
这个问题非常好,因为它正好说明了两种“顺序”不能混为一谈。
1. 设计顺序:从需求往下推
设计时,你关心的是“为了满足目标,我需要哪些能力”。
以 LRU 为例,推导链条通常是这样的:
- 要支持
get(key)和put(key, value) - 题目要求这两个操作都尽量是
O(1) - 所以查找不能靠线性扫描,必须有
dict - 但光有
dict不够,因为还要维护“最近使用顺序” - 你需要支持下面三种操作都尽量
O(1):- 把某个已存在节点移到最前面
- 在最前面插入新节点
- 删除最后面那个最旧节点
- 这正是双向链表擅长的事,所以第二个结构是双向链表
- 既然有双向链表,落地实现时就需要一个
Node
注意这里的顺序是:
先推出需要双向链表,再推出需要 Node。
也就是说,Node 不是凭空出现的,而是由“我要落地一个双向链表”这个设计结论自然导出来的。
2. 编码顺序:按实现依赖往上搭
一旦设计已经确定为“dict + 双向链表”,真正落地写代码时,顺序通常会变成:
- 先写
Node - 再写
__init__,把哨兵节点和map放好 - 再写
_remove()、_add_front() - 再写
_move_front()、_pop_lru() - 最后再把
get()、put()接起来
这不是因为你“还没想到双向链表就先写 Node”,而是因为:
- 设计上已经决定了要用双向链表
- 实现上必须先把双向链表依赖的底层积木搭出来
换句话说:
- 设计顺序偏自顶向下:先看目标,再推结构
- 编码顺序偏自底向上:先搭积木,再拼主流程
3. 一个完整的 LRU 心智顺序
如果把这两种顺序合在一起,比较自然的过程其实是:
先在脑子里定骨架:
get(key): 查 map,命中后移到头部
put(key, value): 更新或插入,满了就淘汰尾部
再推出需要的能力:
需要 dict
需要双向链表
需要 Node
需要 _remove(node)
需要 _add_front(node)
需要 _pop_lru()
最后才开始真正写代码:
class Node:
...
class LRUCache:
def __init__(self, capacity: int):
...
def _remove(self, node: Node) -> None:
...
def _add_front(self, node: Node) -> None:
...
def get(self, key: int) -> int:
...
def put(self, key: int, value: int) -> None:
...
所以更准确的说法不是“设计顺序和编码顺序是反的”,而是:
- 大脑里先
top-down - 手上写时常常
bottom-up - 实际开发中会在两者之间来回迭代
4. 这个规律不只适用于 LRU
大型程序里也经常是同一个模式:
- 先定接口和主流程
- 再实现支撑主流程的底层组件
- 最后把主流程真正接起来
例如在一个下单系统里,你可能先设计:
place_order()
-> load_user()
-> load_product()
-> reserve_inventory()
-> create_order()
但真正写代码时,常常要先把:
Order领域对象InventoryRepositoryreserve_inventory()的事务边界
这些底层能力先搭好,最后才能把 place_order() 写完整。
所以这条方法论最实用的记法是:
脑子里先 top-down,手上写时常常 bottom-up。
决策标准(选型指南)
这套方法不是万能钥匙,但适用面很广。下面给一组经验型判断。
适合优先写骨架的场景
- 公开 API 数量很少,通常在
1到5个之间 - 每个公开方法要反复操作同一批状态对象,通常是
2到5组 - 模块里存在明确不变量,例如顺序、计数、一致性、状态流转
- 你希望后续能单测 helper,而不是每次都靠整体验证
更适合先做探索的场景
- 需求本身还不稳定,今天是
Coupon,明天变成Campaign - 领域名词尚未收敛,名词表三天换一次
- 你连核心状态有哪些都说不清,只能先做快速原型
什么时候需要从“轻量骨架”升级到 DDD
- 同一个业务概念被 3 个以上用例重复引用
- 规则散在服务、控制器、仓储、任务消费者等多个位置
- 你发现“对象是什么”比“先调用哪个函数”更关键
一句话概括:
- 公开行为和状态变换已经清楚:先写骨架
- 业务概念和边界还混乱:先收敛领域模型,再谈骨架
Worked Example(追踪)
用容量为 2 的 LRU 做最小非平凡追踪:
初始状态:
map = {}- 链表:
head <-> tail
执行序列:
put(1, 10)put(2, 20)get(1)put(3, 30)
逐步状态如下:
| 步骤 | 返回值 | 链表(从 MRU 到 LRU) | map 中的 key |
|---|---|---|---|
| 初始 | - | [] | {} |
put(1, 10) | - | [1] | {1} |
put(2, 20) | - | [2, 1] | {1, 2} |
get(1) | 10 | [1, 2] | {1, 2} |
put(3, 30) | - | [3, 1] | {1, 3} |
第 4 步最值得看,因为这里同时触发了两件事:
len(map) == cap,需要淘汰 LRU 节点2- 新节点
3插入头部成为 MRU
如果 _pop_lru() 只负责“弹链表尾部并返回节点”,put() 就可以自己显式做:
old = self._pop_lru()
del self.map[old.key]
这就让副作用边界变得透明:
链表由 _pop_lru() 负责,哈希表由 put() 负责。
正确性(证明草图)
继续以 LRU 为例,设三个核心不变量:
I1:head和tail始终是哨兵节点,不参与真实数据存储I2:map中每个key恰好对应链表中的一个真实节点I3:链表从head.next到tail.prev的顺序始终表示“最近使用到最久未使用”
为什么 _remove(node) 保持不变量
前提是 node 已经在链表中。
它只做两件事:
- 把前驱直接接到后继
- 把后继的
prev回指到前驱
因此:
- 不会创建新节点,也不会复制旧节点,所以
I2不被破坏 - 不会改变其他节点的相对顺序,只是删除当前节点,所以
I3在剩余节点上仍成立
为什么 _add_front(node) 保持不变量
它把节点插到 head 后面:
- 不破坏哨兵结构,
I1仍然成立 - 节点只出现一次,因此不重复,
I2仍然成立 - 新插入节点成为最靠近
head的真实节点,因此自然代表最新使用,I3成立
为什么 get() 正确
- key 不存在时返回
-1,状态不变 - key 存在时,把节点移动到头部并返回值
由于 _move_front(node) = _remove(node) + _add_front(node),它在保持 I1/I2 的同时,把该节点更新为最新使用,因此 get() 的语义正确。
为什么 put() 正确
分三种情况:
- 已存在 key
只更新值并挪到头部,不会产生重复节点 - 不存在 key 且未满
新建节点,插头部,新增映射 - 不存在 key 且已满
弹出尾部旧节点,删除映射,再插入新节点
每种情况都保持 map 与链表一一对应,因此 put() 正确。
复杂度
以本文的 LRU 实现为例:
- 时间复杂度
get:O(1)put:O(1)_remove/_add_front/_move_front/_pop_lru:O(1)
- 空间复杂度
O(cap),其中cap是缓存容量
但本文真正关心的不只是渐进复杂度,还包括认知复杂度:
- 主流程只保留分支和步骤
- helper 只保留原子变换
- 不变量集中承担正确性解释
这会让你在规模从 30 行长到 300 行时,仍然知道“该去哪里改”。
常数项与工程现实
1. 哨兵节点不是花活,是边界消除器
如果没有 head/tail 哨兵,删除头节点、尾节点、唯一节点时都要额外分支。
引入两个哨兵后,链表操作大幅统一,常数项里多 2 个节点,但少掉一整类条件判断。
2. helper 过短也值得抽,前提是它承载重复语义
_remove() 只有 2 行核心赋值,看起来很短;
但它承载的是“从链表中摘节点”这个反复出现的语义。抽出来的价值,不在节省键盘,而在防止高层逻辑重复操作指针。
3. 不要为了省 1 次函数调用,把职责重新揉回去
有人会说:_move_front() 不就是 _remove() + _add_front(),直接写进 get() 不更快?
在 Python 里,少 1 次函数调用的收益,通常远小于职责边界被破坏后的排障成本。
4. 可以用现成库,但要知道它替你维护了什么
例如 Python 的 OrderedDict 可以很快写出一个 LRU。
这在工程里完全合理,但前提是你知道:
- 它替你维护了顺序
- 你仍然要维护 key 的语义和淘汰时机
- 如果要面试、教学或自定义副作用,它不再帮你解释不变量
可运行实现(Language: Python)
下面给出一个完整可运行版本。注意看注释:它们不是解释语法,而是明确 helper 契约。
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Node:
key: int = 0
val: int = 0
prev: "Node | None" = None
next: "Node | None" = None
class LRUCache:
def __init__(self, capacity: int):
self.cap = capacity
self.map: dict[int, Node] = {}
self.head = Node() # MRU side sentinel
self.tail = Node() # LRU side sentinel
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node: Node) -> None:
"""Pre: node is already in the linked list.
Effect: detach node from list only.
Post: remaining list stays connected.
"""
prev_node, next_node = node.prev, node.next
assert prev_node is not None and next_node is not None
prev_node.next = next_node
next_node.prev = prev_node
def _add_front(self, node: Node) -> None:
"""Pre: node is detached.
Effect: insert node right after head only.
Post: node becomes the MRU node.
"""
first = self.head.next
assert first is not None
node.prev = self.head
node.next = first
self.head.next = node
first.prev = node
def _move_front(self, node: Node) -> None:
self._remove(node)
self._add_front(node)
def _pop_lru(self) -> Node:
"""Effect: remove and return the LRU node from list only."""
node = self.tail.prev
assert node is not None and node is not self.head
self._remove(node)
return node
def get(self, key: int) -> int:
node = self.map.get(key)
if node is None:
return -1
self._move_front(node)
return node.val
def put(self, key: int, value: int) -> None:
if self.cap == 0:
return
node = self.map.get(key)
if node is not None:
node.val = value
self._move_front(node)
return
if len(self.map) == self.cap:
old = self._pop_lru()
del self.map[old.key]
node = Node(key, value)
self.map[key] = node
self._add_front(node)
def snapshot(self) -> list[tuple[int, int]]:
cur = self.head.next
out = []
while cur is not None and cur is not self.tail:
out.append((cur.key, cur.val))
cur = cur.next
return out
if __name__ == "__main__":
cache = LRUCache(2)
cache.put(1, 10)
cache.put(2, 20)
print(cache.snapshot()) # [(2, 20), (1, 10)]
print(cache.get(1)) # 10
print(cache.snapshot()) # [(1, 10), (2, 20)]
cache.put(3, 30)
print(cache.snapshot()) # [(3, 30), (1, 10)]
print(cache.get(2)) # -1
工程场景
场景 A:刷题或面试里的数据结构题
这种场景最适合练“骨架 + 契约 + 不变量”。
class LRUCache:
def get(self, key: int) -> int:
...
def put(self, key: int, value: int) -> None:
...
先把公开 API 写出来,再决定:
- 需要哪些状态
- 需要哪些 helper
- 每个 helper 改什么
场景 B:应用层 use case 编排
这里主流程更像 orchestration,helper 更像仓储或领域行为。
def place_order(user_id: int, item_id: int) -> Receipt:
user = user_repo.get(user_id)
item = product_repo.get(item_id)
order = Order.create_for(user)
order.add_item(item, qty=1)
order.confirm()
order_repo.save(order)
return Receipt.from_order(order)
这时要注意:
- 主流程只编排,不替每个对象承载业务细节
- 真正的规则应尽量沉到
Order、InventoryPolicy这类对象里
场景 C:解析/校验/转换管线
很多脚本会把解析、校验、转换、输出写在一个函数里,其实也适合用同样方法拆开。
def run_pipeline(raw: str) -> dict:
tokens = tokenize(raw)
ast = parse(tokens)
validate(ast)
return lower_to_ir(ast)
这里的 helper 契约要回答:
parse是否保证返回合法 ASTvalidate抛错还是返回错误集合lower_to_ir是否会改输入对象
Alternatives / Tradeoffs(替代方案与取舍)
方案 1:纯自底向上,想到哪写到哪
优点:
- 上手快
- 适合一次性脚本和探索期原型
缺点:
- 当公开方法要同时操作
2个以上状态对象时,很快会失去边界 - 局部实现越多,后面越难反推出正确骨架
方案 2:重度前期建模,先把所有 helper、接口、类图一次性定死
优点:
- 文档很完整
- 大团队协作时便于提前对齐
缺点:
- 如果需求尚不稳定,前期模型会反复推倒
- 容易把“骨架”写成“僵化设计”
方案 3:轻量骨架 + 明确契约 + 迭代校正
这通常是最实用的平衡点:
- 先定公开行为和状态边界
- helper 可以先占位,但契约不能模糊
- 随着例子和反例增加,再校正实现和模型
这和 DDD 是什么关系
它们不是对立关系,而是关注点不同:
- 自顶向下骨架回答的是:主流程如何拆步骤、谁调用谁
- DDD回答的是:业务里真正稳定的对象、边界和规则是什么
更准确地说:
- 当你已经知道公开用例要怎么走时,骨架法很有效
- 当你还不知道
Order、Coupon、Inventory谁该拥有规则时,DDD 更重要
所以现实里通常是并存的:
- 先用骨架法写出 use case 流程
- 再用 DDD 把流程里的业务规则沉到合适对象
- 最后让应用层只保留编排
迁移路径(Skill Ladder)
如果你已经能用这套方法稳定写出 LRUCache 这类题,下一步建议按这个顺序升级:
- 不变量思维
不只是“能跑”,而是能说明为什么每一步不破坏结构 - 契约测试
不只测公开 API,也测 helper 的前后置条件 - 领域建模
学会区分 orchestration、entity、repository、domain service - 并发与事务边界
当状态不再只在内存里,helper 的副作用会扩展到锁、事务、消息、重试
更难的一类问题,是分布式系统里的流程编排:
reserve_inventorycharge_paymentcreate_orderpublish_event
这时“helper 是否有隐藏副作用”会直接变成“系统是否会重复扣款”。
常见坑与边界情况
1. helper 名字明确,契约却模糊
例如 _pop_lru() 这个名字看起来已经挺清楚,但仍然可能有两种完全不同的实现:
- 只从链表中弹出旧节点
- 同时从链表和
map里一起删掉
如果名字相同、契约不同,调用方就会踩坑。
2. helper 偷偷做了额外副作用
例如一个名叫 load_user() 的函数,除了查用户,还顺手:
- 刷新最后访问时间
- 写审计日志
- 预加载订单列表
这类函数短期省事,长期会让主流程无法预测成本和行为。
3. 过度拆分 micro-helper
如果一个 12 行函数被拆成 9 个 helper,每个 helper 只做一行,主流程反而失去局部连贯性。
抽 helper 的标准不是“越短越好”,而是“是否代表稳定且可复用的语义”。
4. 只有骨架,没有验证
下面这种代码看起来结构很好,但没有任何信息量:
def solve():
prepare()
process()
finalize()
如果你说不清:
prepare产出什么状态process依赖什么前置条件finalize是否有持久化副作用
那这不是设计,而只是把未知包装成了名字。
最佳实践
- 先写公开 API,再写 helper;不要一上来就在局部细节里打转
- helper 的名字要体现语义,契约要体现副作用范围
- 主流程尽量只保留“判断 + 编排”,底层状态操作下沉
- 明确写出 2 到 3 条核心不变量,再围绕它们检查 helper
- 至少准备 1 个最小非平凡 trace 和 1 个失败反例
- 当业务对象比控制流更重要时,及时引入 DDD 视角重建模型
小结 / Takeaways
- 先写骨架不是先写空壳,而是先写清公开行为、状态结构和不变量。
- helper 可以先占位,但契约不能模糊;否则只是把未知藏进了函数名。
- 主流程应该表达决策顺序,helper 应该表达原子状态变换;两者不要互相抢职责。
- DDD 和自顶向下骨架法不是二选一;前者解决业务模型边界,后者解决实现流程拆分。
- 凡是会反复改动多个状态对象的模块,都值得先做契约化拆解;LRU 是最小练习场,业务流程是自然延伸。
参考与延伸阅读
- George Pólya, How to Solve It
- Edsger W. Dijkstra, A Discipline of Programming
- C. A. R. Hoare, An Axiomatic Basis for Computer Programming
- Eric Evans, Domain-Driven Design
- Martin Fowler, Patterns of Enterprise Application Architecture
行动号召(CTA)
找一段你最近写过的代码,最好满足下面两个条件:
- 公开方法不超过
3个 - 内部至少同时维护
2组状态
按本文方法重写一次:
- 先写公开骨架
- 再写 helper 契约
- 最后补实现和 trace
如果你重写完发现主流程突然变短了,但解释能力变强了,说明你已经抓到这套方法的关键了。