2020/09/04

断言的作用

断言,即「十分肯定地说」。程序运行至某处,断言一个条件 —— 如果该条件不成立,立刻抛出异常。

function assert(
  condition: boolean, 
  message: string = ‘Assert Error) {
  if(!conditon) {
    throw new Error(message);
  }
}

我曾在 Flutter Framework 的代码中见识了大量的断言 —— 随便一个方法,开头就是好几条断言,彼时我觉得这些断言真是又丑又累赘。然而随着我也开始参与开发一些大型复杂项目,我却逐渐体会到断言带来的好处。

在你编写一个大型系统中的模块时,你:

  1. 在执行一个非常明确的任务。
  2. 根本无法预估你的调用者会怎么调用你的模块。

当然,你可以通过方法签名、接口等方式,告诉模块的调用者应该如何使用,但这远远不够。举个例子,你要实现一个函数,将形如 2020-9-4 的字符串转化为包含年、月、日字段的数据结构:

interface IDate {
  year: number;
  month: number;
  date: number;
}

function parse(source: string): IDate {
  const [year, month, date] = source.split('-').map(Number);
  return { year, month, date };
}

很简单,对吗?可是,如果用户传入空字符串,如果用户传入格式不符的字符串(如 hello world ),怎么办呢?很多人就会纷纷动起了歪脑筋:返回 null;返回三个字段都是 0 的数据;返回今天对应的日期等等。

这里,我想问各位读者一个问题:你是否明确知道,在何种情形下,外部会传入一个非法字符串?如果你的答案是否,那么不如断言一下:「外面绝对不会传一个非法字符串」。如果外面真的传了空字符串,就会抛异常。

function parse(source: string): IDate {
  const list = source.split('-');
  assert(list.length === 3);

  const [year, month, date] = list.map(Number);

  const isInt = (n: number): boolean => Math.floor(n) === n;

  assert(isInt(year));
  assert(isInt(month) && month >= 1 && month <= 12);
  assert(isInt(date) && date >= 1 && date <= 31);

  return { year, month, date };
}

把简单的代码改复杂很容易,把复杂的代码改简单则困难得多。断言让你的代码做「计划内」的事情,帮助你保持代码简单 —— 不是看上去最简单,而是逻辑简单。上面的两个例子,虽然前一个例子(没有断言的函数)更简短,但与后一个例子(有断言的函数)相比,它的行为更加难以预测,所以从逻辑上看,后一个例子更简单。

试想,当你的调用者触发了这个异常,他会怎样做?他自然会先检查是不是自己用错了,如果他确实用错了,你的断言帮助他提前发现了一个 bug(全过程完全不需要你的额外参与,简直是异步沟通的典范);如果不是他的错误,他必然有充分的理由来要求你来容错,他会来找你沟通,如果他说服了你,你会通过修改代码(包括将这条边界条件加入到单元测试中)来完成容错 —— 此时,你们对各模块的职责划分有了更清晰的认知,你已经「明确知道」外部在什么时候会传一个边界值了 —— 而修改代码的成本,实在太微不足道了。

这个例子中的另一个常见的边界条件是,如果用户传入 undefined,该怎么办呢?「啊,因为传入 parse 方法的参数是另一个方法返回的,那个方法可能返回 undefined」,你的调用者如是说。其实如果你使用 TypeScript,用不着运行时的断言,静态类型检查就会报错了(参数类型不兼容)。

其实,使用断言的背后是边界条件处理的方法论 。一个面向用户的产品,当然应该处理好自己的边界条件;但是在大型项目的初期,边界条件由哪个模块处理往往很模糊。此时,如果每个模块都去想当然地「妥善」处理自己的边界条件,会写出大量死代码(永远不会被执行到的代码),函数变得臃肿,可读性降低,可维护性变差,更可怕的是,某些原本应该出错的 case 不出错了,被开发者忽略了。反之,如果每个模块都最「强硬」地用断言抛异常的方式处理边界条件,几乎所有边界条件问题都会暴露出来,并合理地落实到各个模块中去,非常有利于项目的早期架构。 断言的运行时特性 类型检查发生在编译时,断言发生在运行时。因此,断言带来了很多便利的特性。首先是类型收窄(type narrowing)。TypeScript 的 asserts 关键字可以收窄类型,就像 if 分支条件一样:

