我们在项目中,经常会碰到需要选择编程语言的情况,对比语言的语法,性能,生态等方面的优缺点。其中,语言是静态还是动态类型的,也是一个经常会考虑的方面。

传统的静态类型语言比如 Java,提供的类型系统非常笨重,让写类型标注变成一件非常痛苦和低效的事情,所以敏捷型的互联网团队,大都倾向于使用灵活的动态类型语言。

然而静态类型语言在工业上的应用经过了多年的发展,已经取得了长足的进步,类型系统逐渐变得完善,让我们在写静态类型语言的时候有着越来越接近动态类型语言的体验。

类型推断

一个例子是作为静态类型的 C#,在某个版本中加入了var关键字,这并不表明 C# 变成了动态类型语言,而是设计者想要把类型确定的任务从开发者手中转移给类型推断系统。回想在写 Java 的时候,思路时常要中断,去回忆某个变量的类型名是啥,确实是一件令人厌烦的事情。

比如在 Java 中,常常用到工厂方法:

  1. AppleIPhoneX phone = ApplePhoneFactory.createX()

在写这样一行代码的时候,是不是时常要考虑 AppleIPhoneX 这个类型的具体命名是什么,到底是 IPhoneX,AppleX,还是别的?当你从左往右编写这行代码,要敲下第一个字母的时候,自动提示也难以给出可靠的答案。

拿我们团队使用的 TypeScript 举例,TypeScript 提供了下面这些形式的类型推断。

  1. // 定义时推断
  2. let foo = 123
  3. let bar = 'Hello'
  4. foo = bar // Error: cannot assign `string` to a `number`
  5. // 返回值推断
  6. function add(a: number, b: number) {
  7. return a + b
  8. }
  9. let foo = add(1, 2) // foo: number
  10. // 结构体推断
  11. let foo = {
  12. a: 123,
  13. b: 456
  14. } // foo: {a: number; b: number;}
  15. let bar = foo.a // bar: number

可以看到上面几个例子中,只有 add 函数的入参是我们手写了类型标注的,其他都是自动推断出来的。

类型兼容

还是拿 Java 举例,在编写程序时,我们常常需要定义不同的数据类型,比如表单,比如服务的参数 Bean。我们时常会碰到这样的情况,多个 class 定义,即使字段完全一样,但只要 class 的 canonical name(包含包名的 class name) 不一样,就需要重新构造:

  1. class Point {
  2. int x;
  3. int y;
  4. Point(int x, int y){ this.x = x; this.y = y;}
  5. }
  6. class Point2D {
  7. Point2D(int x, int y){ this.x = x; this.y = y;}
  8. int x;
  9. int y;
  10. }
  11. public class PointHolder {
  12. takePoint(Point p){}
  13. public static void main(){
  14. Point2D p1 = new Point2D(1,1)
  15. takePoint(new Point(p1.x, p1.y)) // convert Point2D to Point
  16. }
  17. }

而 TypeScript 的对象是按属性匹配的,任何包含了接口定义属性的对象,都可以看作是接口的实现,这点和 go 语言是相同的,可以认为是现代工程语言的一个设计趋势。

  1. interface Point {
  2. x: number
  3. y: number
  4. }
  5. class Point2D {
  6. constructor(public x:number, public y:number){}
  7. }
  8. let p: Point = new Point2D(1,2)
  9. 方法属性也是类似:
  10. interface Point {
  11. x: number
  12. y: number
  13. getDistance(): number
  14. }
  15. class Point2D {
  16. constructor(public x:number, public y:number){}
  17. getDistance() {
  18. return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2))
  19. }
  20. }
  21. let p: Point = new Point2D(1,2)
  22. p.getDistance()

类型演算

动态类型语言有一个明显的好处是,有时候我们希望类型是动态的:到运行时再确定变量的类型,这个特性给我们提供了元编程的体验,大大减少了代码量。所以 TypeScript 也提供了一些高级类型标注语法,帮助我们写出动态的类型标注。

这些语法除了基础的 Intersection,Union,还有一些高级玩法,这里就介绍一些高级类型以体现类型系统的灵活性。

还是举例子,TypeScript 类型标注有这样一个语法

Mapped Type:

  1. { [ P in K ] : T }

利用 Mapped Type 我们可以定义一个 Pick 类型(从一个对象中选出一部分属性构造出的新对象类型)

  1. // From T pick a set of properties K
  2. type Pick<T, K extends keyof T> = {
  3. [P in K]: T[P];
  4. }
  5. function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>;

这样定义出来的pick函数能够精确推导出返回值类型:

  1. let foo = {
  2. a: 1,
  3. b: 'hello',
  4. c: { c1: 1, c2: 'c2'}
  5. }
  6. let bar = pick(foo, 'b', 'c')
  7. // bar: { b: string; c: { c1: number; c2: string }}

我们再展开看看Pick类型的定义:

  1. type Pick<T, K extends keyof T> = {
  2. [P in K]: T[P];
  3. }

首先Pick接收两个泛型参数TK,其中 K 有一个约束K extends keyof T,这个keyof也是 TypeScript 类型演算的一个操作符,keyof T就是T类型的 key 组合,这里对K的约束就是K必须从T类型的 key 里面选。比如上面的例子中K就只能是’a’,b’,’c’这三个字符串。

