C#杂记之贰:协变与逆变

昨天费力把上一篇中的“事件”章节给写完了,弄明白的确实不少(发现弄不明白的更多了)。

由于沉迷《饥荒》,并且也因为有点抗拒没听说过的概念,这一篇鸽到了现在。今天还是开始看吧。

协变(Covariance)和逆变(Contravariance)统称为变体(Variance)。

协变

协变是指,对派生程度更大的类型的支持。也就是说,对于某处要求的类型,传入它的派生类也是没关系的。这是很容易理解的,因为子类可以隐式地转换为基类。协变体现的即是面向对象程序设计思想中,所谓的“里氏替换原则”(Liskov Substitution Principle):任何基类可以出现的地方,子类一定可以出现。

逆变

神秘的问题

比较难以理解的是逆变。与协变相反,这是对派生程度更小的类型的支持。比如在要求string的地方支持使用object。什么地方会出现这种看似违反里氏替换原则的“危险”操作呢?

在翻阅了许多文档之后,我反而是在《深入理解 TypeScript》中找到了较为明白的解释,下面我试着用C#的方式叙述一下。

假设Greyhound (灰狗)是 Dog (狗)的子类,而 Dog 则是 Animal (动物)的子类。由于子类型通常是可传递的,因此我们也称 GreyhoundAnimal 的子类。这是显然的。

那么,一个问题出现了:

如果我们有一个委托Func<Dog, Dog>,是不是真的只能分配那些接受Dog为参数,并返回Dog的方法呢?

根据里氏替换原则,既然灰狗是狗的子类,似乎我们完全可以用灰狗来代替狗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//Dog类的DogBark()
public Dog DogBark(Dog dog)
{
WriteLine("A Dog:" + dog.dogMessage);
return dog;
}

//Greyhound类的GreyhoundBark()
public Greyhound GreyhoundBark(Greyhound greyhound)
{
WriteLine("A Greyhound: " + greyhound.greyhoundMessage);
return greyhound;
}

...

Func<Dog, Dog> LetDogBark = new Func<Dog, Dog>(dog.DogBark);

//CS0123: "GreyhoundBark"没有与委托"Func<Dog,Dog>"匹配的重载
LetDogBark += greyhound.GreyhoundBark;

Greyhound不是可以完全代替Dog吗?这里怎么会报错呢?这是因为当调用委托时,可能传入一个虽然是狗但不是灰狗的对象作为参数,比如德国牧羊犬GermanShepherd。德国牧羊犬当然没法执行灰狗的GreyhoundBark()方法。虽然子类可以代替基类,但子类之间是不一定能兼容的,这就是在委托里出现的特殊情况。

解决的方法就是逆变,即委托所指定的参数类型为Dog时,只能分配参数类型为Dog或派生程度更小的Animal的方法。

1
2
3
4
5
6
7
8
9
10
11
//在Greyhound类另定义GreyhoundBarkAlter(),以Animal为参数
public Greyhound GreyhoundBarkAlter(Animal animal)
{
WriteLine("A Greyhound: " + animal.animalMessage);
return this;
}

...

//这回不报错了
Func<Dog, Dog> LetDogBark = new Func<Animal, Greyhound>(greyhound.GreyhoundBarkAlter);

理解

现在,我们可以回答上面提出的问题了。对于Dog=>Dog的委托,哪些方法是可以分配的呢?

不仅可以将具有匹配签名的方法分配给委托(即Dog=>Dog的方法);

还可以根据协变,分配与委托类型指定的派生类型相比,返回派生程度更大的类型的方法(即Dog=>Greyhound的方法);

或根据逆变,接受具有派生程度更小的类型的参数的方法(即Animal=>Dog的方法)。

这个规则初看起来很怪,让人感觉其中会存在漏洞,比如一个对象协变逆变几次之后可不可能被当成一个八竿子打不着的类的对象?举个例子,除了上述的AnimalDogGreyhound之外,我们再引入一个继承自AnimalFish类。这样,这几个类的继承树就是如此:

