C# 协变与逆变深度解析:为什么 IEnumerable<T> 能转换,而 List<T> 不行?

一、引言:一个违反直觉的类型转换

先看两段代码:

csharp 复制代码
IEnumerable<string> strs = new List<string>();
IEnumerable<object> objs = strs;   //编译通过,运行正常

而下面却无法通过编译:

csharp 复制代码
List<string> strs = new();
List<object> objs = strs;          //编译错误 CS0029

同样都是泛型类型,IEnumerable<T> 可以把 string 集合赋值给 object 集合,List<T> 却不行。很多开发者简单归结为:

IEnumerable 支持协变,而 List 不支持协变。

但更值得深挖的问题其实是:

  • 协变到底是什么?逆变又是什么?
  • CLR 为什么允许这种看上去"把子类型集合赋值给父类型集合"的操作?
  • 为什么 List 永远无法协变?它和 IEnumerable 的关键区别在哪儿?
  • 编译器与运行时如何保证这种转换不会破坏类型安全?

本文将从 CLR 类型系统的设计角度,彻底解析协变与逆变------不满足于"记住哪些接口支持",而是理解为什么它们能成立、为什么另一些必须被禁止


二、泛型为什么默认是不变的(Invariant)

泛型默认规则

在 C# 中,即使 string 继承自 object,CLR 仍然默认禁止 List<string> 转换为 List<object>。这种"泛型类型参数之间不存在继承传递"的规则,称为不变性(Invariant)

也就是说:

复制代码
string : object  成立
List<string> : List<object>  不成立

如果允许会发生什么

假设以下代码合法:

csharp 复制代码
List<string> strs = new List<string> { "abc" };
List<object> objs = strs;   //假设编译通过

那么接下来完全可以写出:

csharp 复制代码
objs.Add(new object());     //向"object 集合"添加一个 object

此时,strs 内部实际存储的元素变成了:

csharp 复制代码
{ "abc", new object() }

当我们再按 string 类型去访问:

csharp 复制代码
string s = strs[1];         //运行时将是 object,无法转为 string

这就产生了类型安全问题:集合承诺自己是"字符串列表",但里面却混入了一个 object。CLR 的核心职责之一就是杜绝这种内存不安全,所以泛型类型参数必须默认不变

不变性的本质

因此 CLR 默认规定:

泛型类型参数既不能向上转换(Derived → Base),也不能向下转换(Base → Derived)。

这种严格规则就是 Invariant 。而协变与逆变,本质上是在严格证明某些位置不会发生上述破坏性操作之后,对不变规则进行的安全放宽。


三、里氏替换原则(LSP)与类型安全

什么是 LSP

里氏替换原则(Liskov Substitution Principle)要求:

子类型必须能够替换父类型,并且不破坏程序的正确性。

例如:

csharp 复制代码
object obj = "Hello";   //完全合法

因为 string 拥有 object 要求的全部行为(GetHashCode、ToString 等),所以任何期望 object 的地方,传入 string 都是安全的。

泛型中的 LSP

协变与逆变可以看作 LSP 在泛型类型系统中的一种体现。CLR 通过严格的变体安全规则(Variance Safety Rules)来验证这些转换是否安全。换句话说:

  • 允许的转换必须安全:经过类型系统证明,不会在运行时产生类型错误。
  • 不安全的转换必须在编译期阻止:绝不允许"先编译通过再爆炸"。

所以当我们问"为什么 IEnumerable 可以协变"时,本质上是在问:CLR 如何证明把 IEnumerable 当成 IEnumerable 使用,依然满足类型安全? 这个问题,后文会给出完整答案。


四、CLR 为什么引入协变与逆变

.NET 2.0 的局限

在 .NET 2.0 引入泛型时,CLR 尚未支持泛型参数变体(Variance),因此所有泛型接口和泛型委托都只能按照不变(Invariant)方式使用。一个很自然的场景就会遇到麻烦:

