问题概述
@H_
301_11@Golang的interface,和别的语言是不同的。它不需要显式的implements,只要某个struct实现了interface里的所有
函数,编译器会
自动认为它实现了这个interface。第一次看到这种设计的时候,我的第一反应是:What the fuck?这种奇葩的设计方式,和主流OO语言显式implement或继承的区别在哪儿呢?
@H_
301_11@直到看了SICP以后,我的观点发生了变化:Golang的这种方式和Java、C++之流并无本质区别,都是实现多态的具体方式。而所谓多态,就是“一个接口,多种实现”。
@H_
301_11@SICP里详细解释了为什么同一个接口,需要根据不同的数据类型,有不同的实现;以及如何做到这一点。在这里没有OO的概念,先把OO放到一边,从原理上看一下这是怎么做到的。
@H_
301_11@先把大概原理放在这里,然后再举例子。为了实现多态,需要维护一张全局的查找表,它的
功能是根据类型名和
方法名,返回对应的
函数入口。当我
增加了一种类型,需要把新类型的名字、相应的
方法名和实际
函数入口
添加到表里。这基本上就是所谓的动态绑定了,类似于C++里的vtable。对于SICP中使用的lisp语言来说,这些工作需要手动完成。而对于java,则通过implements完成了这项工作。而golang则用了更加激进的方式,连implements都省了,编译器
自动发现
自动绑定。
一个复数包的例子
@H_
301_11@SICP里以复数为例,我用clojure、java和golang分别实现了一下,
代码放在
https://github.com/nanoix9/golang-interface。这里的目的是实现一个复数包,它
支持直角坐标(rectangular)和极坐标(polar)两种实现方式,但是两者以相同的形式提供对外的接口,
包括获取实部、虚部、模、辐角四个操作,文中简单起见,仅以
获取实部为例。
代码中有完整的
内容。
Clojure版
@H_
301_11@对于直角坐标,用一个两个元素的列表表示它,分别是实部和虚部。
(defn make-rect [r i] (list r i))
@H_
301_11@对于极坐标,也是含有两个元素的列表,分别表示模和辐角
(defn make-polar [abs arg] (list abs arg))
@H_
301_11@现在要加一个“取实部”的
函数get-real
。问题来了,我希望这个
函数能同时处理两种坐标,而且对于使用者来说,无论使用哪种坐标表示,
get-real
函数的行为是一致的。最简单的想法是,
增加一个
tag
字段用于区分两种类型,然后
get-real
根据类型信息执行不同的操作。
@H_
301_11@为此,定义
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))
@H_
301_11@在构造复数的
函数中加入tag
(defn make-rect [r i] (attach-tag 'rect (list r i)))
(defn make-polar [abs arg] (attach-tag 'polar (list abs arg)))
@H_
301_11@
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))))
@H_
301_11@但是这样有个问题,如果要加第三种类型怎么办?必须
修改get-real函数
。也就是说,要
增加一种实现,必须改动
函数主入口。有没有
方法避免呢?答案就是采用前面的查找表(当然这不是唯一
方法,SICP中还介绍了消息传递
方法,这里就不介绍了)。这个查找表提供
get-op
和
put-op
两个
方法
(defn get-op [tag op-name] ...
(defn put-op [tag op-name func] ...)
@H_
301_11@这里只给出原型,
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 |
... |
@H_
301_11@于是
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)
@H_
301_11@注意这里用了局部
函数letfn
,所以两种类型都用
get-real
作为
函数名并不冲突。
@H_
301_11@定义
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))))
@H_
301_11@
get-real
函数可以实现了
(defn get-real [c]
(apply-generic 'get-real c))
Java版
@H_
301_11@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版
@H_
301_11@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)
}
...
@H_
301_11@乍一看看不出
ComplexRect
和
Complex
之间有什么关系,它是隐含的,编译器
自动发现。这样的做法更灵活,比如
增加一个新的接口类型,编译器会
自动发现那些struct实现了该接口,而无需
修改struct的
代码。如果是java,就必须
修改源
代码,显式的
implements
。
总结
@H_
301_11@通过这个问题,我意识到,OO只不过是一种
方法,其实本没有什么对象。至于为什么要OO,最根本的,是要实现“一个接口,多种实现”,这就要求接口是稳定的,而实现有可能是多变的。如果接口也是经常变的,那就没必要把接口抽象出来了。至于
代码结构是否反映了世界的继承/组合等关系,这并不重要,也不是根本的。重要的是,将稳定的接口和不稳定的实现分离,使得改动某个模块的时候,不至于影响到其他部分。这是软件本质上的复杂性提出的要求,对于大型软件来说,模块的分解和隔离尤为重要。
@H_
301_11@为了达到这个目的,C++实现了vtable,Java提供了interface,Golang则
自动发现这种关系。可以用OO,也可以不用OO。无论语言提供了哪种方式,背后的思想是统一的。甚至我们可以在语言特性满足不了需求的时候,自己实现相关的机制,例如spring,通过xml完成依赖注入,这使得可以在不改动源
代码的情况下,用一种实现替换另一种实现。