Programming with Types —— 类型及类型系统

为什么要有类型

从硬件和机器码这类底层视角来看,程序逻辑(代码)和代码操作的数据都是通过比特(bits)来表示,没有任何区别。
当系统没办法正确地将这两者区分开来,错误就很容易发生。这类松散解析的一个例子就是 JavaScript 中的 eval() 函数。

1
2
3
4
console.log(eval("40+2"));
# => 42
console.log(eval("Hello world!"));
# => Uncaught SyntaxError: Unexpected identifier

除了正确区分代码和数据以外,我们还需要知道如何将一串字节序列中的数据解释出来。
比如一个 16 位的字节序列 1100001010100011 既可以表示无符号 16 位整数 49827,又可以表示有符号 16 位整数 -15709,还可以表示 UTF-8 编码的字符 £,或者其他完全不同的数据。

A sequence of bits can be interpreted in multiple ways

类型赋予数据现实的意义。从而我们的软件能够在特定的上下文中,从一串给定的字节序列中解析出正确的值,不会将其误解成其他的含义。
此外,类型还能够限定变量的取值范围。比如一个有符号的 16 位整数,只能是 -3276832767 之间的任意整数,不能超过这个范围。类型可以看作是由合法的值构成的集合
这种对于取值的限制,很大程度上可以帮助我们减少代码中的错误。

类型的定义

类型是一种对数据的分类,它定义了某类数据上能够执行的操作,允许的取值以及数据本身的意义。编译器或者运行时能够对类型进行检测,确保数据的完整性、访问控制,以及本身的含义没有被曲解。

类型系统的作用

从根本上说,所有的数据都是一堆零和一组成的字节序列。数据本身的属性,比如怎样表示、是否可变、是否对于外部可见等,都是类型级别的性质。
我们将某个变量声明为数字类型,类型检查器会确保不会将数据解析为字符串。我们将某个变量声明为私有或只读的,即便数据本身在内存中和公开的、可变的数据并没有任何区别,类型检查器会确保私有的变量不会在作用域外部被引用,只读的变量不会被修改。

Correctness

类型能够帮助我们向代码中添加更加严格的限制条件,确保其行为正确。

1
2
3
4
5
6
function scriptAt(s: any): number {
return s.indexOf(s);
}

console.log(scriptAt("TypeScript"));
console.log(scriptAt(42));

上述代码运行时会报出 TypeError 错误,因为 42 并不是 scriptAt 函数的合法参数。但是编译器并没有发现这个错误,因为它没有获得足够的类型信息。
将参数 s 的类型从 any 改为 string,修改后的代码会在编译时报出类型错误:

1
2
3
4
5
6
function scriptAt(s: string): number {
return s.indexOf(s);
}

console.log(scriptAt("TypeScript"));
console.log(scriptAt(42)); // Argument of type '42' is not assignable to parameter of type 'string'

借助类型系统,我们可以将原来在运行时爆发的错误提前到影响相对较小的编译期,从而在代码正式运行或发布之前发现和修复 bug。

当程序进入到 bad state 状态时,错误就会发生。bad state 意味着当前所有存活着的变量的状态组合,由于某种原因是非法的。消除这类 bad state 的一种方式,就是通过限制变量可以接受的可能值的数量来减少状态空间。即更精确的类型定义。

strict types

不可变性

不可变性的概念同样来自于将软件系统视为变化的状态空间。当我们处于一个已知的好的状态时,我们能维持该状态的某些部分不发生变化,就能降低出现错误的可能性。

1
2
3
4
5
6
function safeDivide(): number {
let x: number = 42;
if (x == 0) throw new Error("x should not be 0");
x = x - 42;
return 42 / x;
}

上述代码中,变量 x 在检查除数不为零的语句之后发生了改变,导致前面的检查语句变得毫无意义。这类情况在现实中经常出现,比如变量被某一个并发的线程所修改。
可以将变量声明为不可变的常量。若代码中尝试对常量进行修改,编译时就会报出错误。

1
2
3
4
5
6
function safeDivide(): number {
const x: number = 42;
if (x == 0) throw new Error("x should not be 0");
x = x - 42; // error TS2588: Cannot assign to 'x' because it is a constant
return 42 / x;
}

变量和常量在内存中的表示并无任何异同,只是对编译器而言有不同的意义。优化编译器在处理不可变变量时能够生成更高效的代码,此外当涉及到并发时,不可变性的用处非常大。当数据不可变时,数据竞争的情形就不会存在。

封装性

