跳到主要内容

不重不漏 — 一条收费规则该怎么写才不出 bug

这一章讲三件事: 一句话要满足什么条件才能拿来当规则用; 「漏」和「重」长什么样、为什么它们是同一枚硬币的两面; 以及一条最普通的 if 语句,背后其实是一次「把大问题切成两半」。

它在全书链条里的位置: 第 01 章说全书的总方法是「把大问题切成小块」。 这一章管的是切法本身:切完不能有缝,也不能重叠。 后面第 03 到 05 章会把切法越做越细,一直做到能压简一整套复杂规则。 不需要任何基础。

1. 先看现象:6 岁的孩子,车票收多少钱

某巴士公司的收费规则是这么写的1:

收费规则 A
────────────────────────
6 岁以上的乘客 100 元
不到 6 岁的乘客 0 元
────────────────────────

图说:全章就走这一位乘客——一个正好 6 岁的孩子。
他在四份规则下的命运各不相同,这一章就是看这四种命运。

13 岁的 Alice 收 100 元,4 岁的 Bob 免费,这都没有争议。 那么正好 6 岁的 Charlie 呢?

收 100 元。 因为「6 岁以上」这个说法包含 6 岁2。 到这里为止,一点难度都没有——这正是危险的地方。

这本书接下来做的事,是把这条毫无难度的规则改动一两个字,让它出问题。 改完你会发现:出问题的位置永远是同一个,就是 6 岁那一格。

2. 什么样的句子才能拿来当规则:它得能判对错

这一节先立一个概念,后面四节都靠它。

查车费的时候,你其实在判断一句话的对错:「这位乘客的年龄在 6 岁以上」。 Alice(13 岁)——对;Bob(4 岁)——错;Charlie(6 岁)——对。

一句能判断对错的话,叫作命题3。 对的时候说它为(英文写作 true),错的时候说它为(false)4

这里有一条硬要求: 一句话要么真、要么假,不能同时是两者,也不能两者都不是—— 做不到这一点的句子就不算命题5。 「今天有点热」不是命题(判不了),「这位乘客 6 岁以上」是命题。

收费规则 A 的全部内容,其实就是一句话: 拿这个命题去判,真就收 100 元,假就收 0 元6这是主走查的第一步: 6 岁的乘客 → 命题为真 → 100 元。

3. 第一种病:漏

这一节回答:规则漏了一块,长什么样。

把规则 A 改一个字,「6 岁以上」改成「大于 6 岁」:

收费规则 B(有毛病)
────────────────────────
乘客的年龄大于 6 岁 100 元
乘客的年龄不到 6 岁 0 元
────────────────────────

再让 6 岁的 Charlie 来买票——没人管他了。 他不属于「大于 6 岁」,也不属于「不到 6 岁」。这份规则判不了他7这是主走查的第二步。

这就叫遗漏:存在某个对象,规则的每一条都不覆盖它。 判据很干脆:不管来的是谁,那个命题的真假都要判得出来。 规则 A 没有遗漏,因为任何人都有年龄,而任何年龄都能判出「是不是 6 岁以上」8

遗漏在程序里的样子: 一个 switch 少了一个分支、 一串 if / else if 最后没有 else——某一类输入落到地上,什么也没发生。

4. 第二种病:重

这一节回答:规则多出一块,长什么样;以及为什么「多」不一定是病。

再改一次,这回把第二条改成「6 岁以下」:

收费规则 C(有毛病)
────────────────────────
乘客的年龄在 6 岁以上 100 元
乘客的年龄在 6 岁以下 0 元
────────────────────────

6 岁的 Charlie 又出问题了,但这次是反过来:两条都管他。 「6 岁以上」包含 6 岁,「6 岁以下」也包含 6 岁9收 100 还是收 0?这份规则给了两个互相矛盾的答案。这是主走查的第三步。

但要注意一个容易读岔的地方:重复本身不是病,矛盾才是病。 看这一份:

收费规则 D(重复,但不矛盾)
────────────────────────
乘客的年龄在 6 岁以上 100 元
乘客的年龄为 6 岁 100 元 ← 多余,但和上一条说的是同一件事
乘客的年龄不到 6 岁 0 元
────────────────────────

第二条完全是多余的,但它和第一条给的答案一样,都是 100 元。 原书说得很清楚:只有当重复的部分互相矛盾时,规则才不符合逻辑10这是主走查的第四步:6 岁的 Charlie 在规则 D 下,仍然是 100 元。

