首页 / C# 入门教程 / 协变与逆变

C# 入门教程

协变与逆变

本教程共 100 篇 · 第 66 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言泛型协变逆变

66. 协变与逆变

本节目标:学完你能看懂 out T/in T,并解释为何 IEnumerable<子类> 能赋给 IEnumerable<父类>,以及在写泛型接口时何时该用它们。

继承关系里,StudentPerson 的子类。那么 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

inout 不能乱用:标 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,赋给期望 PersonFunc<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 时:

  1. 返回只读序列的接口:如果你的方法返回「一堆 T」,把返回类型写成 IEnumerable<out T>(或 IReadOnlyList<out T>),调用方就能用更宽泛的父类来接,灵活度更高。
  2. 回调 / 事件处理器:委托参数用 in,让「处理父类」的回调也能处理「子类」实例,常见于事件订阅(第 56、57 章)。
  3. 泛型容器只进或只出:当你设计一个只写不读(如日志收集器 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 能统一处理各种集合序列的底层基础。