C#中逆变的实际应用场景详解

目录
  • 前言
  • 协变的应用场景
  • 逆变的应用场景
  • 讨论
  • 总结

前言

早期在学习泛型的协变与逆变时,网上的文章讲解、例子算是能看懂,但关于逆变的具体应用场景这方面的知识,我并没有深刻的认识。
本文将在具体的场景下,从泛型接口设计的角度出发,逐步探讨逆变的作用,以及它能帮助我们解决哪方面的问题?

这篇文章算是协变、逆变知识的感悟和分享,开始之前,你应该先了解协变、逆变的基本概念,以及依赖注入,这类文章很多,这里就不再赘述。

协变的应用场景

虽然协变不是今天的主要内容,但在此之前,我还是想提一下关于协变的应用场景。

其中最常见的应用场景就是——如果方法的某个参数是一个集合时,我习惯将这个集合参数定义为IEnumerable<T>类型。

class Program
{
    public static void Save(IEnumerable<Animal> animals)
    {
        // TODO
    }
}
public class Animal { }

IEnumerable<T>中的T就是标记了代表协变的关键字out

namespace System.Collections.Generic
{
    public interface IEnumerable<out T> : IEnumerable
    {
        IEnumerator<T> GetEnumerator();
    }
}

假如泛型T为父类Animal类型,DogAnimal的子类,其他人在调用这个方法时,

不仅可以传入IEnumerable<Animal>List<Animal>Animal[]类型的参数,

还可以传入IEnumerable<Dog>List<Dog>Dog[]等其他继承自IEnumerable<Animal>类型的参数。

这样,方法的兼容性会更强。

class Program
{
    public static void Save(IEnumerable<Animal> animals)
    {
        // TODO
    }

    static void Main(string[] args)
    {
        var animalList = new List<Animal>();
        var animalArray = new Animal[] { };
        var dogList = new List<Dog>();
        var dogArray = new Dog[] { };

        Save(animalList);
        Save(animalArray);
        Save(dogList);
        Save(dogArray);
    }
}
public class Animal { }
public class Dog : Animal { }

逆变的应用场景

提起逆变,可能大家见过类似下面这段代码:

class Program
{
    static void Main(string[] args)
    {
        IComparer<Animal> animalComparer = new AnimalComparer();
        IComparer<Dog> dogComparer = animalComparer;// 将 IComparer<Animal> 赋值给 IComparer<Dog>
    }
}

public class AnimalComparer : IComparer<Animal>
{
    // 省略具体实现
}

IComparer<T>中的T就是标记了代表逆变的关键字in

namespace System.Collections.Generic
{
    public interface IComparer<in T>
    {
        int Compare(T? x, T? y);
    }
}

在看完这段代码后,不知道你们是否跟我有一样的想法:道理都懂,可是具体的应用场景呢?

要探索逆变可以帮助我们解决哪些问题,我们试着从另一个角度出发——在某个场景下,不使用逆变,是否会遇到某些问题。

假设我们需要保存各种基础资料,根据需求我们定义了对应的接口,以及完成了对应接口的实现。这里假设AnimalHuman就是其中的两种基础资料类型。

public interface IAnimalService
{
    void Save(Animal entity);
}
public interface IHumanService
{
    void Save(Human entity);
}

public class AnimalService : IAnimalService
{
    public void Save(Animal entity)
    {
        // TODO
    }
}

public class HumanService : IHumanService
{
    public void Save(Human entity)
    {
        // TODO
    }
}

public class Animal { }
public class Human { }

现在增加一个批量保存基础资料的功能,并且实时返回保存进度。

public class BatchSaveService
{
    private static readonly IAnimalService _animalSvc;
    private static readonly IHumanService _humanSvc;
    // 省略依赖注入代码

    public void BatchSaveAnimal(IEnumerable<Animal> entities)
    {
        foreach (var animal in entities)
        {
            _animalSvc.Save(animal);
            // 省略监听进度代码
        }
    }
    public void BatchSaveHuman(IEnumerable<Human> entities)
    {
        foreach (var human in entities)
        {
            _humanSvc.Save(human);
            // 省略监听进度代码
        }
    }
}

完成上面代码后,我们可以发现,监听进度的代码写了两次,如果像这样的基础资料类型很多,想要修改监听进度的代码,则会牵一发而动全身,这样的代码就不便于维护。

为了使代码能够复用,我们需要抽象出一个保存基础资料的接口ISave<T>

使IAnimalServiceIHumanService继承ISave<T>,将泛型T分别定义为AnimalHuman

public interface ISave<T>
{
    void Save(T entity);
}

public interface IAnimalService : ISave<Animal> { }
public interface IHumanService : ISave<Human> { }