csharp 复制代码
IEnumerable<string> strs = new List<string> { "a", "b" };
IEnumerable<object> objs = strs;   //编译错误

开发者想做的不过是"把字符串集合当成 object 集合来遍历",这是完全安全的操作,但当时的类型系统不允许。于是你不得不写一堆无意义的转换代码:

csharp 复制代码
foreach (var item in strs)
{
    object obj = item;
    // ...
}

或者调用 .Cast<object>() 来绕开限制。这显然不符合"写出清晰、可组合代码"的语言设计目标。

顺带一提:非泛型的数组协变(string[]object[])从 .NET 1.0 就已存在,但它属于运行时检查的历史遗留设计,与泛型变体是完全不同的机制。本文后面会专门讨论。

.NET 4.0 的改进

.NET 4.0 引入了泛型类型参数的 outin 标记,允许:

  • 只输出 的泛型类型支持协变(Covariance)
  • 只输入 的泛型类型支持逆变(Contravariance)

在保证类型安全的前提下,大幅提高代码的复用与组合能力。从此,IEnumerable<string> 可以直接赋值给 IEnumerable<object>,就像直觉所预期的那样。


五、协变(Covariance)与 out

协变的定义

协变允许:

复制代码
Derived → Base

方向的泛型转换。例如:

复制代码
IEnumerable<string>   →   IEnumerable<object>

out 的语义

协变类型参数在接口或委托中用 out T 声明:

csharp 复制代码
interface ICovariant<out T>
{
    T Get();
}

out 表示:T 只能出现在输出位置 (方法返回值、只读属性 getter、委托返回值)。任何试图把 T 当作输入参数的做法都会导致编译错误。

协变为什么安全

回到开篇的例子:

csharp 复制代码
IEnumerable<string> strs = new List<string> { "hello" };
IEnumerable<object> objs = strs;
object obj = objs.First();   //实际拿到的仍然是 string

由于 IEnumerable<T> 只有"取出"能力,没有"写入"能力,所以通过 IEnumerable<object> 对集合的操作仅限于读取。每次读出的元素在运行时依然是 string,而 string → object 的向上转型在任何时刻都是安全的。因此这种转换被证明不会破坏类型安全。

编译器如何保证安全

如果尝试在协变接口中引入输入位置:

csharp 复制代码
interface ICovariant<out T>
{
    void Add(T item);   //编译错误
}

编译器会直接报错并拒绝编译。这是 CLR 防御措施的第一层:在编译阶段就消灭非法变体声明

常见协变接口

csharp 复制代码
IEnumerable<out T>
IEnumerator<out T>
IReadOnlyList<out T>
Func<out TResult>

六、为什么 IReadOnlyList<T> 可以协变

很多开发者会困惑:IReadOnlyList<T> 可以协变,但 List<T> 却不行,它们看起来不都是"集合"吗?

答案在于 IReadOnlyList<T> 只提供读取能力

csharp 复制代码
public interface IReadOnlyList<out T> : IEnumerable<T>
{
    T this[int index] { get; }   //只有 getter
    int Count { get; }
}

它没有 AddInsertRemove 等任何写入操作。因此对 IReadOnlyList<object> 的任何使用,都不会向原始集合注入"错误类型的对象"。这正是它能标记为 out T,从而支持协变的根本原因。

判断一个接口能否声明为协变,真正的规则是:

当 T 仅出现在协变允许的位置(返回值、只读属性,或其他合法协变上下文)时,接口才能声明为 out T

例如,以下接口是合法的:

csharp 复制代码
interface ITest<out T>
{
    void Test(Action<T> action);
}

这是因为 Action<T> 的参数 T 标记了 in,属于逆变嵌套,整体位置依然是合法的协变上下文。所以不能只看"参数列表里有没有 T",而要看 T 是否处于输出位置。


七、逆变(Contravariance)与 in

逆变的定义

逆变允许:

复制代码
Base → Derived

方向的泛型转换。例如:

复制代码
Action<object>   →   Action<string>