function assert(
  condition: boolean, 
  message: string = ‘Assert Error) : asserts condition {
  if(!conditon) {
    throw new Error(message);
  }
}

function foo(bar: string | number) {
  // 这里 bar 的类型是 string | number
  assert(typeof bar === 'string');
  // 这里 bar 的类型是 string
}

这个功能配合 type guard 非常好用。

我常用的 assert 的另一个场景如下:

class Foo {

  bar: string | null = null;

  init(bar: string) {
    assert(this.bar === null);
    this.bar = bar;
  }

  consume(){
    assert(this.bar !== null);
    // 消费 this.bar 的逻辑
    // 相比 this.bar?.toLowerCase(),你可以直接写 this.bar.toLowerCase()
  }
}

Foo 有一个名为 bar 的成员变量,通常 bar 都是存在的,但是因为某些原因,没有办法在 constructor 中为其赋值,而是在 Foo 类型的实例被构造后的某个时机,通过调用 init 方法来进行初始化。

通常我会在 init 方法中放一个断言,来保证 init 只被调用一次(也许我后面会在这里做一些真正的初始化工作呢,所以谁要是想 init 两次,那必须得找我来沟通)。

接下来,我会在 consume 方法中放一个断言,保证在消费 bar 的时候,bar 已经被初始化了。你看,consume 方法中的这一句断言,将 this.bar 的类型从 string | null 收窄为了 string,我可以直接调用 this.bar.toLowerCase() 而不用加一个累赘的问号了。

除了作类型收窄,断言也可以作为静态类型系统和动态系统间的某种「胶水」。我们知道 TypeScript 有编译时的静态检查,但运行却是发生在解释器中的。有时候我希望有限地突破静态系统的某些约束,但又不想直接用 any(这样污染的 scope 太大),那么通过 assert(false) 直接抛异常是个不错的选择。

举个例子,我有一个 Bird Class 表示鸟类,然后还有一个 Ostrich Class 表示鸵鸟:

class Bird {
  fly(): void {
    console.log('Flying...');
  }
}

class Ostrich extends Bird {
  fly(): void {
    assert(false);
  }
}

写到这里偏题了,因为直接抛异常也可以,没必要用断言。不过断言似乎更可读:assert(false) 表示:「绝对不会(运行到这里)!」。

鸟会飞;鸵鸟是鸟;鸵鸟不会飞 —— 按照逻辑三段论推理,鸟会飞 + 鸵鸟是鸟可以推断出鸵鸟会飞的,第三句话与前两句矛盾了;但是在自然语言中,三句话都讲得通,因为自然语言有一些默认的上下文,鸟会飞是指「绝大部分鸟会飞」。

程序代码不仅是严谨的逻辑表达,也是对自然语言的模拟。在 OOP 程序设计的时候,会飞是鸟类最重要的通用特性之一,Bird 基类上有 fly 方法是极为自然的。既然「有的鸟不会飞」是鸵鸟等少数鸟类带来的,那么它的影响自然应当由少数鸟类来承担。

体现在代码中,Ostrich 继承自 Bird,所以 Bird 上的方法 Ostrich 上都有,Ostrich 想摆脱 fly 方法是不可能了。可是鸵鸟真不会飞,怎么办呢,那就在飞的时候抛一个异常。这真的很好用:在类型系统看来,鸵鸟还是鸟,但是程序运行过程中起来,肯定是不会让鸵鸟去飞的。

此问题解决方案还有 Mixin / 多重继承等。