这样,就可以将BatchSaveAnimal()BatchSaveHuman()合并为一个BatchSave<T>()

public class BatchSaveService
{
    private static readonly IServiceProvider _svcProvider;
    // 省略依赖注入代码

    public void BatchSave<T>(IEnumerable<T> entities)
    {
        ISave<T> service = _svcProvider.GetRequiredService<ISave<T>>();// GetRequiredService()会在无对应接口实现时抛出错误

        foreach (T entity in entities)
        {
            service.Save(entity);
            // 省略监听进度代码
        }
    }
}

重构后的代码达到了可复用、易维护的目的,但很快你会发现新的问题。

在调用重构后的BatchSave<T>()时,传入Human类型的集合参数,或Animal类型的集合参数,代码能够正常运行,但在传入Dog类型的集合参数时,代码运行到第8行就会报错,因为我们并没有实现ISave<Dog>接口。

虽然DogAnimal的子类,但却不能使用保存Animal的方法,这肯定会被接口调用者吐槽,因为它不符合里氏替换原则

static void Main(string[] args)
{
    List<Human> humans = new() { new Human() };
    List<Animal> animals = new() { new Animal() };
    List<Dog> dogs = new() { new Dog() };

    var saveSvc = new BatchSaveService();

    saveSvc.BatchSave(humans);
    saveSvc.BatchSave(animals);
    saveSvc.BatchSave(dogs);// 由于没有实现ISave<Dog>接口,因此代码运行时会报错
}

TDog时,要想获取ISave<Animal>这个不相关的服务,我们可以从IServiceCollection服务集合中去找。

虽然我们拿到了注册的所有服务,但如何才能在TDog类型时,拿到对应的ISave<Animal>服务呢?

这时,逆变就派上用场了,我们将接口ISave<T>加上关键字in后,就可以将ISave<Animal>分配给ISave<Dog>

public interface ISave<in T>// 加上关键字in
{
    void Save(T entity);
}

public class BatchSaveService
{
    private static readonly IServiceProvider _svcProvider;
    private static readonly IServiceCollection _svcCollection;
    // 省略依赖注入代码

    public void BatchSave<T>(IEnumerable<T> entities)
    {
        // 假设T为Dog,只有在ISave<T>接口标记为逆变时,
        // typeof(ISave<Animal>).IsAssignableTo(typeof(ISave<Dog>)),才会是true
        Type serviceType = _svcCollection.Single(x => x.ServiceType.IsAssignableTo(typeof(ISave<T>))).ServiceType;

        ISave<T> service = _svcProvider.GetRequiredService(serviceType) as ISave<T>;// ISave<Animal> as ISave<Dog>

        foreach (T entity in entities)
        {
            service.Save(entity);
            // 省略监听进度代码
        }
    }
}

现在BatchSave<T>()算是符合里氏替换原则,但这样的写法也有缺点

  • 优点:调用时,写法干净简洁,不需要设置过多的泛型参数,只需要传入对应的参数变量即可。
  • 缺点:如果传入的参数没有对应的接口实现,编译仍然会通过,只有在代码运行时才会报错,提示不够积极、友好。
    并且如果我们实现了ISave<Dog>接口,那代码运行到第16行时会得到ISave<Dog>ISave<Animal>两个结果,不具有唯一性。

要想在错误使用接口时,编译器及时提示错误,可以将接口重构成下面这样

public class BatchSaveService
{
    private static readonly IServiceProvider _svcProvider;
    // 省略依赖注入代码

    // 增加一个泛型参数TService,用来指定调用哪个服务的Save()
    // 并约定 TService : ISave<T>
    public void BatchSave<TService, T>(IEnumerable<T> entities) where TService : ISave<T>
    {
        ISave<T> service = _svcProvider.GetService<TService>();
        foreach (T entity in entities)
        {
            service.Save(entity);
            // 省略监听进度代码
        }
    }
}

class Program
{
    static void Main(string[] args)
    {
        List<Human> humans = new() { new Human() };
        List<Animal> animals = new() { new Animal() };
        List<Dog> dogs = new() { new Dog() };

        var saveSvc = new BatchSaveService();

        saveSvc.BatchSave<IHumanService, Human>(humans);
        saveSvc.BatchSave<IAnimalService, Animal>(animals);
        saveSvc.BatchSave<IAnimalService, Dog>(dogs);
        // 假如实现了继承ISave<Dog>的接口IDogService,可以改为
        // saveSvc.BatchSave<IDogService, Dog>(dogs);
    }
}

这样在错误使用接口时,编译器就会及时报错,但由于需要设置多个泛型参数,使用起来会有些麻烦。

关于 C# 协变和逆变 msdn 解释如下: 

“协变”是指能够使用与原始指定的派生类型相比,派生程度更大的类型。

