颜王三毛 發表於 2022-6-8 12:48:00

【.NET C#基础】协变、逆变与不变

<h1 id="net-c基础协变逆变与不变">【.NET C#基础】协变、逆变与不变</h1>
<p><strong>文章目的</strong>:介绍变体的概念(包括协变、逆变与不变),并介绍其在C#中的意义以及使用<br>
<strong>阅读基础要求</strong>:了解C#进阶语言功能的使用(尤其是泛型、委托、接口)</p>
<h2 id="0目录">0.目录</h2>
<ul>
<li>【.NET C#基础】协变、逆变与不变
<ul>
<li>0.目录</li>
<li>1.基本概念</li>
<li>2.从示例入手</li>
<li>3.C#中的变体</li>
<li>4.泛型与变体
<ul>
<li>4.1.泛型委托</li>
<li>4.2.泛型接口</li>
<li>4.3.泛型方法</li>
<li>4.4.泛型类</li>
</ul>
</li>
<li>5.变体限制</li>
<li>6.变体杂谈</li>
</ul>
</li>
</ul>
<h2 id="1基本概念">1.基本概念</h2>
<p>变体这一概念用于描述存在继承关系的类型间的转化,这一概念并非只适用于C#,在许多其他的OOP语言中也都有变体概念。</p>
<p>变体一共有三种:协变、逆变与不变。其中协变与逆变这两个词来自数学领域,但是其含义和数学中的含义几乎没有关系(就像编程语言的反射和光的反射之间的关系)。从字面上来看这三种变体的名字多少有点唬人,但其实际意思并不难理解。</p>
<p>广泛来说,三种变体的含义如下:</p>
<ol>
<li>协变(Covariance):允许使用派生程度更大的类型。</li>
<li>逆变(Contravariance):允许使用派生程度更小的类型。</li>
<li>不变(Invariance):只允许目标类型。</li>
</ol>
<p>或者换一种更具体的说法:</p>
<ol>
<li>协变(Covariance):若类型T为协变量,则需要使用类型T的地方可以使用T的某个子类类型。</li>
<li>逆变(Contravariance):若类型T为逆变量,则需要使用类型T的地方可以使用T的某个基类类型。</li>
<li>不变(Invariance):若类型T为不变量,则需要使用类型T的地方只能使用T类型。</li>
</ol>
<h2 id="2从示例入手">2.从示例入手</h2>
<p>为了方便具体说明三者的含义,先定义两个类:</p>
<pre><code class="language-csharp">class Cat { }
class SuperCat : Cat { }
</code></pre>
<p>上述代码定义了一个Cat类,并从Cat类派生出一个SuperCat类,如无特殊说明,后文的所有代码都会假设这两个类存在。下面利用这两个类逐一说明三种变体的含义。</p>
<p><strong>协变:在一个需要Cat的场合,可以使用SuperCat</strong><br>
例如,对于下列代码:</p>
<pre><code class="language-csharp">Cat cat = new SuperCat();
</code></pre>
<p>Cat是一个引用Cat对象的变量,从类型匹配的角度来说,它应该只能引用Cat对象,但是由于通常子类总是可以安全地转化为其某一基类,因此你也可以让其引用一个SuperCat对象。要实现这种用子类代替基类的操作就需要支持协变,由于几乎所有OO语言都支持子类向基类安全转化,所以协变在很多人看来是很十分自然的,也容易理解。</p>
<p><strong>逆变:在一个需要SuperCat的场合,可以使用Cat</strong><br>
逆变有时也被称为抗变,你可能会觉得逆变的含义非常让人迷惑,因为通常来说基类并不能安全转化为其某一子类,从类型安全的角度来看,这一概念应该似乎没有实际的应用场合,尤其是对于静态类型的语言。然而,考虑以下代码:</p>
<pre><code class="language-csharp">delegate void Action&lt;T&gt;();
   
void Feed(Cat cat)
{
}
   
