Loading... 协变(Covariant)、逆变(Contravariant)是从数学中学来的概念,一般会用在泛型系统中,与之对应的还有不变(Invariant)。 ## 泛型特化 之所以编程语言中会引入协变等概念,是由于泛型特化导致的。也就是说再实际使用泛型时,泛型中的占位符会被替换为对应的类型,比如一个非常经典的 `Animal` 和 `Cat` 的例子,其中 `Cat` 是 `Animal` 的子类。这是毫无疑问的,但是 `List<Cat>` 并不是 `List<Animal>` 的子类,因为它们实际上是一个平级的关系,都是 `List<T>` 。为了描述这种反直觉的关系(在直觉上 `List<Cat>` 是肯定可以当作一个 `List<Animal>` ),就引入了协变和逆变的概念。 ## 基本概念 * **协变**:允许用子类型替换父类型。例如,如果 `Cat` 是 `Animal` 的子类,那么 `List<Cat>` 可以替换 `List<Animal>`。 * **逆变**:允许用父类型替换子类型。比如,如果我们有一个接受 `Animal` 类型参数的方法,那么我们可以将它替换成一个接受 `Cat` 的方法。 * **不变**:不允许用子类型或父类型替换。例如,`List<Animal>` 不能替换 `List<Cat>`,因为这会导致类型安全问题。 ### 里氏替换原则 里氏替换原则(Liskov Substitution Principle)是面向对象设计的一个重要原则,指出如果 S 是 T 的子类型,那么对象类型为 T 的程序可以用对象类型为 S 的实例替换,而不影响程序的正确性。这与协变和逆变密切相关,因为我们在使用子类时,希望能够安全地替换父类。 ## 具体例子 ### 协变(Covariance) 协变允许将**子类型的对象**赋值给**父类型的泛型引用**。换句话说,如果 `Dog` 是 `Animal` 的子类,那么在协变上下文中,`List<Dog>` 可以被当作 `List<Animal>` 来使用。协变通常用于**只读场景**,因为这样可以保证类型安全。 以 Kotlin 为例,协变通过 `out` 关键字来实现。`out` 表示类型参数只能作为输出,即你只能从这个类型中读取,而不能写入。 ```kotlin open class Animal { fun makeSound() { println("Animal sound") } } class Dog : Animal() { fun bark() { println("Bark") } } // 使用 out 关键字使泛型 T 协变 class AnimalShelter<out T>(private val animal: T) { fun getAnimal(): T { return animal // 只能读取(输出) } } fun main() { val dogShelter: AnimalShelter<Dog> = AnimalShelter(Dog()) val animalShelter: AnimalShelter<Animal> = dogShelter // 协变:允许将子类泛型赋值给父类泛型 val animal = animalShelter.getAnimal() animal.makeSound() // 输出 "Animal sound" } ``` 解释 * **`AnimalShelter<out T>`**:使用 `out` 使 `T` 是协变的,即允许 `AnimalShelter<Dog>` 赋值给 `AnimalShelter<Animal>`,因为 `Dog` 是 `Animal` 的子类。 * **读取操作**:`getAnimal()` 允许从 shelter 中读取一个 `T` 类型的对象。由于协变保证了类型安全,我们可以读取 `Dog`,然后将其当作 `Animal` 使用。 ### 逆变(Contravariance) 逆变是协变的反向概念,它允许**父类型的对象**赋值给**子类型的泛型引用**。换句话说,如果 `Animal` 是 `Dog` 的父类,那么在逆变上下文中,`Handler<Animal>` 可以被当作 `Handler<Dog>` 来使用。逆变通常用于**只写场景**,即你只向泛型中写入值而不读取。 在 Kotlin 中,逆变通过 `in` 关键字来实现。`in` 表示类型参数只能作为输入,即你只能写入这个类型,而不能从中读取。 ```kotlin open class Animal { open fun makeSound() { println("Animal sound") } } class Dog : Animal() { override fun makeSound() { println("Bark") } } // 使用 in 关键字使泛型 T 逆变 class AnimalTrainer<in T> { fun train(animal: T) { animal.makeSound() // 只能传入(输入) } } fun main() { val animalTrainer: AnimalTrainer<Animal> = AnimalTrainer() val dogTrainer: AnimalTrainer<Dog> = animalTrainer // 逆变:允许将父类泛型赋值给子类泛型 dogTrainer.train(Dog()) // 输出 "Bark" } ``` * **`AnimalTrainer<in T>`**:使用 `in` 使 `T` 是逆变的,即允许 `AnimalTrainer<Animal>` 赋值给 `AnimalTrainer<Dog>`,因为训练 `Animal` 的方法自然也能训练 `Dog`。 * **写入操作**:`train()` 方法只接受一个 `T` 类型的对象作为参数。由于逆变允许将父类型赋值给子类型,因此 `AnimalTrainer<Animal>` 可以处理 `Dog` 的训练。 ### 不变(Invariant) 不变意味着泛型类型之间没有子类和父类的关系,也就是说你不能随意将一个泛型类的子类型或父类型泛型赋值给另一个,泛型默认就是不变的。 ### 协变、逆变和不变的对比 | 类型 | 协变 | 逆变 | 不变 | | --------- | ------------------------ | ------------------------ | --------------------------- | | 方向 | 子类型泛型 → 父类型泛型 | 父类型泛型 → 子类型泛型 | 不允许相互赋值 | | 用法 | 只读场景,允许输出 | 只写场景,允许输入 | 不允许父子类型间互相赋值 | | Kotlin 中 | `out` 关键字 | `in` 关键字 | 无关键字表示不变 | | 举例 | `List<out T>` | `Handler<in T>` | `List<T>` 无法进行类型替换 | ### 现实中的类比 * **协变**:想象你有一个书架,书架上放满了各种类型的书(比如小说、传记)。如果书架只允许取书出来而不允许放书进去,那么你可以将放有小说的书架看作是一个放有书的书架,因为你只会从书架上取出书,而不影响类型安全。 * **逆变**:逆变就像你有一个垃圾回收站,它可以处理各种类型的垃圾。如果垃圾回收站可以处理所有类型的垃圾,那么它当然也可以处理某一类特定的垃圾,所以父类的垃圾处理站可以当作子类的使用。 * **不变**:不变像是你有一个容器,装的是什么类型就只能是这种类型。比如一个装苹果的箱子既不能变成装水果的箱子,也不能变成装梨的箱子。 ## Dart 中的 covariant 关键字 正常来说,子类从父类中重写的方法应该满足逆变的特性,就是说参数的范围需要足够的宽。考虑以下例子: ```dart class Animal { void chase(Animal x) {} } class Mouse extends Animal {} class Cat extends Animal { @override void chase(Animal x) {} } ``` 这里为 `Cat` 重写了一个来自 `Animal` 的追逐方法,假如我现在希望 `Cat` 只能追逐 `Mouse` ,也就是我希望收紧参数 `Animal` 的范围,就可以使用 `covariant` 关键字来完成。看起来像这样: ```dart class Animal { void chase(Animal x) {} } class Mouse extends Animal {} class Cat extends Animal { @override void chase(covariant Mouse x) {} } ``` 实际上这样违反了里氏替换原则,使用 `covariant` 关键字可以绕过静态检查,改为运行时检查。所以当你这么做时,最好知道**你在做什么**。 © 允许规范转载 打赏 赞赏作者 微信 赞