封装代表一种隐藏代码内部细节的能力,它能够帮助我们应对复杂性问题。我们将代码分割成一个个相对较小的组件,每个组件只向外部暴露有限的功能,其内部的实现细节则被隐藏和隔离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SafeDivisor {
divisor: number = 1;

setDivisor(value: number) {
if (value == 0) throw new Error("Value should not be 0");
this.divisor = value;
}

divide(x: number): number {
return x / this.divisor;
}
}

let sd = new SafeDivisor();
sd.divisor = 0;
console.log(sd.divide(42));

在上述代码中,divisor 不再是不可变的常量,而是可以通过公开的 API setDivisor() 更新的变量。但新的问题在于,调用者可以绕过包含检查功能的赋值接口,直接访问实例的 divisor 属性并将其改为任意值。因为 divisor 属性对于外部世界是可以公开访问的。
为了使 divisor 属性只对实例内部可见,外部对该属性的访问只能通过 setDivisor() 这类刻意公开的方法,可以将属性声明为 private

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SafeDivisor {
private divisor: number = 1;

setDivisor(value: number) {
if (value == 0) throw new Error("Value should not be 0");
this.divisor = value;
}

divide(x: number): number {
return x / this.divisor;
}
}

let sd = new SafeDivisor();
sd.divisor = 0; // error TS2341: Property 'divisor' is private and only accessible within class 'SafeDivisor'
console.log(sd.divide(42));

封装性能够帮助我们将逻辑和数据分派给公开的接口和非公开的实现,这对于构建大型系统非常有利。面向接口(抽象)编程可以减轻我们理解特定代码片段的心智负担,当我们引用某个功能时,我们只需要知晓公开的接口如何工作、如何使用,不必掌握任何内部的实现细节。
同时封装性能够帮助我们将非公开的信息封锁在特定的边界内,保证没有任何外部代码会对其进行改动,提高了代码的安全性。

组合性
1
2
3
4
5
6
7
8
9
10
11
function findFirstNegativeNumber(numbers: number[]): number | undefined {
for (let i of numbers) {
if (i < 0) return i;
}
}

function findFirstOneCharacterString(strings: string[]): string | undefined {
for (let str of strings) {
if (str.length == 1) return str;
}
}

上述两个函数有着几乎一致的逻辑,这造成了一定程度的冗余代码。可以将它们之间通用的逻辑抽象成一个共享的算法,将变化的部分(操作的类型、判断条件)作为参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
function first<T>(range: T[], p: (elem: T) => boolean): T | undefined {
for (let elem of range) {
if (p(elem)) return elem;
}
}

function findFirstNegativeNumber(numbers: number[]): number | undefined {
return first(numbers, n => n < 0);
}

function findFirstOneCharacterString(strings: string[]): string | undefined {
return first(strings, str => str.length == 1);
}

假如我们需要为上述所有的实现添加自定义的 logging,只更新 first 函数的实现即可。又或者我们发现了一种更加高效的算法,只需要更新 first 函数,所有 first 的调用者就都能享受到性能的提升。

将相互独立的组件组合成一个灵活的模块化的系统,各部分组件松散地结合在一起,相互之间有着更少的冗余代码。整体的代码量也会大大降低。新需求的添加往往只需要独立地修改特定的组件,而不会影响到整个系统。同时这样的模块化系统理解起来也更加容易,因为其中的每一个组件都可以拆下来,独立地进行分析。

可读性

读代码的动作远远多于写代码。类型提供了额外的非常有价值的信息,能够令代码更加清晰、易读。

1
2
3
4
declare function find(range: any, pred: any): any;

declare function first<T>(range: T[],
p: (elem: T) => boolean): T | undefined;

类型系统的分类

动态类型 vs 静态类型
动态类型不会在编译期强加任何类型约束,类型只会在运行时生效。
静态类型正相反,会在编译期执行类型检查,任何不恰当的类型都会导致编译错误。能够令类型错误在编译期就爆出来,不至于导致正在运行的程序发生故障,是静态类型的主要优势。

JavaScript、Python 属于动态类型,TypeScript、Java 属于静态类型。

弱类型 vs 强类型
类型系统的强弱,用来描述系统在强制执行类型约束时的严格程度。一个弱的类型系统会隐式地尝试将某个值从实际的类型转换成期待的类型。

在强类型系统中,“牛奶”不等于“白色”。牛奶是一种液体,而颜色是另外一种不同的事物,两者无法进行比较;
在弱类型的世界里,我们可以直接说,因为牛奶的颜色是白色,牛奶等于白色。不需要像强类型那样,对类型显式地进行转换。

JavaScript 是弱类型的:

1
2
3
4
> '2' == 2
true
> 1 + '1'
'11'

隐式类型转换非常危险。

参考资料

Programming with Types