Code for Love

Write Poem, With Code

0%

深刻理解C#中的协变与逆变

今日,让我们来探讨一下C#中协变、逆变、不变的编译器处理和内部逻辑上的差别,并对重点作出总结。力求能够深刻的理解协变、逆变的本质,并在实际工作中加以利用。

引入

先看一个C#语句:

1
Person p = new Student()

这个语句中,Student类是Person类的派生类,而这个赋值写法是遵循里氏替换原则的。之所以高级编程语言都支持这样的替换原则,本质上是因为这样一个基础逻辑:

1
公理:一个派生类拥有其基类的所有资源(属性或方法等),因此任何需要使用基类(实际上就是使用这些资源)的地方,让其使用拥有同样甚至更多资源的派生类,是必然可行的。

基于这样的公理,我们得到一个定理:

1
定理:一个Student必然也是一个Person(是一个更具体的Person),因此,在任何使用Person的场景下,让其使用Student也完全行得通。

但是,再看看下面这个语句:

1
List<Person> pList = new List<Student>()	// 编译错误

既然一个Student也一定是一个Person,那理论上一群Student也一定是一群Person才对呀?

咋一听,好像没错?

其实不然,问题的关键有两点:编译器处理差异原理差异

编译器处理差异好解释,因为想要泛型类List<T>实现类似的替换,就需要有类似这样的继承关系:

1
2
3
4
class List<Person>:List<Student>
{
// ...
}

显然这种继承是不存在的…

而对于原理差异,只要紧紧抓住里氏替换原则的本质,很容易就能找出Person和泛型类List<Person>的区别所在。

单个Person的情况下,无论你希望怎么使用这个Person(实际上是使用Person的属性或方法等资源),我给你一个Student你也一定也可以使用它(因为它必然包含了其基类的所有资源)。

而对于泛型类List<Person>,假定List<T>是这样定义的:

1
2
3
4
5
6
7
class List<T>
{
public T CreateT()
{
// ...
};
}

现在我们看一下使用List<Person>的资源的地方是否一定能够替换为使用List<Student>:

1
2
Person p = new List<Person>().CreateT();	// 使用CreateT()资源创建一个Person提供给外部变量p,成功
Person p = new List<Student>().CreateT(); // 使用CreateT()资源创建一个Student提供给外部变量p,成功,因为赋值语句需要一个Person,但右值实际上是一个Student,根据上面定理,任何需要Person的地方给个Student是可行的

我们发现,将上面的语句中的List<Person>()直接替换为List<Student>(),是可行的,其核心代码表述为:

1
List<Person> pList = new List<Student>()

现在我们为List<Person>再添加一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
class List<T>
{
public T CreateT()
{
// ...
};

public void AddT(T t)
{
// ...
};
}

再看看对于List<Person>的新的方法资源的使用是否能替换为使用List<Student>:

1
2
3
Person p = new Pserson()	// 外部资源
new List<Person>().AddT(p); // 使用AddT(T)消费外部资源p,成功
new List<Student>().AddT(p); // 使用AddT(T)消费外部资源p,失败。因为AddT操作需要一个Student,但实际只提供了一个Person外部资源,这种反向替换是不可行

我们发现,将上面的语句中的List<Person>()直接替换为List<Student>(),是不可行的,但如果把替换翻转一下:

1
2
3
Student s = new Student()	// 外部资源
new List<Student>().AddT(s); // 使用AddT(T)消费一个外部资源s,成功
new List<Person>().AddT(s); // 使用AddT(T)消费一个外部资源s,成功。因为AddT操作需要一个Person,而实际提供的资源是一个Student,根据上面定理,任何需要Person的地方给个Student是可行的

反而是可行,其核心代码表述为:

1
List<Student> sList = new List<Person>()

看到了吧,泛型类List<T>存在两种方向截然相反的替换,所以它根本不像具体类Person和Student的替换这么单纯,二者不是一个概念。

等等!List<T>与具体类不一样就算了,为什么List<T>自身会存在两种可行但截然不同的替换???上面的看起来倒像是里氏替换,下面的这种又是什么鬼???

