0. 文章目的:

  介绍变体的概念,并介绍其对C#的意义

1. 阅读基础

  了解C#进阶语言功能的使用(尤其是泛型、委托、接口)

2. 从示例入手,理解变体

  变体这一概念用于描述存在继承关系的类型间的转化,这一概念并非只适用于C#,在许多其他的OOP语言中也都有变体概念。变体一共有三种:协变、逆变与不变。其中协变与逆变这两个词来自数学领域,但是其含义和数学中的含义几乎没有关系(就像编程语言的反射和光的反射之间的关系)。从字面上来看这三种变体的名字多少有点唬人,但其实际意思并不难理解。广泛来说,三种变体的意思如下:

  • 协变(Covariance):允许使用派生程度更大的类型
  • 逆变(Contravariance):允许使用派生程度更小的类型
  • 不变(Invariance):只允许目标类型

  或者换一种更具体的说法:

  • 协变(Covariance):若类型A为协变量,则需要使用类型A的地方可以使用A的某个子类类型。
  • 逆变(Contravariance):若类型A为逆变量,则需要使用类型A的地方可以使用A的某个基类类型。
  • 不变(Invariance):若类型A为不变量,则需要使用类型A的地方只能使用A类型。

(注意是‘协变/量’而不是‘协/变量’)

  为了方便说明三者的含义,先定义两个类:

class Cat { }
class SuperCat : Cat { }

  上述代码定义了一个Cat类,并从Cat类派生出一个SupreCat类,如无特殊说明,后文的所有代码都会假设这两个类存在。下面利用这两个类逐一说明三种变体的含义。

2.1 协变:在一个需要Cat的场合,可以使用SuperCat

  例如,对于下列代码:

Cat cat = new SuperCat();

  cat是一个引用Cat对象的变量,从类型安全的角度来说,它应该只能引用Cat对象,但是由于通常子类总是可以安全地转化为其某一基类,因此你也可以让其引用一个SuperCat对象。要实现这种用子类代替基类的操作就需要支持协变,由于OOP语言基本都支持子类向基类安全转化,所以协变在很多人看来是很十分自然的,也容易理解。

2.2 逆变:在一个需要SuperCat的场合,可以使用Cat

  逆变有时也被称为抗变,你可能会觉得逆变的含义非常让人迷惑,因为通常来说基类是不能安全转化为其子类的,从类型安全的角度来看,这一概念应该似乎没有实际的应用场合,尤其是对于静态类型的语言。然而,考虑以下代码:

delegate void Action<T>();

void Feed(Cat cat)
{
...
} Action<SuperCat> f = Feed;

  Feed是一个‘参数为Cat对象的方法’,而f是一个引用‘参数为SuperCat对象的方法’的委托。从类型安全的角度来说,委托f应该只能引用参数为SuperCat对象的方法。然而如果你仔细思考上述代码,就会意识到既然委托f在调用时需要传入的是一个SuperCat对象,那么可以处理Cat类型的Feed方法显然也可以处理SuperCat(因为SuperCat可以安全转化为Cat),因此上面的代码从逻辑上来说是可以正常运行的。那么也就是说,本来需要SuperCat类型的地方(这里是委托的参数类型)现在实际给的却是Cat类型,要实现这种用基类代替子类的操作就需要逆变。

  不过,结合上述,你会发现所谓逆变实际还是依靠‘子类可以向基类安全转化’这一原则,只是因为我们是从委托f的角度去考虑而已。

2.3 不变:在一个需要Cat的场合,只能使用Cat

  相比逆变和协变,不变更容易理解:只接受指定类型,不接受其基类或者子类。比如如果Cat类型具有不变性,那么下述代码将无法通过编译:

Cat cat = new SuperCat(); // 错误,cat只能引用Cat类型

  显然不变从表现上来说是理所当然与符合常识的,故本文主要阐述协变与抗变。

3. C#中的变体

