要理解 协变(Covariance)和逆变(Contravariance) ,核心是抓住「泛型接口 / 泛型委托中,派生类型与基类型的转换规则 」------ 它们是泛型的进阶特性,解决了 "IEnumerable<子类> 能不能转 IEnumerable<基类>""Action<基类> 能不能转 Action<子类>" 这类实际开发中的类型转换问题。
结合之前的泛型、类型安全、引用类型等基础,下面用「前提铺垫→核心定义→通俗示例→代码实战→误区总结」的逻辑,从零教会你协变逆变:
一、先搞懂:协变逆变的「适用场景」和「前提条件」
在学具体概念前,必须先明确两个关键前提(否则会越学越乱):
1. 适用范围
仅支持 泛型接口 (如 IEnumerable<T>)和 泛型委托 (如 Func<T>、Action<T>),泛型类不支持协变逆变 (比如 List<T> 不能直接转 List<基类>)。换句话说就是只有接口和委托才有这种逆天操作。
2. 核心前提
仅针对 引用类型 的转换(如 string→object、DigitalGoods→Goods),值类型不支持协变逆变 (如 int→object 是装箱,不是协变)。
3. 先明确:什么是 "派生类型→基类型""基类型→派生类型"?
假设有两个引用类型的继承关系(校园二手交易平台场景):
cs
// 基类:商品
public class Goods { public int Id { get; set; } }
// 派生类1:数码商品(继承自商品)
public class DigitalGoods : Goods { public int WarrantyMonths { get; set; } }
// 派生类2:书籍商品(继承自商品)
public class BookGoods : Goods { public string Author { get; set; } }
- 派生→基 :
DigitalGoods(子类) →Goods(父类)(天然兼容,比如Goods g = new DigitalGoods();合法); - 基→派生 :
Goods(父类) →DigitalGoods(子类)(不天然兼容,需强制转换,可能抛异常)。
二、协变(Covariance):子类→基类的 "安全转换"
1. 定义
协变是指:泛型接口 / 委托中,当泛型参数是 "输出型"(仅返回 / 读取,不修改)时,允许将 "泛型参数为子类" 的类型,隐式转换为 "泛型参数为基类" 的类型。
核心关键字:out(标记泛型参数为 "输出型",仅用于返回值 / 只读场景)。
2. 通俗理解:"能装子类的容器,也能当装基类的容器用"
比如:IEnumerable<DigitalGoods>(数码商品集合)可以隐式转 IEnumerable<Goods>(商品集合)------ 因为你从集合里 "读"(输出)的都是数码商品,而数码商品本质是商品,完全兼容。
3. 为什么需要协变?(解决的痛点)
没有协变时,泛型接口的子类类型无法直接转基类类型,导致代码冗余:
cs
// 假设有一个无协变的接口(没有out关键字)
public interface IReadOnlyCollection<T>
{
T GetItem(int index); // 仅读取(输出),不修改
}
// 实现:数码商品集合
public class DigitalGoodsCollection : IReadOnlyCollection<DigitalGoods>
{
public DigitalGoods GetItem(int index) => new DigitalGoods { Id = index };
}
// 问题:无法直接转换,编译报错!
IReadOnlyCollection<DigitalGoods> digitalColl = new DigitalGoodsCollection();
// 报错:Cannot implicitly convert type 'IReadOnlyCollection<DigitalGoods>' to 'IReadOnlyCollection<Goods>'
IReadOnlyCollection<Goods> goodsColl = digitalColl;
有了协变(加 out 关键字),就能直接隐式转换,代码更简洁。
4. 协变实战:用 out 实现泛型接口协变
cs
// 协变接口:泛型参数T加out关键字,标记为"输出型"
public interface IReadOnlyCollection<out T>
{
T GetItem(int index); // 仅返回T(输出),不接收T作为参数(不修改)
}
// 实现:数码商品集合
public class DigitalGoodsCollection : IReadOnlyCollection<DigitalGoods>
{
public DigitalGoods GetItem(int index) => new DigitalGoods { Id = index, WarrantyMonths = 12 };
}
// 实现:书籍商品集合
public class BookGoodsCollection : IReadOnlyCollection<BookGoods>
{
public BookGoods GetItem(int index) => new BookGoods { Id = index, Author = "张三" };
}
// 测试协变转换(合法!)
public static void CovarianceDemo()
{
// 1. 数码商品集合 → 商品集合(子类→基类)
IReadOnlyCollection<DigitalGoods> digitalColl = new DigitalGoodsCollection();
IReadOnlyCollection<Goods> goodsColl1 = digitalColl; // 隐式转换,无报错
// 2. 书籍商品集合 → 商品集合(子类→基类)
IReadOnlyCollection<BookGoods> bookColl = new BookGoodsCollection();
IReadOnlyCollection<Goods> goodsColl2 = bookColl; // 隐式转换,无报错
// 调用:返回的是子类对象,但用基类接收,安全!
Goods goods1 = goodsColl1.GetItem(0); // 实际是DigitalGoods
Goods goods2 = goodsColl2.GetItem(0); // 实际是BookGoods
Console.WriteLine(goods1.GetType().Name); // 输出:DigitalGoods
Console.WriteLine(goods2.GetType().Name); // 输出:BookGoods
}
5. 系统内置的协变接口:IEnumerable<out T>
C# 中最常用的协变接口就是 IEnumerable<out T>(注意 T 带 out),所以你平时写的:
cs
List<DigitalGoods> digitalList = new List<DigitalGoods> { new DigitalGoods() };
IEnumerable<Goods> goodsEnumerable = digitalList; // 合法!因为IEnumerable<T>是协变的
本质就是协变的应用 ------List<DigitalGoods> 实现了 IEnumerable<DigitalGoods>,而协变允许它隐式转 IEnumerable<Goods>。
三、逆变(Contravariance):基类→子类的 "反向安全转换"
1. 定义
逆变是指:泛型接口 / 泛型委托中,当泛型参数是 "输入型"(仅接收参数,不返回)时,允许将 "泛型参数为基类" 的类型,隐式转换为 "泛型参数为子类" 的类型。
核心关键字:in(标记泛型参数为 "输入型",仅用于接收参数,不用于返回值)。
2. 通俗理解:"能操作基类的方法 / 接口,也能操作子类"
比如:Action<Goods>(接收商品参数的委托)可以隐式转 Action<DigitalGoods>(接收数码商品参数的委托)------ 因为 "操作商品的逻辑"(如修改商品 ID),必然能作用于 "数码商品"(数码商品是商品的子类,拥有商品的所有属性)。
3. 为什么需要逆变?(解决的痛点)
没有逆变时,针对基类的操作无法直接用于子类,导致代码冗余:
cs
// 假设有一个无逆变的接口(没有in关键字)
public interface IWriter<T>
{
void Write(T item); // 仅接收T(输入),不返回T
}
// 实现:商品写入器(操作基类Goods)
public class GoodsWriter : IWriter<Goods>
{
public void Write(Goods item) => Console.WriteLine($"修改商品ID为:{item.Id + 100}");
}
// 问题:无法直接转换,编译报错!
IWriter<Goods> goodsWriter = new GoodsWriter();
// 报错:Cannot implicitly convert type 'IWriter<Goods>' to 'IWriter<DigitalGoods>'
IWriter<DigitalGoods> digitalWriter = goodsWriter;
有了逆变(加 in 关键字),就能直接隐式转换,复用基类的操作逻辑。
4. 逆变实战:用 in 实现泛型接口逆变
cs
// 逆变接口:泛型参数T加in关键字,标记为"输入型"
public interface IWriter<in T>
{
void Write(T item); // 仅接收T(输入),不返回T
}
// 实现:商品写入器(操作基类Goods)
public class GoodsWriter : IWriter<Goods>
{
public void Write(Goods item) => Console.WriteLine($"商品ID更新为:{item.Id + 100}");
}
// 测试逆变转换(合法!)
public static void ContravarianceDemo()
{
// 基类写入器 → 子类写入器(基类→子类)
IWriter<Goods> goodsWriter = new GoodsWriter();
IWriter<DigitalGoods> digitalWriter = goodsWriter; // 隐式转换,无报错
IWriter<BookGoods> bookWriter = goodsWriter; // 隐式转换,无报错
// 调用:传入子类对象,用基类的逻辑操作,安全!
digitalWriter.Write(new DigitalGoods { Id = 1001 }); // 输出:商品ID更新为:1101
bookWriter.Write(new BookGoods { Id = 2001 }); // 输出:商品ID更新为:2101
}
5. 系统内置的逆变委托:Action<in T>
C# 中常用的逆变委托是 Action<in T>(注意 T 带 in),所以你平时写的:
cs
// 接收Goods参数的委托(基类)
Action<Goods> updateGoodsId = g => g.Id += 100;
// 转换为接收DigitalGoods参数的委托(子类),合法!
Action<DigitalGoods> updateDigitalId = updateGoodsId;
// 调用:传入DigitalGoods,执行基类的逻辑
updateDigitalId(new DigitalGoods { Id = 1001 }); // 结果:Id=1101
本质就是逆变的应用 ------Action<Goods> 是基类委托,逆变允许它隐式转 Action<DigitalGoods>。
四、协变 vs 逆变:核心区别对比(必记)
| 特性 | 协变(Covariance) | 逆变(Contravariance) |
|---|---|---|
| 关键字 | out(标记泛型参数) |
in(标记泛型参数) |
| 转换方向 | 子类→基类(T子类→T基类) |
基类→子类(T基类→T子类) |
| 泛型参数用途 | 仅输出(返回值、只读属性) | 仅输入(方法参数) |
| 核心场景 | "读取 / 获取" 数据(如集合遍历) | "写入 / 操作" 数据(如修改对象属性) |
| 是否允许修改 | 不允许修改泛型参数对应的对象 | 允许修改,但仅操作基类共有的属性 |
| 系统示例 | IEnumerable<out T>、Func<out T> |
Action<in T>、IComparer<in T> |
关键口诀(帮你记):
out协变:出(输出)子类,能转基类;in逆变:入(输入)基类,能转子类。
五、协变逆变的「核心限制」和「常见误区」
1. 仅支持引用类型,值类型不生效
协变逆变的本质是 "引用的安全转换",值类型(int、DateTime)没有引用,所以不支持:
cs
// 错误:int是值类型,不支持协变
IEnumerable<int> intList = new List<int> { 1 };
// IEnumerable<object> objList = intList; // 编译报错!
// 正确:string是引用类型,支持协变
IEnumerable<string> strList = new List<string> { "a" };
IEnumerable<object> objList = strList; // 合法
2. out 和 in 不能混用
一个泛型参数不能同时加 out 和 in,必须明确是 "输出" 还是 "输入":
cs
// 错误:T不能同时是out和in
public interface IInvalid<out in T> { }
3. 泛型类不支持协变逆变(仅接口 / 委托支持)
比如 List<T> 是泛型类,即使 T 是引用类型,也不能直接协变:
cs
List<DigitalGoods> digitalList = new List<DigitalGoods>();
// List<Goods> goodsList = digitalList; // 编译报错!因为List<T>是泛型类,不支持协变
// 正确:转成支持协变的接口IEnumerable<T>
IEnumerable<Goods> goodsEnumerable = digitalList; // 合法
4. 协变接口不能接收泛型参数作为方法参数
协变接口的 T 带 out(输出),如果方法接收 T 作为参数,编译报错(避免修改子类对象导致不安全):
cs
public interface IReadOnlyCollection<out T>
{
T GetItem(int index); // 合法(输出)
// void SetItem(int index, T item); // 错误!协变接口不能接收T作为参数
}
5. 逆变接口不能返回泛型参数
逆变接口的 T 带 in(输入),如果方法返回 T,编译报错(避免返回基类对象赋值给子类导致不安全):
cs
public interface IWriter<in T>
{
void Write(T item); // 合法(输入)
// T GetItem(int index); // 错误!逆变接口不能返回T
}
六、实战:协变逆变在项目中的应用(校园二手交易平台)
场景 1:协变 ------ 统一遍历不同类型的商品
cs
// 模拟数据:不同类型的商品列表
List<DigitalGoods> digitalGoods = new List<DigitalGoods>
{
new DigitalGoods { Id=1001, Name="二手笔记本", WarrantyMonths=12 },
new DigitalGoods { Id=1002, Name="二手手机", WarrantyMonths=6 }
};
List<BookGoods> bookGoods = new List<BookGoods>
{
new BookGoods { Id=2001, Name="C#编程", Author="张三" },
new BookGoods { Id=2002, Name="Java入门", Author="李四" }
};
// 协变:统一用IEnumerable<Goods>接收,复用遍历逻辑
void PrintAllGoods(IEnumerable<Goods> goods)
{
foreach (var g in goods)
{
Console.WriteLine($"商品ID:{g.Id},名称:{g.Name}");
}
}
// 调用:不同类型的商品列表都能传入
PrintAllGoods(digitalGoods); // 遍历数码商品
PrintAllGoods(bookGoods); // 遍历书籍商品
场景 2:逆变 ------ 统一操作不同类型的商品
cs
// 逆变委托:用Action<Goods>统一操作所有商品
Action<Goods> discountGoods = g => g.Price *= 0.8m; // 所有商品打8折
// 转换为操作子类的委托
Action<DigitalGoods> discountDigital = discountGoods;
Action<BookGoods> discountBook = discountGoods;
// 调用:不同类型的商品都能享受折扣逻辑
var laptop = new DigitalGoods { Id=1001, Name="二手笔记本", Price=2999.9m };
var book = new BookGoods { Id=2001, Name="C#编程", Price=29.9m };
discountDigital(laptop);
discountBook(book);
Console.WriteLine(laptop.Price); // 输出:2399.92
Console.WriteLine(book.Price); // 输出:23.92
七、总结:协变逆变的核心价值
协变逆变的本质是「在泛型接口 / 委托中,让类型转换更灵活,同时保证类型安全」------ 它们没有增加新功能,而是解决了 "泛型类型转换冗余" 的问题,让代码更简洁、复用性更强。
核心知识点速记:
- 协变:
out+ 子类→基类 + 输出场景(如遍历集合); - 逆变:
in+ 基类→子类 + 输入场景(如修改对象); - 仅支持泛型接口 / 委托,仅支持引用类型;
- 系统常用示例:
IEnumerable<out T>(协变)、Action<in T>(逆变)。
对实战来说,协变逆变最常用的场景是「统一处理不同类型的商品」(如遍历所有商品、批量修改商品属性),或者「封装通用的泛型工具类」(如通用的商品筛选、排序逻辑)------ 学会后能让你的泛型代码更灵活,避免不必要的类型转换和代码冗余。