C# 逆变与协变全解析:看懂泛型里的 `in` 和 `out`,接口与委托不再绕!

发布时间:2026/10/23 7:58:42
C# 逆变与协变全解析:看懂泛型里的 `in` 和 `out`,接口与委托不再绕!
一句话概括协变out让“更具体的结果”可以当成“更通用的结果”来用逆变in让“能处理更通用数据的方法”也能处理更具体的数据。理解它们就能看懂IEnumerableT、ActionT、FuncT, TResult背后的类型转换规则。文章目录为什么学了继承还会被泛型类型转换难住先搞懂一个前提派生类可以当基类用为什么ListDog不能直接当成ListAnimal协变out让“返回更具体的类型”变得灵活逆变in让“接收更通用的类型”也能处理具体对象自定义泛型接口把in和out用到自己的设计里委托中的逆变与协变看懂Action和Funcin、out不只一种含义别和参数关键字混淆常见泛型接口与委托速查新手最容易踩的 6 个坑实战总结什么时候需要关注逆变与协变一、为什么学了继承还会被泛型类型转换难住假设项目里有一个基类Animal还有两个派生类Dog和CatpublicclassAnimal{}publicclassDog:Animal{}publicclassCat:Animal{}因为Dog继承自Animal所以普通的继承转换完全没问题DogdognewDog();Animalanimaldog;这很好理解狗是一种动物因此可以把狗对象放到一个Animal类型的变量中。但当泛型出现后情况就不一样了ListDogdogsnewListDog();// ListAnimal animals dogs; // 编译错误虽然Dog是Animal但ListDog并不是ListAnimal。这往往让刚学完继承和泛型的人感到困惑既然狗是动物为什么装狗的列表不能当成装动物的列表答案是泛型类型默认是不变的。如果编译器允许上面的转换就可能把一只猫放进原本只允许存放狗的列表里破坏类型安全。C# 的逆变与协变就是在保证类型安全的前提下让部分泛型接口和委托支持更灵活的类型转换。二、先搞懂一个前提派生类可以当基类用为了讲清楚逆变和协变先统一本文使用的继承关系publicclassAnimal{publicstringName{get;set;}publicAnimal(stringname){Namename;}}publicclassDog:Animal{publicDog(stringname):base(name){}}publicclassCat:Animal{publicCat(stringname):base(name){}}其中Animal是基类Dog和Cat是派生类。普通赋值遵循下面的方向DogdognewDog(旺财);Animalanimaldog;// 可以Dog 转成 Animal可以把它记成具体的东西可以按更通用的身份来看待但通用的东西不一定能当成某个具体类型。例如狗一定是动物但一只动物不一定是狗。后面所有的规则都可以围绕这个方向理解协变主要关注“提供、返回什么”允许把更具体的类型结果当成更通用的类型结果使用。逆变主要关注“接收、处理什么”允许把能处理更通用类型的对象当成能处理更具体类型的对象使用。三、为什么ListDog不能直接当成ListAnimal先看一个容易误判的例子ListDogdogsnewListDog();dogs.Add(newDog(旺财));// ListAnimal animals dogs; // 编译错误问题在于ListT不只是读取数据它还可以添加数据。假设这次转换被允许代码就可以这样写// 假设这种转换可以成立实际上不能编译ListDogdogsnewListDog();// ListAnimal animals dogs;// animals.Add(new Cat(小花));如果animals真能引用同一个只接受Dog的列表通过animals.Add(new Cat(...))就会把猫放进狗列表中后续读取时便可能出现类型安全问题。因此ListT的类型参数T是不变的InvariantListDog不会因为Dog继承Animal就自动变成ListAnimal。但如果只需要读取数据可以使用支持协变的IEnumerableTListDogdogsnewListDog{newDog(旺财),newDog(团子)};IEnumerableDogdogEnumerabledogs;IEnumerableAnimalanimalEnumerabledogEnumerable;foreach(AnimalanimalinanimalEnumerable){Console.WriteLine(animal.Name);}输出旺财 团子IEnumerableout T主要用于枚举、读取元素不提供向其中添加元素的接口成员因此可以安全地把IEnumerableDog当成IEnumerableAnimal使用。这就是协变最常见的应用之一。四、协变out让“返回更具体的类型”变得灵活4.1 协变到底是什么**协变Covariance**允许泛型接口或委托的类型参数沿着继承关系从派生类型转换到基类类型。声明协变类型参数时使用out。先记住一个简单口诀只负责提供结果时通常可以考虑协变。例如IEnumerableDogdogsnewListDog();IEnumerableAnimalanimalsdogs;// 可以IEnumerableT 的 T 是协变的这里的方向是从Dog到Animal。IEnumerableDog提供的每个元素都是Dog而调用方只要求得到Animal所以把它当成IEnumerableAnimal使用是安全的。4.2 自己声明一个协变接口可以使用out声明一个只负责返回数据的泛型接口publicinterfaceIProduceroutT{TGet();}publicclassDogProducer:IProducerDog{publicDogGet()newDog(旺财);}使用时IProducerDogdogProducernewDogProducer();IProducerAnimalanimalProducerdogProducer;AnimalanimalanimalProducer.Get();Console.WriteLine(animal.Name);输出旺财DogProducer生产的是狗但接口调用方只需要一个动物因此可以把IProducerDog转换成IProducerAnimal。4.3 为什么out T不能随便用协变类型参数T可以用在接口成员的返回位置但不能直接用作普通输入参数的类型publicinterfaceIProduceroutT{TGet();// void Add(T item); // 编译错误协变的 T 不能作为输入参数类型}原因并不复杂假设IProducerDog可以转成IProducerAnimal如果这个接口还允许调用方添加任意Animal就可能把Cat塞给只适合处理Dog的实现。限制out T只能出现在允许的位置是为了保证类型安全。理解重点out在泛型接口和委托的声明中表示“协变”重点是类型参数可以出现在输出位置它不是说这个接口一定要有一个名为out的参数。五、逆变in让“接收更通用的类型”也能处理具体对象5.1 逆变到底是什么**逆变Contravariance**允许泛型接口或委托的类型参数沿着继承关系从基类方向用于派生类类型参数。声明逆变类型参数时使用in。可以这样理解能处理所有动物的工具当然也能处理狗。但只会处理狗的工具不一定能处理猫。假设有一个接口专门用于接收并处理数据publicinterfaceIConsumerinT{voidConsume(Titem);}publicclassAnimalPrinter:IConsumerAnimal{publicvoidConsume(Animalanimal){Console.WriteLine($收到动物{animal.Name});}}AnimalPrinter可以处理任何Animal自然也可以处理Dog。所以可以这样赋值IConsumerAnimalanimalConsumernewAnimalPrinter();IConsumerDogdogConsumeranimalConsumer;dogConsumer.Consume(newDog(旺财));输出收到动物旺财注意转换方向这里是IConsumerAnimal转成IConsumerDog和协变正好相反。因为这个消费者能处理更广泛的动物类型所以把它当成一个专门接收狗的消费者使用是安全的。5.2 为什么in T适合输入位置逆变类型参数T可以用在接口成员的输入参数位置publicinterfaceIConsumerinT{voidConsume(Titem);// T Get(); // 编译错误逆变的 T 不能直接作为返回类型}如果一个只接收Dog的变量实际指向IConsumerAnimal调用方传入狗没有问题因为狗就是动物。反过来如果只接收狗的对象被当作可以处理任意动物的对象就可能传入猫而出错。因此逆变的转换方向必须是这样设计的。六、自定义泛型接口把in和out用到自己的设计里实际项目中可以根据接口承担的职责决定是否声明逆变或协变。6.1 只提供结果考虑out例如视觉检测系统中有一个结果读取接口publicinterfaceIResultProvideroutT{TGetResult();}如果某个实现提供CircleResult而调用方只关心更通用的ShapeResult协变就能让它们更容易配合使用。前提是CircleResult确实继承自ShapeResult并且接口设计符合协变的输出规则。6.2 只接收数据考虑in例如给检测结果写日志、输出报告或执行统一处理publicinterfaceIResultHandlerinT{voidHandle(Tresult);}一个能处理所有ShapeResult的处理器也可以接收更具体的CircleResult。这样的设计能减少不必要的类型适配代码。6.3 同时输入和输出时通常不能标记为变体如果同一个类型参数既要作为输入又要作为输出一般不能直接声明为in或outpublicinterfaceIRepositoryT{TGetById(intid);voidSave(Titem);}T既出现在GetById的返回位置也出现在Save的参数位置所以此处应保持不变。这个设计并不是错误而是它的职责决定了它需要同时读取和接收T。需要注意in和out只能用于泛型接口、泛型委托的类型参数声明不能用于给普通泛型类或泛型结构体声明变体类型参数。例如不能写成class Boxout T。七、委托中的逆变与协变看懂Action和Func逆变和协变不仅用于泛型接口也大量出现在 C# 内置委托中。平时经常使用的ActionT和FuncT, TResult就已经定义好了对应规则。7.1ActionT参数位置支持逆变ActionT表示一个接收参数、没有返回值的委托。它的类型参数是逆变的因此ActionAnimal可以赋值给ActionDogActionAnimalprintAnimalanimalConsole.WriteLine(animal.Name);ActionDogprintDogprintAnimal;printDog(newDog(旺财));输出旺财原因是printAnimal本来就能接收任何动物接收狗当然没有问题。7.2FuncT, TResult输入逆变输出协变FuncT, TResult的第一个类型参数用于输入支持逆变最后一个类型参数用于返回结果支持协变。FuncAnimal,stringdescribeAnimalanimalanimal.Name;FuncDog,objectdescribeDogdescribeAnimal;objectresultdescribeDog(newDog(团子));Console.WriteLine(result);输出团子这段代码同时使用了两种变体规则输入类型从Animal变成Dog属于逆变。原方法能接收所有动物因此接收狗没有问题。返回类型从string变成object属于协变。string可以向上转换为object所以调用方把结果当作object接收是安全的。7.3 方法组也可以体现变体转换协变和逆变不只存在于泛型类型之间在委托绑定方法时也能见到相似规则staticstringGetName()旺财;staticvoidPrintObject(objectvalue)Console.WriteLine(value);FuncobjectgetObjectGetName;ActionstringprintStringPrintObject;Console.WriteLine(getObject());printString(Hello C#);GetName返回string而委托允许返回objectPrintObject接收object而Actionstring只会传入字符串。这两种调用都是安全的。八、in、out不只一种含义别和参数关键字混淆这是 C# 学习中很容易混淆的地方in和out既能出现在泛型类型参数声明中也能作为方法参数的关键字但它们表达的不是同一件事。写法所在位置含义IEnumerableout T泛型接口声明out表示协变限制类型参数的使用位置IConsumerin T泛型接口声明in表示逆变限制类型参数的使用位置void Read(in int value)方法参数列表参数以只读引用方式传递调用方的值不能通过该参数被修改bool TryGet(out int value)方法参数列表方法通过out参数向调用方写入结果调用前不要求变量已赋值因此泛型声明中的out不是“方法返回值”泛型声明中的in也不是“只读引用参数”。判断含义时先看关键字出现在哪个位置。九、常见泛型接口与委托速查.NET 中已经有许多定义好变体规则的接口和委托可以直接使用不需要自己重新声明。类型变体规则通俗理解IEnumerableout T协变枚举读取元素IEnumerableDog可以当成IEnumerableAnimalIReadOnlyListout T协变通过只读列表读取元素支持协变转换IEnumeratorout T协变通过枚举器读取元素IComparerin T逆变能比较更通用的类型也能用于比较派生类型IEqualityComparerin T逆变能判断基类对象是否相等也可用于派生类型Actionin T逆变方法接收参数、不返回结果Funcin T, out TResult输入逆变、返回协变输入和返回类型分别按各自规则转换ListT不变可读可写不能直接将ListDog赋值给ListAnimal记住不是所有泛型都支持逆变或协变。必须看这个泛型类型本身有没有声明相应的变体规则。十、新手最容易踩的 6 个坑坑 1以为继承关系会自动传递到所有泛型类型Dog继承Animal并不代表ListDog继承ListAnimal。只有声明为协变或逆变的泛型接口与委托才能按规则进行对应的类型转换。坑 2把协变和逆变的方向记反记住两个最常见的例子IEnumerableDogdogsnewListDog();IEnumerableAnimalanimalsdogs;// 协变Dog - AnimalActionAnimalhandleAnimalanimalConsole.WriteLine(animal.Name);ActionDoghandleDoghandleAnimal;// 逆变ActionAnimal - ActionDog协变关注输出通常从更具体的类型到更通用的类型逆变关注输入转换方向看起来相反。坑 3认为ListT也可以直接变体转换ListT提供添加和读取等操作类型参数是不变的。需要只读访问时可以考虑使用IEnumerableT或IReadOnlyListT这样的接口。坑 4把in、out当成能随处添加的修饰符只能在支持变体的泛型接口和泛型委托的类型参数声明中使用协变、逆变声明。普通泛型类和结构体不能直接声明class Boxout T或struct Boxin T。坑 5认为值类型也能像引用类型一样使用变体泛型变体转换适用于引用类型的类型参数。例如IEnumerableDog可以转换成IEnumerableAnimal但IEnumerableint不能因此转换成IEnumerableobject。值类型的装箱转换不会让泛型接口自动获得协变能力。坑 6把“能赋值”理解成“原来的对象类型变了”变体不会把Dog对象变成Animal对象也不会修改列表内部的数据。它只是允许你通过一个更通用或符合规则的接口、委托类型来使用原有对象并且仍然由类型系统保证安全。十一、实战总结什么时候需要关注逆变与协变开发时不必为了使用in或out而刻意设计复杂的接口。更好的思路是先明确接口的职责再决定是否需要变体。接口只负责提供结果类型参数只出现在输出位置时可以考虑使用out让派生类型的提供者能够用于更通用的接口变量。接口只负责接收或处理对象类型参数只出现在输入位置时可以考虑使用in让通用处理器也能处理派生类型。接口既读取又写入同一类型通常保持不变例如常见的可读写集合或仓储接口。使用框架提供的接口和委托优先了解它们已有的规则例如IEnumerableT、ActionT和FuncT, TResult通常就能解决大部分实际问题。写在最后逆变与协变看起来抽象真正理解时只需要抓住一条主线协变解决“结果能不能更具体”逆变解决“输入能不能更通用”。out常见于提供数据的接口in常见于接收数据的接口而Action、Func则把这些规则带进了日常委托使用中。当你再次遇到IEnumerableDog为什么能赋值给IEnumerableAnimal或者ActionAnimal为什么能赋值给ActionDog时就可以从“输入还是输出”“类型转换是否安全”两个角度判断而不必死记硬背。觉得有收获欢迎点赞、收藏。理解泛型的类型转换规则能让你在设计接口、使用委托和阅读 .NET 源码时更加得心应手。