3.1 C#中的变体

  同大多数语言一样,C#同样遵循‘基类引用可以指向子类’这一基本原则,因此对C#来说协变是普遍存在的:

Feed(Cat cat)
{
...
} Cat cat = new SuperCat(); // 本来需要指向Cat对象的变量cat被指向了SuperCat对象,利用了协变性
SuperCat superCat = new SuperCat();
Feed(superCat); // 同理,Feed方法需要Cat对象但是传入的是SuperCat对象,利用了协变性

  C#中的不变体现在值类型上,这是因为值类型都不允许继承与被继承,自然也不存在基类或子类的概念,也不存在类型间通过继承转化的情况。

  C#中的逆变在一般情况下没有体现,因为将基类转化为派生类是不安全的,C#不支持这种操作。所以逆变对C#来说很多时候其实只是概念上的认识,真正让逆变对C#有意义的情况是使用泛型的场合,这在接下来就会提到。

  从学习语言语法的角度来说,了解变体对学习C#的帮助其实不大,但如果想更进一步理解C#中泛型的设计原理,就有必要理解变体了。

3.2 泛型与变体

  理解变体对理解C#的泛型设计原理有重要意义,C#中泛型的类型参数默认为不变量,但可以是outin关键字来指示类型为参数为协变量或者逆变量。简单来说,in关键字用于修饰输入参数的兼容性,out关键字用于修饰输出参数的兼容性。这一节会通过具体的泛型使用示例来解释变体概念对C#泛型的意义。

3.2.1 泛型委托

  (1)输入参数的兼容性:逆变

  考虑下面的泛型委托声明:

delegate void Action<T>(T arg);

  上述委托可以接受一个参数类型为T,返回类型为TReturn的委托。下面来定义一个方法:

void Feed(Cat cat)
{ }

  Foo是一个接受一个Cat对象,并返回一个SuperCat对象的方法。因此,下面的代码是理所当然的:

Action<Cat> act = Feed;

  然而,从逻辑上来讲,下面的代码也应该是合法的:

Action<SuperCat> act = Feed;

  委托act接受的参数类型为SuperCat,也就是说当调用委托act的时候传入的将会是一个SuperCat对象,显然SuperCat对象可以安全地转换为Foo所需要的Cat对象,因此这一转变是安全的。我们以委托act的视角来看:本来act应该引用的是一个‘参数类型为SuperCat’的方法,然而我们却把一个‘参数类型为Cat的’Feed方法赋值给了它,但结合上面的分析我们知道这一赋值行为是安全的。也就是说,本来此时泛型委托Action<T>中泛型类型参数T需要的类型是SuperCat,但现在实际给的类型却是Cat:

(红色是方法参数类型)

  Cat是SuperCat的基类,也就是说这时候泛型委托Action<T>的类型参数T这个位置上出现了逆变。尽管从逻辑上来说这是合理的,但是C#中泛型类型参数默认具有不变性,因此如果要使上述代码通过编译,还需要将泛型委托Func的类型参数T声明为逆变量,在C#中,可以通过在泛型类型参数前添加in关键字将泛型参数声明为逆变量:

delegate void Action<in T>(T arg);

  (2):输出参数的兼容性:协变  

  另一方面,下面的代码从逻辑上说也应该是合法的:

delegate T Func<T>();

SuperCat GetSuperCat()
{
...
} Func<Cat> func = GetSuperCat;

  委托func被调用时需要返回一个Cat对象,而GetSuperCat返回的是一个SuperCat对象,这显然是满足func的要求的:

  同样以委托func的视角来看,本来需要类型Cat的地方现在实际给的类型是SuperCat,也就是说,此时出现了协变。同样的,如果要使上述代码通过编译,应该需要将Func的类型参数T声明为协变量,可以在泛型参数前添加out关键字将泛型类型参数声明为协变量:

delegate T Func<out TReturn>();

3.2.2 泛型接口