继承树

如果我想捣点乱,比如试图把一只Dog传进去,经过一些变体操作后将它伪装成一条Fish,传进某个委托的参数里,引发崩溃。是否可能实现这一邪恶计划呢?

假如现在有一个Fish=>Fish的委托,是我们要下手的目标。根据委托参数的逆变性,我们只能传入AnimalFish。那么,我们就先把Dog伪装成Animal。这好像很简单,只要用一个Dog=>Dog的方法返回自己,再依照协变性将这个方法分配给Dog=>Animal的委托就能做到,甚至直接as Animal都可以。

但当我写下上面这一段之后,我意识到了我认知上的错误。

根据委托参数的逆变性,我们只能传入AnimalFish

这是不对的。必须强调,逆变性并不是指我们能实现“给Fish=>Fish类型的委托传入Animal类型参数”这样的“基类代替子类”的操作,而是指我们能把Animal=>Fish的方法封装进Fish=>Fish类型的委托变量。 在这里AnimalFish的父类,看起来就像是违反了里氏替换原则。但实际上,这恰恰是里氏替换原则的体现:

当我们调用要求传入Fish的委托时,根据里氏替换原则,只能传入派生程度一致或更高的参数,比如Fish类自己的对象。而这个Fish类型的参数,相比起被封装在Fish=>Fish委托里面的Animal=>Fish方法所要求的Animal类型参数而言,其派生程度是更高的。换句话说,对于被封装的Animal=>Fish方法,逆变恰恰使得Fish这个子类代替了Animal这个基类,这是符合里氏替换原则的!

另外还有一个概念是不变体(Invariance),这限定了只能使用原始指定的类型,无论是其基类还是子类还是别的类都不被接受。

自定义泛型委托中的变体

上面说的支持变体的委托,是指Func<>Action<>这两种C#预定义的泛型委托,它默认参数类型逆变,而返回类型协变。如果是自定义的泛型委托,则需要手动指定参数和返回类型的变体(不过很少会有需要自定义泛型委托的场景)。

我们用outin泛型修饰符来进行指定,out代表支持协变,in代表支持逆变:

协变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 支持协变的委托,out修饰符修饰的是返回类型
public delegate R DCovariant<out R>();

// 符合委托签名的方法
public static Control SampleControl()
{ return new Control(); }

public static Button SampleButton()
{ return new Button(); }

public void Test()
{
// 将委托实例化
DCovariant<Control> dControl = SampleControl;
DCovariant<Button> dButton = SampleButton;

// dButton可以赋给dControl
// 因为DCovariant委托是支持协变的
dControl = dButton;

// 调用委托
dControl();
}

逆变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 支持逆变的委托,in修饰符修饰的是参数
public delegate void DContravariant<in A>(A argument);

// 符合委托签名的方法
public static void SampleControl(Control control)
{ }
public static void SampleButton(Button button)
{ }

public void Test()
{
// 将委托实例化
DContravariant<Control> dControl = SampleControl;
DContravariant<Button> dButton = SampleButton;

// dControl可以赋给dButton
// 因为DContravariant委托是支持逆变的
dButton = dControl;

// 调用委托
dButton(new Button());
}

泛型接口中的变体

除了泛型委托之外,泛型接口里也会运用到变体。

非常常用的IEnumerable<T>IEnumerator<T>IQueryable<T>IGrouping<TKey,TElement>泛型接口,它们的所有类型参数都是协变类型参数,这些类型参数只用于成员的返回类型。

另外还有IComparer<T>IComparable<T>IEqualityComparer<T>等接口,它们的所有类型参数都是逆变类型参数,只用于接口成员中的参数。

关于接口我的理解还不够深入,这里就不展开讨论了。

开题答辩即将开始,报告一笔未动,不知道最近还有没有机会写LINQ与异步,看情况吧。