“逆变”则是指能够使用派生程度更小的类型。

解释的很正确,大致就是这样,不过不够直白。

直白的理解:

“协变”->”和谐的变”->”很自然的变化”->string->object :协变。 

“逆变”->”逆常的变”->”不正常的变化”->object->string 逆变。 

上面是个人对协变和逆变的理解,比起记住那些派生,类型,原始指定,更大,更小之类的词语,个人认为要容易点。

讨论

以上是我遇见的比较常见的关于逆变的应用场景,上述两种方式你觉得哪种更好?是否有更好的设计方式?或者大家在写代码时遇见过哪些逆变的应用场景?

总结

到此这篇关于C#中逆变实际应用场景的文章就介绍到这了,更多相关C# 逆变应用场景内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C#逆变与协变详解

    该文章中使用了较多的 委托delegate和Lambda表达式,如果你并不熟悉这些,请查看我的文章<委托与匿名委托>.<匿名委托与Lambda表达式>以便帮你建立完整的知识体系. 在C#从诞生到发展壮大的过程中,新知识点不断引入.逆变与协变并不是C#独创的,属于后续引入.在Java中同样存在逆变与协变,后续我还会写一篇Java逆变协变的文章,有兴趣的朋友可以关注一下. 逆变与协变,听起来很抽象.高深,其实很简单.看下面的代码: class Person { } class Stud

  • C#4.0新特性之协变与逆变实例分析

    本文实例讲述了C#4.0新特性的协变与逆变,有助于大家进一步掌握C#4.0程序设计.具体分析如下: 一.C#3.0以前的协变与逆变 如果你是第一次听说这个两个词,别担心,他们其实很常见.C#4.0中的协变与逆变(Covariance and contravariance)有了进一步的完善,主要是两种运行时的(隐式)泛型类型参数转换.简单来讲,所谓协变(Covariance)是指把类型从"小"升到"大",比如从子类升级到父类:逆变则是指从"大"变到

  • C#中的协变与逆变深入讲解

    什么是协变与逆变 MSDN的解释: https://msdn.microsoft.com/zh-cn/library/dd799517.aspx 协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型. 泛型类型参数支持协变和逆变,可在分配和使用泛型类型方面提供更大的灵活性. 一开始我总是分不清协变和逆变,因为MSDN的解释实在是严谨有余而易读不足. 其实从中文的字面上来理解这两个概念就挺容易的

  • 一篇文章看懂C#中的协变、逆变

    1. 基本概念 官方:协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型.[MSDN] 公式: 协变:IFoo<父类> = IFoo<子类>: 逆变:IBar<子类> =  IBar<父类>: 暂时不理解没关系,您接着往下看. 2. 协变(Covariance) 1) out关键字 对于泛型类型参数,out 关键字可指定类型参数是协变的. 可以在泛型接口

  • c#协变和逆变实例分析

    本文实例讲述了c#协变和逆变的原理及应用.分享给大家供大家参考.具体如下: 由子类向父类方向转变是协变,用out关键字标识,由父类向子类方向转变是逆变,用in关键字 协变和逆变的应用   一. 数组的协变 复制代码 代码如下: Animal[] animalArray = new Dog[]{}; 说明:声明的数组数据类型是Animal,而实际上赋值时给的是Dog数组:每一个Dog对象都可以安全的转变为Animal.Dog向Animal方法转变是沿着继承链向上转变的所以是协变   二. 委托中的

  • C#中逆变的实际应用场景详解

    目录 前言 协变的应用场景 逆变的应用场景 讨论 总结 前言 早期在学习泛型的协变与逆变时,网上的文章讲解.例子算是能看懂,但关于逆变的具体应用场景这方面的知识,我并没有深刻的认识.本文将在具体的场景下,从泛型接口设计的角度出发,逐步探讨逆变的作用,以及它能帮助我们解决哪方面的问题? 这篇文章算是协变.逆变知识的感悟和分享,开始之前,你应该先了解协变.逆变的基本概念,以及依赖注入,这类文章很多,这里就不再赘述. 协变的应用场景 虽然协变不是今天的主要内容,但在此之前,我还是想提一下关于协变的应用

  • Java中自定义注解介绍与使用场景详解

    注解的概念及分类 1.首先我们来看一下什么是注解: 注解就是某种注解类型的一个实例,我们可以用它在某个类上进行标注,这样编译器在编译我们的文件时,会根据我们自己设定的方法来编译类. 2.注解的分类 注解大体上分为三种:标记注解,一般注解,元注解,@Override用于标识,该方法是继承自超类的.这样,当超类的方法修改后,实现类就可以直接看到了.而@Deprecated注解,则是标识当前方法或者类已经不推荐使用,如果用户还是要使用,会生成编译的警告. 本文主要介绍的是关于Java自定义注解,下面话

  • React中useLayoutEffect钩子使用场景详解

    目录 简介 useEffect钩子的概述 钩子流程 useLayoutEffect钩子的概述 钩子流程 什么时候使用useLayoutEffect钩子? 总结 简介 不久前,React对其功能组件进行了一次重大更新(在2019年3月的16.8版本中),终于为这些组件提供了一种变得有状态的方法. 钩子的加入不仅意味着功能组件将能够提供自己的状态,而且还能通过引入useEffect钩子来管理自己的生命周期事件. 此外,这次更新还引入了一个全新的useLayoutEffect钩子,根据React文档,

  • Java中volatile关键字的作用与用法详解

    volatile这个关键字可能很多朋友都听说过,或许也都用过.在Java 5之前,它是一个备受争议的关键字,因为在程序中使用它往往会导致出人意料的结果.在Java 5之后,volatile关键字才得以重获生机. volatile 关键字作用是,使系统中所有线程对该关键字修饰的变量共享可见,可以禁止线程的工作内存对volatile修饰的变量进行缓存. volatile 2个使用场景: 1.可见性:Java提供了volatile关键字来保证可见性. 当一个共享变量被volatile修饰时,它会保证修

  • Vue vm.$attrs使用场景详解

    1.vm.$attrs简介 首先我们来看下vue官方对vm.$attrs的介绍: 包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外).当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件--在创建更高层次的组件时非常有用. 猛一看有点看不明白.... 2.场景介绍 vue中一个比较令人烦恼的事情是属性只能从父组件传递给子组件

  • Java中Validated、Valid 、Validator区别详解

    目录 1. 结论先出 JSR 380 Valid VS Validated 不同点? Validator 2. @Valid和​​​​​​​@Validated 注解 3. 例子 4.使用@Valid嵌套校验 5. 组合使用@Valid和@Validated 进行集合校验 6. 自定义校验 自定义约束注解 工作原理 结论 参考链接: 1. 结论先出 Valid VS Validated 相同点 都可以对方法和参数进行校验 @Valid和@Validated 两种注释都会导致应用标准Bean验证.

  • TS 中的类型推断与放宽实例详解

    目录 简介 类型推断与放宽概念 常规类型推断 最佳通用类型 按上下文归类 类型放宽 常规类型放宽 非严格类型检查模式 严格类型检查模式 字面量类型放宽 对象.数组字面量类型的放宽 类字面量类型的放宽 函数返回值字面量类型的放宽 TS 内部类型放宽规则 实例分析 开篇问题解答 简介 我们知道在编码时即使不标注变量类型,TypeScript 编译器也能推断出变量类型,那 TypeScript 编译器是怎么进行类型推断,在类型推断时又是如何判断兼容性的呢? 此文,正好为你解开这个疑惑的,掌握本文讲解的

  • 8个Spring事务失效场景详解

    目录 前言 Spring事务原理 Spring事务失效场景 1. 抛出检查异常 2. 业务方法本身捕获了异常 3. 同一类中的方法调用 4. 方法使用 final 或 static关键字 5. 方法不是public 6. 错误使用传播机制 7. 没有被Spring管理 8. 多线程 总结 前言 作为Java开发工程师,相信大家对Spring种事务的使用并不陌生.但是你可能只是停留在基础的使用层面上,在遇到一些比较特殊的场景,事务可能没有生效,直接在生产上暴露了,这可能就会导致比较严重的生产事故.

  • 工作中Java集合的规范使用操作详解

    目录 一.前言 二.规范使用Java集合 一.前言 现代软件行业的高速发展对开发者的综合素质要求越来越高,因为不仅是编程知识点,其它维度的知识点也会影响到软件的最终交付质量.比如:五花八门的错误码会人为地增加排查问题的难度:数据库的表结构和索引设计缺陷带来的系统架构缺陷或性能风险:工程结构混乱导致后续项目维护艰难:没有鉴权的漏洞代码容易被黑客攻击等.依据约束力强弱及故障敏感性,规约依次分为[强制].[推荐].[参考]三大类.在延伸的信息中,“说明”对规约做了适当扩展和解释:“正例”提倡什么样的编

  • 配置管理和服务发现之Confd和Consul使用场景详解

    目录 Confd和Consul是什么鬼? Confd Consul Confd Consul Confd+Consul 案例1 案例2 Confd和Consul是什么鬼? Confd和Consul都是用于配置管理和服务发现的工具. https://www.consul.io/ https://www.tail-f.com/confd-basic/ Confd Confd是一个轻量级的工具,用于管理分布式系统中的配置文件.它通过将配置文件和模板分离来解决配置管理的挑战.Confd监视由Etcd.Z

随机推荐