.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>(逆变)。

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

相关推荐
步步为营DotNet2 小时前
深度解析.NET中HttpClient的生命周期管理:构建稳健高效的HTTP客户端
网络协议·http·.net
缺点内向3 小时前
如何在 C# 中高效的将 XML 转换为 PDF
xml·后端·pdf·c#·.net
时光追逐者3 小时前
Visual Studio 2026 正式版下载与安装详细教程!
ide·c#·.net·.net core·visual studio
唐青枫3 小时前
C# 列表模式(List Patterns)深度解析:模式匹配再进化!
c#·.net
步步为营DotNet1 天前
深入理解IAsyncEnumerable:.NET中的异步迭代利器
服务器·前端·.net
玩泥巴的1 天前
强的飞起的 Roslyn 编译时代码生成,实现抽象类继承与依赖注入的自动化配置
c#·.net·代码生成·roslyn
mudtools1 天前
强的飞起的 Roslyn 编译时代码生成,实现抽象类继承与依赖注入的自动化配置
c#·.net
马达加斯加D1 天前
Web框架 --- .Net中的Worker Service
.net