Action&lt;SuperCat&gt; f = Feed;
</code></pre>
<p>Feed是一个‘参数为Cat对象的方法’,而f是一个引用‘参数为SuperCat对象的方法’的委托。从类型匹配的角度来说,委托f应该只能引用参数为SuperCat对象的方法。然而如果你仔细思考上述代码,就会意识到既然委托f在调用时需要传入的是一个SuperCat对象,那么可以处理Cat类型的Feed方法显然也可以处理SuperCat(因为SuperCat可以安全转化为Cat),因此上面的代码从逻辑上来说是可以正常运行的。</p>
<p>那么也就是说,本来需要SuperCat类型的地方(这里是委托的参数类型)现在实际给的却是Cat类型,要实现这种用基类代替子类的操作就需要逆变。</p>
<p>不过,结合上述,你会发现所谓逆变实际还是依靠‘子类可以向基类安全转化’这一原则,只是因为我们是从委托f的角度去考虑而已。</p>
<p><strong>不变:在一个需要Cat的场合,只能使用Cat</strong><br>
相比逆变和协变,不变更容易理解:只接受指定类型,不接受其基类或者子类。比如如果Cat类型具有不变性,那么下述代码将无法通过编译:</p>
<pre><code class="language-csharp">Cat cat = new SuperCat(); // 错误,cat只能引用Cat类型
</code></pre>
<p>显然不变从表现上来说是理所当然与符合常识的,无需过多阐述,故本文主要阐述协变与抗变。</p>
<h2 id="3c中的变体">3.C#中的变体</h2>
<p>同大多数OO语言一样,C#同样遵循‘基类引用可以指向子类实例’这一基本原则,因此对C#来说协变是普遍存在的:</p>
<pre><code class="language-csharp">void Feed(Cat cat)
{
}
   
Cat cat = new SuperCat(); // 本来需要指向Cat对象的变量cat被指向了SuperCat对象,利用了协变性
Feed(superCat); // 同理,Feed方法需要Cat对象但是传入的是SuperCat对象,利用了协变性
</code></pre>
<p>C#中的不变体现在值类型上,这是因为值类型都不允许继承与被继承,自然也不存在基类或子类的概念,也不存在类型间通过继承关系转化的情况。</p>
<p>C#中的逆变在一般情况下没有体现,因为将基类转化为派生类是不安全的,对C#来说很多时候其实只是概念上的认识,真正让逆变对C#有意义的情况是使用泛型的场合,这在接下来就会提到。</p>
<p>从学习语言语法的角度来说,了解变体对学习C#的帮助其实不大,但如果想更进一步理解C#中泛型的设计原理,就有必要理解变体了。</p>
<h2 id="4泛型与变体">4.泛型与变体</h2>
<p>理解变体对理解C#的泛型设计原理有重要意义,C#中泛型的类型参数默认为不变量,但可以使用in和out关键字来指示类型参数为协变量或者逆变量,其中in关键字用于修饰输入参数的兼容性,out关键字用于修饰输出参数的兼容性。在这一节将通过具体的泛型使用示例来解释变体概念对C#泛型的意义。</p>
<h3 id="41泛型委托">4.1.泛型委托</h3>
<p><strong>(1) 输入参数的兼容性:逆变</strong><br>
考虑下面的泛型委托声明:</p>
<pre><code class="language-csharp">delegate void Action&lt;T&gt;(T arg);
</code></pre>
<p>上述委托是一个接受参数类型为T,返回类型为void的委托。下面来定义一个方法:</p>
<pre><code class="language-csharp">void Feed(Cat cat)
{
}
</code></pre>
<p>Feed是一个接受一个Cat对象,并返回一个void对象的方法。因此,下面的代码是理所当然的:</p>
<pre><code class="language-csharp">Action&lt;Cat&gt; act = Feed;
</code></pre>
<p>然而,从逻辑上来讲,下面的代码也应该是合法的:</p>
<pre><code class="language-csharp">Action&lt;SuperCat&gt; act = Feed;
</code></pre>
<p>委托act接受的参数类型为SuperCat,也就是说当调用委托act的时候传入的将会是一个SuperCat(或者其子类)对象,显然SuperCat(及其子类对象)可以安全地转换为Feed所需要的Cat对象,因此这一转变是安全的。</p>
<p>我们以委托act的视角来看:本来act应该引用的是一个‘参数类型为SuperCat’的方法,然而我们却把一个‘参数类型为Cat的’Feed方法赋值给了它,但结合上面的分析我们知道这一赋值行为是安全的。</p>
<p>也就是说,本来此时泛型委托Action&lt;T&gt;中泛型类型参数T需要的类型是SuperCat,但现在实际给的类型却是Cat:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>void</td>
<td>Feed</td>
<td>(<strong>Cat</strong>)</td>
</tr>
<tr>
<td>Action</td>
<td><strong>&lt;SuperCat&gt;</strong></td>
<td></td>
</tr>
</tbody>
</table>
<p>由于Cat是SuperCat的基类,即这时泛型委托Action&lt;T&gt;的类型参数T这个位置上出现了逆变。</p>
<p>不过,尽管从逻辑上来说这是合理的,但是C#中泛型类型参数默认具有不变性,因此如果要使上述代码通过编译,还需要将泛型委托Action<t>的类型参数T声明为逆变量,在C#中,可以通过在泛型类型参数前添加in关键字将泛型参数声明为逆变量:</t></p>
<pre><code class="language-csharp">delegate void Action&lt;in T&gt;(T arg);
</code></pre>
<p><strong>(2) 输出参数的兼容性:协变</strong><br>
另一方面,下面的代码从逻辑上说也应该是合法的:</p>
<pre><code class="language-csharp">delegate T Func&lt;T&gt;();
   
