c#中逆变的实际应用场景详解-kb88凯时官网登录

来自:网络
时间:2023-01-01
阅读:
免费资源网 - https://freexyz.cn/
目录

前言

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

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

协变的应用场景

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

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

class program
{
    public static void save(ienumerable animals)
    {
        // todo
    }
}
public class animal { }

ienumerable中的t就是标记了代表协变的关键字out

namespace system.collections.generic
{
    public interface ienumerable : ienumerable
    {
        ienumerator getenumerator();
    }
}

假如泛型t为父类animal类型,doganimal的子类,其他人在调用这个方法时,

不仅可以传入ienumerablelistanimal[]类型的参数,

还可以传入ienumerablelistdog[]等其他继承自ienumerable类型的参数。

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

class program
{
    public static void save(ienumerable animals)
    {
        // todo
    }
    static void main(string[] args)
    {
        var animallist = new list();
        var animalarray = new animal[] { };
        var doglist = new list();
        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 animalcomparer = new animalcomparer();
        icomparer dogcomparer = animalcomparer;// 将 icomparer 赋值给 icomparer
    }
}
public class animalcomparer : icomparer
{
    // 省略具体实现
}

icomparer中的t就是标记了代表逆变的关键字in

namespace system.collections.generic
{
    public interface icomparer
    {
        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 entities)
    {
        foreach (var animal in entities)
        {
            _animalsvc.save(animal);
            // 省略监听进度代码
        }
    }
    public void batchsavehuman(ienumerable entities)
    {
        foreach (var human in entities)
        {
            _humansvc.save(human);
            // 省略监听进度代码
        }
    }
}

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

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

使ianimalserviceihumanservice继承isave,将泛型t分别定义为animalhuman

public interface isave
{
    void save(t entity);
}
public interface ianimalservice : isave { }
public interface ihumanservice : isave { }

这样,就可以将batchsaveanimal()batchsavehuman()合并为一个batchsave()

public class batchsaveservice
{
    private static readonly iserviceprovider _svcprovider;
    // 省略依赖注入代码
    public void batchsave(ienumerable entities)
    {
        isave service = _svcprovider.getrequiredservice>();// getrequiredservice()会在无对应接口实现时抛出错误
        foreach (t entity in entities)
        {
            service.save(entity);
            // 省略监听进度代码
        }
    }
}

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

在调用重构后的batchsave()时,传入human类型的集合参数,或animal类型的集合参数,代码能够正常运行,但在传入dog类型的集合参数时,代码运行到第8行就会报错,因为我们并没有实现isave接口。

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

static void main(string[] args)
{
    list humans = new() { new human() };
    list animals = new() { new animal() };
    list dogs = new() { new dog() };
    var savesvc = new batchsaveservice();
    savesvc.batchsave(humans);
    savesvc.batchsave(animals);
    savesvc.batchsave(dogs);// 由于没有实现isave接口,因此代码运行时会报错
}

tdog时,要想获取isave这个不相关的服务,我们可以从iservicecollection服务集合中去找。

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

这时,逆变就派上用场了,我们将接口isave加上关键字in后,就可以将isave分配给isave

public interface isave// 加上关键字in
{
    void save(t entity);
}
public class batchsaveservice
{
    private static readonly iserviceprovider _svcprovider;
    private static readonly iservicecollection _svccollection;
    // 省略依赖注入代码
    public void batchsave(ienumerable entities)
    {
        // 假设t为dog,只有在isave接口标记为逆变时,
        // typeof(isave).isassignableto(typeof(isave)),才会是true
        type servicetype = _svccollection.single(x => x.servicetype.isassignableto(typeof(isave))).servicetype;
        isave service = _svcprovider.getrequiredservice(servicetype) as isave;// isave as isave
        foreach (t entity in entities)
        {
            service.save(entity);
            // 省略监听进度代码
        }
    }
}

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

  • 优点:调用时,写法干净简洁,不需要设置过多的泛型参数,只需要传入对应的参数变量即可。

  • 缺点:如果传入的参数没有对应的接口实现,编译仍然会通过,只有在代码运行时才会报错,提示不够积极、友好。
    并且如果我们实现了isave接口,那代码运行到第16行时会得到isaveisave两个结果,不具有唯一性。

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

public class batchsaveservice
{
    private static readonly iserviceprovider _svcprovider;
    // 省略依赖注入代码
    // 增加一个泛型参数tservice,用来指定调用哪个服务的save()
    // 并约定 tservice : isave
    public void batchsave(ienumerable entities) where tservice : isave
    {
        isave service = _svcprovider.getservice();
        foreach (t entity in entities)
        {
            service.save(entity);
            // 省略监听进度代码
        }
    }
}
class program
{
    static void main(string[] args)
    {
        list humans = new() { new human() };
        list animals = new() { new animal() };
        list dogs = new() { new dog() };
    
        var savesvc = new batchsaveservice();
        savesvc.batchsave(humans);
        savesvc.batchsave(animals);
        savesvc.batchsave(dogs);
        // 假如实现了继承isave的接口idogservice,可以改为
        // savesvc.batchsave(dogs);
    }
}

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

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

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

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

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

直白的理解: 

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

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

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

讨论

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

总结

免费资源网 - https://freexyz.cn/
返回顶部
顶部
网站地图