这个方向恰好与协变相反:父类型出现在左边,子类型出现在右边。

in 的语义

逆变类型参数用 in T 声明:

csharp 复制代码
interface IComparer<in T>
{
    int Compare(T x, T y);
}

in 表示:T 只能出现在输入位置 (方法参数、委托参数)。任何试图把 T 作为返回值都会导致编译错误。

为什么逆变方向是反的

直觉上,把一个"能处理 object 的动作"赋值给"只能处理 string 的动作",仿佛是在"缩小能力"。但从类型角度看,这正是安全的:

csharp 复制代码
Action<object> print = obj => Console.WriteLine(obj);
Action<string> strPrint = print;   //逆变转换

print 能处理任意 object(包括 stringintDateTime 等),而 strPrint 只要求能处理 string。所以 Action<object> 所具备的能力,完全覆盖了 Action<string> 的要求。因此:

Action<object> 可以安全地替代 Action<string>

这就是逆变成立的根本依据。方向之所以看起来反,是因为变体规则关注的是"类型参数出现在输入位置"时的替代关系。

常见逆变接口

csharp 复制代码
Action<in T>
IComparer<in T>
IEqualityComparer<in T>

八、为什么 List<T> 永远无法协变

现在可以给出最终答案了。

List<T> 同时包含:

  • 输出操作T this[int index] { get; }FindGetRange
  • 输入操作void Add(T item)InsertRemove

即:

复制代码
既输出 T
又输入 T

因此如果允许协变,立刻就会重现我们在第二章见过的危险场景:

csharp 复制代码
List<string> strs = new List<string> { "abc" };
List<object> objs = strs;   //假设允许
objs.Add(new object());     //向"object 列表"混入一个 object
string s = strs[1];         //运行时灾难

这种情况下:

  • 如果标记为 out T:无法禁止 Add(T) 等输入操作 → 编译器报错
  • 如果标记为 in T:无法禁止 T 作为返回值 → 编译器报错

因此 List<T> 只能保持 Invariant。这就是为什么:

  • IEnumerable<T> 能协变
  • IReadOnlyList<T> 能协变
  • List<T> 不能协变

的完整技术原因。

一个容易产生的疑问

很多人会继续追问:List<string> 明明实现了 IEnumerable<string>,而 IEnumerable<T> 又支持协变,为什么 List<string> 不能自动转换为 List<object>

原因在于:

协变是声明在 IEnumerable<out T> 上的,而不是声明在 List<T> 上的。

因此,List<string> 可以被当作 IEnumerable<object> 使用(通过协变转换),但它本身仍然是一个 Invariant 的 List<string> ,不能隐式变成 List<object>。变体资格完全由类型自身的声明决定,不会因为实现了哪个接口而被"传染"。这一点对于理解类型系统的保守设计非常重要。


九、委托中的协变与逆变

委托天然是协变和逆变的绝佳载体,因为它们几乎纯粹由"输入"和"输出"组成。

Func 的协变

csharp 复制代码
Func<string> f1 = () => "Hello";
Func<object> f2 = f1;   //协变:string → object

返回值是输出位置,string 返回给期望 object 的调用者完全安全。

Action 的逆变

csharp 复制代码
Action<object> a1 = obj => Console.WriteLine(obj);
Action<string> a2 = a1;   //逆变:object → string

参数是输入位置,能处理 object 的委托,必然能处理 string

委托为什么适合变体

委托本质上只描述:

复制代码
输入参数  →  in
返回值    →  out

这种天然的函数式特征,让变体规则在委托上表现得特别清晰,几乎没有歧义。


十、CLR 如何实现协变与逆变

元数据标记

CLR 在元数据中用枚举标记泛型类型参数的变体属性:

  • 协变:GenericParameterAttributes.Covariant
  • 逆变:GenericParameterAttributes.Contravariant

IL 中的表现

协变接口在 IL 层面会带上 + 号:

il 复制代码
.class interface public abstract auto ansi ICovariant`1<+ T>
{
    .method public hidebysig newslot abstract virtual !T Get() cil managed { }
}

逆变接口会带上 - 号:

il 复制代码
.class interface public abstract auto ansi IContravariant`1<- T>
{
    .method public hidebysig newslot abstract virtual
        int32 Compare(!T x, !T y) cil managed { }
}

+- 直观地表达了变体方向。

CLR 如何保证安全

在运行时加载类型时,CLR 会:

  1. 检查泛型参数的变体标记是否与使用位置一致。
  2. 验证变体转换的有效性:CLR 规定变体转换仅适用于引用类型。对于值类型,即使存在装箱转换(例如 intobject),也不会参与协变和逆变规则。 因此 IEnumerable<int> 永远无法转换为 IEnumerable<object>
  3. 拒绝非法的变体赋值。

这是 CLR 实现类型安全的第二道防线------即便有恶意 IL 试图绕过 C# 编译器,也无法突破运行时的校验。因此,协变与逆变并不是单纯的编译器语法糖,而是 CLR 类型系统的一部分。


十一、使用反射查看变体标记

协变与逆变不仅仅停留在语法层面,它们被忠实地记录在元数据中,可以通过反射直接验证。这种能力对于理解"编译器规则背后的 CLR 事实"非常有帮助。

csharp 复制代码
Type enumerableType = typeof(IEnumerable<>);
Type arg = enumerableType.GetGenericArguments()[0];
Console.WriteLine(arg.GenericParameterAttributes);
//输出: Covariant

Type comparerType = typeof(IComparer<>);
arg = comparerType.GetGenericArguments()[0];
Console.WriteLine(arg.GenericParameterAttributes);
//输出: Contravariant

通过反射可以看到,outin 最终都被编码为 GenericParameterAttributes 枚举值。它们是 CLR 元数据的一部分,而不仅仅是 C# 编译器的内部标记。这也是为什么即便你用其他语言或直接发射 IL,变体规则依然会被强制遵守。


十二、数组协变:一个历史遗留设计

在讨论变体的限制之前,有必要提一下 C# 中另一个"看起来像协变"的特性:数组协变。

csharp 复制代码
string[] strs = new string[1];
object[] objs = strs;   //居然编译通过且运行正常

然而:

csharp 复制代码
objs[0] = new object(); //运行时抛出 ArrayTypeMismatchException

数组协变从 .NET 1.0 就已存在,目的是为了与 Java 等语言保持兼容,以及方便编写某些通用算法。但它本质上不是编译期类型安全的设计------非法写入直到运行时才被拦截。

这与泛型协变形成鲜明对比:

机制 检查时机 安全保障
数组协变 运行时 写入时抛出异常
泛型协变 编译期 完全杜绝非法写入

泛型协变通过 out 标记直接从元数据层面禁止了写入操作,因此你永远不会在 IEnumerable<object> 上看到 Add 方法。数组协变则是历史包袱:它试图在提供灵活性的同时,靠运行时类型检查来弥补漏洞,但始终无法做到编译期杜绝。理解二者的差异,能够帮助你更深刻地体会 CLR 类型系统的演进------从"运行时报错"到"编译期保证"的进步。


十三、限制与陷阱

限制 原因
仅支持接口和委托 CLR 规范
类不能声明 out/in CLR 规范明确只允许接口和委托声明变体参数
值类型不支持变体转换 变体转换仅适用于引用类型,值类型不参与
不能同时输入输出 破坏类型安全,编译器禁止
编译器会严格检查使用位置 杜绝运行时类型错误

为什么类不能声明变体

如果你尝试:

csharp 复制代码
class Repository<out T>   //编译错误
{
}

编译器会直接报错:

Only interface and delegate type parameters can be specified as variant.

