一句话概括: 协变(out)让“更具体的结果”可以当成“更通用的结果”来用;逆变(in)让“能处理更通用数据的方法”也能处理更具体的数据。理解它们,就能看懂 IEnumerable<T>、Action<T>、Func<T, TResult> 背后的类型转换规则。
文章目录
一、为什么学了继承,还会被泛型类型转换难住
假设项目里有一个基类 Animal,还有两个派生类 Dog 和 Cat:
public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }
因为 Dog 继承自 Animal,所以普通的继承转换完全没问题:
Dog dog = new Dog();
Animal animal = dog;
这很好理解:狗是一种动物,因此可以把狗对象放到一个 Animal 类型的变量中。
但当泛型出现后,情况就不一样了:
List<Dog> dogs = new List<Dog>();
// List<Animal> animals = dogs; // 编译错误
虽然 Dog 是 Animal,但 List<Dog> 并不是 List<Animal>。这往往让刚学完继承和泛型的人感到困惑:既然狗是动物,为什么装狗的列表不能当成装动物的列表?
答案是:泛型类型默认是不变的。如果编译器允许上面的转换,就可能把一只猫放进原本只允许存放狗的列表里,破坏类型安全。
C# 的逆变与协变,就是在保证类型安全的前提下,让部分泛型接口和委托支持更灵活的类型转换。
二、先搞懂一个前提:派生类可以当基类用
为了讲清楚逆变和协变,先统一本文使用的继承关系:
public class Animal
{
public string Name { get; set; }
public Animal(string name) { Name = name; }
}
public class Dog : Animal
{
public Dog(string name) : base(name) { }
}
public class Cat : Animal
{
public Cat(string name) : base(name) { }
}
其中,Animal 是基类,Dog 和 Cat 是派生类。普通赋值遵循下面的方向:
Dog dog = new Dog("旺财");
Animal animal = dog; // 可以:Dog 转成 Animal
可以把它记成:具体的东西,可以按更通用的身份来看待;但通用的东西,不一定能当成某个具体类型。 例如,狗一定是动物,但一只动物不一定是狗。
后面所有的规则,都可以围绕这个方向理解:
- 协变: 主要关注“提供、返回什么”,允许把更具体的类型结果,当成更通用的类型结果使用。
- 逆变: 主要关注“接收、处理什么”,允许把能处理更通用类型的对象,当成能处理更具体类型的对象使用。
三、为什么 List<Dog> 不能直接当成 List<Animal>
先看一个容易误判的例子:
List<Dog> dogs = new List<Dog>();
dogs.Add(new Dog("旺财"));
// List<Animal> animals = dogs; // 编译错误
问题在于 List<T> 不只是读取数据,它还可以添加数据。假设这次转换被允许,代码就可以这样写:
// 假设这种转换可以成立(实际上不能编译)
List<Dog> dogs = new List<Dog>();
// List<Animal> animals = dogs;
// animals.Add(new Cat("小花"));
如果 animals 真能引用同一个只接受 Dog 的列表,通过 animals.Add(new Cat(…)) 就会把猫放进狗列表中,后续读取时便可能出现类型安全问题。
因此,List<T> 的类型参数 T 是不变的(Invariant):List<Dog> 不会因为 Dog 继承 Animal 就自动变成 List<Animal>。
但如果只需要读取数据,可以使用支持协变的 IEnumerable<T>:
List<Dog> dogs = new List<Dog>
{
new Dog("旺财"),
new Dog("团子")
};
IEnumerable<Dog> dogEnumerable = dogs;
IEnumerable<Animal> animalEnumerable = dogEnumerable;
foreach (Animal animal in animalEnumerable)
{
Console.WriteLine(animal.Name);
}
输出:
旺财
团子
IEnumerable<out T> 主要用于枚举、读取元素,不提供向其中添加元素的接口成员,因此可以安全地把 IEnumerable<Dog> 当成 IEnumerable<Animal> 使用。这就是协变最常见的应用之一。
四、协变:out 让“返回更具体的类型”变得灵活
4.1 协变到底是什么
**协变(Covariance)**允许泛型接口或委托的类型参数,沿着继承关系从派生类型转换到基类类型。声明协变类型参数时使用 out。
先记住一个简单口诀:只负责提供结果时,通常可以考虑协变。
例如:
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // 可以,IEnumerable<T> 的 T 是协变的
这里的方向是从 Dog 到 Animal。IEnumerable<Dog> 提供的每个元素都是 Dog,而调用方只要求得到 Animal,所以把它当成 IEnumerable<Animal> 使用是安全的。
4.2 自己声明一个协变接口
可以使用 out 声明一个只负责返回数据的泛型接口:
public interface IProducer<out T>
{
T Get();
}
public class DogProducer : IProducer<Dog>
{
public Dog Get() => new Dog("旺财");
}
使用时:
IProducer<Dog> dogProducer = new DogProducer();
IProducer<Animal> animalProducer = dogProducer;
Animal animal = animalProducer.Get();
Console.WriteLine(animal.Name);
输出:
旺财
DogProducer 生产的是狗,但接口调用方只需要一个动物,因此可以把 IProducer<Dog> 转换成 IProducer<Animal>。
4.3 为什么 out T 不能随便用
协变类型参数 T 可以用在接口成员的返回位置,但不能直接用作普通输入参数的类型:
public interface IProducer<out T>
{
T Get();
// void Add(T item); // 编译错误:协变的 T 不能作为输入参数类型
}
原因并不复杂:假设 IProducer<Dog> 可以转成 IProducer<Animal>,如果这个接口还允许调用方添加任意 Animal,就可能把 Cat 塞给只适合处理 Dog 的实现。限制 out T 只能出现在允许的位置,是为了保证类型安全。
理解重点: out 在泛型接口和委托的声明中表示“协变”,重点是类型参数可以出现在输出位置;它不是说这个接口一定要有一个名为 out 的参数。
五、逆变:in 让“接收更通用的类型”也能处理具体对象
5.1 逆变到底是什么
**逆变(Contravariance)**允许泛型接口或委托的类型参数,沿着继承关系从基类方向用于派生类类型参数。声明逆变类型参数时使用 in。
可以这样理解:能处理所有动物的工具,当然也能处理狗。 但只会处理狗的工具,不一定能处理猫。
假设有一个接口,专门用于接收并处理数据:
public interface IConsumer<in T>
{
void Consume(T item);
}
public class AnimalPrinter : IConsumer<Animal>
{
public void Consume(Animal animal)
{
Console.WriteLine($"收到动物:{animal.Name}");
}
}
AnimalPrinter 可以处理任何 Animal,自然也可以处理 Dog。所以可以这样赋值:
IConsumer<Animal> animalConsumer = new AnimalPrinter();
IConsumer<Dog> dogConsumer = animalConsumer;
dogConsumer.Consume(new Dog("旺财"));
输出:
收到动物:旺财
注意转换方向:这里是 IConsumer<Animal> 转成 IConsumer<Dog>,和协变正好相反。因为这个消费者能处理更广泛的动物类型,所以把它当成一个专门接收狗的消费者使用是安全的。
5.2 为什么 in T 适合输入位置
逆变类型参数 T 可以用在接口成员的输入参数位置:
public interface IConsumer<in T>
{
void Consume(T item);
// T Get(); // 编译错误:逆变的 T 不能直接作为返回类型
}
如果一个只接收 Dog 的变量实际指向 IConsumer<Animal>,调用方传入狗没有问题,因为狗就是动物。反过来,如果只接收狗的对象被当作可以处理任意动物的对象,就可能传入猫而出错。因此逆变的转换方向必须是这样设计的。
六、自定义泛型接口:把 in 和 out 用到自己的设计里
实际项目中,可以根据接口承担的职责决定是否声明逆变或协变。
6.1 只提供结果:考虑 out
例如,视觉检测系统中有一个结果读取接口:
public interface IResultProvider<out T>
{
T GetResult();
}
如果某个实现提供 CircleResult,而调用方只关心更通用的 ShapeResult,协变就能让它们更容易配合使用。前提是 CircleResult 确实继承自 ShapeResult,并且接口设计符合协变的输出规则。
6.2 只接收数据:考虑 in
例如,给检测结果写日志、输出报告或执行统一处理:
public interface IResultHandler<in T>
{
void Handle(T result);
}
一个能处理所有 ShapeResult 的处理器,也可以接收更具体的 CircleResult。这样的设计能减少不必要的类型适配代码。
6.3 同时输入和输出时,通常不能标记为变体
如果同一个类型参数既要作为输入,又要作为输出,一般不能直接声明为 in 或 out:
public interface IRepository<T>
{
T GetById(int id);
void Save(T item);
}
T 既出现在 GetById 的返回位置,也出现在 Save 的参数位置,所以此处应保持不变。这个设计并不是错误,而是它的职责决定了它需要同时读取和接收 T。
需要注意:in 和 out 只能用于泛型接口、泛型委托的类型参数声明,不能用于给普通泛型类或泛型结构体声明变体类型参数。例如,不能写成 class Box<out T>。
七、委托中的逆变与协变:看懂 Action 和 Func
逆变和协变不仅用于泛型接口,也大量出现在 C# 内置委托中。平时经常使用的 Action<T> 和 Func<T, TResult>,就已经定义好了对应规则。
7.1 Action<T>:参数位置支持逆变
Action<T> 表示一个接收参数、没有返回值的委托。它的类型参数是逆变的,因此 Action<Animal> 可以赋值给 Action<Dog>:
Action<Animal> printAnimal = animal => Console.WriteLine(animal.Name);
Action<Dog> printDog = printAnimal;
printDog(new Dog("旺财"));
输出:
旺财
原因是 printAnimal 本来就能接收任何动物,接收狗当然没有问题。
7.2 Func<T, TResult>:输入逆变,输出协变
Func<T, TResult> 的第一个类型参数用于输入,支持逆变;最后一个类型参数用于返回结果,支持协变。
Func<Animal, string> describeAnimal = animal => animal.Name;
Func<Dog, object> describeDog = describeAnimal;
object result = describeDog(new Dog("团子"));
Console.WriteLine(result);
输出:
团子
这段代码同时使用了两种变体规则:
- 输入类型从 Animal 变成 Dog,属于逆变。原方法能接收所有动物,因此接收狗没有问题。
- 返回类型从 string 变成 object,属于协变。string 可以向上转换为 object,所以调用方把结果当作 object 接收是安全的。
7.3 方法组也可以体现变体转换
协变和逆变不只存在于泛型类型之间,在委托绑定方法时也能见到相似规则:
static string GetName() => "旺财";
static void PrintObject(object value) => Console.WriteLine(value);
Func<object> getObject = GetName;
Action<string> printString = PrintObject;
Console.WriteLine(getObject());
printString("Hello C#");
GetName 返回 string,而委托允许返回 object;PrintObject 接收 object,而 Action<string> 只会传入字符串。这两种调用都是安全的。
八、in、out 不只一种含义:别和参数关键字混淆
这是 C# 学习中很容易混淆的地方:in 和 out 既能出现在泛型类型参数声明中,也能作为方法参数的关键字,但它们表达的不是同一件事。
| IEnumerable<out T> | 泛型接口声明 | out 表示协变,限制类型参数的使用位置 |
| IConsumer<in T> | 泛型接口声明 | in 表示逆变,限制类型参数的使用位置 |
| void Read(in int value) | 方法参数列表 | 参数以只读引用方式传递,调用方的值不能通过该参数被修改 |
| bool TryGet(out int value) | 方法参数列表 | 方法通过 out 参数向调用方写入结果,调用前不要求变量已赋值 |
因此,泛型声明中的 out 不是“方法返回值”,泛型声明中的 in 也不是“只读引用参数”。判断含义时,先看关键字出现在哪个位置。
九、常见泛型接口与委托速查
.NET 中已经有许多定义好变体规则的接口和委托,可以直接使用,不需要自己重新声明。
| IEnumerable<out T> | 协变 | 枚举读取元素,IEnumerable<Dog> 可以当成 IEnumerable<Animal> |
| IReadOnlyList<out T> | 协变 | 通过只读列表读取元素,支持协变转换 |
| IEnumerator<out T> | 协变 | 通过枚举器读取元素 |
| IComparer<in T> | 逆变 | 能比较更通用的类型,也能用于比较派生类型 |
| IEqualityComparer<in T> | 逆变 | 能判断基类对象是否相等,也可用于派生类型 |
| Action<in T> | 逆变 | 方法接收参数、不返回结果 |
| Func<in T, out TResult> | 输入逆变、返回协变 | 输入和返回类型分别按各自规则转换 |
| List<T> | 不变 | 可读可写,不能直接将 List<Dog> 赋值给 List<Animal> |
记住:不是所有泛型都支持逆变或协变。必须看这个泛型类型本身有没有声明相应的变体规则。
十、新手最容易踩的 6 个坑
坑 1:以为继承关系会自动传递到所有泛型类型
Dog 继承 Animal,并不代表 List<Dog> 继承 List<Animal>。只有声明为协变或逆变的泛型接口与委托,才能按规则进行对应的类型转换。
坑 2:把协变和逆变的方向记反
记住两个最常见的例子:
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // 协变:Dog -> Animal
Action<Animal> handleAnimal = animal => Console.WriteLine(animal.Name);
Action<Dog> handleDog = handleAnimal; // 逆变:Action<Animal> -> Action<Dog>
协变关注输出,通常从更具体的类型到更通用的类型;逆变关注输入,转换方向看起来相反。
坑 3:认为 List<T> 也可以直接变体转换
List<T> 提供添加和读取等操作,类型参数是不变的。需要只读访问时,可以考虑使用 IEnumerable<T> 或 IReadOnlyList<T> 这样的接口。
坑 4:把 in、out 当成能随处添加的修饰符
只能在支持变体的泛型接口和泛型委托的类型参数声明中使用协变、逆变声明。普通泛型类和结构体不能直接声明 class Box<out T> 或 struct Box<in T>。
坑 5:认为值类型也能像引用类型一样使用变体
泛型变体转换适用于引用类型的类型参数。例如,IEnumerable<Dog> 可以转换成 IEnumerable<Animal>,但 IEnumerable<int> 不能因此转换成 IEnumerable<object>。值类型的装箱转换不会让泛型接口自动获得协变能力。
坑 6:把“能赋值”理解成“原来的对象类型变了”
变体不会把 Dog 对象变成 Animal 对象,也不会修改列表内部的数据。它只是允许你通过一个更通用或符合规则的接口、委托类型来使用原有对象,并且仍然由类型系统保证安全。
十一、实战总结:什么时候需要关注逆变与协变
开发时不必为了使用 in 或 out 而刻意设计复杂的接口。更好的思路是先明确接口的职责,再决定是否需要变体。
- 接口只负责提供结果: 类型参数只出现在输出位置时,可以考虑使用 out,让派生类型的提供者能够用于更通用的接口变量。
- 接口只负责接收或处理对象: 类型参数只出现在输入位置时,可以考虑使用 in,让通用处理器也能处理派生类型。
- 接口既读取又写入同一类型: 通常保持不变,例如常见的可读写集合或仓储接口。
- 使用框架提供的接口和委托: 优先了解它们已有的规则,例如 IEnumerable<T>、Action<T> 和 Func<T, TResult>,通常就能解决大部分实际问题。
写在最后
逆变与协变看起来抽象,真正理解时只需要抓住一条主线:协变解决“结果能不能更具体”,逆变解决“输入能不能更通用”。 out 常见于提供数据的接口,in 常见于接收数据的接口,而 Action、Func 则把这些规则带进了日常委托使用中。
当你再次遇到 IEnumerable<Dog> 为什么能赋值给 IEnumerable<Animal>,或者 Action<Animal> 为什么能赋值给 Action<Dog> 时,就可以从“输入还是输出”“类型转换是否安全”两个角度判断,而不必死记硬背。
觉得有收获?欢迎点赞、收藏。理解泛型的类型转换规则,能让你在设计接口、使用委托和阅读 .NET 源码时更加得心应手。
网硕互联帮助中心


评论前必须登录!
注册