C# - 逆變的具體應用場景

TinyMaD發表於2022-01-14

前言

早期在學習泛型的協變與逆變時,網上的文章講解、例子算是能看懂,但關於逆變的具體應用場景這方面的知識,我並沒有深刻的認識。
本文將在具體的場景下,從泛型介面設計的角度出發,逐步探討逆變的作用,以及它能幫助我們解決哪方面的問題?

這篇文章算是協變、逆變知識的感悟和分享,開始之前,你應該先了解協變、逆變的基本概念,以及依賴注入,這類文章很多,這裡就不再贅述。

協變的應用場景

雖然協變不是今天的主要內容,但在此之前,我還是想提一下關於協變的應用場景。
其中最常見的應用場景就是——如果方法的某個引數是一個集合時,我習慣將這個集合引數定義為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>介面,那程式碼執行到第11行時會得到ISave<Dog>ISave<Animal>兩個結果,不具有唯一性。

要想在錯誤使用介面時,編譯器及時提示錯誤,可以將介面重構成下面這樣

public class BatchSaveService
{
    private static readonly IServiceProvider _svcProvider;
    // 省略依賴注入程式碼

    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);
    }
}

這樣在錯誤使用介面時,編譯器就會及時報錯,但由於需要設定多個泛型引數,使用起來會有些麻煩。

討論

以上是我遇見的比較常見的關於逆變的應用場景,上述兩種方式你覺得哪種更好?是否有更好的設計方式?或者大家在寫程式碼時遇見過哪些逆變的應用場景?
歡迎大家留言討論和分享。

相關文章