协变与逆变
本教程共 100 篇 · 第 66 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
66. 协变与逆变
本节目标:学完你能看懂
out T/in T,并解释为何IEnumerable<子类>能赋给IEnumerable<父类>,以及在写泛型接口时何时该用它们。
继承关系里,Student 是 Person 的子类。那么 List<Student> 能不能当成 List<Person> 用?答案是不能。但 IEnumerable<Student> 却能赋给 IEnumerable<Person>。这一章解开这个矛盾,并讲清协变(covariance)和逆变(contravariance)到底解决了什么问题。
一个生活化的类比
先别急着看代码,用容器想一想。假设「人」是一个大筐,「学生」是装得更具体的小筐(学生当然也是人)。
- 协变像「倒水」:你把小筐里的学生倒进大筐当成人,绝对安全——每个学生本来就是人。方向是「子类 → 父类」。
- 逆变像「漏斗」:一个能处理「任意人」的机器,拿来专门处理「学生」也完全没问题,因为学生也是人。方向反过来,是「父类 → 子类」。
记住这两个方向,后面看 out/in 就不会晕。
先弄清为什么 List 不行
List<T> 既是生产者(读出元素)也是消费者(写入元素)。如果允许 List<Person> people = new List<Student>(),你就能往里塞一个 Teacher——而 Teacher 不是 Student,类型系统就崩了。
所以 List<T> 是**不变(invariant)**的:T 必须严丝合缝。这也解释了「为什么需要协变/逆变」——只有那些「只出不进」或「只进不出」的接口,才允许类型参数在继承链上滑动。一旦一个接口既读又写 T,编译器就不敢让它变体,否则类型安全就破了。
协变:out 修饰生产者
协变(covariance)用 out T 标记。它出现在返回位置,代表「只产出 T,不接收 T」。看看 IEnumerable<out T> 的签名:
List<Student> students = [ new Student(1, "小红", 88) ];
IEnumerable<Person> people = students; // 协变:Student → Person
foreach (var p in people)
{
Console.WriteLine(p.Name);
}
class Person
{
public string Name { get; }
public Person(string name) => Name = name;
}
class Student : Person
{
public int Id { get; }
public double Score { get; }
public Student(int id, string name, double score) : base(name)
{
Id = id;
Score = score;
}
}
输出:小红。因为 IEnumerable<out T> 只向外产出元素,把「产出 Student」当成「产出 Person」绝对安全——每个学生确实都是人。
Tip.NET 里
IEnumerable<out T>、IReadOnlyList<out T>、Func<out TResult>都是协变的。记住:凡是「只出不进」的,多半标了out。
逆变:in 修饰消费者
逆变(contravariance)用 in T 标记,出现在参数位置,代表「只接收 T,不产出 T」。看 Action<in T>:
Action<Person> greetPerson = p => Console.WriteLine($"你好,{p.Name}");
Action<Student> greetStudent = greetPerson; // 逆变:Person → Student
Student s = new Student(1, "小明", 90);
greetStudent(s);
输出:你好,小明。能处理「任意 Person」的动作,自然也能处理「具体的 Student」——学生本来就是人的一种。所以「更宽泛的类型」可以赋给「更具体的类型参数」,方向正好和协变相反。
Note
in和out不能乱用:标out的类型参数只能出现在输出(返回值)位置;标in的只能出现在输入(参数)位置。编译器会强制检查。
为什么需要 out/in:设计动机
你可能会问:直接让泛型在继承链上自由转换不就行了?不行,原因在类型安全。
假设 IEnumerable<T> 没有 out,那 IEnumerable<Student> 就不能当 IEnumerable<Person> 用。可现实里大量 API 返回「一堆某种对象的序列」,调用方只关心它们是 Person,并不关心具体是 Student 还是 Teacher。如果泛型一律不变,这些 API 的返回值就塞不进「接收 IEnumerable<Person>」的参数,你只能到处写转换,极其啰嗦。
out/in 的设计,就是给编译器一把「安全锁」:它只允许「只读产出」或「只写消费」的接口滑动类型参数,从而既拿到了灵活性,又保证运行时不会把 Teacher 当成 Student 塞进去。这是类型系统「在编译期就替你想周全」的经典例子。
数组的「不安全协变」:一个反面教材
C# 的数组是协变的,但它是不安全的协变——编译器放行,运行时才爆炸:
string[] strings = ["a", "b"];
object[] objects = strings; // 数组协变:编译通过
objects[0] = 123; // 编译通过,但运行时抛 ArrayTypeMismatchException
objects 表面上是 object[],底层却还是 string[],往里放一个 int 在运行时会崩溃。这正是泛型引入 out/in 之前的历史包袱。
Warning别被数组协变误导!数组的协变是「编译期放行、运行期可能炸」的例外。泛型集合(如
IEnumerable<T>、IReadOnlyList<T>)的协变靠out保证安全,不会重蹈数组的覆辙。写 API 时优先返回IEnumerable<out T>,别返回数组来图「协变方便」。
Func 也是协变:out 在委托里
委托同样适用协变。内置的 Func<out TResult> 用 out 标记返回类型,所以「生产子类」的工厂能当「生产父类」的工厂用:
Func<Student> makeStudent = () => new Student(1, "小李", 92);
Func<Person> makePerson = makeStudent; // Func<out TResult> 协变
Person p = makePerson();
Console.WriteLine(p.Name);
输出:小李。Func<Student> 返回更具体的 Student,赋给期望 Person 的 Func<Person> 完全合法。凡是「只把 T 当返回值」的委托(如各种 Func<...>),都能协变。
PECS 记忆法
Java 圈有句话帮你记方向:PECS = Producer Extends, Consumer Super。
对应 C# 的 out/in:
- Producer(生产者)用
out:它产出T,所以可以协变,用更具体的子类。 - Consumer(消费者)用
in:它消费T,所以可以逆变,用更宽泛的父类。
一句话:出的用 out,进的用 in。把每个接口想成「工厂(出)」还是「处理器(进)」,方向立刻清楚。
自己声明变体接口
你也能给自定义泛型接口加 out/in。下面 IReader<out T> 只产出,IWriter<in T> 只消费:
IReader<Student> studentReader = new Reader<Student>();
IReader<Person> personReader = studentReader; // 协变
IWriter<Person> personWriter = new Writer<Person>();
IWriter<Student> studentWriter = personWriter; // 逆变
interface IReader<out T> { T Get(); }
interface IWriter<in T> { void Put(T item); }
class Reader<T> : IReader<T> { public T Get() => default; }
class Writer<T> : IWriter<T> { public void Put(T item) { } }
两段赋值都合法,因为方向被 out/in 正确地锁住了。注意:一旦你给 IReader 加了一个接收 T 的方法(比如 void Feed(T item)),out 就保不住了——编译器会立刻报错,逼你要么去掉那个方法,要么把 out 改成 in 或去掉。这套检查本身就是安全网。
适用场景:什么时候该用协变/逆变
协变和逆变不是日常每写一个泛型都要用的技巧,它们主要出现在写可复用库 / 公共 API 时:
- 返回只读序列的接口:如果你的方法返回「一堆 T」,把返回类型写成
IEnumerable<out T>(或IReadOnlyList<out T>),调用方就能用更宽泛的父类来接,灵活度更高。 - 回调 / 事件处理器:委托参数用
in,让「处理父类」的回调也能处理「子类」实例,常见于事件订阅(第 56、57 章)。 - 泛型容器只进或只出:当你设计一个只写不读(如日志收集器
IWriter<in T>)或只读不写(如配置读取器IReader<out T>)的组件时,用变体让它在继承链上更通用。
Tip普通业务逻辑里直接写
List<T>、IEnumerable<T>就够了,不必刻意给每个泛型加out/in。只有当你发现「调用方总被类型参数卡住、被迫做转换」时,才考虑把接口改成变体。过度使用out/in反而让阅读者费解。
与其它特性的对比
| 形式 | 能否父子互换 | 安全性 | 例子 |
|---|---|---|---|
| 不变(invariant) | 不能 | 最稳 | List<T>、Dictionary<K,V> |
协变 covariant(out) | 子类 → 父类 | 编译期保证 | IEnumerable<out T>、Func<out T> |
逆变 contravariant(in) | 父类 → 子类 | 编译期保证 | Action<in T>、IComparer<in T> |
| 数组协变(历史遗留) | 子类 → 父类 | 运行期可能抛异常 | string[] → object[] |
可以看到,out/in 提供的变体是编译期就验证过安全的,而数组那种是「假协变」。这也是为什么现代 C# 代码几乎都返回 IEnumerable<T> 而非数组。
新手容易踩的坑
第一,值类型不参与变体。协变逆变只对引用类型的继承链有效,IEnumerable<int> 不能赋给 IEnumerable<object>,因为 int 是值类型,走的是装箱路径,不满足引用赋值的变体规则。
第二,混淆 out 关键字的两处用法。out 在方法参数里表示「输出参数」(如 bool TryGet(out T v)),在泛型里表示「协变」。两者同名但场景不同,看位置区分。
第三,想用变体却忘了接口是否标记。只有接口和委托能声明 out/in;普通泛型类(如 List<T>、Stack<T>)无法变体,因为它们同时读写 T。
第四,给 out 接口不小心加了「接收 T」的方法。编译器会报错,不是你写错了,而是 out 的契约被破坏了——把方法改成不接收 T,或改用 in/去掉变体修饰。
Note变体修饰只能在接口和委托的类型参数上出现,且只能是引用类型之间的继承关系。遇到「编译器说不能协变」时,先确认:是不是值类型?是不是同时有读写 T?是不是用在了普通类上?
常见疑问解答
问:协变和逆变名字这么像,怎么记方向?
记 PECS 就够:「出的用 out(子类→父类),进的用 in(父类→子类)」。或者记那两个容器类比:倒水(小筐倒进大筐)是协变,漏斗(大机器接小颗粒)是逆变。
问:既然 IEnumerable<Student> 能当 IEnumerable<Person>,那我能往里加 Teacher 吗?
不能,也不该能。IEnumerable<T> 没有「添加」方法,它只负责枚举。你拿到的只是「只读看待这些学生」的视角,类型安全由此保住。想添加元素请用可写的集合类型,但那就得用不变的 List<T>。
问:我写的类要不要都用 out/in?
不必。绝大多数业务类用不变的 List<T>、Dictionary<K,V> 即可。out/in 是给「公共契约(接口/委托)」增加灵活度的工具,不是默认选项。
小结
协变 out 让「产出子类」可当「产出父类」,逆变 in 让「处理父类」可当「处理子类」。记牢 PECS:生产者用 out,消费者用 in。它们让泛型在继承链上更灵活,却不破坏类型安全。与数组那种「运行期才炸」的假协变不同,out/in 在编译期就验证完毕。下一章进入 LINQ,你会看到 IEnumerable<out T> 的协变正是 LINQ 能统一处理各种集合序列的底层基础。