(1)输出参数的兼容性:协变

  假设现有以下用于表示集合的接口声明与实现该接口的泛型类:

interface ICollection<T>
{
} class Collection<T> : ICollection<T>
{
}

  根据上述定义,理所当然的,下面的语句是合法的:

ICollection<Cat> cats = new Collection<Cat>();

  然而,从逻辑上讲,下面的语句也应该是合法的:

ICollection<Cat> cats = new Collection<SuperCat>();

  原因如下:既然SuperCat是Cat的子类,那么Collection中的任意一个SuperCat对象都应该可以安全转化为Cat对象,那么SuperCat的集合也应该视为Cat的集合。从事实上讲,若对任何一个需要Cat对象集合的方法,即便传入的是一个SuperCat对象的集合也应该可以正常工作。同样以类型为ICollection<Cat>的接口变量cats的视角来看,ICollection<Cat>类型上本来应该为Cat类型的地方现在被SuperCat类型所替代:

  SuperCat代替了Cat,也就是说出现了协变,那么如果要使上述代码通过编译,则需要将类型参数T声明为协变量:

interface ICollection<out T>
{
}

  C#中的IEnumerable接口就将其类型参数T声明为了协变量,因此下面的代码可以正常运行:

IEnumerable<Cat> cats = new List<SuperCat>();

(2)输入参数的兼容性:逆变 

  接着再来考虑一个接口与实现类:

interface IHand<T>
{
void Pet(T animal);
} class Hand<T> : IHand<T>
{
void Pet(T animal) { ... }
}

  下面的代码应该是合理的:

SuperCat cat = new SuperCat();
IHand<SuperCat> hand = new Hand<Cat>();
hand.Pet(cat);

  原因如下:实现IHand<Cat>接口的Hand<Cat>的Pet方法可以处理Cat类型,显然其应该也可以处理作为Cat子类的SuperCat。同样的,以类型为IHand<SuperCat>的接口变量hand来看,本来应该需要类型为SuperCat的地方现在实际却是Cat类型:

  Cat替代了SuperCat,也就是说此时发生了逆变。同样的,如果要让上述代码通过编译,需要将IHand<>的类型参数T声明为逆变量:

interface IHand<in T>
{
void Pet(T animal);
}

  这样下述代码就可以通过编译:

IHand<SuperCat> hand = new Hand<Cat>();

3.2.3 泛型方法

  与泛型委托和泛型接口不同的是,泛型方法不允许修改类型参数的变体类型,泛型方法的类型参数只能是不变量,因为让泛型方法的类型参数为变体没有意义。一方面,泛型方法的类型参数会在方法被调用时直接使用目标类型,因此不存在需要变体的情况:

void Pet<T>(T cat)
{
...
} Pet(new Cat()); // 此时T为Cat
Pet(new SuperCat()); // 此时T为SuperCat

  另一方面,你不能给一个方法赋值。

TReturn Foo<T, TReturn>(T t)
{
...
} Foo = ...; // ???

  显然上述代码是无法通过编译的。综上,给泛型方法的类型参数定义为协变量或者逆变量是没有意义的,因此也没有必要提供这一功能。

3.2.4 泛型类

  C#中的泛型类的类型参数同样只允许为不变量,这里以常用的泛型List<>为例,下面的代码是不允许的:

List<Cat> cats = new List<SuperCat>();

  哪怕从概念上说一个SuperCat的对象的集合用于需要Cat对象的集合的场景是合法的,但是这一行为确实是不允许的,原因是CLR不支持。此外,C#限制协变量只能为方法的返回类型(后文会解释),所以下面的类定义是不可行的:

class Foo<out T>
{
public T Get() { } // 可以,协变量用于返回类型
public Set(T arg) { } // 错误,协变量不可用于方法参数
public T Field; // 错误,参数类型T既不是作为方法的返回类型,也不是作为方法的参数
}

  既然连字段的类型都不能是协变的泛型类型,那么显然这样的类没有太大的意义。由于以上原因,泛型变体对于定义泛型类的意义不大。

