為什麼我不喜歡Go語言式的介面(即Structural Typing)

老趙發表於2013-04-10

所謂Go語言式的介面,就是不用顯示宣告型別T實現了介面I,只要型別T的公開方法完全滿足介面I的要求,就可以把型別T的物件用在需要介面I的地方。這種做法的學名叫做Structural Typing,有人也把它看作是一種靜態的Duck Typing。除了Go的介面以外,類似的東西也有比如Scala裡的Traits等等。有人覺得這個特性很好,但我個人並不喜歡這種做法,所以在這裡談談它的缺點。當然這跟動態語言靜態語言的討論類似,不能簡單粗暴的下一個“好”或“不好”的結論。

那麼就從頭談起:什麼是介面。其實通俗的講,介面就是一個協議,規定了一組成員,例如.NET裡的ICollection介面:

public interface ICollection {
    int Count { get; }
    object SyncRoot { get; }
    bool IsSynchronized { get; }
    void CopyTo(Array array, int index);
}

這就是一個協議的全部了嗎?事實並非如此,其實介面還規定了每個行為的“特徵”。打個比方,這個介面的Count除了需要返回集合內元素的數目以外,還隱含了它需要在O(1)時間內返回這個要求。這樣一個使用了ICollection介面的方法才能放心地使用Count屬性來獲取集合大小,才能在知道這些特徵的情況下選用正確的演算法來編寫程式,而不用擔心帶來效能問題,這才能實現所謂的“面向介面程式設計”。當然這種“特徵”並不但指“效能”上的,例如Count還包含了例如“不修改集合內容”這種看似十分自然的隱藏要求,這都是ICollection協議的一部分。

由此我們還可以解釋另外一些問題,例如為什麼.NET裡的List<T>不叫做ArrayList<T>,當然這些都只是我的推測。我的想法是,由於List<T>IList<T>介面是配套出現的,而像IList<T>的某些方法,例如索引器要求能夠快速獲取元素,這樣使用IList<T>介面的方法才能放心地使用下標進行訪問,而滿足這種特徵的資料結構就基本與陣列難以割捨了,於是名字裡的Array就顯得有些多餘。

假如List<T>改名為ArrayList<T>,那麼似乎就暗示著IList<T>可以有其他實現,難道是LinkedList<T>嗎?事實上,LinkedList<T>根本與IList<T>沒有任何關係,因為它的特徵和List<T>相差太多,它有的盡是些AddFirstInsertBefore方法等等。當然,LinkedList<T>List<T>都是ICollection<T>,所以我們可以放心地使用其中一小部分成員,它們的行為特徵是明確的。

這方面的反面案例之一便是Java了。在Java類庫中,ArrayListLinkedList都實現了List介面,它們都有get方法,傳入一個下標,返回那個位置的元素,但是這兩種實現中前者耗時O(1)後者耗時O(N),兩者大相近庭。那麼好,我現在要實現一個方法,它要求從第一個元素開始,返回每隔P個位置的元素,我們還能面向List介面程式設計麼?假如我們依賴下標訪問,則外部一不小心傳入LinkedList的時候,演算法的時間複雜度就從期望的O(N/P)變成了O(N2/P)。假如我們選擇遍歷整個列表,則即便是ArrayList我們也只能得到O(N)的效率。話說回來,Java類庫的List介面就是個笑話,連Stack類都實現了List,真不知道當年的設計者是怎麼想的。

簡單地說,假如介面不能保證行為特徵,則“面向介面程式設計”沒有意義。

而Go語言式的介面也有類似的問題,因為Structural Typing都只是從表面(成員名,引數數量和型別等等)去理解一個介面,並不關注介面的規則和含義,也沒法檢查。忘了是Coursera裡哪個課程中提到這麼一個例子:

interface IPainter {
    void Draw();
}

interface ICowBoy {
    void Draw();
}

在英語中Draw同時具有“畫畫”和“拔槍”的含義,因此對於畫家(Painter)和牛仔(Cow Boy)都可以有Draw這個行為,但是兩者的含義截然不同。假如我們實現了一個“小明”型別,他明明只是一個畫家,但是我們卻讓他去跟其他牛仔決鬥,這樣就等於讓他去送死嘛。另一方面,“小王”也可以既是一個“畫家”也是個“牛仔”,他兩種Draw都會,在C#裡面我們就可以把他實現為:

class XiaoWang : IPainter, ICowBoy {
    void IPainter.Draw() {
        // 畫畫
    }

    void ICowBoy.Draw() {
        // 掏槍
    }
}

因此我也一直不理解Java的取捨標準。你說這樣一門強調物件導向強調介面強調設計的語言,還要求強制異常,怎麼就不支援介面的顯示實現呢?

這就是我更傾向於Java和C#中顯式標註異常的原因。因為程式是人寫的,完全不會因為一個類只是因為存在某些成員,就會被當做某些介面去使用,一切都是經過“設計”而不是自然發生的。就好像我們在泰國不會因為一個人看上去是美女就把它當做女人,這年頭的化妝和PS技術太可怕了。

我這裡再小人之心一把:我估計有人看到這裡會說我只是酸葡萄心理,因為C#中沒有這特性所以說它不好。還真不是這樣,早在當年我還沒聽說Structural Typing這學名的時候就考慮過這個問題。我寫了一個輔助方法,它可以將任意型別轉化為某種介面,例如:

XiaoMing xm = new XiaoMing();
ICowBoy cb = StructuralTyping.From(xm).To<ICowBoy>();

於是,我們就很快樂地將只懂畫畫的小明送去決鬥了。其內部實現原理很簡單,只是使用Emit在執行時動態生成一個封裝類而已。此外,我還在編譯後使用Mono.Cecil分析程式集,檢查FromTo的泛型引數是否匹配,這樣也等於提供了編譯期的靜態檢查。此外,我還支援了協變逆變,還可以讓不需要返回值的介面方法相容存在返回值的方法,這可比簡單通過名稱和引數型別判斷要強大多了。

有了多種選擇,我才放心地說我喜歡哪個。JavaScript中只能用回撥編寫程式碼,於是很多人說它是JavaScript的優點,說回撥多麼多麼美妙我會深不以為然——只是沒法反抗開始享受罷了嘛……

這篇文章好像吐槽有點多?不過這小文章還挺爽的。

(本文亦發表至個人部落格

相關文章