早期在学习泛型的协变与逆变时,网上的文章讲解、例子算是能看懂,但关于逆变的具体应用场景这方面的知识,我并没有深刻的认识。
本文将在具体的场景下,从泛型接口设计的角度出发,逐步探讨逆变的作用,以及它能帮助我们解决哪方面的问题?
这篇文章算是协变、逆变知识的感悟和分享,开始之前,你应该先了解协变、逆变的基本概念,以及依赖注入,这类文章很多,这里就不再赘述。
虽然协变不是今天的主要内容,但在此之前,我还是想提一下关于协变的应用场景。
其中最常见的应用场景就是——如果方法的某个参数是一个集合时,我习惯将这个集合参数定义为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
类型,Dog
为Animal
的子类,其他人在调用这个方法时,
不仅可以传入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 { }
提起逆变,可能大家见过类似下面这段代码:
csharpclass 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); } }
在看完这段代码后,不知道你们是否跟我有一样的想法:道理都懂,可是具体的应用场景呢?
要探索逆变可以帮助我们解决哪些问题,我们试着从另一个角度出发——在某个场景下,不使用逆变,是否会遇到某些问题。
假设我们需要保存各种基础资料,根据需求我们定义了对应的接口,以及完成了对应接口的实现。这里假设Animal
与Human
就是其中的两种基础资料类型。
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 { }
现在增加一个批量保存基础资料的功能,并且实时返回保存进度。
csharppublic 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>
。
使IAnimalService
、IHumanService
继承ISave<T>
,将泛型T
分别定义为Animal
、Human
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>
接口。
虽然Dog
是Animal
的子类,但却不能使用保存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>接口,因此代码运行时会报错 }
在T
为Dog
时,要想获取ISave<Animal>
这个不相关的服务,我们可以从IServiceCollection
服务集合中去找。
虽然我们拿到了注册的所有服务,但如何才能在T
为Dog
类型时,拿到对应的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>
两个结果,不具有唯一性。
要想在错误使用接口时,编译器及时提示错误,可以将接口重构成下面这样
csharppublic 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); } }
这样在错误使用接口时,编译器就会及时报错,但由于需要设置多个泛型参数,使用起来会有些麻烦。
以上是我遇见的比较常见的关于逆变的应用场景,上述两种方式你觉得哪种更好?是否有更好的设计方式?或者大家在写代码时遇见过哪些逆变的应用场景?
欢迎大家留言讨论和分享。
__EOF__