聊聊TypeScript类型兼容,协变、逆变、双向协变以及不变性

前言

学过集合论的同学一定知道子集的概念,使用ES6 class写过继承的同学一定知道子类的概念,而使用过TypeScript的同学,也许知道子类型的概念。

但是你知道协变 (Covariant)、逆变 (Contravariant)、双向协变 (Bivariant) 和不变 (Invariant) 这些概念吗?你知道像TypeScript这种强大的静态类型检查的编程语言,是怎么做类型兼容的吗?我们今天来聊聊。

image

关于Subtyping

子类型是编程语言中一个有趣的概念,源自于数学中子集的概念:

如果集合A的任意一个元素都是集合B的元素,那么集合A称为集合B的子集。

image

而子类型则是面向对象设计语言里常提到的一个概念,是继承机制的一个产物,以下概念来源百度:

在编程语言理论中,子类型是一种类型多态的形式。这种形式下,子类型可以替换另一种相关的数据类型(超类型,英语:supertype)。

子类型与面向对象语言中(类或对象)的继承是两个概念。子类型反映了类型(即面向对象中的接口)之间的关系;而继承反映了一类对象可以从另一类对象创造出来,是语言特性的实现。因此,子类型也称接口继承;继承称作实现继承。

我们可以理解子类就是实现继承,子类型就是接口继承,下面这幅图更精确的定义了这个概念,很多同学应该知道这个例子:

image

这幅图中,猫是一种动物,所以我们说猫是动物的子集,猫是动物的子类,或者说猫这种类型是动物这种类型的子类型。

Co..., Contra..., Bi..., Invariant?

一下提到四个陌生的单词,很多同学肯定一下就懵了。React开发者应该对HOC (High Order Component) 不陌生,它就是使用一个基础组件作为参数,返回一个高阶组件的函数。React的基础是组件 (Component),在TypeScript里是类型 (Type),因此我们用HOT (High Order Type) 来表示一个复杂类型,这个复杂类型接收一个泛型参数,返回一个复合类型。

下面我用一个例子来阐述这四个概念,你可以将它使用TypeScript Playground运行,查看静态错误提示,进行更深刻理解:

interface SuperType {
    base: string;
}
interface SubType extends SuperType {
    addition: string;
};

// subtype compatibility
let superType: SuperType = { base: 'base' };
let subType: SubType = { base: 'myBase', addition: 'myAddition' };
superType = subType;

// Covariant
type Covariant<T> = T[];
let coSuperType: Covariant<SuperType> = [];
let coSubType: Covariant<SubType> = [];
coSuperType = coSubType;

// Contravariant --strictFunctionTypes true
type Contravariant<T> = (p: T) => void;
let contraSuperType: Contravariant<SuperType> = function(p) {}
let contraSubType: Contravariant<SubType> = function(p) {}
contraSubType = contraSuperType;

// Bivariant --strictFunctionTypes false
type Bivariant<T> = (p: T) => void;
let biSuperType: Bivariant<SuperType> = function(p) {}
let biSubType: Bivariant<SubType> = function(p) {}
// both are ok
biSubType = biSuperType;
biSuperType = biSubType;

// Invariant --strictFunctionTypes true
type Invariant<T> = { a: Covariant<T>, b: Contravariant<T> };
let inSuperType: Invariant<SuperType> = { a: coSuperType, b: contraSuperType }
let inSubType: Invariant<SubType> = { a: coSubType, b: contraSubType }
// both are not ok
inSubType = inSuperType;
inSuperType = inSubType;

我们将基础类型叫做T,复合类型叫做Comp<T>

  • 协变 (Covariant):协变表示Comp<T>类型兼容和T的一致。
  • 逆变 (Contravariant):逆变表示Comp<T>类型兼容和T相反。
  • 双向协变 (Covariant):双向协变表示Comp<T>类型双向兼容。
  • 不变 (Bivariant):不变表示Comp<T>双向都不兼容。

TS类型系统

在一些其他编程语言里面,使用的是名义类型 Nominal type,比如我们在Java中定义了一个class Parent,在语言运行时就是有这个Parent的类型。因此如果有一个继承自ParentChild类型,则Child类型和Parent就是类型兼容的。但是如果两个不同的class,即使他们内部结构完全一样,他俩也是完全不同的两个类型。

但是我们知道JavaScript的复杂数据类型Object,是一种结构化的类型。哪怕使用了ES6的class语法糖,创建的类型本质上还是Object,因此TypeScript使用的也是一种结构化的类型检查系统 structural typing

