有趣也有用的现代类型系统

有趣也有用的现代类型系统

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

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

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

类型推断

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

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

AppleIPhoneX phone = ApplePhoneFactory.createX()

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

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

// 定义时推断
let foo = 123
let bar = 'Hello'
foo = bar // Error: cannot assign `string` to a `number`

// 返回值推断
function add(a: number, b: number) {
    return a + b
}
let foo = add(1, 2) // foo: number

// 结构体推断
let foo = {
    a: 123,
    b: 456
} // foo: {a: number; b: number;}

let bar = foo.a // bar: number

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

类型兼容

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

class Point {
  int x;
  int y;
  Point(int x, int y){ this.x = x; this.y = y;}
}
class Point2D {
  Point2D(int x, int y){ this.x = x; this.y = y;}
  int x;
  int y;
}
public class PointHolder {
  takePoint(Point p){}
  public static void main(){
    Point2D p1 = new Point2D(1,1)
    takePoint(new Point(p1.x, p1.y)) // convert Point2D to Point
  }
}

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

interface Point {
  x: number
  y: number
}
class Point2D {
  constructor(public x:number, public y:number){}
}
let p: Point = new Point2D(1,2)

方法属性也是类似:

interface Point {
  x: number
  y: number
  getDistance(): number
}
class Point2D {
  constructor(public x:number, public y:number){}
  getDistance() {
    return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2))
  }
}
let p: Point = new Point2D(1,2)
p.getDistance()

类型演算

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

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

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

Mapped Type:

{ [ P in K ] : T }

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

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

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

let foo = {
  a: 1,
  b: 'hello',
  c: { c1: 1, c2: 'c2'}
}
let bar = pick(foo, 'b', 'c') 
// bar: { b: string; c: { c1: number; c2: string }}

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

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

首先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<T>,很多语言是支持把Array<Cat>赋值给Array<Animal>的,(前提是CatAnimal的子类)。涉及到类型中包含泛型方法的情况,协变逆变就要复杂一些,比较常见的规则是如果类型参数T出现在P的方法返回值(out)中,那么P对于T是协变的;如果T出现在P的方法参数(in)中,那么P对于T是逆变的。

用下面两张图来说明:

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

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

参数在返回值中:协变
出现在方法参数:逆变

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

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

interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }

enum EventType { Mouse, Keyboard }
function addEventListener(eventType: EventType, handler: (n: Event) => void) {
    /* ... */
}
addEventListener(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y))

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。在团队协作效率和工程质量上都取得了显著性的提高,我们在实践中也逐渐加强了这个观点:有了强大灵活的类型系统,静态类型语言也可以很高效的开发。


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

参考:

Type Compatibility

Advanced Types · TypeScript

Covariance and contravariance (computer science)

What is Gradual Typing

文章被以下专栏收录