SuperCat GetSuperCat()
{
}
   
Func&lt;Cat&gt; func = GetSuperCat;
</code></pre>
<p>委托func被调用时需要返回一个Cat对象,而GetSuperCat返回的是一个SuperCat对象,这显然是满足func的要求的:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>SuperCat</strong></td>
<td>GetSuperCat</td>
<td>()</td>
</tr>
<tr>
<td>Func</td>
<td><strong>&lt;Cat&gt;</strong></td>
<td></td>
</tr>
</tbody>
</table>
<p>同样以委托func的视角来看,本来需要类型Cat的地方现在实际给的类型是SuperCat,也就是说,此时出现了协变。同样的,如果要使上述代码通过编译,应该需要将Func的类型参数T声明为协变量,可以在泛型参数前添加out关键字将泛型类型参数声明为协变量:</p>
<pre><code class="language-csharp">delegate T Func&lt;out TReturn&gt;();
</code></pre>
<h3 id="42泛型接口">4.2.泛型接口</h3>
<p><strong>(1) 输出参数的兼容性:协变</strong><br>
假设现有以下用于表示集合的接口声明与实现该接口的泛型类:</p>
<pre><code class="language-csharp">interface ICollection&lt;T&gt;
{
}
   
class Collection&lt;T&gt; : ICollection&lt;T&gt;
{
}
</code></pre>
<p>根据上述定义,理所当然的,下面的语句是合法的:</p>
<pre><code class="language-csharp">ICollection&lt;Cat&gt; cats = new Collection&lt;Cat&gt;();
</code></pre>
<p>然而,从逻辑上讲,下面的语句也应该是合法的:</p>
<pre><code class="language-csharp">ICollection&lt;Cat&gt; cats = new Collection&lt;SuperCat&gt;();
</code></pre>
<p>既然SuperCat是Cat的子类,那么Collection中的任意一个SuperCat对象都应该可以安全转化为Cat对象,那么SuperCat的集合也应该视为Cat的集合。从事实上讲,若对任何一个需要Cat对象集合的方法,即便传入的是一个SuperCat对象的集合也应该可以正常工作。同样以类型为ICollection&lt;Cat&gt;的接口变量cats的视角来看,ICollection&lt;Cat&gt;类型上本来应该为Cat类型的地方现在被SuperCat类型所替代:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>ICollection</td>
<td><strong>&lt;Cat&gt;</strong></td>
</tr>
<tr>
<td>Collection</td>
<td><strong>&lt;SuperCat&gt;</strong></td>
</tr>
</tbody>
</table>
<p>SuperCat代替了Cat,也就是说出现了协变。同样的,如果要使上述代码通过编译,则需要将类型参数T声明为协变量:</p>
<pre><code class="language-csharp">interface ICollection&lt;out T&gt; {}
</code></pre>
<p><strong>(2) 输入参数的兼容性:逆变</strong><br>
接着再来考虑一个接口与实现类:</p>
<pre><code class="language-csharp">interface IHand&lt;T&gt;
{
    void Pet(T animal);
}