5. 怎么看出来:画一根数轴,把端点画清楚

这一节回答:上面三种毛病,靠读文字很难看出来,那靠什么。

原书给的办法是画一根数轴11。把每一条规则覆盖的年龄段画成一条线段, 端点包不包含,用实心点和空心点区分:

规则 A(没毛病) 规则 B(漏) 规则 C(重)
───────────────── ───────────────── ─────────────────
0 ─────○ 0 ─────○ 0 ─────●
6 ●────→ 6 ○────→ 6 ●────→
───────────────── ───────────────── ─────────────────
6 那一格被右边那条 6 那一格两边都是空 6 那一格两边都是实
线的实心点接住了 心点 —— 掉下去了 心点 —— 被数了两次

图说:● 表示包含这个端点,○ 表示不包含。
三张图的差别只在 6 那一格上,而漏和重都发生在那里。

这就是这一章最实用的一句话:错误几乎都出在端点上。 原书的原话是:需求文档的错误,或者程序员的错误,往往发生在边界值上12。 所以画图的时候必须把「包不包含端点」画清楚,一张端点含糊的图是无效的

这条经验在写代码时有直接的对应: > 还是 >=、 数组下标从 0 到 n-1 还是到 n、时间区间是左闭右开还是全闭。 你被这类 bug 坑过的次数,大概比被难题绕晕的次数还多。

6. 这两件事有名字:完整性与排他性

这一节把前面三节收成两个词。

  • 没有遗漏,叫完整性——这条规则无论碰上谁都能给出答案13;
  • 没有重复,叫排他性——这条规则不会给出两个互相打架的答案14

为什么要给它们起名字?因为它们是切分大问题的验收标准。 原书说:遇到大问题时通常要把它分解成多个小问题, 而检查分解得对不对,靠的就是这两条15

这套讲法在管理咨询那边也有个现成的名字,原书顺手提了一句: MECE(Mutually Exclusive and Collectively Exhaustive,互相不重、合起来不漏)16同一件事,两个圈子各起了一个名字。

回到第 01 章那句「把大问题分解为小单元」: 那句话只说了要切, 这一节说的是切完怎么验收。 缝和重叠是切分唯一的两种失败方式。

7. 一条 if 语句,就是一次不重不漏的切分

这一节回答:上面这些和写代码有什么关系。

假设要照规则 A 写一个显示车费的程序。这个问题可以整个切成两半17:

显示乘客的车费
├── 年龄在 6 岁以上 → 显示「100 元」
└── 不到 6 岁 → 显示「0 元」

图说:左边是原问题,右边是切开之后的两个小问题。
两个小问题都简单到一句话就能解决 —— 这就是切分的收益。

写成代码就是最普通的一段18:

if (乘客的年龄在6岁以上) {
显示"费用为100元"
} else {
显示"费用为0元"
}

关键在 else 有了它,「不到 6 岁」这一半自动被接住—— 不可能漏,也不可能重。 一条 if / else 天生就是一次合格的切分19

那 bug 从哪来? 原书的回答很实在: 熟练的程序员写单条 if 根本不用想什么完整性排他性,刷刷就写完了。 可是一个程序里要写几十条、几百条 if,而且它们互相嵌套、互相组合—— 只要稍微出点错,就会产生 bug20

所以这一章教的不是怎么写一条 if,是怎么在几百条 if 里保持不漏不重。 逻辑从根本上说,就是完整性和排他性的组合表达21

8. 作者的判断、我们的判断,以及这一章的边界

先分清哪些有推导,哪些是作者的主张。

说法书里给了什么
规则 B 有漏、规则 C 有重给了具体反例:6 岁的乘客,两处都在同一格出问题
错误往往发生在边界值上是作者的经验之谈,书里没有给统计数据
逻辑就是完整性与排他性的组合是作者的主张,用来统领后面几章
分解大问题要检查这两条给了做法(数轴、真假两分),没有给失败案例

判断(我们的,不是书里的):这一章看着最浅,却是全书唯一一件天天用得上的工具。 后面的余数、递归、指数爆炸,一个程序员可能几个月才碰上一次; 而「这个条件覆盖全了吗、有没有两条同时命中」是每天都在做的判断。 值得专门练的是那根数轴:看到区间就画端点,别在脑子里过。 如果错,会错在: 如果一个人写的代码大量依赖类型系统或者模式匹配 (编译器会替他检查「分支是不是穷尽了」),那这条手工检查确实用不上多少。 判据是:他用的语言在分支不穷尽时会不会报错。

