Skip to content

pstack 拆解 06 · 类型与状态 ​

模型会在编译器允许的范围内写。所以"合法但没意义"的状态它照样写得出来,而崩溃之后的重跑它也不一定会收敛。

这一篇讲两条:让非法状态无法表达,让操作幂等收敛。

type-system-discipline ​

类型系统纪律的背后,是把正确性从运行时往前挪到编译期。类型系统在这里的角色不是"标注",而是一种轻量的形式化方法:你写下类型,它替你检查,凡是它能证明不成立的东西,就进不了生产。它用很低的成本,拿到了形式化验证里最有用的那一部分。

它的核心工具是让非法状态无法表达:用和类型、品牌类型、穷尽匹配,把一整类 bug 从"运行时可能发生"变成"编译不过"。判断标准也很直白:如果你的类型还允许写出一个没有意义的字段组合,那类型就太松了。

它有一句很好的话:类型检查器是一个证明助手。

总和类型、全函数、穷尽匹配,这些都是函数式语言的基本工具。这个原则把它们借了过来,不管你用的是哪种语言。两个词解释一下:全函数指对所有合法输入都有定义,不会因为某个输入就崩(比如“空列表的第一个元素”没有答案,那它就不该接受空列表);穷尽匹配指把一个和类型的每种情况都处理,漏了一种就编译不过。

这个原则本身是语言无关的。每种语言的具体写法,放在对应的语言 skill 里,比如 TypeScript 的就在 typescript-best-practices(第 21 篇)。上面那句在 TS 里长什么样,那边列成了可查的表。

用它把三类东西在编译期消掉:不可能的状态、对不上的原始类型、没处理的变体。原文还有一句:一个类型让你能忽略的情况,会变成编译器本可以拦下的运行时故障。所以宁可把错误和特殊情况定义得不复存在,也不要增加一堆处理分支。

它的做法。

让非法状态无法表达。 用和类型建模变体,不要用一堆可选字段,因为矛盾的组合会通过编译。一个微妙的反例是 { completed: boolean; completedAt?: Date },它允许 completed: true 同时 completedAt: undefined,这个组合没有意义。要么从一个来源派生布尔,要么显式建模 { kind: 'open' } | { kind: 'done'; at: Date }。如果你需要写一句注释解释"这组字段什么时候才合法",类型就太松了。

类型是构造,不是限制。 从你想要的值把类型建出来,而不是从一个更宽的类型用检查切出来。非空列表是"一个头加一个尾",不是"一个带长度检查的列表"。合法的时间区间是"起点加时长",不是"两个需要保持有序的时间戳"。选一个无法被构造成非法值的形状。

给语义原始类型打品牌。 UserId 和 OrderId 底层都是字符串,但不该互换。

外部数据在被解析之前是无类型的。 RPC 载荷、JSON、IPC 消息、CLI 参数、配置、环境变量、数据库行,每个边界一个解析函数。

不要对类型系统撒谎。 cast、unsafe coercion、绕过编译器的断言函数,都是潜伏的运行时崩溃。编译器证明不了的事实,要么去证明它,要么承认这个 cast 是隐患。

穷尽匹配是编译器的工作。 加一个变体却没处理,编译就必须失败。

从权威 schema 派生类型。 proto、OpenAPI、GraphQL、数据库迁移、设计 token,从它派生,不要手写一个平行的类型。

只在完整性出现裂口的地方加强类型。 运行时的断言、null 检查、"这不该发生"的 throw,都标记了类型太弱的地方。把那个检查推进类型里,然后停。类型系统的工作是追踪每个使用点必须处理的情况,不是尽可能精确地描述数据。

make-operations-idempotent ​

幂等操作的背后是一个关于运行环境的现实:命令、生命周期步骤、处理循环,都跑在一个崩溃、重启、重试是常态的世界里。既然"只执行一次"这件事你保证不了,那就把正确性从"执行次数"挪到"终态":不管跑几次、从哪里开始、上一次在哪一步崩的,最终都收敛到同一个结果。这是把不确定性挡在系统外面的设计。

它有一句判据:让操作收敛到同一个终态,不管它跑了几次,也不管它从哪里开始。

它的做法。启动时收敛:扫描已有状态,清理过期产物,接管还活着的会话。清理按内容等价判断,不按创建顺序。锁能自愈:PID 判断过期。调度幂等:失败的工作干净地重生,每个周期后重新生成新输入。

它有三个检验问题。这个操作连着跑两次会怎样?上一次在每一个可能点崩溃会怎样?重执行会收敛到同一个终态吗?任何一个答案是"取决于留下了什么状态",这个操作就需要一个 reconciliation 步骤。

参考 ​

原文都在 pstack 上游仓库(cursor/plugins):