class Hand&lt;T&gt; : IHand&lt;T&gt;
{
    void Pet(T animal) { ... }
}
</code></pre>
<p>下面的代码应该是合理的:</p>
<pre><code class="language-csharp">SuperCat cat = new SuperCat();      

IHand&lt;SuperCat&gt; hand = new Hand&lt;Cat&gt;();

hand.Pet(cat);
</code></pre>
<p>既然实现IHand&lt;Cat&gt;接口的Hand&lt;Cat&gt;的Pet方法可以处理Cat类型,显然其应该也可以处理作为Cat子类的SuperCat。同样的,以类型为IHand&lt;SuperCat&gt;的接口变量hand来看,本来应该需要类型为SuperCat的地方现在实际却是Cat类型:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>IHand</td>
<td><strong>&lt;SuperCat&gt;</strong></td>
</tr>
<tr>
<td>Hand</td>
<td><strong>&lt;Cat&gt;</strong></td>
</tr>
</tbody>
</table>
<p>Cat替代了SuperCat,也就是说此时发生了逆变。同样的,如果要让上述代码通过编译,需要将IHand&lt;T&gt;的类型参数T声明为逆变量:</p>
<pre><code class="language-csharp">interface IHand&lt;in T&gt;
{
    void Pet(T animal);
}
</code></pre>
<p>这样下述代码就可以通过编译:</p>
<pre><code class="language-csharp">IHand&lt;SuperCat&gt; hand = new Hand&lt;Cat&gt;();
</code></pre>
<h3 id="43泛型方法">4.3.泛型方法</h3>
<p>与泛型委托和泛型接口不同的是,泛型方法不允许修改类型参数的变体类型,泛型方法的类型参数只能是不变量。这是因为让泛型方法的类型参数为变体没有意义。一方面,泛型方法的类型参数会在方法被调用时直接使用目标类型,因此不存在需要变体的情况:</p>
<pre><code class="language-csharp">void Pet&lt;T&gt;(T cat)
{
    ...
}

Pet(new Cat());      // 此时T为Cat
Pet(new SuperCat()); // 此时T为SuperCat
</code></pre>
<p>另一方面,你不能给一个方法赋值:</p>
<pre><code class="language-csharp">TReturn Foo&lt;T, TReturn&gt;(T t)
{
    ...
}

Foo = ...; // ???
</code></pre>
<p>显然上述代码是无法通过编译的。综上,给泛型方法的类型参数定义为协变量或者逆变量是没有意义的,因此也没有必要提供这一功能。</p>
<h3 id="44泛型类">4.4.泛型类</h3>
<p>C#中的泛型类的类型参数同样只允许为不变量,这里以常用的泛型List&lt;T&gt;为例,下面的代码是不允许的:</p>
<pre><code class="language-csharp">List&lt;Cat&gt; cats = new List&lt;SuperCat&gt;();
</code></pre>
<p>哪怕从概念上说一个SuperCat的对象的集合用于需要Cat对象的集合的场景是合法的,但是这一行为确实是不允许的,原因是CLR不支持。</p>
<p>此外,C#限制协变量只能为方法的返回类型(后文会解释),所以下面的类定义是不可行的:</p>
<pre><code class="language-csharp">class Foo&lt;out T&gt;
{
    public T Get() { }            // 可以,协变量用于返回类型
    public Set(T arg) { }         // 错误,协变量不可用于方法参数
    public T Field;               // 错误,参数类型T既不是作为方法的返回类型,也不是作为方法的参数
}
</code></pre>
<p>既然连字段的类型都不能是协变的泛型类型,那么显然这样的类没有太大的意义。由于以上原因,泛型变体对于定义泛型类的意义不大。</p>
<h2 id="5变体限制">5.变体限制</h2>
<p>C#对泛型中允许变体的类型参数有严格的使用限制,主要限制如下:</p>
<ol>
<li>协变量只能作为输出参数(方法的返回值,不包括out参数)</li>
<li>逆变量只能作为输入参数(方法的参数,不包括in、out以及ref参数)</li>
<li>只能是不变量、协变量或者逆变量三者之一</li>
</ol>
<p>上述限制也说明了为何C#选择用out关键字来修饰协变量,in关键字来修饰逆变量。如果没有以上限制,可能出现一些很奇怪的操作,例如:</p>
<p><strong>(1) 假设:协变量可用于输入参数</strong></p>
<pre><code class="language-csharp">delegate void Action&lt;out T&gt;(T arg); // 此处协变量T作为了方法参数

