最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • TypeScript 进阶之类型兼容——逆变、协变、双向协变和不变 - 掘金

    正文概述 掘金(橘子小睿)   2021-10-16   46

    这篇文章主要分析 TypeScript 中的类型兼容性,并通过例子详细介绍逆变、协变、双向协变和不变。

    结构化

    在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。而结构性类型系统是基于类型的组成结构,且不要求明确地声明。

    TS 是结构性的类型系统。简单来说,要判断两个类型是否是兼容的,只需要看两个类型的结构是否兼容就可以了,不需要关心类型的名称是否相同。比如:

    interface Named {
        name: string;
    }
    
    class Person {
        name: string;
    }
    
    let p: Named;
    
    // 兼容,因为是结构化类型
    p = new Person();
    

    变型

    什么是变型,为什么需要变型?让我们先来看一个简单的例子:

    假设我要给我的猫喂吃的,但是现在家里只有狗粮了。但既然猫粮和狗粮都是吃的,我能拿狗粮去喂猫吗?这是不是一个安全的行为,猫吃了会不会生病?这就是我们需要思考的问题。

    变型都是发生在父子类型之间的。你可能会纠结要不要拿狗粮去喂猫,但是你肯定不会纠结要不要拿一件衣服去喂猫。

    父子类型

    让我们来写一个简单的父子类型:

    interface Animal {
      age: number
    }
    
    interface Dog extends Animal {
      bark(): void
    }
    

    Dog 继承于 Animal,拥有比 Animal 更多的方法。因此我们说 Animal 是父类型,Dog 是它的子类型。需要注意的是,子类型的属性比父类型更多、更具体: ​

    • 在类型系统中,属性更多的类型是子类型。
    • 在集合论中,属性更少的集合是子集。

    在联合类型中需要注意父子类型的关系,因为确实有点「反直觉」。'a' | 'b' | 'c' 乍一看比 'a' | 'b' 的属性更多,那么 'a' | 'b' | 'c''a' | 'b' 的子类型吗?其实正相反,'a' | 'b' | 'c''a' | 'b' 的父类型,因为前者包含的范围更广,而后者则更具体。 ​

    type Parent = "a" | "b" | "c";
    type Child = "a" | "b";
    
    let parent: Parent;
    let child: Child;
    
    // 兼容
    parent = child
    
    // 不兼容,因为 parent 可能为 c,而 c 无法 assign 给 "a" | "b"
    child = parent
    

    小结:

    • 父类型比子类型更宽泛,涵盖的范围更广,而子类型比父类型更具体
    • 子类型一定可以赋值给父类型

    extends

    前面我们已经了解了父子类型。说到父子类型,我立刻想起了在 TS 中经常用到的 extends 关键字。比如在 TS 的内置类型中,我们经常看到这样的代码: ​

    type NonNullable<T> = T extends null | undefined ? never : T;
    type Diff<T, U> = T extends U ? never : T; 
    type Filter<T, U> = T extends U ? T : never;
    

    extends 是一个 条件类型关键字, 下面的代码可以理解为:如果 T 是 U 的子类型,那么结果为 X,否则结果为 Y

    T extends U ? X : Y
    

    只要理解了父类型和子类型,理解条件类型就非常 easy 了。 ​

    当 T 是联合类型时,叫做分布式条件类型(Distributive conditional types)。类似于数学中的因式分解:

    (a + b) * c = ac + bc
    

    也就是说当 T 为 "A" | "B" 时, 会拆分成 ("A" extends U ? X : Y) | ("B" extends U ? X : Y)

    type Diff<T, U> = T extends U ? never : T;
    
    let demo: Diff<"a" | "b" | "d", "d" | "f">;
    
    // result: "a" | "b"
    
    1. "a" 不是 "d" | "f" 的子集,取 "a"
    2. "b" 不是 "d" | "f" 的子集,取 "b"
    3. "d""d" | "f" 的子集,取 never
    4. 最后得出结果 "a" | "b"

    协变和逆变

    维基百科上关于协变和逆变的解释有点晦涩难懂。这里,我们用更通俗一点的语言来表述:

    • 协变: 允许子类型转换为父类型
    • 逆变: 允许父类型转换为子类型

    还是觉得有点难懂?没关系,接下来我们再具体分析。

    协变

    协变可以用「鸭子类型」来理解。所谓鸭子类型,简单来说就是「如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子」。我们不用关心它是一只真的鸭子,还是一只鸡扮演的鸭子。

    让我们来看一段代码:

    let animal: Animal = { age: 12 };
    
    let dog: Dog = {
      age: 12,
      bark: () => {
      }
    };
    
    // 兼容,能赋值成功,这就是一个协变
    animal = dog
    
    // 不兼容,会抛出类型错误:Property 'bark' is missing in type 'Animal' but required in type 'Dog'
    dog = animal
    

    在上面的代码中,dog 能够赋值给 animal。根据鸭子类型理论,只要一个类型包含 age,我就可以认为它是一个和 Animal 兼容的类型。因此 dog 可以成功赋值给 animal,而对于多出来的 bark() 方法,可以忽略不计。 ​

    反过来,animal 却不能赋值给 dog。因为 dog 要求的是 Dog 类型, 必须包含 age 和 bark,而 Animal 不满足这个条件。

    逆变

    有如下两个函数:

    let visitAnimal = (animal: Animal): Dog => {
      animal.age;
    
      return {
        age: 12,
        bark() {
        }
      }
    }
    
    let visitDog = (dog: Dog): Animal => {
      dog.age;
      dog.bark();
    
      return {
        age: 20
      }
    }
    
    // 兼容
    visitDog = visitAnimal
    
    // 不兼容, 会抛出类型错误
    visitAnimal = visitDog
    

    为什么 visitAnimal 可以赋值给 visitDog,反之则会报错?改写一下上面的函数:

    // before
    visitDog = visitAnimal
    
    // after
    visitDog = (dog: Dog): Animal => {
      // 入参 dog 满足 visitAnimal 入参需要的 Animal 类型
      // 并且 visitAnimal 返回值 dog 包含更多的信息,也符合 visitDog 返回值要求的 Animal 类型
      const dog = visitAnimal(dog);
      return dog.age;
    }
    

    这样是不是就好理解多了?把 visitAnimal 赋值给 visitDog,可以理解为在 visitDog 里面调用 visitAnimal 这个函数。 visitAnimal 的入参需要的是一个 Animal 类型,而 dog 包含更多的信息,显然满足这个条件。

    反之则不行,我们可以按照上面的方法来改写:

    // before
    visitAnimal = visitDog
    
    // after 
    visitAnimal = (animal: Animal): Dog => {
      // 入参 animal 不满足 visitDog 入参要求的 Dog 类型
      // 并且 visitDog 返回值 animal 不符合 visitDog 返回值要求的 Dog 类型。如果调用 animal.bark() 会导致程序抛错
      const animal = visitDog(animal); 
      return animal;
    }
    

    根据上面的实现,可以抽象出如下两个函数类型:

    let visitAnimal: (animal: Animal) => Dog;
    let visitDog: (dog: Dog) => Animal;
    

    其中:

    • 函数参数是逆变:Animal 变换成 Dog,父类型 -> 子类型
    • 函数返回值是协变:Dog 变成 Animal,子类型 -> 父类型
    // 可以想象成下面这样的类型
    interface Fn {
      params: any[]; // 逆变
      return: any;  // 协变
    }
    

    双向协变

    在老版本的 TS 中,函数参数是双向协变的。也就是说,既可以协变又可以逆变,但是这并不是类型安全的。 在新版本 TS (2.6+) 中 ,你可以通过开启 strictFunctionTypesstrict 来修复这个问题。设置之后,函数参数就不再是双向协变的了。

    不变

    不变就非常好理解了,就是不允许变型。比如我想要一个梨,你就必须给我一个梨,给我苹果、香蕉等任何水果都是不行的。

    interface Duck {
      name: string;
      age: number;
      city: string;
    }
    
    const fakeDuck: Duck = {
      name: "aDuck",
      age: 12,
      city: "America",
      price: 100  // 类型错误,因为多了一个 price 属性
    }
    

    总结

    不管是协变还是逆变,归根到底都是在保证类型安全的前提下,提供一些灵活性。我们也不用刻意去记什么是逆变、协变,在遇到问题时,可以按照文中的方法,简单改写一下表达式,就能够知道这样使用是否是类型安全的,在安全的情况下,可以接受和当前定义不完全一模一样的类型。

    参考:

    • TypeScript 中的子类型、逆变、协变是什么?
    • TypeScript 类型系统 协变与逆变的理解 函数类型的问题
    • TS 官方文档
    • 深入理解 TS-协变与逆变

    起源地 » TypeScript 进阶之类型兼容——逆变、协变、双向协变和不变 - 掘金

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元