.NET进阶——深入理解泛型(4)泛型的协变逆变

要理解 协变(Covariance)和逆变(Contravariance) ,核心是抓住「泛型接口 / 泛型委托中,派生类型与基类型的转换规则 」------ 它们是泛型的进阶特性,解决了 "IEnumerable<子类> 能不能转 IEnumerable<基类>""Action<基类> 能不能转 Action<子类>" 这类实际开发中的类型转换问题。

结合之前的泛型、类型安全、引用类型等基础,下面用「前提铺垫→核心定义→通俗示例→代码实战→误区总结」的逻辑,从零教会你协变逆变:

一、先搞懂:协变逆变的「适用场景」和「前提条件」

在学具体概念前,必须先明确两个关键前提(否则会越学越乱):

1. 适用范围

仅支持 泛型接口 (如 IEnumerable<T>)和 泛型委托 (如 Func<T>Action<T>),泛型类不支持协变逆变 (比如 List<T> 不能直接转 List<基类>)。换句话说就是只有接口委托才有这种逆天操作。

2. 核心前提

仅针对 引用类型 的转换(如 string→objectDigitalGoods→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>(注意 Tout),所以你平时写的:

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>(注意 Tin),所以你平时写的:

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. 仅支持引用类型,值类型不生效

协变逆变的本质是 "引用的安全转换",值类型(intDateTime)没有引用,所以不支持:

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. outin 不能混用

一个泛型参数不能同时加 outin,必须明确是 "输出" 还是 "输入":

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. 协变接口不能接收泛型参数作为方法参数

协变接口的 Tout(输出),如果方法接收 T 作为参数,编译报错(避免修改子类对象导致不安全):

cs 复制代码
public interface IReadOnlyCollection<out T>
{
    T GetItem(int index); // 合法(输出)
    // void SetItem(int index, T item); // 错误!协变接口不能接收T作为参数
}

5. 逆变接口不能返回泛型参数

逆变接口的 Tin(输入),如果方法返回 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

七、总结:协变逆变的核心价值

协变逆变的本质是「在泛型接口 / 委托中,让类型转换更灵活,同时保证类型安全」------ 它们没有增加新功能,而是解决了 "泛型类型转换冗余" 的问题,让代码更简洁、复用性更强。

核心知识点速记:

  1. 协变:out + 子类→基类 + 输出场景(如遍历集合);
  2. 逆变:in + 基类→子类 + 输入场景(如修改对象);
  3. 仅支持泛型接口 / 委托,仅支持引用类型;
  4. 系统常用示例:IEnumerable<out T>(协变)、Action<in T>(逆变)。

对实战来说,协变逆变最常用的场景是「统一处理不同类型的商品」(如遍历所有商品、批量修改商品属性),或者「封装通用的泛型工具类」(如通用的商品筛选、排序逻辑)------ 学会后能让你的泛型代码更灵活,避免不必要的类型转换和代码冗余。

相关推荐
波波0079 小时前
每日一题:中间件是如何工作的?
中间件·.net·面试题
无风听海10 小时前
.NET 10之可空引用类型
数据结构·.net
码云数智-园园11 小时前
基于 JSON 配置的 .NET 桌面应用自动更新实现指南
.net
无风听海11 小时前
.NET 10 之dotnet run的功能
.net
岩屿11 小时前
Ubuntu下安装Docker并部署.NET API(二)
运维·docker·容器·.net
码云数智-大飞11 小时前
.NET 中高效实现 List 集合去重的多种方法详解
.net
easyboot11 小时前
使用tinyply.net保存ply格式点云
.net
张人玉11 小时前
WPF 多语言实现完整笔记(.NET 4.7.2)
笔记·.net·wpf·多语言实现·多语言适配
波波0071 天前
Native AOT 能改变什么?.NET 预编译技术深度剖析
开发语言·.net
Crazy Struggle2 天前
.NET 中如何快速实现 List 集合去重?
c#·.net