問題概述
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完成依賴注入,這使得可以在不改動源代碼的情況下,用一種實現替換另一種實現。