深入类型系统_TypeScript笔记8

一.类型推断

赋值推断

类型推断机制减轻了强类型带来的语法负担,例如:

let x = 3;
// 等价于
let x: number = 3;

编译器能够根据变量初始值3推断出变量类型是number,因此多数场景下不必显式声明类型,它猜得到

P.S.即使在一切都要提前确定类型的Haskell中,也并非处处充斥着类型声明,而是相当简洁,正是因为编译器提供了强大的类型推断支持

在类似赋值的场景能够根据目标值来确定类型,具体如下:

  • 变量或(类)成员初始值

  • 参数默认值

  • 函数返回值

这3类值都能提供直接的类型信息,进而确定目标类型。除此之外,还有一些不那么直接的场景,比如数组类型

数组类型

let x = [0, 1, null];

数组中的元素除了number就是null,而number“兼容”null,因此推断x的类型是number[]

Null、Undefined和Never是其它类型的子类型,因此可以赋值给任何其它类型变量

(摘自基本类型_TypeScript笔记2

也就是说,要确定数组类型的话,先要确定每个元素的类型,再考虑其兼容关系,最终确定一个最“宽”的类型(包容数组中所有其它类型,称为best common type)作为数组类型

如果数组元素中没有一个能够兼容其它所有类型的类型(即找不出best common type),就用联合类型,例如:

// 推断 mixin: (string | number | boolean)[]
let mixin = [1, '2', true];

class Animal {}
class Elephant extends Animal {}
class Snake extends Animal {}
// 推断 zoo: (Elephant | Snake)[]
let zoo: Animal[] = [new Elephant(), new Snake()];

上下文推断

与赋值推断相比,上下文推断是另一种不同的思路:

    推断
值 ------> 变量类型
       查找             匹配(推断)
上下文 -----> 上下文类型 -----------> 变量类型

前者从值到类型,后者从类型到类型。根据上下文得出类型信息,再按位置映射到变量上,例如:

// 推断 mouseEvent: MouseEvent
window.onmousedown = function(mouseEvent) {
  // ...
};

右侧匿名函数作为mousedown事件处理器,遵从DOM API的类型约束,从而得出参数类型:

interface MouseEvent extends UIEvent {
  readonly clientX: number;
  readonly clientY: number;
  //...等等很多属性
}
interface GlobalEventHandlers {
  onmousedown: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | null;
}
interface Window extends GlobalEventHandlers {/*...*/}

declare var window: Window;

(摘自TypeScript/lib/lib.dom.d.ts

如果脱离了mousedown事件处理器这个上下文,就推断不出参数类型了:

// 推断 mouseEvent: any
function handler(mouseEvent) {
  console.log(mouseEvent.clickTime);
}

很多场景都会根据上下文推断类型,例如:

  • 函数调用中的参数

  • 赋值语句的右侧

  • 类型断言

  • 对象成员和数组字面量

  • return语句

二.子类型兼容性

TypeScript的13种基本类型中,类型层级关系如下:

TypeScript类型关系

TypeScript类型关系

简单总结:

  • Any最“宽”。兼容其它所有类型(换言之,其它类型都是Any的子类型)

  • Never最“窄”。不兼容任何其它类型

  • Void兼容Undefined和Null

  • 其它类型都兼容Never和Void

P.S.兼容可以简单理解可否赋值(文末有严谨描述),例如:

let x: any;
let y: number;
let z: null;

// Any兼容Number
x = y;
// Number兼容Null
y = z;
// Null不兼容Number
// 错误 Type 'number' is not assignable to type 'null'.
z = y;

不只基本类型有层级,函数、类、泛型等复杂类型间也有这样的兼容关系

三.函数

兼容性判定

对类型系统而言,需要准确判断一个函数类型是否兼容另一个函数类型,例如在赋值的场景:

let log: (msg: string) => void
let writeToFile: (msg: any, encode: string) => void

// 类型兼容吗?该不该报错
log = writeToFile;
writeToFile = log;

从类型安全角度来看,把log换成writeToFile不安全(缺encode参数,writeToFile不一定能正常工作),反过来的话是安全的,因为返回值类型相同,参数绰绰有余,msg的类型也兼容(stringany的子类型)

具体的,TypeScript类型系统对函数类型的兼容性判定规则如下:

  • 参数:要求对应参数的类型兼容,数量允许多余

    let x = (a: number) => 0;
    let y = (b: number, s: string) => 0;
    
    y = x; // OK
    x = y; // Error
    
  • 返回值:要求返回值类型兼容

    let x = () => ({name: "Alice"});
    let y = () => ({name: "Alice", location: "Seattle"});
    
    x = y; // OK
    y = x; // Error, because x() lacks a location property
    
  • 函数类型:要求满足双变性约束

函数类型的双变性(bivariance)

双变是指同时满足协变和逆变,简单地讲:

  • 协变(covariant):允许出现父类型的地方,也允许出现子类型,即里氏替换原则

  • 逆变(contravariant):协变反过来,即允许出现子类型的地方,也允许出现父类型

  • 双变(bivariant):同时满足协变和逆变

  • 不变(invariant或nonvariant):既不满足协变也不满足逆变

协变很容易理解,子类型兼容父类型,此外还具有一些(父类型不具备的)扩展属性或方法,因此用子类型换掉父类型后,仍能正常工作(类型安全)

而逆变并不很直观,什么场景下,用父类型换掉子类型后,仍能保证类型安全呢?

继承关系中的成员函数重写,算是逆变的典型例子:

class Example {
  foo(maybe: number | undefined) { }
  str(str: string) { }
  compare(ex: Example) { }
}

class Override extends Example {
  foo(maybe: number) { } // Bad: should have error.
  str(str: 'override') { } // Bad: should have error.
  compare(ex: Override) { } // Bad: should have error.
}

(摘自Overridden method parameters are not checked for parameter contravariance

对比重写前后的函数类型:

// foo
(maybe: number | undefined) => any
(maybe: number) => any
// str
(str: string) => any
(str: 'override') => any
// compare
(ex: Example) => any
(ex: Override) => any

P.S.str(str: 'override')str(str: string)“窄”个undefined,默认值使得参数值集少了undefined

参数都从“宽”的类型变成了更“窄”的类型,即从父类型变为子类型,显然,这样做是不安全的,例如:

function callFoo(example: Example) {
  return example.foo(undefined);
}

callFoo(new Example());   // 没问题
callFoo(new Override());  // 可能会出错,因为子类的foo不接受undefined

相反地,如果子类重写后的参数类型更“宽”,那么就是安全的,例如:

class Example {
  foo(maybe: number | undefined) { }
}

class Override extends Example {
  foo(maybe: number) { }  // Sound
}

这就是所谓的逆变,对成员函数的参数而言,用父类型换掉子类型是安全的,即:

允许出现子类型的地方,也允许出现父类型

从类型角度来看,子类型允许类型之间有层级(继承)关系,从宽泛类型到特殊类型,而协变、逆变等关系就建立在这种类型层级之上:

  • 协变:简单类型的层级关系保留到了复杂类型,这个复杂类型就是协变的,例如AnimalCat的父类型,而数组Animal[]也是Cat[]的父类型,所以数组类型是协变的

  • 逆变:简单类型的层级关系到复杂类型中反过来了,这个复杂类型就是逆变的。例如函数类型Animal => stringCat => string的子类型(因为后者接受的参数更“窄”),而简单类型AnimalCat的父类型,那么函数类型就是逆变的

P.S.如我们所见,逆变并不直观,因此为了保持类型系统简单,有的语言也认为类型构造器不变,虽然这样可能会违反类型安全

特殊地,TypeScript里的函数类型是双变的,例如:

interface Comparer<T> {
  compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

(摘自Strict function types

理论上应该要求函数参数逆变,以确保类型安全,因此:

// 把父类型赋值给子类型,在逆变的场景中是安全的
dogComparer = animalComparer;  // Ok
// 把子类型赋值给父类型,在逆变的场景(函数类型)中是不安全的
animalComparer = dogComparer;  // Ok because of bivariance

后者不安全,但在JavaScript世界里很常见:

This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns.

所以TypeScript并没有强制约束函数类型逆变,而是允许双变。更进一步地,在比较两个函数类型时,只要一方参数兼容另一方的参数即可,如上例中dogCompareranimalComparer能够相互赋值

可选参数和剩余参数

比较参数兼容性时,不要求匹配可选参数,比如原类型具有额外的可选参数是合法的,目标类型缺少相应的可选参数也是合法的

对于剩余参数,就当成是无限多个可选参数,也不要求严格匹配。虽然从类型系统的角度来看不安全,但在实际应用中是一种相当常见的“模式”,例如用不确定的参数调用回调函数:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

函数重载

对于存在多个重载的函数,要求源函数的每个重载版本在目标函数上都有对应的版本,以保证目标函数可以在所有源函数可调用的地方调用,例如:

interface sum {
  (a: number, b: number): number;
  (a: number[]): number;
}

// Sum要求的两个版本
function add(a: any, b: any);
function add(a: any[], b?: any): any;
// 额外的版本
function add(a: any[], b: any, c: number): any;
function add(a, b) { return a; }
let sum: sum = add;

sum函数有两个重载版本,所以目标函数至少要兼容这两个版本

四.枚举

首先,来自不同枚举类型的枚举值不兼容,例如:

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let s = Status.Ready;
// Type 'Color.Green' is not assignable to type 'Status'.
s = Color.Green;  // Error

特殊地,数值枚举与数值相互类型兼容,例如:

enum Status { Ready, Waiting };
// 数值兼容枚举值
let ready: number = Status.Ready;
// 枚举值兼容数值
let waiting: Status = 1;

字符串枚举并不与字符串类型相互兼容

enum Status { Ready = '1', Waiting = '0' };
let ready: string = Status.Ready;
// 报错 Type '"0"' is not assignable to type 'Status'.
let waiting: Status = '0';

P.S.虽然从实际类型上看,上例赋值是合法的,但在类型系统中认为二者不兼容,因此报错

五.类

类与对象字面量类型和接口类似,区别在于,类同时具有实例类型和静态类型,而比较两个类实例时,仅比较实例成员

因此,静态成员和构造函数并不影响兼容性:

class Animal {
  static id: string = 'Kitty';
  feet: number;
  constructor(name: string, numFeet: number) { }
}

class Size {
  feet: number;
  constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s;  // OK
s = a;  // OK

私有成员和受保护成员

成员修饰符privateprotected也会影响类型兼容性,具体地,要求这些成员源自同一个类,以此保证父类兼容子类:

class Animal {
  private feet: number;
  constructor() { }
}

class Cat extends Animal { }

// 和Animal长得完全一样的Tree
class Tree {
  private feet: number;
  constructor() { }
}

// 正确 父类兼容子类
let animal: Animal = new Cat();
// 错误 Type 'Tree' is not assignable to type 'Animal'.
animal = new Tree();
// 正确 二者“形状”完全一样
let kitty: Cat = new Animal();

Tree实例赋值给Animal类型会报错,虽然二者看起来完全一样,但私有属性feet源自不同的类:

Types have separate declarations of a private property ‘feet’.

同样的,上例中把Animal实例赋值给Cat类型之所以不报错,是因为二者成员列表相同,并且私有属性feet也源自同一个Animal

六.泛型

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // OK, because y matches structure of x
y = x;  // OK, because x matches structure of y

尽管Empty<number>Empty<string>差别很大,但泛型定义中并没有用到类型参数T(类似于unused variable,没什么意义),因此互相兼容

interface NotEmpty<T> {
    data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

// 错误 Type 'Empty<string>' is not assignable to type 'Empty<number>'.
x = y;

此时,指定了类型参数的泛型与一般具体类型一样严格比较,对于未指定类型参数的泛型,就当类型参数是any,再进行比较,例如:

let identity = function<T>(x: T): T {
  //...
  return x;
}
let reverse = function<U>(y: U): U {
  //...
  return y;
}

// 正确 等价于把(y: any) => any赋值给(x: any) => any
identity = reverse;

七.类型兼容性

实际上,TypeScript规范中只定义了2种兼容性,子类型兼容性与赋值兼容性,二者存在细微的区别:

Assignment extends subtype compatibility with rules to allow assignment to and from any, and to and from enum with corresponding numeric values.

赋值兼容性扩展了子类型兼容性,允许any相互赋值,以及enum和对应数值相互赋值

至于类型兼容性,规范中并未定义这个概念,在多数语境下,所谓的类型兼容性遵从赋值兼容性,implementsextends子句也不例外

参考资料

深入类型系统_TypeScript笔记8》上有1条评论

  1. 赵商

    class Example { foo(maybe: number | undefined) { } }

    class Override extends Example { foo(maybe: number) { } // Sound }

    这里是不是写错了,明明是变窄,你说成变宽

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code