事实上,对比下List<T>CreateTAddT两种资源中T的位置,你会发现,前者的T是作为返回参数或输出值,后者的T是作为输入值。通俗点讲就是:

  • 对于输出型资源CreateT的使用,是创造了一个T提供给外部变量。若外部变量原本需要一个基类,根据里氏替换原则,提供给其一个派生类是可行的。因此,可以将对基类泛型类List<Person>的资源CreateT的使用,替换为对派生类泛型类List<Student>的资源CreateT的使用。
  • 对于输入型资源AddT的使用,需要提供一个外部变量T给其消费。若外部资源提供了一个派生类,根据里氏替换原则,将其作为一个基类进行消费是可行的。因此,可以将对派生类泛型类List<Student>的资源AddT的使用,替换为对基类泛型类List<Person>的资源AddT的使用。

看到这里应该明白了吧?二者的共同点:都是对里氏替换原则的利用,都是里氏替换原则在泛型类中的体现;二者的区别:对于泛型T的生产/消费(或输入/输出)关系的反转导致了对于泛型类本身的替换反向的反转。

概念

协变

对于一个泛型类MyClass<T>,若其内部所有对于T的引用均是对T的产出,则可以将其定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass<out T>	// out关键字告知编译器此泛型类对于T的使用均为产出型,这样编辑器就可以支持该泛型类的协变语法
{
public T a{get;}

public T A()
{
// ...
};

public void B(out T t)
{
// ...
};
}

此时,该泛型类将将支持协变,赋值关系举例为:

1
List<Person> pList = new List<Student>();	// 注意,虽然协变的写法看起来像里氏替换,但它不是里氏替换,而里里氏替换的一种体现(或推论)

注意:协变不是里氏替换(虽然写法看起来相似)!

基础库内常见的支持协变的泛型:IEnumerable<T>Func<T>

逆变

对于一个泛型类MyClass<T>,若其内部所有对于T的引用均是对T的消费,则可以将其定义为:

1
2
3
4
5
6
7
8
9
class MyClass<in T>	// in关键字告知编译器此泛型类对于T的使用均为消费型,这样编辑器就可以支持该泛型类的逆变语法
{
public T a{set;}

public void A(T t)
{
// ...
};
}

此时,该泛型类将将支持逆变,赋值关系举例为:

1
List<Student> sList = new List<Person>();	// 注意,虽然逆变的写法看起来完全不似里氏替换,但它与协变一样,也是里氏替换的一种体现(或推论)
不变

也就是常规的泛型类啦。对于一个泛型类MyClass<T>,若其内部对于T的引用既含有对T的产出也含有对T的消费,则在其定义名称的泛型T前加in或out都是不被允许的;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass<T>	// in或out关键字都是不被允许的
{
public T a{set;set;}

public T A()
{
// ...
};

public void B(T t)
{
// ...
};
}

虽然这样的常规泛型类无法在关键时刻像协变逆变这样替换使用,但是它的优势在于类定义中对T的使用相对更加自由。这一点也能够从.Net基础库中得到佐证:在基础库中,通常支持协变或逆变的泛型类大都是泛型接口,反而List<T>这种常用的大而全的泛型类既不支持协变也不支持逆变。因为一般来说,接口的定义相对简洁精炼,更容易满足协变或逆变对于泛型T的生产/消费关系的严格要求,而List<T>由于需要提供非常多常用的API,如索引器this[index](产生T)和Add(T)(消费T),所以无法支持协变和逆变。

总结

【协变】:泛型类或泛型接口中,若所有引用了T的资源(属性或方法)均是生产T的资源,则在定义该泛型类或泛型接口时,可在名称T前加上out关键字,使编译器支持该泛型类或泛型接口的协变用法。

【逆变】:泛型类或泛型接口中,若所有引用了T的资源(属性或方法)均是消费T的资源,则在定义该泛型类或泛型接口时,可在名称T前加上int关键字,使编译器支持该泛型类或泛型接口的逆变用法。
【不变】:泛型类或泛型接口中,若引用了T的资源(属性或方法)既包含消费T的资源,也包含生产T的资源,则在定义该泛型类或泛型接口时,不允许在名称T前加上int/out关键字。这也是常规泛型类或泛型接口的定义形式。
协变、逆变虽然看起来一个像里氏替换,一个像反向的里氏替换,但是二者都不是里氏替换,而是里氏替换的一种体现、一种应用、一种推论。
协变、逆变、不变在定义和用法上各有偏重和优劣,需要根据具体场景进行设计。