這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Golang的Interface是個什麼鬼
問題概述
Golang的interface,和別的語言是不同的。它不需要顯式的implements,只要某個struct實現了interface裡的所有函數,編譯器會自動認為它實現了這個interface。第一次看到這種設計的時候,我的第一反應是:What the fuck?這種奇葩的設計方式,和主流OO語言顯式implement或繼承的區別在哪兒呢?
直到看了SICP以後,我的觀點發生了變化:Golang的這種方式和Java、C++之流並無本質區別,都是實現多態的具體方式。而所謂多態,就是“一個介面,多種實現”。
SICP裡詳細解釋了為什麼同一個介面,需要根據不同的資料類型,有不同的實現;以及如何做到這一點。在這裡沒有OO的概念,先把OO放到一邊,從原理上看一下這是怎麼做到的。
先把大概原理放在這裡,然後再舉例子。為了實現多態,需要維護一張全域的尋找表,它的功能是根據類型名和方法名,返回對應的函數入口。當我增加了一種類型,需要把新類型的名字、相應的方法名和實際函數入口添加到表裡。這基本上就是所謂的動態綁定了,類似於C++裡的vtable。對於SICP中使用的lisp語言來說,這些工作需要手動完成。而對於java,則通過implements完成了這項工作。而golang則用了更加激進的方式,連implements都省了,編譯器自動探索自動綁定。
一個複數包的例子
SICP裡以複數為例,我用clojure、java和golang分別實現了一下,代碼放在https://github.com/nanoix9/golang-interface。這裡的目的是實現一個複數包,它支援直角座標(rectangular)和極座標(polar)兩種實現方式,但是兩者以相同的形式提供對外的介面,包括擷取實部、虛部、模、輻角四個操作,文中簡單起見,僅以擷取實部為例。代碼中有完整的內容。
Clojure版
對於直角座標,用一個兩個元素的列表表示它,分別是實部和虛部。
(defn make-rect [r i] (list r i))
對於極座標,也是含有兩個元素的列表,分別表示模和輻角
(defn make-polar [abs arg] (list abs arg))
現在要加一個“取實部”的函數get-real。問題來了,我希望這個函數能同時處理兩種座標,而且對於使用者來說,無論使用哪種座標表示,get-real函數的行為是一致的。最簡單的想法是,增加一個tag欄位用於區分兩種類型,然後get-real根據類型資訊執行不同的操作。
為此,定義attach-tag、get-tag和get-content函數用於關聯標籤、提取標籤和提取內容:
(defn attach-tag [tag data] (list tag data))(defn get-tag [data-with-tag] (first data-with-tag))(defn get-content [data-with-tag] (second data-with-tag))
在構造複數的函數中加入tag
(defn make-rect [r i] (attach-tag 'rect (list r i)))(defn make-polar [abs arg] (attach-tag 'polar (list abs arg)))
get-real函數首先擷取tag,根據直角座標或極座標執行不同的操作
(defn get-real [c] (let [tag (get-tag c) num (get-content c)] (cond (= tag 'rect) (first num) (= tag 'polar) (* (first num) (Math/cos (second num))) :else (println "Unknown complex type:" tag))))
但是這樣有個問題,如果要加第三種類型怎麼辦?必須修改get-real函數。也就是說,要增加一種實現,必須改動函數主入口。有沒有方法避免呢?答案就是採用前面的尋找表(當然這不是唯一方法,SICP中還介紹了訊息傳遞方法,這裡就不介紹了)。這個尋找表提供get-op和put-op兩個方法
(defn get-op [tag op-name] ... (defn put-op [tag op-name func] ...)
這裡只給出原型,get-op根據類型名和方法名,擷取對應的函數入口。而put-op向表中增加類型名、方法名和函數入口。這張表的內容直觀上可以這麼理解
| tag\op-name |
'get-real |
'get-image |
... |
| 'rect |
get-real-rect |
get-image-rect |
... |
| 'polar |
get-real-polar |
get-image-polar |
... |
於是get-real函數可以這樣實現:首先每種類型各自將自己的函數入口添加到尋找表
(defn install-rect [] (letfn [(get-real [c] (first c))] put-op 'rect 'get-real get-real))(defn install-polar [] (letfn [(get-real [c] (* (first c) (Math/cos (second c))))] put-op 'polar 'get-real get-real))(install-rect)(install-polar)
注意這裡用了局部函數letfn,所以兩種類型都用get-real作為函數名並不衝突。
定義apply-generic函數,用來從尋找表中擷取函數入口,並把tag去掉,將內容和剩餘參數送給擷取到的函數
(defn apply-generic [op-name tagged-data & args] (let [tag (get-tag tagged-data) content (get-content tagged-data) func (get-op tag op-name)] (if (null? func) (println "No entry for data type" tag "and method" op-name)) (apply func (cons content args))))
get-real函數可以實現了
(defn get-real [c] (apply-generic 'get-real c))
Java版
Java實現複數包就不需要這麼麻煩了,編譯器完成了大部分工作。當然Java是靜態語言,還有類型檢查。
public interface Complex { public double getReal(); ...}public class ComplexRect implements Complex { private double real; private double image; public double getReal() { return real; } ...}public class ComplexPolar implements Complex { private double abs; private double arg; public double getReal() { return abs * Math.cos(arg); } ...}
Golang版
Golang和Java的差別就是省去了implements
type Complex interface { GetReal() float64 ...}type ComplexRect struct { real, image float64}func (c ComplexRect) GetReal() float64 { return c.real}...type ComplexPolar struct { abs, arg float64}func (c ComplexPolar) GetReal() float64 { return c.abs * math.Cos(c.arg)}...
乍一看看不出ComplexRect和Complex之間有什麼關係,它是隱含的,編譯器自動探索。這樣的做法更靈活,比如增加一個新的介面類型,編譯器會自動探索那些struct實現了該介面,而無需修改struct的代碼。如果是java,就必須修改原始碼,顯式的implements。
總結
通過這個問題,我意識到,OO只不過是一種方法,其實本沒有什麼對象。至於為什麼要OO,最根本的,是要實現“一個介面,多種實現”,這就要求介面是穩定的,而實現有可能是多變的。如果介面也是經常變的,那就沒必要把介面抽象出來了。至於代碼結構是否反映了世界的繼承/組合等關係,這並不重要,也不是根本的。重要的是,將穩定的介面和不穩定的實現分離,使得改動某個模組的時候,不至於影響到其他部分。這是軟體本質上的複雜性提出的要求,對於大型軟體來說,模組的分解和隔離尤為重要。
為了達到這個目的,C++實現了vtable,Java提供了interface,Golang則自動探索這種關係。可以用OO,也可以不用OO。無論語言提供了哪種方式,背後的思想是統一的。甚至我們可以在語言特性滿足不了需求的時候,自己實現相關的機制,例如spring,通過xml完成依賴注入,這使得可以在不改動原始碼的情況下,用一種實現替換另一種實現。