原因并不是编译器"无法检查",而是 CLR 规范明确规定:只有接口和委托可以声明变体类型参数。接口主要描述行为契约,委托本质上描述函数签名,二者都能明确分析类型参数的位置(输入或输出)。而类涉及字段、继承、虚方法、可变状态等多种复杂职责,允许类参与变体会显著增加 CLR 的类型验证复杂度,甚至可能引入不可预知的安全隐患。因此规范在顶层直接将其禁止。


十四、实际应用场景

插件系统

csharp 复制代码
public interface IPlugin<out TResult>
{
    TResult Execute();
}

插件只负责产生结果,不修改外部状态,天然适用协变。

消息总线 / 管道

csharp 复制代码
public interface IMessageHandler<in TMessage>
{
    void Handle(TMessage message);
}

消息处理器只消费消息,不输出带 TMessage 的结果,天然适用逆变。

MediatR 的变体思想

MediatR 并没有直接把 TResponse 声明为协变,但 IRequestHandler<in TRequest, TResponse> 的设计充分利用了逆变思想:

csharp 复制代码
public interface IRequestHandler<in TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

其中的 in TRequest 使得更通用的请求处理器能够匹配更具体的请求类型,极大提升了管道路由的灵活性。

只读仓储接口

csharp 复制代码
public interface IReadOnlyRepository<out T>
{
    T GetById(int id);
    IEnumerable<T> GetAll();
}

支持协变后,IReadOnlyRepository<string> 可以直接赋值给 IReadOnlyRepository<object>,方便上层模块使用基类型编程。


十五、设计泛型接口的最佳实践

在项目中设计泛型接口时,可以遵循以下决策流程:

  • 只输出 T → 标记 out T

    csharp 复制代码
    interface IReadOnlyRepository<out T> { T Get(); }
  • 只输入 T → 标记 in T

    csharp 复制代码
    interface IValidator<in T> { bool Validate(T item); }
  • 既输入又输出 T → 保持 Invariant,不标记

    csharp 复制代码
    interface IRepository<T>
    {
        T Get();
        void Save(T item);
    }

这样做不仅让类型更安全,也能在使用时获得更直观的赋值兼容性,减少使用方的转换成本。


十六、总结

协变、逆变与不变的对比:

类型 转换方向 示例
协变(Covariance) Derived → Base IEnumerable<string>IEnumerable<object>
逆变(Contravariance) Base → Derived Action<object>Action<string>
不变(Invariant) 不允许转换 List<string> 无法转换为 List<object>

常见泛型类型的变体特性:

泛型类型 类型特征 原因
IEnumerable<T> 协变 纯输出
IReadOnlyList<T> 协变 纯输出
Func<TResult> 协变 返回值是输出
Action<T> 逆变 参数是输入
IComparer<T> 逆变 参数是输入
List<T> 不变 输入输出并存

最后用一句话收尾:

协变与逆变并不是 CLR 对类型安全的妥协,而是在严格类型验证基础上的一种受控放宽。它们让泛型既保持了静态类型系统的安全性,又获得了面向抽象编程所需的灵活性。

相关推荐
星环科技2 小时前
数据标准Agent ,让企业数据说同一种语言
java·开发语言·前端
dadaobusi2 小时前
RISC-V 虚拟化:虚拟机TLB处理
java·开发语言
夏幻灵2 小时前
深度解析 JavaScript 异步编程:从回调地狱到 Promise 的重构
开发语言·javascript·重构
鱼子星_2 小时前
C++从零开始系列篇(二):C++入门——函数重载,引用,inline与nullptr
开发语言·c++·笔记
程序猿乐锅2 小时前
【 苍穹外卖day03 | 菜品管理 】
java·开发语言·数据库·mysql
派大鑫wink2 小时前
Java 高级编程技巧(生产级实用,覆盖性能、并发、设计、JVM、语法、避坑)
开发语言·python
JSON_L2 小时前
PHP实现大文件分片上传
开发语言·php
凤山老林2 小时前
JDK 11 升级至 JDK 17
java·开发语言·jdk17·jdk升级·jdk11
指令集梦境2 小时前
图解:单调栈算法模板(Java语言)
java·开发语言·算法