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-tagget-tagget-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-opput-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)
}

...

乍一看看不出ComplexRectComplex之間有什麼關係,它是隱含的,編譯器自動發現。這樣的做法更靈活,比如增加一個新的接口類型,編譯器會自動發現那些struct實現了該接口,而無需修改struct的代碼。如果是java,就必須修改源代碼,顯式的implements

總結

通過這個問題,我意識到,OO只不過是一種方法,其實本沒有什麼對象。至於爲什麼要OO,最根本的,是要實現“一個接口,多種實現”,這就要求接口是穩定的,而實現有可能是多變的。如果接口也是經常變的,那就沒必要把接口抽象出來了。至於代碼結構是否反映了世界的繼承/組合等關係,這並不重要,也不是根本的。重要的是,將穩定的接口和不穩定的實現分離,使得改動某個模塊的時候,不至於影響到其他部分。這是軟件本質上的複雜性提出的要求,對於大型軟件來說,模塊的分解和隔離尤爲重要。

爲了達到這個目的,C++實現了vtable,Java提供了interface,Golang則自動發現這種關係。可以用OO,也可以不用OO。無論語言提供了哪種方式,背後的思想是統一的。甚至我們可以在語言特性滿足不了需求的時候,自己實現相關的機制,例如spring,通過xml完成依賴注入,這使得可以在不改動源代碼的情況下,用一種實現替換另一種實現。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章