这一章的边界:

  • 原书这一章还有一半内容不在这里:真值表、文氏图、卡诺图、三值逻辑 分别在第 03、04、05 章;
  • 没有讲怎么证明一组规则完整——只给了数轴这种一眼看的办法, 对付两三个条件够用,条件一多就不够了(第 05 章的二维表接这一棒);
  • MECE 只提了名字,原书没有展开它在咨询与结构化表达里的用法;
  • 没有讨论「规则本身合不合理」——这一章只管规则自己跟自己不打架,不管它公不公平。

9. 可带走的

  1. 能判断对错的一句话,叫命题;要么真、要么假,不能既真又假,也不能都不是;
  2. 一条规则的两条底线:不漏(完整性)、不重(排他性);
  3. 漏 = 存在某个对象没人管;「大于 6 岁 / 不到 6 岁」漏掉了正好 6 岁的人;
  4. 重 = 存在某个对象被两条同时管,而且两条给的答案还不一样;
  5. 重复本身不是病,矛盾才是病——啰嗦但答案一致的规则只是多余;
  6. 错误几乎都出在端点上:> 还是 >=、含不含边界,这是需求文档和代码共同的高发区;
  7. 看到区间就画数轴,端点画成实心/空心——比在脑子里过可靠得多;
  8. 一条 if / else 天生不漏不重,危险的是几百条 if 互相嵌套之后。

10. 原文地图

主题原书章原文位置
逻辑是消除歧义的工具第2章 逻辑text/07-ch02.txt:39(搜「规格说明书」) · :42(搜「消除自然语言歧义」)
收费规则 A 与 6 岁的乘客第2章 逻辑text/07-ch02.txt:70(搜「6 岁以上的乘客」) · :73(搜「13 岁的 Alice 的车费为 100 元」)
命题、真与假第2章 逻辑text/07-ch02.txt:83(搜「陈述句叫作命题」) · :90(搜「命题正确时」) · :98(搜「命题要么为 true 要么为 false」)
遗漏(规则 B)第2章 逻辑text/07-ch02.txt:110(搜「所谓的“没有遗漏”」) · :126(搜「遗漏了乘客为 6 岁的情况」)
重复(规则 C、D)第2章 逻辑text/07-ch02.txt:149(搜「当乘客为 6 岁时发生重复」) · :152(搜「只有当重复的部分互相矛盾时」)
数轴与边界值第2章 逻辑text/07-ch02.txt:167(搜「那样画一根数轴」) · :206(搜「往往发生在边界值上」)
完整性、排他性、MECE第2章 逻辑text/07-ch02.txt:219(搜「即具备完整性」) · :220(搜「即具备排他性」) · :223(搜「MECE」)
if 语句与问题分解第2章 逻辑text/07-ch02.txt:228(搜「解成命题」) · :243(搜「就是程序中常用的 if 语句」) · :260(搜「程序员要写几十条、几百条 if 语句」)