然后Pick类型利用泛型参数构造出了一个新的结构体类型,这个结构体类型用Mapped Type来表达,他包含的 key 是[P in K],就是我们选择的原对象 foo 的子集,value 是T[P],和原对象对应的键值对相同。

T[P]又是另外一个知识点了,不过按面意思很容易理解,和对象取字段操作一样,T[P]就是T结构体类型的P字段的类型。

总结一下我们用到了两个特性,一个是Index type,包含两个操作符:keyof用于查询类型 key,是 query operator,T[P]用于获取具体类型,是 access operator。还有一个就是Mapped Type了,用于遍历结构体类型中的 key。这两个特性在 TypeScript 中经常用到,是灵活类型系统的支柱。

这样pick这种很动态的函数定义就表达出来了,是不是很灵活?反观在传统静态类型语言中,要达到同样效果,只能费很大劲使用反射,而用了反射,就意味着放弃了类型检查,还不如直接使用动态类型语言。 所以 TypeScript 的类型系统是一个很契合动态语言的系统,能够很灵活的构造出新的类型而不要求事先定义。

协变,逆变和 “双变 “

使用过有泛型的静态类型语言的同学会对协变(covariant)和逆变(contra-variant)比较了解。这里先简单回顾一下这两个概念:对复合类型P<T>,如果 P 的继承方向和 T 相同,则 P 是对 T 协变的,如果相反则是逆变的。看似很简单的一句话,实际上由于 T 在 P 类型中出现的位置不同,P 是协变还是逆变也会不同,就衍生出一些比较复杂的情况,即使经验丰富的程序员也经常会判断错误,类型系统也很难分析,所以大多数静态类型语言不会完整的支持协变和逆变检查。

比如一个基本的协变类型是Array,很多语言是支持把Array<Cat>赋值给Array<Animal>的。涉及到方法的协变逆变就要复杂一些,比较常见的规则是如果类型参数T出现在P的方法返回值 (out) 中,那么P对于T是协变的;如果T出现在P的方法参数 (in) 中,那么P对于T是逆变的。

用下面两张图来说明:

图一中,由于返回值 (out) 协变规则,ClassB继承ClassA的时候,对于要覆盖的方法method,其返回值T'必须是父类型中返回值T的子类型:即方法返回值类型的继承方向与 class 的继承方向一致。

图二中,由于入参 (in) 逆变规则,ClassB要覆盖的方法method入参T必须是父类型中同名方法入参T'的父类型:即方法参数类型与 class 继承方向相反。

现代类型语言的特点 - 对比传统静态类型语言 - 图1

图 1. 参数在返回值中:协变

现代类型语言的特点 - 对比传统静态类型语言 - 图2

图 2. 出现在方法参数:逆变

更复杂的情况是:如果P同时在方法的返回值和参数中都使用到了T,那么P对于T应该是协变和逆变?有时候甚至是不变:协变和逆变都不适用,把P<Cat>P<Animal>看作完全不相关的类型。

而在 JavaScript 中,常常会有这样的场景:

  1. interface Event { timestamp: number; }
  2. interface MouseEvent extends Event { x: number; y: number }
  3. interface KeyEvent extends Event { keyCode: number }
  4. enum EventType { Mouse, Keyboard }
  5. function addEventListener(eventType: EventType, handler: (n: Event) => void) {
  6. /* ... */
  7. }
  8. addEventListener(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y))
  9. addEventListener(EventType.Mouse, (e: number) => console.log(e)) // error

在这个例子中,handler 对于它的参数类型 Event 是逆变的,正常情况下MouseEvent=>void是不能赋值给Event=>void的,但是 TypeScript 的一个原则是方便,尽量让我们有着写动态 JavaScript 的体验,所以造出了bivariant这个概念:在方法参数的协变逆变判断这个场景中,子类型和父类型可以相互替换。 我不能说这是一个很好的设计,毕竟牺牲了一部分类型检查的可靠性,但是也算是和开发效率之间的权衡了。

Gradual Typing

前面提了一些 TypeScript 的类型机制,这些机制让写静态类型语言有着接近动态语言的体验,同时也享受了静态类型的好处。除此之外 TypeScript 还有一个杀手锏,也是 TypeScript 的基本:它是 Javascript 的超集,也就是说,你只需要把. js 文件后缀名改为. ts,然后可以选择性的在 JavaScript 代码中添加类型标注,TypeScript 编译器会尽可能的利用有限的类型标注做类型检查和提供自动完成提示。这种做法不是 TypeScript 开创的,被称为Gradual Typing。 当然,提供这种机制是为了方便我们从遗留的 JavaScript 项目转换到 TypeScript 项目,破坏动态语言坚守者的最后一道心理防线。如果是新的项目,最好还是提供充分的类型标注。尤其是在大型团队项目中,类型标注不仅帮助个人在编译期提前发现错误,还起到给其他成员提供接口信息的作用,拿到一个团队成员提供的接口,有了类型标注,减轻了很多理解负担,也减少了沟通成本,这些好处就不用赘述了。

即刻后端在项目起始,由于各方面的原因,选择了 NodeJs 作为主力开发语言,并在项目逐渐成长庞大之后,由动态类型的 JavaScript 逐渐迁移到了静态类型的 TypeScript。在团队协作效率和工程质量上都取得了显著性的提高,我们在实践中也逐渐加强了这个观点:有了强大灵活的类型系统,静态类型语言也可以很高效的开发。

作者:我我(知乎 & 即刻) 参考: