Typescript中的逆变与协变

2023-09-20 10:29:14

许多不是很熟悉 TS 的朋友对于逆变和协变的概念会感到莫名的恐惧,没关系。它们仅仅代表阐述表现的概念而已,放心我们并不会从概念入手而是通过实例来逐步为你揭开它的面纱。

逆变 (函数入参)

首先,我们先来思考这样一个场景:

let a!: { a: string; b: number };
let b!: { a: string };

b = a

我们都清楚 TS 属于静态类型检测,所谓类型的赋值是要保证安全性的。

通俗来说也就是多的可以赋值给少的,上述代码因为 a 的类型定义中完全包括 b 的类型定义,所以 a 类型完全是可以赋值给 b 类型,这被称为类型兼容性。

之后,我们再来思考这样一段代码:

let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;

fn1 = fn2; // TS Error: 不能将fn2的类型赋值给fn1

我们将 fn2 赋值给 fn1 ,刚刚才提到类型兼容性的原因 TS 允许不同类型进行互相赋值(只需要父/子集关系),那么明明 fn2 的参数包括了所有的 fn1 为什么会报错?

上述的问题,其实和刚刚没有什么本质区别。我们来换一个角度来理解这个问题:

针对于 fn1 声明时,函数类型需要接受两个参数,换句话说调用 fn1 时我需要支持两个参数的传入分别是a:stringb:number

同理 fn2 函数定义时,定义了三个参数那么调用 fn2 时自然也需要传入三个参数。

那么此时,我们将 fn2 赋值给 fn1 ,我们可以思考下。如果赋值成功了,当我调用 fn1 时,其实相当于调用 fn2 没错吧。

但是,由于 fn1 的函数类型定义仅仅支持两个参数a:stringb:number即可。但是由于我们执行了fn1 = fn2

调用 fn1 时,实际相当于调用了 fn2 函数。但是类型定义上来说 fn1 满足两个参数传入即可,而 fn2 是实打实的需要传入 3 个参数。

那么此时,如果执行了fn1 = fn2当调用 fn1 时明显参数个数会不匹配(由于类型定义不一致)会缺少一个第三个参数,显然这是不安全的,自然也不是被 TS 允许的。

那么反过来呢?

let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;

fn2 = fn1; // 正确,被允许

按照刚才的思路来分析,我们将 fn1 赋值给 fn2 。fn2 的类型定义需要支持三个参数的传入,但实际 fn2 内部指针已经被修改称为 fn1 的指针。

fn1 在执行时仅仅需要两个参数a: string, b: number,显然 fn2 的类型定义中是满足这个条件的(当然它还多传递了第三个参数c:boolean,在 JS 中对于函数而言调用时的参数个数大于定义时的参数个数是被允许的)。

自然,这是安全的也是被 TS 允许赋值。

就比如上述函数的参数类型赋值就被称为逆变,参数少(父)的可以赋给参数多(子)的那一个。看起来和类型兼容性(多的可以赋给少的)相反,但是通过调用的角度来考虑的话恰恰满足多的可以赋给少的兼容性原则。

上述这种函数之间互相赋值,他们的参数类型兼容性是典型的逆变。

我们再来看一个稍微复杂点的例子来加深所谓逆变的理解:

class Parent {}

// Son继承了Parent 并且比parent多了一个实例属性 name
class Son extends Parent { 
  public name: string = '19Qingfeng';
}

// GrandSon继承了Son 在Son的基础上额外多了一个age属性
class Grandson extends Son { 
  public age: number = 3;
}

// 分别创建父子实例
const son = new Son();

function someThing(cb: (param: Son) => any) { 
  // do some someThing 
  // 注意:这里调用函数的时候传入的实参是Son 
  cb(Son);
}

someThing((param: Grandson) => param); // error
someThing((param: Parent) => param); // correct

这里我们定义了三个类,他们之间的关系分别是 Parent 是基类,Son 继承 Parent ,Grandson 继承 Son 。

同时我们定义了一个函数,它接受一个 cb 回调参数作为参数,我们定义了这个回调函数的类型为接受一个 param 为 Son 实例类型的参数,此时我们不关心它的返回值给一个 any 即可。

注意这里,我们先用刚才的结论来推导。刚才我们提到过函数的参数的方式被称为逆变,所以当我们调用 someThing 时传递的 callback 需要赋给定义 something 函数中的 cb 。

换句话说类型(param: Grandson) => param需要赋给cb: (param: Son) => any,这显然是不被允许的。

因为逆变的效果函数的参数只允许“从少的赋值给多的”,显然 Grandson 相较于 Son 来说多了一个 name 属性少,所以这是不被允许的。

相反,第二个someThing((param: Parent) => param);相当于函数参数重将 Parent 赋给 Son 将少的赋给多的满足逆变,所以是正确的。

之后我们在尝试分析为什么第二个someThing((param: Parent) => param);是正确的。

首先我们需要注意到我们在定义 someThing 函数时,声明了这个函数接受一个 cb 的函数。这个函数接受一个类型为 Son 的参数。

someThing 内部cb 函数声明时需要满足 Son 的参数,它会在 cb 函数调用时传入一个 Son 参数的实参。

所以当我们传入someThing((param: Parent) => param)时,相当于在 something 函数内部调用(param: Parent) => param时会根据 someThing 中callback的定义传入一个 Son 。

那么此时,我们函数真实调用时期望得到是 Parent,但是实际得到了 Son 。Son 是 Parent 的子类涵盖所有 Parent 的公共属性方法,自然也是满足条件的。

反而言之,当我们使用someThing((param: Grandson) => param);,由于 something 定义 cb 的类型传入 Son,但是真实调用 someThing 时,我们确需要一个 Grandson 类型参数的函数,这显然是不符合的。

关于逆变我用了比较多的篇幅去描述它,我希望通过文章大家都可以对于逆变结合实例来理解并应用它。因为它的确稍微有些绕。

协变 (函数出参)

解决了逆变之后,其实协变对于大伙儿来说都是小意思。我们先来看看这个 Demo:

let fn1!: (a: string, b: number) => string;
let fn2!: (a: string, b: number) => string | number | boolean;

fn2 = fn1; // correct 
fn1 = fn2 // error: 不可以将 string|number|boolean 赋给 string 类型

这里,函数类型赋值兼容时函数的返回值就是典型的协变场景,我们可以看到 fn1 函数返回值类型规定为 string,fn2 返回值类型规定为string | number | boolean

显然string | number | boolean是无法分配给 string 类型的,但是 string 类型是满足string | number | boolean其中之一,所以自然可以赋值给string | number | boolean组成的联合类型。

其实这就是协变…当然你也可以尝试从函数运行角度来解读协变的概念,比如当 fn1 运行结束要求返回 string , fn2 运行结束后要求返回string | number | boolean

将 fn1 赋给 fn2 ,fn1 要求返回值是 string ,而真实调用的fn1=fn2相当于调用了 fn2 自然string | number | boolean无法满足string类型的要求,所以 TS 会认为这是错误的。

更多推荐

【Python】python -m pip install 和 pip install 的区别

文章目录一、pipinstall二、python-mpipinstall三、两者的总结一、pipinstall当你使用pipinstall命令时,你正在使用Python包管理器pip来安装Python包或模块。以下是关于pipinstall的详细理解:安装包:pipinstall<package>命令用于安装指定的Py

【Python】保姆级万字讲解:Python中的 pip 和 conda 的理解

文章目录一、pip的理解1.1安装1.2如何使用1.3升级1.4安装某个版本的包1.5卸载或者是更新包1.6查看某个包的信息1.7查看需要被升级的包1.8查看兼容问题1.9指定国内源来安装1.10下载包但是不安装1.11批量安装软件包二、conda的理解2.1下载源channel详解2.1.1国内部分好用conda下载

Vue学习笔记

初识Vue需要创建Vue实例root容器代码需要符合html规范,但是混入了一些特殊的Vue语法root容器代码被称为Vue模板容器和Vue实力之间一一对应注意区分js表达式、js代码:一个表达式会生成一个值,可以放在任何一个需要值的地方{{}}需要写js表达式,可以读取data中的所有属性data中属性的值发生改变,

回归与聚类算法系列⑤:逻辑回归

目录1、介绍2、原理输入激活函数3、损失及其优化损失函数优化4、API5、案例:乳腺癌肿瘤预测数据集代码🍃作者介绍:双非本科大三网络工程专业在读,阿里云专家博主,专注于Java领域学习,擅长web应用开发、数据结构和算法,初步涉猎Python人工智能开发。🦅主页:@逐梦苍穹📕回归与聚类算法系列⭐①:概念简述⭐②:

Spring Bean&生命周期图&扩展接口介绍&spring的简化配置

目录1.生命周期简图2.扩展接口介绍2.1Aware接口2.2BeanPostProcessor接口2.3InitializingBean2.4DisposableBean2.5BeanFactoryPostProcessor接口3.spring的简化配置3.1项目搭建3.2Bean的配置和值注入3.3AOP的示例1.

学习Bootstrap 5的第十四天

目录Toast如何创建Toast实例打开Toast实例滚动监听(Scrollspy)如何创建滚动监听实例侧边栏导航(Offcanvas)如何创建Offcanvas侧边栏实例侧边栏的方向实例设置背景及背景是否可滚动实例侧边栏案例实例ToastToast组件类似警告框,当发生某些事情时(例如当用户单击按钮、提交表单等)时,

7.从句学习

目录一、从句。(1)从句总结。(2)从句类型。(3)引导词(常见的引导词)。(3.1)名词性从句:(3.2)形容词性从句:(3.3)副词性从句:(4)从句举例。(4.1)名词性从句举例。(4.2)形容词性从句举例。(4.3)副词性从句举例。一、从句。(1)从句总结。1.名词性从句:从句(引导词+句子/单词)直接充当句子

Redis RedLock算法和底层源码分析

Redlock红锁算法官网地址:DistributedLockswithRedis|Redis为什么要使用RedLock?解释:线程1首先获取锁成功,将键值对写入redis的master节点,在redis将该键值对同步到slave节点之前,master发生了故障;redis触发故障转移,其中一个slave升级为新的ma

Java开发面试--Redis专区

1、什么是Redis?它的主要特点是什么?答:Redis是一个开源的、基于内存的高性能键值对存储系统。它主要用于缓存、数据存储和消息队列等场景。高性能:Redis将数据存储在内存中,并采用单线程的方式处理请求,使得其读写速度非常快,能够达到10万+的读写操作每秒。数据结构丰富:Redis支持多种数据结构,包括字符串、列

ChunJun(OldNameIsFlinkX)

序言ChunJun主要是基于Flink实时计算框架,封装了不同数据源之间的数据导入与导出功能.我们只需要按照ChunJun的要求提供原始与目标数据源的相关信息给Chunjun,然后它会帮我们生成能运行与Flink上的算子任务执行,这样就避免了我们自己去根据不同的数据源重新编辑读入与读出的方案了cuiyaonan2000

Vue.js模板语法[下](事件处理,表单综合案例,自定义组件)---详细讲解

一,事件处理1.`.stop`:阻止事件冒泡。使用该修饰符可以阻止事件向父元素传播2.`.prevent`:阻止默认事件。使用该修饰符可以阻止事件的默认行为。3.`.capture`:使用事件捕获模式。默认情况下,事件是在冒泡阶段处理的,使用该修饰符可以改为在捕获阶段处理。4.`.self`:只在事件触发的元素自身上触

热文推荐