TypeScript uses structural typing. This system is different than the type system employed by some other popular languages you may have used (e.g. Java, C#, etc.)

The idea behind structural typing is that two types are compatible if their members are compatible.

因此在TypeScript中,判断两个类型是否兼容,只需要判断他们的“结构”是否一致,也就是说结构属性名和类型是否一致。而不需要关心他们的“名字”是否相同。

基于上面这点,我们可以来看看TypeScript中那些“奇怪”的疑问:

为什么TS中的函数类型是双向协变的?

首先我们需要知道,函数这一类型是逆变的。

对于协变,我们很好理解,比如DogAnimal,那Array<Dog>自然也是Array<Animal>。但是对于某种复合类型,比如函数。(p: Dog) => void却不是(p: Animal) => void,反过来却成立。这该怎么理解?我这里提供两种思路:

假设(p: Dog) => voidAction<Dog>(p: Animal) => voidAction<Animal>

  1. 基于函数的本质

    我们知道,函数就是接收参数,然后做一些处理,最后返回结果。函数就是一系列操作的集合,而对于一个具体的类型Dog作为参数,函数不仅仅可以把它当成Animal,来执行一些操作;还可以访问其作为Dog独有的一些属性和方法,来执行另一部分操作。因此Action<Dog>的操作肯定比Action<Animal>要多,因此后者是前者的子集,兼容性是相反的,是逆变。

  2. 基于第三方函数对该函数调用

    假设有一个函数F,其参数为Action<Animal>,也就是type F = (fp: Action<Animal>) => void。我们假设Action<Dog>Action<Animal>兼容,此时我们如果传递Action<Dog>来调用函数F,会不会有问题呢?

    答案是肯定的,因为在函数F的内部,会对其参数fp也就是(p: Animal) => void进行调用,此时F也可以使用Cat这一Animal对其进行调用。而此时我们传递的参数fp(p: Dog) => voidfp被调用时使用的是Cat这一参数。这显然会使程序崩溃!

    因此对于函数这一特殊类型,兼容性需要和其参数的兼容性相反,是逆变。

其次我们再来看看为什么TS里的函数还同时支持协变,也就是双向协变的?

前面提到,TS使用的是结构化类型。因此如果Array<Dog>Array<Animal>兼容,我们可以推断:

  • Array<Dog>.pushArray<Animal>.push兼容
    • 也就是(item: Dog) => number(item: Animal) => number兼容
      • ((item: Dog) => number).arguments((item: Animal) => number).arguments兼容
        • DogAnimal兼容

为了维持结构化类型的兼容性,TypeScript团队做了一个权衡 (trade-off)。保持了函数类型的双向协变性。但是我们可以通过设置编译选项--strictFunctionTypes true来保持函数的逆变性而关闭协变性。

为什么参数少的函数可以和参数多的函数兼容?

这个问题其实和函数类型逆变兼容一个道理,也可以用上述的两种思路理解,Dog相当于多个参数,Animal相当于较少的参数。

为什么返回值不是void的函数可以和返回值是void的函数兼容?

从第三方函数调用的角度,如果参数是一个非void的函数。则表明其不关心这个函数参数执行后的返回结果,因此哪怕给一个有返回值的函数参数,第三方的调用函数也不关系,是类型安全的,可以兼容。

怎么构造像Java那样的名义类型?

通常情况下,我们不需要构造名义类型。但是一定要实现的话,也有一些trick:

名义字符串:

// Strings here are arbitrary, but must be distinct
type SomeUrl = string & {'this is a url': {}};
type FirstName = string & {'person name': {}};

// Add type assertions
let x = <SomeUrl>'';
let y = <FirstName>'bob';
x = y; // Error

// OK
let xs: string = x;
let ys: string = y;
xs = ys;

名义结构体:

interface ScreenCoordinate {
  _screenCoordBrand: any;
  x: number;
  y: number;
}
interface PrintCoordinate {
  _printCoordBrand: any;
  x: number;
  y: number;
}

function sendToPrinter(pt: PrintCoordinate) {
  // ...
}
function getCursorPos(): ScreenCoordinate {
  // Not a real implementation
  return { x: 0, y: 0 };
}

// Error
sendToPrinter(getCursorPos());

如何在运行时检测变量的“名义”类型?

TypeScript的类型检测只是一种编译时的转译,编译后类型是擦除的,无法使用JavaScript的instanceof关键字实现类型检验:

interface SomeInterface {
  name: string;
  length: number;
}
interface SomeOtherInterface {
  questions: string[];
}

function f(x: SomeInterface|SomeOtherInterface) {
  // Can't use instanceof on interface, help?
  if (x instanceof SomeInterface) {
    // ...
  }
}

如果要实现检测,需要我们自己实现函数判断类型内部的结构:

function isSomeInterface(x: any): x is SomeInterface {
  return typeof x.name === 'string' && typeof x.length === 'number';

function f(x: SomeInterface|SomeOtherInterface) {
  if (isSomeInterface(x)) {
    console.log(x.name); // Cool!
  }
}

还有更多“奇怪”的疑问,可以参考TypeScript Wiki FAQs

类型安全和不变性

最后来聊一下不变性 (Invariant) 的应用。上面我们提到Array<T>这一复合类型是协变。但是对于可变数组,协变并不安全。同样,逆变也不安全(不过一般逆变不存在于数组)。

下面这个例子中运行便会报错:

class Animal { }

class Cat extends Animal {
    meow() {
        console.log('cat meow');
    }
}

class Dog extends Animal {
    wow() {
        console.log('dog wow');
    }
}

let catList: Cat[] = [new Cat()];
let animalList: Animal[] = [new Animal()];
let dog = new Dog();

// covariance is not type safe
animalList = catList;
animalList.push(dog);
catList.forEach(cat => cat.meow()); // cat.meow is not a function

// contravariance is also not type safe, if it exist here
catList = animalList;
animalList.push(dog);
catList.forEach(cat => cat.meow());

因此,我们使用可变数组时应该避免出现这样的错误,在做类型兼容的时候尽量保持数组的不可变性 (immutable)。而对于可变数组,类型本应该做到不变性。但是编程语言中很难实现,在Java中数组类型也都是可变而且协变的

参考

  1. What are covariance and contravariance?
  2. Covariance, contravariance and a little bit of TypeScript
  3. TypeScript Deep Dive
  4. Type System Behavior

本篇文章由一文多发平台ArtiPub自动发布

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349