C# 方法多載的特異功能

  • 7229
  • 0
  • 2020-04-10

C# 的 method overloading 是一個我們熟到不能再熟的技巧,但它卻和其他程式語言有一個微妙的差異。
 

『方法多載有甚麼了不起?我天天都在寫。』或許你看到這篇文章標題的時候是這麼想的,但是 C# 的方法多載的確有個很特別的地方,是和其他的程式語言不太一樣的。

這篇文章把複雜的行為縮到最小,只談論方法只有一個參數的多載解析,而且暫不考慮泛型方法。在一般的情況下,多載解析總是傾向於選擇最接近呼叫端引數變數型別的那一份多載方法,也就是隱性轉換成本最低的那一個。拿以下的程式碼當例子,list1 變數的型別是 List<string> ,依照最小轉換原則會呼叫的是 Request(IList<string> value) ;list2 變數的型別是 ICollection<string> ,因此會選擇 Request(IEnumerable<string>  value) 。下方程式碼執行的結果也是如此,原則上和我們印象中的多載行為相同。

    class Program
    {
        static void Main(string[] args)
        {
            List<string> list1 = new List<string>();
            var o = new MyClass();
            o.Request(list1);
            ICollection<string> list2 = list1;
            o.Request(list2);
            Console.ReadLine();
        }
    }

    public class MyClass
    {
        public void Request(IEnumerable<string>  value)
        {
            Console.WriteLine("執行 IEnumerable<string> 參數方法");
        }

        public void Request(IList<string> value)
        {
            Console.WriteLine("執行 IList<string> 參數方法");
        }
    }
註:上述型別的關係是 IEnumerable ← IEnumerable<T> ← ICollection<T> ← IList<T> ← List<T>

截至目前為止,沒有甚麼讓人驚訝之處,但是在繼承的情況下,C# 編譯器對於多載的選擇就會發生變化,我們把範例改成如下的情形。
 

    class Program
    {
        static void Main(string[] args)
        {
            List<string> list = new List<string>();
            BaseClass o1 = new BaseClass();
            DerivedClass o2 = new DerivedClass();
            o1.Request(list);
            o2.Request(list);
            Console.ReadLine();
        }
    }

    public class BaseClass
    {
        public void Request(IList<string> value)
        {
            Console.WriteLine("執行 IList<string> 參數方法");
        }
    }

    public class DerivedClass : BaseClass
    {
        public void Request(IEnumerable<string> value)
        {
            Console.WriteLine("執行 IEnumerable<string> 參數方法");
        }
    }

DerivedClass 繼承了 BaseClass 並且同時多載了 Request method。照一般的印象來說,o1.Request 和 o2.Request 應該都會呼叫 BaseClass 中的 Request(IList<string> value) ,看倌們可以執行看看,答案是 o2.Request 呼叫了 DerivedClass 的 Request(IEnumerable<string>  value)

註:這個測試是基於 C# 3.0 及其以上的版本,如果你使用的是 C# 1.0 ~ 2.0 之間的版本測試結果不同,麻煩請留言告訴我一下,在此先謝過。

有趣的點在於,如果這段程式碼改換成 VB.NET 或是 Java,執行的結果毫不意外地都是呼叫   BaseClass 中的 Request(IList<string> value) ,我們將上述的程式碼改編為 VB 如下,你會發現執行的結果和 C# 是不同的。
 

Module Module1

    Sub Main()
        Dim list As List(Of String) = New List(Of String)
        Dim o1 As BaseClass = New BaseClass()
        Dim o2 As DerivedClass = New DerivedClass()
        o1.Request(list)
        o2.Request(list)
        Console.ReadLine()
    End Sub

End Module

Public Class BaseClass
    Public Sub Request(ByVal value As IList(Of String))
        Console.WriteLine("執行 IList<string> 參數方法")
    End Sub
End Class

