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

 更新时间:2022年01月19日 10:35:36   作者:TinyMaD  
在好多的.net的书籍中都看到过逆变和协变的概念,也在网上搜了一些关于这两个概念的解释,但是一直感觉似懂非懂的,直到最近在项目中实际遇到了一个问题,恰好用到了逆变,下面这篇文章主要给大家介绍了关于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# 逆变应用场景内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • C#中私有构造函数的特点和用途实例解析

    C#中私有构造函数的特点和用途实例解析

    这篇文章主要介绍了C#中私有构造函数的特点和用途,需要的朋友可以参考下
    2014-08-08
  • C#彩色图片灰度化算法实例

    C#彩色图片灰度化算法实例

    这篇文章主要介绍了C#彩色图片灰度化算法,以实例形式对灰度化算法进行了较为详细的介绍,非常具有实用价值,需要的朋友可以参考下
    2014-10-10
  • C#实现带进度条的ListView

    C#实现带进度条的ListView

    这篇文章主要介绍了C#实现带进度条的ListView 的相关资料,需要的朋友可以参考下
    2016-02-02
  • C#删除文件夹和文件到回收站示例

    C#删除文件夹和文件到回收站示例

    本文介绍了C#删除文件和文件夹到回收站的方法,平时都是使用File.Delete方法删除文件,这样删除之后就真没有了。下面的代码可以将文件删除放到回收站里
    2014-01-01
  • C#线程池用法详细介绍

    C#线程池用法详细介绍

    在C#编程语言中,使用线程池可以并行地处理工作,当强制线程和更新进度条时,会使用内建架构的ThreadPool类,为批处理使用多核结构,这里我们来看在C#编程语言中一些关于来自System.Threading的ThreadPool的用法的例子
    2013-11-11
  • C#七大经典排序算法系列(下)

    C#七大经典排序算法系列(下)

    这篇文章主要为大家详细介绍了C#七大经典排序算法系列下篇,直接插入排序,希尔排序和归并排序,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-04-04
  • c# 实现语音合成

    c# 实现语音合成

    这篇文章主要介绍了c# 实现语音合成的方法,帮助大家更好的理解和使用c#,感兴趣的朋友可以了解下
    2020-12-12
  • C# Winform截图指定控件范围内的图像的流程步骤

    C# Winform截图指定控件范围内的图像的流程步骤

    工作所需,需要截图软件跑出来的界面上的图表,但是窗口本身是可以缩放的,图表也是做的可以跟着窗体大小一起缩放,所以就写了一个函数,用于截图图表容器内的图像,文中有函数源码供大家参考,需要的朋友可以参考下
    2024-10-10
  • C#远程获取图片文件流的方法

    C#远程获取图片文件流的方法

    这篇文章主要介绍了C#远程获取图片文件流的方法,涉及C#针对图片及文件流操作的相关技巧,需要的朋友可以参考下
    2016-02-02
  • Unity实现主角移动与摄像机跟随

    Unity实现主角移动与摄像机跟随

    这篇文章主要为大家详细介绍了Unity实现主角移动与摄像机跟随,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-03-03

最新评论