C# 协变与逆变详解
一、先从一个生活中的困惑说起
假设你养了一只狗(Dog 继承自 Animal),现在有人问你要一只动物(Animal),你把狗给他——完全没问题,因为狗本来就是动物。
class Animal { }
class Dog : Animal { }
// 日常认知:狗可以当作动物
Dog dog = new Dog();
Animal animal = dog; // ✅ 没问题,狗是动物
但到了泛型和委托里,这个逻辑就"卡住"了:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // ❌ 编译错误!为什么不行?
Func<Dog> getDog = () => new Dog();
Func<Animal> getAnimal = getDog; // ❌ 也不让!
问题来了:Dog 是 Animal,但 List<Dog> 不是 List<Animal>,Func<Dog> 也不是 Func<Animal>。
协变和逆变就是用来解决"泛型之间能不能互相转换"这个问题的。
二、核心概念——用"箱子"来理解
2.1 类比:水果和苹果
🍎 Apple 继承自 🍏 Fruit
常识:
Apple → Fruit ✅ 苹果就是水果
困惑:
List<Apple> → List<Fruit> ❌ 一筐苹果不是一筐水果??
Func<Apple> → Func<Fruit> ❌ 这个怎么判断?
原因:筐是一个"容器",容器里的东西和容器本身是两码事。
2.2 一张图看懂协变和逆变
协变(out)
用"更具体"代替"更宽泛"——"输出"位置
─────────────────────────────→
基类型 派生类型
Animal ←──────────────── Dog
Fruit ←──────────────── Apple
IEnumerable<Animal> ←─────── IEnumerable<Dog>
Func<Animal> ←─────── Func<Dog>
←─────────────────────────────
逆变(in)
用"更宽泛"代替"更具体"——"输入"位置
关键词记忆:
协变 = out = 输出 = 派生 → 基类 = "能出得去"
逆变 = in = 输入 = 基类 → 派生 = "能进得来"
三、协变(Covariance)—— out 关键字
3.1 什么是协变?
协变允许你使用"更具体"的类型代替"更宽泛"的类型,前提是类型参数只出现在"输出"位置。
用生活场景理解:
一个"生产水果"的机器,你给它换成"生产苹果"的机器,完全没问题。因为苹果也是水果,生产出来的东西仍然是水果。
// 协变 = 派生类型 → 基类型(在输出位置)
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // ✅ 一堆狗可以当一堆动物来看
Func<Dog> getDog = () => new Dog();
Func<Animal> getAnimal = getDog; // ✅ 返回狗的委托可以当返回动物的委托用
3.2 定义支持协变的泛型接口
用 out 关键字标记类型参数:
// 定义一个支持协变的接口——"只出不进"
interface IProducer<out T>
{
T Produce(); // ✅ T 在"输出"位置 → 可以用 out
// void Consume(T item); // ❌ 如果加这个,T 也出现在"输入"位置,out 就失效了
}
// 实现
class DogProducer : IProducer<Dog>
{
public Dog Produce()
{
return new Dog();
}
}
// 使用——协变让转换合法
IProducer<Dog> dogProducer = new DogProducer();
IProducer<Animal> animalProducer = dogProducer; // ✅ 协变!
Animal result = animalProducer.Produce(); // 实际返回的是 Dog
Console.WriteLine(result.GetType().Name); // Dog
3.3 C# 内置的支持协变的类型(已经帮你写好了 out)
// 这些接口都定义了 out T,所以支持协变:
IEnumerable<out T> // ✅ 只能从中取数据
IEnumerator<out T> // ✅ 只能从中取数据
IReadOnlyList<out T> // ✅ 只读列表
IReadOnlyCollection<out T>
Func<out TResult> // ✅ 只返回值,不接收参数
// 示例
IEnumerable<string> strings = new List<string> { "a", "b", "c" };
IEnumerable<object> objects = strings; // ✅ 协变!
foreach (object obj in objects)
{
Console.WriteLine(obj); // 一个个取出来,都是 object
}
3.4 委托中的协变(Func)
class Animal { }
class Dog : Animal { }
class Cat : Animal { }
// Func<out TResult>:返回值位置是 out
Func<Dog> makeDog = () => new Dog();
Func<Animal> makeAnimal = makeDog; // ✅ 协变
Animal animal = makeAnimal();
Console.WriteLine(animal.GetType().Name); // Dog
// 更复杂的例子
Func<string, Dog> parseDog = s => new Dog();
Func<string, Animal> parseAnimal = parseDog; // ✅ 返回值是 Dog,可以当 Animal
四、逆变(Contravariance)—— in 关键字
4.1 什么是逆变?
逆变允许你使用"更宽泛"的类型代替"更具体"的类型,前提是类型参数只出现在"输入"位置。
用生活场景理解:
一个"给狗喂食"的机器,你给它换成"给动物喂食"的机器——更宽泛了。但因为狗也是动物,给动物喂食的机器同样能给狗喂食。完全 OK!
// 逆变 = 基类型 → 派生类型(在输入位置)
Action<Animal> feedAnimal = a => Console.WriteLine($"喂了一只{a.GetType().Name}");
Action<Dog> feedDog = feedAnimal; // ✅ 能喂动物的,肯定能喂狗
feedDog(new Dog()); // 输出: 喂了一只Dog
4.2 定义支持逆变的泛型接口
用 in 关键字标记类型参数:
// 定义一个支持逆变的接口——"只进不出"
interface IConsumer<in T>
{
void Consume(T item); // ✅ T 在"输入"位置 → 可以用 in
// T Produce(); // ❌ 如果加这个,T 也出现在"输出"位置,in 就失效了
}
// 实现
class AnimalFeeder : IConsumer<Animal>
{
public void Consume(Animal animal)
{
Console.WriteLine($"喂了一只 {animal.GetType().Name}");
}
}
// 使用——逆变让转换合法
IConsumer<Animal> animalFeeder = new AnimalFeeder();
IConsumer<Dog> dogFeeder = animalFeeder; // ✅ 逆变!能喂动物的也能喂狗
dogFeeder.Consume(new Dog()); // 输出: 喂了一只 Dog
4.3 C# 内置的支持逆变的类型
// 这些接口都定义了 in T,所以支持逆变:
IComparer<in T> // ✅ 比较器——接收参数,不返回 T
IEqualityComparer<in T> // ✅ 相等比较器
Action<in T> // ✅ 接收参数,不返回值
// 示例
class AnimalComparer : IComparer<Animal>
{
public int Compare(Animal x, Animal y)
{
return x.GetType().Name.CompareTo(y.GetType().Name);
}
}
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // ✅ 逆变!能比较动物的也能比较狗
4.4 委托中的逆变(Action)
// Action<in T>:参数位置是 in
Action<Animal> handleAnimal = a => Console.WriteLine($"处理动物: {a.GetType().Name}");
Action<Dog> handleDog = handleAnimal; // ✅ 逆变
handleDog(new Dog()); // 输出: 处理动物: Dog
// 能处理 Animal 的方法,用来处理 Dog 完全没问题
4.5 Func 中同时有协变和逆变
// Func<in T, out TResult>
// 参数 T 是 in(逆变),返回值 TResult 是 out(协变)
Func<Animal, Dog> transform = a => new Dog();
// 逆变:参数可以用更宽泛的
// 协变:返回值可以用更具体的
// 所以组合起来:
Func<Animal, Dog> 原始: 输入 Animal → 输出 Dog
Func<Dog, Animal> 结果: 输入 Dog → 输出 Animal
// 逆变:参数从 Animal 变 Dog(更具体)—— 能处理 Animal 就能处理 Dog
// 协变:返回值从 Dog 变 Animal(更宽泛)—— 返回 Dog 就是返回 Animal
五、实战对比——能转和不能转
5.1 完整对比示例
using System;
using System.Collections.Generic;
class Animal
{
public string Name { get; set; }
}
class Dog : Animal
{
public void Bark() => Console.WriteLine($"{Name}: 汪汪!");
}
class Program
{
static void Main()
{
// ===== 1. 协变:IEnumerable<out T> =====
List<Dog> dogs = new List<Dog>
{
new Dog { Name = "旺财" },
new Dog { Name = "大黄" }
};
// ✅ 协变:IEnumerable<Dog> → IEnumerable<Animal>
IEnumerable<Animal> animals = dogs;
foreach (Animal a in animals)
{
Console.WriteLine(a.Name); // 只能看到 Animal 的属性
}
// ===== 2. 逆变:Action<in T> =====
Action<Animal> printAnimal = a => Console.WriteLine($"动物的名字: {a.Name}");
// ✅ 逆变:Action<Animal> → Action<Dog>
Action<Dog> printDog = printAnimal;
printDog(new Dog { Name = "小黑" }); // 输出: 动物的名字: 小黑
// ===== 3. List<T> 不支持协变/逆变!=====
List<Dog> dogList = new List<Dog>();
// List<Animal> animalList = dogList; // ❌ 编译错误!
// 为什么?因为如果允许,就会出这种问题:
// animalList.Add(new Cat()); // Cat 是 Animal,但不是 Dog!
// 那 dogList 里就有了 Cat,类型不安全!
// ===== 4. Func<out T> 协变 =====
Func<Dog> createDog = () => new Dog { Name = "新狗" };
// ✅ 协变:Func<Dog> → Func<Animal>
Func<Animal> createAnimal = createDog;
Animal result = createAnimal();
Console.WriteLine(result.Name); // 新狗
// ===== 5. 逆变:IComparer<in T> =====
class AnimalAgeComparer : IComparer<Animal>
{
public int Compare(Animal x, Animal y)
=> x.Name.CompareTo(y.Name);
}
IComparer<Animal> comparer = new AnimalAgeComparer();
// ✅ 逆变:IComparer<Animal> → IComparer<Dog>
IComparer<Dog> dogComparer = comparer;
}
}
5.2 关键规则——为什么 List<T> 不支持?
// IEnumerable<T>(只读接口)定义:
// public interface IEnumerable<out T> ← 有 out,支持协变
// 原因是:你只能从里面"取"数据,不能"塞"数据。
// 取出来的 Dog 当 Animal 看待,安全!
// List<T> 定义(简化):
// public class List<T>
// 没有 out/in 标记 ← 不支持协变/逆变
// 原因是:List 既能"取"又能"塞",如果支持协变就危险了!
List<Dog> dogs = new List<Dog>();
// List<Animal> animals = dogs; // 假设允许(实际不允许)
// animals.Add(new Cat()); // Cat 是 Animal,可以加进来
// Dog dog = dogs[0]; // 但实际上拿到的是 Cat!类型不安全!
// 对比:数组居然支持协变!(C# 历史遗留的坑)
Dog[] dogArray = new Dog[3];
Animal[] animalArray = dogArray; // ⚠️ 编译通过,但危险!
// animalArray[0] = new Cat(); // 运行时报错!ArrayTypeMismatchException
六、完整实战示例:消息处理系统
用一个完整的例子,把协变和逆变串起来:
using System;
using System.Collections.Generic;
// ===== 消息类型体系 =====
class Message
{
public string From { get; set; }
public DateTime Time { get; set; }
public override string ToString() => $"[{Time:HH:mm}] 来自 {From}";
}
class TextMessage : Message
{
public string Content { get; set; }
public override string ToString() => base.ToString() + $": {Content}";
}
class ImageMessage : Message
{
public string ImageUrl { get; set; }
public override string ToString() => base.ToString() + $" [图片: {ImageUrl}]";
}
class VoiceMessage : Message
{
public int Duration { get; set; }
public override string ToString() => base.ToString() + $" [语音: {Duration}秒]";
}
// ===== 协变接口:消息读取器(只出不进) =====
interface IMessageReader<out T>
{
T GetLatest();
IEnumerable<T> GetAll();
}
class TextMessageReader : IMessageReader<TextMessage>
{
private List<TextMessage> _messages = new List<TextMessage>();
public void Add(TextMessage msg) => _messages.Add(msg);
public TextMessage GetLatest() => _messages[^1];
public IEnumerable<TextMessage> GetAll() => _messages;
}
// ===== 逆变接口:消息处理器(只进不出) =====
interface IMessageHandler<in T>
{
void Handle(T message);
}
class LogMessageHandler : IMessageHandler<Message>
{
public void Handle(Message message)
{
Console.WriteLine($"📝 [日志] {message}");
}
}
class NotificationHandler : IMessageHandler<Message>
{
public void Handle(Message message)
{
Console.WriteLine($"🔔 [通知] 收到新消息!");
}
}
// ===== 应用 =====
class Program
{
static void Main()
{
// 准备数据
var reader = new TextMessageReader();
reader.Add(new TextMessage { From = "张三", Time = DateTime.Now, Content = "你好!" });
reader.Add(new TextMessage { From = "李四", Time = DateTime.Now, Content = "在吗?" });
// ✅ 协变:IMessageReader<TextMessage> → IMessageReader<Message>
IMessageReader<TextMessage> textReader = reader;
IMessageReader<Message> messageReader = textReader; // 协变!
Console.WriteLine("===== 读取消息(协变) =====");
foreach (Message msg in messageReader.GetAll())
{
Console.WriteLine(msg);
}
// ✅ 逆变:IMessageHandler<Message> → IMessageHandler<TextMessage>
IMessageHandler<Message> logHandler = new LogMessageHandler();
IMessageHandler<TextMessage> textLogHandler = logHandler; // 逆变!
Console.WriteLine("\n===== 处理消息(逆变) =====");
TextMessage latest = textReader.GetLatest();
textLogHandler.Handle(latest); // 能处理 Message 的,就能处理 TextMessage
// ✅ 逆变链:给一个类型注册多个处理器
var handlers = new List<IMessageHandler<TextMessage>>();
handlers.Add(new LogMessageHandler()); // 逆变!
handlers.Add(new NotificationHandler()); // 逆变!
Console.WriteLine("\n===== 多处理器(逆变链) =====");
foreach (var handler in handlers)
{
handler.Handle(latest);
}
}
}
输出:
===== 读取消息(协变) =====
[14:30] 来自 张三: 你好!
[14:31] 来自 李四: 在吗?
===== 处理消息(逆变) =====
📝 [日志] [14:31] 来自 李四: 在吗?
===== 多处理器(逆变链) =====
📝 [日志] [14:31] 来自 李四: 在吗?
🔔 [通知] 收到新消息!
七、委托中的协变与逆变——完整示例
using System;
class Animal
{
public string Name { get; set; }
public override string ToString() => Name;
}
class Dog : Animal
{
public void Bark() => Console.WriteLine($"{Name}: 汪汪");
}
class Cat : Animal
{
public void Meow() => Console.WriteLine($"{Name}: 喵喵");
}
class Program
{
// 委托定义(用 Func 和 Action,已经内置了 in/out)
// Func<in T, out TResult> —— 参数是逆变,返回值是协变
// Action<in T> —— 参数是逆变
static void Main()
{
// ===== 1. Func 中的协变(返回值) =====
Func<Dog> createDog = () => new Dog { Name = "旺财" };
Func<Animal> createAnimal = createDog; // ✅ 协变
Animal a = createAnimal();
Console.WriteLine($"创建了: {a}"); // 创建了: 旺财
// ===== 2. Action 中的逆变(参数) =====
Action<Animal> printAnimalName = animal =>
Console.WriteLine($"动物名叫: {animal.Name}");
Action<Dog> printDogName = printAnimalName; // ✅ 逆变
printDogName(new Dog { Name = "大黄" }); // 动物名叫: 大黄
// ===== 3. Func 中参数逆变 + 返回值协变 =====
Func<Animal, Dog> original = animal => new Dog { Name = "变换来的" };
// 参数逆变:Animal → Dog(能处理 Animal 的就能处理 Dog)
// 返回值协变:Dog → Animal(返回 Dog 就是返回 Animal)
Func<Dog, Animal> converted = original; // ✅ 双向转换!
Animal result = converted(new Dog { Name = "输入" });
Console.WriteLine(result); // 变换来的
}
}
八、快速判断表——能不能转?
8.1 常用的类型
| 类型 | 支持协变? | 支持逆变? | 原因 |
|---|---|---|---|
IEnumerable<T> |
✅ | ❌ | 只读(只出不进),定义有 out |
IEnumerator<T> |
✅ | ❌ | 只读,定义有 out |
IReadOnlyList<T> |
✅ | ❌ | 只读,定义有 out |
Func<TResult> |
✅ | ❌ | 只有返回值,定义有 out |
IComparer<T> |
❌ | ✅ | 只接收参数(只进不出),定义有 in |
IEqualityComparer<T> |
❌ | ✅ | 同上,定义有 in |
Action<T> |
❌ | ✅ | 只接收参数,定义有 in |
List<T> |
❌ | ❌ | 又能读又能写,不安全 |
Dictionary<TKey,TValue> |
❌ | ❌ | 又能读又能写 |
T[] (数组) |
⚠️ 危险 | ❌ | 历史遗留,运行时检查 |
8.2 判断口诀
类型参数用在哪?问自己两个问题:
1. 只出现在返回值位置? → 用 out(协变)
例:T GetItem() → IEnumerable<T>, Func<T>
2. 只出现在参数位置? → 用 in(逆变)
例:void SetItem(T item) → IComparer<T>, Action<T>
3. 两个位置都出现? → 不能变!
例:List<T> 的 Add(T item) 和 T this[int i]
8.3 一张图总结
只输出(返回值)
← out 协变可行 →
派生类型 ────────────────────────────→ 基类型
Dog IEnumerable<Dog> IEnumerable<Animal>
Cat Func<Cat> Func<Animal>
string IReadOnlyList<string> IReadOnlyList<object>
只输入(参数)
← in 逆变可行 →
基类型 ────────────────────────────→ 派生类型
Animal IComparer<Animal> IComparer<Dog>
object Action<object> Action<string>
Message IMessageHandler<Message> IMessageHandler<TextMessage>
九、自定义泛型时的注意事项
9.1 什么时候加 out?
// ✅ 正确:T 只出现在返回位置
interface IReader<out T>
{
T Read(); // ✅ 返回 T —— 输出位置
IEnumerable<T> ReadAll(); // ✅ 返回 T —— 输出位置
}
// ❌ 错误:T 还出现在参数位置
// interface IRepository<out T>
// {
// T GetById(int id); // ✅ 返回,没问题
// void Save(T entity); // ❌ T 出现在参数位置了!
// }
9.2 什么时候加 in?
// ✅ 正确:T 只出现在参数位置
interface IWriter<in T>
{
void Write(T data); // ✅ 参数 —— 输入位置
void WriteAll(T[] data); // ✅ 参数 —— 输入位置
}
// ❌ 错误:T 还出现在返回位置
// interface IProcessor<in T>
// {
// void Process(T data); // ✅ 输入,没问题
// T GetResult(); // ❌ T 出现在返回值位置了!
// }
9.3 同时需要进和出?拆成两个接口
// 如果一个类型既需要读又需要写,拆开来:
interface IReader<out T>
{
T Read();
}
interface IWriter<in T>
{
void Write(T item);
}
// 实现时合并
class Repository<T> : IReader<T>, IWriter<T>
{
private T _data;
public T Read() => _data;
public void Write(T item) => _data = item;
}
// 使用——各取所需
Repository<Dog> dogRepo = new Repository<Dog>();
IReader<Animal> reader = dogRepo; // ✅ 协变(只读)
IWriter<Dog> writer = dogRepo; // ✅ 直接赋值
// IWriter<Animal> animalWriter = dogRepo; // ❌ 这不行
// 但可以这样:
Action<Dog> writeDog = d => dogRepo.Write(d);
十、常见易错点(避坑指南)
坑1:泛型类本身不支持协变/逆变
// ❌ 只有接口和委托可以标记 in/out,类不行!
// class MyClass<out T> {} // 编译错误!
// ✅ 只能用在接口或委托上
interface IMyInterface<out T> { } // ✅ 接口可以
delegate T MyDelegate<out T>(); // ✅ 委托可以
坑2:值类型不参与协变/逆变
// ❌ 协变/逆变只适用于引用类型!
IEnumerable<int> ints = new List<int> { 1, 2, 3 };
// IEnumerable<object> objs = ints; // 编译错误!int 是值类型
// ✅ 引用类型可以
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objs = strings; // ✅ string 是引用类型
坑3:不能同时标记 out 和 in
// ❌ 一个类型参数不能同时是 out 和 in
// interface IBoth<out in T> {} // 编译错误!
坑4:数组假协变
// ⚠️ 数组支持协变是历史旧账,很危险!
Dog[] dogs = { new Dog { Name = "A" }, new Dog { Name = "B" } };
Animal[] animals = dogs; // ⚠️ 编译通过
// animals[0] = new Cat(); // 运行时 ArrayTypeMismatchException!
// 建议:用只读集合代替
IReadOnlyList<Dog> safeDogs = new List<Dog> { new Dog(), new Dog() };
IReadOnlyList<Animal> safeAnimals = safeDogs; // ✅ 安全,不能写
坑5:逆变方法——参数是"能接收什么"不是"本身就是什么"
// 逆变的理解关键:
Action<Animal> feedAnimal = a => Console.WriteLine($"喂 {a.GetType().Name}");
// 逆变:Action<Animal> → Action<Dog>
// 意思是:一个"能接收 Animal"的方法,可以当作"能接收 Dog"的方法来用
// 因为 Dog 是 Animal 的子集,能处理 Animal 肯定能处理 Dog
Action<Dog> feedDog = feedAnimal; // ✅
feedDog(new Dog());
// 而不是反过来理解!
// Action<Dog> → Action<Animal> 这个不成立
十一、总结
核心概念速查表
| 概念 | 关键字 | 方向 | 生活比喻 | 典型类型 |
|---|---|---|---|---|
| 协变 | out |
派生→基类 | 苹果箱可以当水果箱读数 | IEnumerable<T>, Func<T> |
| 逆变 | in |
基类→派生 | 能喂动物的机器就能喂狗 | IComparer<T>, Action<T> |
| 不变 | 无 | 不能转 | List 既能读又能写 | List<T>, Dictionary<K,V> |
记忆三句话
协变 out(输出) → 派生代替基 → "返回的狗就是动物" → IEnumerable<T>
逆变 in (输入) → 基代替派生 → "能喂动物的就能喂狗" → Action<T>
不变 无标记 → 都不能代替 → List 读了又写不安全
什么时候会用到?
| 场景 | 用的什么 |
|---|---|
把 List<Dog> 当 IEnumerable<Animal> 遍历 |
协变 |
把 DogComparer 用在 List<Animal> 排序 |
(不需要,反过来是逆变) |
给 Action<Dog> 绑定一个处理 Animal 的方法 |
逆变 |
返回 Dog 的方法当作返回 Animal 的方法用 |
协变 |
接收 Message 的处理器用来处理 TextMessage |
逆变 |
一句话总结:协变(out)让"输出更具体类型的"可以当成"输出更宽泛类型的"来用;逆变(in)让"输入更宽泛类型的"可以当成"输入更具体类型的"来用。核心原则:只出不进用 out,只进不出用 in,又进又出不能变。