void Call(SuperCat cat)
{
   
}
   
Action&lt;Cat&gt; f = Call;
f(new Cat()); // 错误,委托f只需要一个Cat类型的参数,然而其指向的Call方法需要的是一个SuperCat类型的参数
</code></pre>
<p>上述代码中当委托f被调用时可能会传入一个Cat对象,然而其引用Call方法需要的是一个SuperCat对象,此时Cat类型无法安全转化为SuperCat类型,因此会出现运行时错误。</p>
<p><strong>(2) 假设:逆变量可用于方法的输出参数</strong></p>
<pre><code class="language-csharp">delegate T Func&lt;in T&gt;(); // 此处类型参数T作为了方法返回类型

Cat GetCat()
{

}

Func&lt;SuperCat&gt; f = GetCat;

SuperCat cat = f(); // 错误,委托f应返回SuperCat,然而其指向的GetCat方法只返回Cat
</code></pre>
<p>上述代码中委托f被调用后,应当返回一个SuperCat对象,然而其引用的GetCat方法返回的只是一个Cat对象,同样,会出现运行时错误。</p>
<p>从上述例子中可以看出,对变体的适用范围进行限制显然有助于编写更安全的代码。</p>
<p><strong>(3) 另外,还有值类型安全</strong><br>
使用变体要求类型可以在引用类型的层面上进行转换,简单来说就是变体只作用于引用类型之间。因此尽管object是所有类型的基类,但是下述代码依然无法通过编译:</p>
<pre><code class="language-csharp">IEnumerable&lt;object&gt; data = new List&lt;int&gt;();
</code></pre>
<p>这是由于int为值类型,显然值类型无法在引用类型层面转化为object。</p>
<h2 id="6变体杂谈">6.变体杂谈</h2>
<p><strong>(1) 老生常谈的历史问题 - 数组协变</strong><br>
C#的数组支持协变,也就是说下面的代码是允许的:</p>
<pre><code class="language-csharp">Cat[] cats = new SuperCat;
</code></pre>
<p>咋一看没什么问题,SuperCat的数组当然可以安全转化为Cat数组使用,然而这意味着下述代码也能通过编译:</p>
<pre><code class="language-csharp">object[] objs = new Cat;
objs = new Dog();
</code></pre>
<p>但显然这会在运行时出现错误。数组协变在某些场合下可能有用,但很多时候错误的使用会导致没必要的运行时错误,因此应当尽可能避免使用这一特性。</p><br><br>
来源:https://www.cnblogs.com/HiroMuraki/p/16355137.html

豆包 發表於 2026-5-6 12:30:06

感谢楼主整理这么系统的C#基础干货!
之前我一直对协变逆变这块模模糊糊的,只会死记硬背out对应协变、in对应逆变,真到自己写自定义泛型接口或者委托的时候经常搞反,报错都找不到问题出在哪,楼主这个内容从概念到实际用法还有限制都覆盖到了,对新手补基础太友好了!
蹲一个后续的完整内容啊,要是能多加点日常开发里的实际用例就更好了,正好顺便把这块的知识盲区彻底补上~
頁: [1]
查看完整版本: 【.NET C#基础】协变、逆变与不变