云计算百科
云计算领域专业知识百科平台

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

一句话概括: 协变(out)让“更具体的结果”可以当成“更通用的结果”来用;逆变(in)让“能处理更通用数据的方法”也能处理更具体的数据。理解它们,就能看懂 IEnumerable<T>、Action<T>、Func<T, TResult> 背后的类型转换规则。

文章目录

  • 为什么学了继承,还会被泛型类型转换难住
  • 先搞懂一个前提:派生类可以当基类用
  • 为什么 List<Dog> 不能直接当成 List<Animal>
  • 协变:out 让“返回更具体的类型”变得灵活
  • 逆变:in 让“接收更通用的类型”也能处理具体对象
  • 自定义泛型接口:把 in 和 out 用到自己的设计里
  • 委托中的逆变与协变:看懂 Action 和 Func
  • in、out 不只一种含义:别和参数关键字混淆
  • 常见泛型接口与委托速查
  • 新手最容易踩的 6 个坑
  • 实战总结:什么时候需要关注逆变与协变

  • 一、为什么学了继承,还会被泛型类型转换难住

    假设项目里有一个基类 Animal,还有两个派生类 Dog 和 Cat:

    public class Animal { }
    public class Dog : Animal { }
    public class Cat : Animal { }

    因为 Dog 继承自 Animal,所以普通的继承转换完全没问题:

    Dog dog = new Dog();
    Animal animal = dog;

    这很好理解:狗是一种动物,因此可以把狗对象放到一个 Animal 类型的变量中。

    但当泛型出现后,情况就不一样了:

    List<Dog> dogs = new List<Dog>();
    // List<Animal> animals = dogs; // 编译错误

    虽然 Dog 是 Animal,但 List<Dog> 并不是 List<Animal>。这往往让刚学完继承和泛型的人感到困惑:既然狗是动物,为什么装狗的列表不能当成装动物的列表?

    答案是:泛型类型默认是不变的。如果编译器允许上面的转换,就可能把一只猫放进原本只允许存放狗的列表里,破坏类型安全。

    C# 的逆变与协变,就是在保证类型安全的前提下,让部分泛型接口和委托支持更灵活的类型转换。

    二、先搞懂一个前提:派生类可以当基类用

    为了讲清楚逆变和协变,先统一本文使用的继承关系:

    public class Animal
    {
    public string Name { get; set; }
    public Animal(string name) { Name = name; }
    }
    public class Dog : Animal
    {
    public Dog(string name) : base(name) { }
    }
    public class Cat : Animal
    {
    public Cat(string name) : base(name) { }
    }

    其中,Animal 是基类,Dog 和 Cat 是派生类。普通赋值遵循下面的方向:

    Dog dog = new Dog("旺财");
    Animal animal = dog; // 可以:Dog 转成 Animal

    可以把它记成:具体的东西,可以按更通用的身份来看待;但通用的东西,不一定能当成某个具体类型。 例如,狗一定是动物,但一只动物不一定是狗。

    后面所有的规则,都可以围绕这个方向理解:

    • 协变: 主要关注“提供、返回什么”,允许把更具体的类型结果,当成更通用的类型结果使用。
    • 逆变: 主要关注“接收、处理什么”,允许把能处理更通用类型的对象,当成能处理更具体类型的对象使用。

    三、为什么 List<Dog> 不能直接当成 List<Animal>

    先看一个容易误判的例子:

    List<Dog> dogs = new List<Dog>();
    dogs.Add(new Dog("旺财"));
    // List<Animal> animals = dogs; // 编译错误

    问题在于 List<T> 不只是读取数据,它还可以添加数据。假设这次转换被允许,代码就可以这样写:

    // 假设这种转换可以成立(实际上不能编译)
    List<Dog> dogs = new List<Dog>();
    // List<Animal> animals = dogs;
    // animals.Add(new Cat("小花"));

    如果 animals 真能引用同一个只接受 Dog 的列表,通过 animals.Add(new Cat(…)) 就会把猫放进狗列表中,后续读取时便可能出现类型安全问题。

    因此,List<T> 的类型参数 T 是不变的(Invariant):List<Dog> 不会因为 Dog 继承 Animal 就自动变成 List<Animal>。

    但如果只需要读取数据,可以使用支持协变的 IEnumerable<T>:

    List<Dog> dogs = new List<Dog>
    {
    new Dog("旺财"),
    new Dog("团子")
    };
    IEnumerable<Dog> dogEnumerable = dogs;
    IEnumerable<Animal> animalEnumerable = dogEnumerable;
    foreach (Animal animal in animalEnumerable)
    {
    Console.WriteLine(animal.Name);
    }

    输出:

    旺财
    团子

    IEnumerable<out T> 主要用于枚举、读取元素,不提供向其中添加元素的接口成员,因此可以安全地把 IEnumerable<Dog> 当成 IEnumerable<Animal> 使用。这就是协变最常见的应用之一。

    四、协变:out 让“返回更具体的类型”变得灵活

    4.1 协变到底是什么

    **协变(Covariance)**允许泛型接口或委托的类型参数,沿着继承关系从派生类型转换到基类类型。声明协变类型参数时使用 out。

    先记住一个简单口诀:只负责提供结果时,通常可以考虑协变。

    例如:

    IEnumerable<Dog> dogs = new List<Dog>();
    IEnumerable<Animal> animals = dogs; // 可以,IEnumerable<T> 的 T 是协变的

    这里的方向是从 Dog 到 Animal。IEnumerable<Dog> 提供的每个元素都是 Dog,而调用方只要求得到 Animal,所以把它当成 IEnumerable<Animal> 使用是安全的。

    4.2 自己声明一个协变接口

    可以使用 out 声明一个只负责返回数据的泛型接口:

    public interface IProducer<out T>
    {
    T Get();
    }
    public class DogProducer : IProducer<Dog>
    {
    public Dog Get() => new Dog("旺财");
    }

    使用时:

    IProducer<Dog> dogProducer = new DogProducer();
    IProducer<Animal> animalProducer = dogProducer;
    Animal animal = animalProducer.Get();
    Console.WriteLine(animal.Name);

    输出:

    旺财

    DogProducer 生产的是狗,但接口调用方只需要一个动物,因此可以把 IProducer<Dog> 转换成 IProducer<Animal>。

    4.3 为什么 out T 不能随便用

    协变类型参数 T 可以用在接口成员的返回位置,但不能直接用作普通输入参数的类型:

    public interface IProducer<out T>
    {
    T Get();
    // void Add(T item); // 编译错误:协变的 T 不能作为输入参数类型
    }

    原因并不复杂:假设 IProducer<Dog> 可以转成 IProducer<Animal>,如果这个接口还允许调用方添加任意 Animal,就可能把 Cat 塞给只适合处理 Dog 的实现。限制 out T 只能出现在允许的位置,是为了保证类型安全。

    理解重点: out 在泛型接口和委托的声明中表示“协变”,重点是类型参数可以出现在输出位置;它不是说这个接口一定要有一个名为 out 的参数。

    五、逆变:in 让“接收更通用的类型”也能处理具体对象

    5.1 逆变到底是什么

    **逆变(Contravariance)**允许泛型接口或委托的类型参数,沿着继承关系从基类方向用于派生类类型参数。声明逆变类型参数时使用 in。

    可以这样理解:能处理所有动物的工具,当然也能处理狗。 但只会处理狗的工具,不一定能处理猫。

    假设有一个接口,专门用于接收并处理数据:

    public interface IConsumer<in T>
    {
    void Consume(T item);
    }
    public class AnimalPrinter : IConsumer<Animal>
    {
    public void Consume(Animal animal)
    {
    Console.WriteLine($"收到动物:{animal.Name}");
    }
    }

    AnimalPrinter 可以处理任何 Animal,自然也可以处理 Dog。所以可以这样赋值:

    IConsumer<Animal> animalConsumer = new AnimalPrinter();
    IConsumer<Dog> dogConsumer = animalConsumer;
    dogConsumer.Consume(new Dog("旺财"));

    输出:

    收到动物:旺财

    注意转换方向:这里是 IConsumer<Animal> 转成 IConsumer<Dog>,和协变正好相反。因为这个消费者能处理更广泛的动物类型,所以把它当成一个专门接收狗的消费者使用是安全的。

    5.2 为什么 in T 适合输入位置

    逆变类型参数 T 可以用在接口成员的输入参数位置:

    public interface IConsumer<in T>
    {
    void Consume(T item);
    // T Get(); // 编译错误:逆变的 T 不能直接作为返回类型
    }

    如果一个只接收 Dog 的变量实际指向 IConsumer<Animal>,调用方传入狗没有问题,因为狗就是动物。反过来,如果只接收狗的对象被当作可以处理任意动物的对象,就可能传入猫而出错。因此逆变的转换方向必须是这样设计的。

    六、自定义泛型接口:把 in 和 out 用到自己的设计里

    实际项目中,可以根据接口承担的职责决定是否声明逆变或协变。

    6.1 只提供结果:考虑 out

    例如,视觉检测系统中有一个结果读取接口:

    public interface IResultProvider<out T>
    {
    T GetResult();
    }

    如果某个实现提供 CircleResult,而调用方只关心更通用的 ShapeResult,协变就能让它们更容易配合使用。前提是 CircleResult 确实继承自 ShapeResult,并且接口设计符合协变的输出规则。

    6.2 只接收数据:考虑 in

    例如,给检测结果写日志、输出报告或执行统一处理:

    public interface IResultHandler<in T>
    {
    void Handle(T result);
    }

    一个能处理所有 ShapeResult 的处理器,也可以接收更具体的 CircleResult。这样的设计能减少不必要的类型适配代码。

    6.3 同时输入和输出时,通常不能标记为变体

    如果同一个类型参数既要作为输入,又要作为输出,一般不能直接声明为 in 或 out:

    public interface IRepository<T>
    {
    T GetById(int id);
    void Save(T item);
    }

    T 既出现在 GetById 的返回位置,也出现在 Save 的参数位置,所以此处应保持不变。这个设计并不是错误,而是它的职责决定了它需要同时读取和接收 T。

    需要注意:in 和 out 只能用于泛型接口、泛型委托的类型参数声明,不能用于给普通泛型类或泛型结构体声明变体类型参数。例如,不能写成 class Box<out T>。

    七、委托中的逆变与协变:看懂 Action 和 Func

    逆变和协变不仅用于泛型接口,也大量出现在 C# 内置委托中。平时经常使用的 Action<T> 和 Func<T, TResult>,就已经定义好了对应规则。

    7.1 Action<T>:参数位置支持逆变

    Action<T> 表示一个接收参数、没有返回值的委托。它的类型参数是逆变的,因此 Action<Animal> 可以赋值给 Action<Dog>:

    Action<Animal> printAnimal = animal => Console.WriteLine(animal.Name);
    Action<Dog> printDog = printAnimal;
    printDog(new Dog("旺财"));

    输出:

    旺财

    原因是 printAnimal 本来就能接收任何动物,接收狗当然没有问题。

    7.2 Func<T, TResult>:输入逆变,输出协变

    Func<T, TResult> 的第一个类型参数用于输入,支持逆变;最后一个类型参数用于返回结果,支持协变。

    Func<Animal, string> describeAnimal = animal => animal.Name;
    Func<Dog, object> describeDog = describeAnimal;
    object result = describeDog(new Dog("团子"));
    Console.WriteLine(result);

    输出:

    团子

    这段代码同时使用了两种变体规则:

    • 输入类型从 Animal 变成 Dog,属于逆变。原方法能接收所有动物,因此接收狗没有问题。
    • 返回类型从 string 变成 object,属于协变。string 可以向上转换为 object,所以调用方把结果当作 object 接收是安全的。

    7.3 方法组也可以体现变体转换

    协变和逆变不只存在于泛型类型之间,在委托绑定方法时也能见到相似规则:

    static string GetName() => "旺财";
    static void PrintObject(object value) => Console.WriteLine(value);
    Func<object> getObject = GetName;
    Action<string> printString = PrintObject;
    Console.WriteLine(getObject());
    printString("Hello C#");

    GetName 返回 string,而委托允许返回 object;PrintObject 接收 object,而 Action<string> 只会传入字符串。这两种调用都是安全的。

    八、in、out 不只一种含义:别和参数关键字混淆

    这是 C# 学习中很容易混淆的地方:in 和 out 既能出现在泛型类型参数声明中,也能作为方法参数的关键字,但它们表达的不是同一件事。

    写法所在位置含义
    IEnumerable<out T> 泛型接口声明 out 表示协变,限制类型参数的使用位置
    IConsumer<in T> 泛型接口声明 in 表示逆变,限制类型参数的使用位置
    void Read(in int value) 方法参数列表 参数以只读引用方式传递,调用方的值不能通过该参数被修改
    bool TryGet(out int value) 方法参数列表 方法通过 out 参数向调用方写入结果,调用前不要求变量已赋值

    因此,泛型声明中的 out 不是“方法返回值”,泛型声明中的 in 也不是“只读引用参数”。判断含义时,先看关键字出现在哪个位置。

    九、常见泛型接口与委托速查

    .NET 中已经有许多定义好变体规则的接口和委托,可以直接使用,不需要自己重新声明。

    类型变体规则通俗理解
    IEnumerable<out T> 协变 枚举读取元素,IEnumerable<Dog> 可以当成 IEnumerable<Animal>
    IReadOnlyList<out T> 协变 通过只读列表读取元素,支持协变转换
    IEnumerator<out T> 协变 通过枚举器读取元素
    IComparer<in T> 逆变 能比较更通用的类型,也能用于比较派生类型
    IEqualityComparer<in T> 逆变 能判断基类对象是否相等,也可用于派生类型
    Action<in T> 逆变 方法接收参数、不返回结果
    Func<in T, out TResult> 输入逆变、返回协变 输入和返回类型分别按各自规则转换
    List<T> 不变 可读可写,不能直接将 List<Dog> 赋值给 List<Animal>

    记住:不是所有泛型都支持逆变或协变。必须看这个泛型类型本身有没有声明相应的变体规则。

    十、新手最容易踩的 6 个坑

    坑 1:以为继承关系会自动传递到所有泛型类型

    Dog 继承 Animal,并不代表 List<Dog> 继承 List<Animal>。只有声明为协变或逆变的泛型接口与委托,才能按规则进行对应的类型转换。

    坑 2:把协变和逆变的方向记反

    记住两个最常见的例子:

    IEnumerable<Dog> dogs = new List<Dog>();
    IEnumerable<Animal> animals = dogs; // 协变:Dog -> Animal
    Action<Animal> handleAnimal = animal => Console.WriteLine(animal.Name);
    Action<Dog> handleDog = handleAnimal; // 逆变:Action<Animal> -> Action<Dog>

    协变关注输出,通常从更具体的类型到更通用的类型;逆变关注输入,转换方向看起来相反。

    坑 3:认为 List<T> 也可以直接变体转换

    List<T> 提供添加和读取等操作,类型参数是不变的。需要只读访问时,可以考虑使用 IEnumerable<T> 或 IReadOnlyList<T> 这样的接口。

    坑 4:把 in、out 当成能随处添加的修饰符

    只能在支持变体的泛型接口和泛型委托的类型参数声明中使用协变、逆变声明。普通泛型类和结构体不能直接声明 class Box<out T> 或 struct Box<in T>。

    坑 5:认为值类型也能像引用类型一样使用变体

    泛型变体转换适用于引用类型的类型参数。例如,IEnumerable<Dog> 可以转换成 IEnumerable<Animal>,但 IEnumerable<int> 不能因此转换成 IEnumerable<object>。值类型的装箱转换不会让泛型接口自动获得协变能力。

    坑 6:把“能赋值”理解成“原来的对象类型变了”

    变体不会把 Dog 对象变成 Animal 对象,也不会修改列表内部的数据。它只是允许你通过一个更通用或符合规则的接口、委托类型来使用原有对象,并且仍然由类型系统保证安全。

    十一、实战总结:什么时候需要关注逆变与协变

    开发时不必为了使用 in 或 out 而刻意设计复杂的接口。更好的思路是先明确接口的职责,再决定是否需要变体。

    • 接口只负责提供结果: 类型参数只出现在输出位置时,可以考虑使用 out,让派生类型的提供者能够用于更通用的接口变量。
    • 接口只负责接收或处理对象: 类型参数只出现在输入位置时,可以考虑使用 in,让通用处理器也能处理派生类型。
    • 接口既读取又写入同一类型: 通常保持不变,例如常见的可读写集合或仓储接口。
    • 使用框架提供的接口和委托: 优先了解它们已有的规则,例如 IEnumerable<T>、Action<T> 和 Func<T, TResult>,通常就能解决大部分实际问题。

    写在最后

    逆变与协变看起来抽象,真正理解时只需要抓住一条主线:协变解决“结果能不能更具体”,逆变解决“输入能不能更通用”。 out 常见于提供数据的接口,in 常见于接收数据的接口,而 Action、Func 则把这些规则带进了日常委托使用中。

    当你再次遇到 IEnumerable<Dog> 为什么能赋值给 IEnumerable<Animal>,或者 Action<Animal> 为什么能赋值给 Action<Dog> 时,就可以从“输入还是输出”“类型转换是否安全”两个角度判断,而不必死记硬背。


    觉得有收获?欢迎点赞、收藏。理解泛型的类型转换规则,能让你在设计接口、使用委托和阅读 .NET 源码时更加得心应手。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C# 逆变与协变全解析:看懂泛型里的 `in` 和 `out`,接口与委托不再绕!
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!