4. 变体限制

  C#对泛型中允许变体的类型参数有严格的使用限制,主要限制如下:

  1. 协变量只能作为输出参数(方法的返回值,不包out参数)
  2. 逆变量只能作为输入参数(方法的参数,不包括in、out以及ref参数)
  3. 只能是不变量、协变量或者逆变量三者之一

  上述限制也说明了为何C#选择用out关键字来修饰协变量,in关键字来修饰逆变量。如果没有以上限制,可能出现一些很奇怪的操作,例如:

(1)假设:协变量可用于输入参数:

delegate void Action<out T>(T arg); // 此处协变量T作为了方法参数

void Call(SuperCat cat)
{ } Action<Cat> f = GetCat;

  上述代码中当委托f被调用时可能会传入一个Cat对象,然而其引用Call方法需要的是一个SuperCat对象,此时Cat类型无法安全转化为SuperCat类型,因此会出现运行时错误。

(2)假设:逆变量可用于方法的输出参数

delegate T Func<in T>(); // 此处类型参数T作为了方法返回类型

Cat GetCat()
{
...
} Func<SuperCat> f = GetCat;

  上述代码中当委托f被调用后,应当返回一个SuperCat对象,然而其引用的GetCat方法返回的只是一个Cat对象,同样,会出现运行时错误。

  从上述例子中可以看出,对变体的适用范围进行限制显然有助于提高编写更安全的代码。

6. 变体杂谈

6.1 历史问题

  C#的数组支持协变,也就是说下面的代码是允许的:

Cat[] cats = new SuperCat[10];

  咋一看没什么问题,SuperCat的数组当然可以安全转化为Cat数组使用,然而这意味着下述代码也能通过编译:

object[] objs = new Cat[10];
objs[0] = new Dog();

  但显然这会在运行时出现错误。数组协变在某些场合下可能有用,但很多时候错误的使用或者误用会导致没必要的运行时错误,因此应当尽可能避免使用这一特性。

6.2 缺点

  使用变体要求类型可以在引用类型的层面上进行转换,简单来说就是变体只作用于引用类型之间。因此尽管object是所有类型的基类,但是下述代码依然无法通过编译:

IEnumerable<object> data = new List<int>();

  这是由于int为值类型,显然值类型无法在引用类型层面转化为object。