Footnotes

  1. 出处:「第2章 逻辑——真与假的二元世界」第 70 段(text/07-ch02.txt:70,搜「6 岁以上的乘客」)。原书把这份规则叫「收费规则 A」,后面的 B、C、D 都是在它身上改一两个字改出来的。

  2. 出处:「第2章 逻辑——真与假的二元世界」第 73 段(text/07-ch02.txt:73,搜「13 岁的 Alice 的车费为 100 元」)。原文特意加了一句括号说明:「6 岁以上」这个说法是包含 6 岁的。中文版还有一条编者注,写明本书中的「以上」「以下」皆包含本数——这是编者的补充,不是作者的原话

  3. 出处:「第2章 逻辑——真与假的二元世界」第 83 段(text/07-ch02.txt:83,搜「陈述句叫作命题」)。原文给的英文是 proposition,并举了三个例子:Alice(13 岁)、Bob(4 岁)、Charlie(6 岁)各自「年龄在 6 岁以上」。

  4. 出处:「第2章 逻辑——真与假的二元世界」第 90 段(text/07-ch02.txt:90,搜「命题正确时」)。原文写明:命题正确时称为「真」,不正确时称为「假」,也把真称作 true、假称作 false。

  5. 出处:「第2章 逻辑——真与假的二元世界」第 98 段(text/07-ch02.txt:98,搜「命题要么为 true 要么为 false」)。原文接着说:同时满足 true 和 false 的不能称为命题,既不为 true 也不为 false 的也不能称为命题。

  6. 出处:「第2章 逻辑——真与假的二元世界」第 100 段(text/07-ch02.txt:100,搜「通过乘客的年龄来判定」)。原文说:如果为真,车费 100 元;如果为假,车费 0 元。

  7. 出处:「第2章 逻辑——真与假的二元世界」第 126 段(text/07-ch02.txt:126,搜「遗漏了乘客为 6 岁的情况」)。原文说,由于存在这种「遗漏」,把规则 B 作为乘车收费规则是不恰当的。

  8. 出处:「第2章 逻辑——真与假的二元世界」第 110 段(text/07-ch02.txt:110,搜「所谓的“没有遗漏”」)与第 112 段(text/07-ch02.txt:112,搜「任何人都有年龄」)。原文的判据是:不管针对哪个乘客,都能判定那个命题的真假。

  9. 出处:「第2章 逻辑——真与假的二元世界」第 149 段(text/07-ch02.txt:149,搜「当乘客为 6 岁时发生重复」)。原文指出「6 岁以上」和「6 岁以下」都包含 6 岁,而这两种情况下的费用各不相同。

  10. 出处:「第2章 逻辑——真与假的二元世界」第 152 段(text/07-ch02.txt:152,搜「只有当重复的部分互相矛盾时」)。原文说,在收费规则 D 中,整 6 岁的那条说明是多余的,但并不与「6 岁以上」矛盾。

  11. 出处:「第2章 逻辑——真与假的二元世界」第 167 段(text/07-ch02.txt:167,搜「那样画一根数轴」)与第 177 段(text/07-ch02.txt:177,搜「为真的年龄范围用」)。原书用 ● 表示包含该点,○ 表示不包含。

  12. 出处:「第2章 逻辑——真与假的二元世界」第 206 段(text/07-ch02.txt:206,搜「往往发生在边界值上」)。原文说,在本章举出的收费规则中,0 岁和 6 岁是边界所在;画数轴时必须清楚指明包含不包含边界,边界不清晰的图是无效的。

  13. 出处:「第2章 逻辑——真与假的二元世界」第 219 段(text/07-ch02.txt:219,搜「即具备完整性」)。原文说:没有「遗漏」,即具备完整性,由此明确该规则无论在什么情况下都能适用。

  14. 出处:「第2章 逻辑——真与假的二元世界」第 220 段(text/07-ch02.txt:220,搜「即具备排他性」)。原文说:没有「重复」,即具备排他性,由此明确该规则不存在矛盾之处。

  15. 出处:「第2章 逻辑——真与假的二元世界」第 221 段(text/07-ch02.txt:221,搜「通常将其分解为多个小问题」)。原文说,即使是难以解决的大问题,也能通过这种方法转换成容易解决的小问题。

  16. 出处:「第2章 逻辑——真与假的二元世界」第 223 段(text/07-ch02.txt:223,搜「MECE」)。原书只给了这个缩写和它的全称,没有展开。

  17. 出处:「第2章 逻辑——真与假的二元世界」第 228 段(text/07-ch02.txt:228,搜「解成命题」)与第 242 段(text/07-ch02.txt:242,搜「这是个关键点」)。原文说:将大问题「分解」为 2 个小问题,这是个关键点。

  18. 出处:「第2章 逻辑——真与假的二元世界」第 245 段(text/07-ch02.txt:245,搜「if (乘客的年龄在6岁以上)」)。这段代码是原书给的,用的是中文伪代码。

  19. 出处:「第2章 逻辑——真与假的二元世界」第 251 段(text/07-ch02.txt:251,搜「if 语句的条件分支体现了」)。原文的原话是:if 语句的条件分支体现了「兼具完整性和排他性的分解」。

  20. 出处:「第2章 逻辑——真与假的二元世界」第 257 段(text/07-ch02.txt:257,搜「熟练的程序员」)与第 260 段(text/07-ch02.txt:260,搜「程序员要写几十条、几百条 if 语句」)。原文说,在错综复杂的 if 语句的组合中,只要稍微出点错就会产生 bug。

  21. 出处:「第2章 逻辑——真与假的二元世界」第 264 段(text/07-ch02.txt:264,搜「逻辑从根本上说是对完整性和排他性的组合表达」)。原文接着说:虽然完整性和排他性只是两个简单的特性,但存在于任何一个或简单或复杂的命题之中。