Public Class DerivedClass
    Inherits BaseClass
    Public Overloads Sub Request(ByVal value As IEnumerable(Of String))
        Console.WriteLine("執行 IEnumerable<string> 參數方法")
    End Sub
End Class
註:C++  的情況會更複雜。

我一度以為這是C# 的 bug,因為這行為明確的和里氏替換原則 (Liskov’s Substitution Principle 簡稱為 LSP)不符。LSP 可以簡單規範為一句話:『所有使用基底類別的地方都可以使用衍生類別替代,而行為不會發生變化。』,這也就是為什麼很多文章提到 LSP 會講一個多載撰寫原則是『衍生類別多載基底類別方法時,其參數型別應該比基底類別中被多載的方法參數寬鬆』。

回頭看看前面的第二個  C# 範例,明顯地違反這個原則。可是很意外地,這其實是個 feature 而不個 bug,這話不是平常我們程式設計師之間開玩笑的雙關語,這個行為真的是個 feature。在 C# 語言規範 7.4.3 談到 Overload resolution,以下節錄這個章節的內容:

Overload resolution is a compile-time mechanism for selecting the best function member to invoke given an argument list and a set of candidate function members. Overload resolution selects the function member to invoke in the following distinct contexts within C#:
•    Invocation of a method named in an invocation-expression (§7.5.5.1).
•    Invocation of an instance constructor named in an object-creation-expression (§7.5.10.1).
•    Invocation of an indexer accessor through an element-access (§7.5.6).
•    Invocation of a predefined or user-defined operator referenced in an expression (§7.2.3 and §7.2.4).
Each of these contexts defines the set of candidate function members and the list of arguments in its own unique way, as described in detail in the sections listed above. For example, the set of candidates for a method invocation does not include methods marked override (§7.3), and methods in a base class are not candidates if any method in a derived class is applicable (§7.5.5.1).

粗體字的部分就說明了這個原則:『當呼叫端的引數適用於衍生類別的方法時,覆寫方法與基底類別的方法不會成為候選者』,也因如此,方法多載的呼叫結果會因為是多載在同一個類別或是繼承後才多載產生不同的結果。

bug 和 feature 的分別就在這裡,因為 C# 規範就是這樣寫的,所以必然是個 feature。但我心裡還是很難想像為什麼當時會這麼設計規範,後來找到了一篇文章  Future Breaking Changes, Part Three ,這裡面講了一個聽起來頗有道理的理由 -- 套句政治圈的話『不滿意,但是可以接受』。

大概說明一下這個理由,假設我們撰寫了一個函式庫專案如下:
 

    public class BaseClass
    {
        
    }

    public class DerivedClass : BaseClass
    {
        public void Request(IEnumerable<string> value)
        {
            Console.WriteLine("執行 IEnumerable<string> 參數方法");
        }
    }

 而客戶應用了這個函式庫開發專案,理所當然會呼叫的是 DerivedClass 的 Request(IEnumerable<string>  value)  ,因為基底類別根本沒有這個方法可以呼叫。 

    class Program
    {
        static void Main(string[] args)
        {
            List<string> list = new List<string>();
            DerivedClass o2 = new DerivedClass();
            o2.Request(list);
            Console.ReadLine();
        }
    }

有一天,我方的程式設計師決定修改基底類別成以下的形式,然後通知客戶更新到最新版的函式庫。

    public class BaseClass
    {
        public void Request(IList<string> value)
        {
            Console.WriteLine("執行 IList<string> 參數方法");
        }
    }

如果 C# 的編譯器對於方法多載解析的方式是和 VB.NET 或 Java 相同,這時候就會發生一個問題,客戶在更新函式庫後會發生意料之外的執行結果,也就是呼叫了 BaseClass 的  Request(IList<string> value) ;為了避免這樣個問題產生,所以 C# 編譯器採用了以衍生類別方法優先的多載解析。老實說,這兩種不同的解析方式,好壞還真難說得很,就留給大家自己解釋吧。