.NET C#杂谈(1):变体 - 协变、逆变与不变的更多相关文章

  1. 解读经典《C#高级编程》最全泛型协变逆变解读 页127-131.章4

    前言 本篇继续讲解泛型.上一篇讲解了泛型类的定义细节.本篇继续讲解泛型接口. 泛型接口 使用泛型可定义接口,即在接口中定义的方法可以带泛型参数.然后由继承接口的类实现泛型方法.用法和继承泛型类基本没有 ...

  2. C#中泛型方法与泛型接口 C#泛型接口 List<IAll> arssr = new List<IAll>(); interface IPerson<T> c# List<接口>小技巧 泛型接口协变逆变的几个问题

    http://blog.csdn.net/aladdinty/article/details/3486532 using System; using System.Collections.Generi ...

  3. C#的in/out关键字与协变逆变

    C#提供了一组关键字in&out,在泛型接口和泛型委托中,若不使用关键字修饰类型参数T,则该类型参数是不可变的(即不允许协变/逆变转换),若使用in修饰类型参数T,保证"只将T用于输 ...

  4. Programming In Scala笔记-第十九章、类型参数,协变逆变,上界下界

    本章主要讲Scala中的类型参数化.本章主要分成三个部分,第一部分实现一个函数式队列的数据结构,第二部分实现该结构的内部细节,最后一个部分解释其中的关键知识点.接下来的实例中将该函数式队列命名为Que ...

  5. java协变逆变,PECS

    public static void main(String[] args) { // Object <- Fruit <- Apple <- RedApple System.out ...

  6. 协变 & 逆变

    都跟里氏替换原则有关. 协变:你可以用一个子类对象去替换相应的一个父类对象,这是完全符合里氏替换原则的,和协(谐)的变.如:用Swan替换Bird. 逆变:你可以用一个父类对象去替换相应的一个子类对象 ...

  7. C#核心语法讲解-泛型(详细讲解泛型方法、泛型类、泛型接口、泛型约束,了解协变逆变)

    泛型(generic)是C#语言2.0和通用语言运行时(CLR)的一个新特性.泛型为.NET框架引入了类型参数(type parameters)的概念.类型参数使得设计类和方法时,不必确定一个或多个具 ...

  8. C#核心语法-泛型(详细讲解泛型方法、泛型类、泛型接口、泛型约束,了解协变逆变)

    泛型(generic)是C#语言2.0和通用语言运行时(CLR)的一个新特性.泛型为.NET框架引入了类型参数(type parameters)的概念.类型参数使得设计类和方法时,不必确定一个或多个具 ...

  9. c#-泛型、协变、逆变

    泛型简单介绍: 可以使用泛型声明的元素:类.接口.方法.委托 泛型之前:泛型之前使用object封装不同类型的参数,缺点:性能差.运行时判断类型(不安全)...泛型是在编译期间转为实际类型副本,所以性 ...

随机推荐

  1. 使用React实现一个TodoList案例

    1.效果图: 2.项目源码 3.源码 TodoList.js import React, { Component, Fragment } from 'react'; import TodoItem f ...

  2. 关于webpack,你想知道的都在这;

    咱也标题党一回 哈哈哈 要使用webpack优化项目打包构建速度,首先得知道问题出在哪, 要知道问题出在哪,首先得知道webpack 打包的基本原理才能针对性的去做优化,下面首先了解webpack基本 ...

  3. 【版本2020.03】使用idea导入maven项目

    心得1:不同版本的idea,一些选项的名称稍微有点不同,比如以前导入项目的选项名称都是import Project,但是我使用的版本是2020.03 导入项目的名称是 import Settings ...

  4. c++对c的拓展_增强

    一:新增bool类型关键字:c中bool类型需要添加stdbool.h头文件,c++则可直接使用 void test(){ bool a=true; //c++可直接定义而c需添加头文件 true和f ...

  5. LC-349

    Given two integer arrays nums1 and nums2, return an array of their intersection. Each element in the ...

  6. Redis 缓存击穿(失效)、缓存穿透、缓存雪崩怎么解决?

    原始数据存储在 DB 中(如 MySQL.Hbase 等),但 DB 的读写性能低.延迟高. 比如 MySQL 在 4 核 8G 上的 TPS = 5000,QPS = 10000 左右,读写平均耗时 ...

  7. SIP信令跟踪工具HOMER

    概述 HOMER是一款100%开源的针对SIP/VOIP/RTC的抓包工具和监控工具. HOMER是一款强大的.运营商级.可扩展的数据包和事件捕获系统,是基于HEP/EEP协议的VoIP/RTC监控应 ...

  8. Python多线程Threading爬取图片,保存本地,openpyxl批量插入图片到Excel表中

    之前用过openpyxl库保存数据到Excel文件写入不了,换用xlsxwriter 批量插入图片到Excel表中 1 import os 2 import requests 3 import re ...

  9. 9.2 Linux硬盘分区和挂载

    一块新的硬盘存储设备后,先需要分区,然后再格式化文件系统,最后才能挂载并正常使用. 分区:根据需求和硬盘大小划分空间 格式化:对分区安装文件系统 挂载:将设备文件与一个目录关联的动作叫挂载 硬盘分区格 ...

  10. Http GET 请求参数中文乱码

    两种解决方式 第1种:代码里转换 String name = request.getParamter("name"); String nameUtf8 = new String(n ...