评: 为什么我不喜欢Go语言式的接口

最近在Go语言的QQ群里看到关于图灵社区有牛人老赵吐槽许式伟《Go语言编程》的各种争论.

我之前也看了老赵吐槽许式伟《Go语言编程》的文章, 当时想老赵如果能将许大书中不足部分补充完善了也是一个好事情. 因此, 对老赵的后续文章甚是期待.

谁知道看了老赵之后的两篇吐槽Go语言的文章, 发现完全不是那回事情, 吐槽内容偏差太远.
本来没想掺和进来, 但是看到QQ群里和图灵社区有很多人甚至把老赵的文章当作真理一样.
实在忍不住, 昨天注册了帐号, 进来也说下我的观点.

这是老赵的几篇文章:

本文在图灵社区的网址:

补充说明:

因为当前这篇文章主要是针对老赵的不喜欢Go语言式的接口
评论. 因为标题的原因, 也造成了很大的争议性(因为很多人说我理解的很多观点和老赵的原文不相符).

后面我会对Go语言的一些特性一些简单的介绍, 但是不会是现在这种方式.


所谓Go语言式的接口,就是不用显示声明类型T实现了接口I,只要类型T的公开方法完全满足接口I的要求,就可以把类型T的对象用在需要接口I的地方。这种做法的学名叫做Structural Typing,有人也把它看作是一种静态的Duck Typing。除了Go的接口以外,类似的东西也有比如Scala里的Traits等等。有人觉得这个特性很好,但我个人并不喜欢这种做法,所以在这里谈谈它的缺点。当然这跟动态语言静态语言的讨论类似,不能简单粗暴的下一个“好”或“不好”的结论。

原文观点:

  • Go的隐式接口其实就是静态的Duck Typing. 很多语言(主要是动态语言)早就有.
  • 静态类型和动态类型没有绝对的好和不好.

我的观点:

  • Go的隐式接口Duck Typing确实不是新技术, 但是在主流静态编程语言中支持Duck Typing应该是很少的(不清楚目前是否只有Go语言支持).
  • 静态类型和动态类型虽然没有绝对的好和不好, 但是每个都是有自己的优势的, 没有哪一个可以包办一切. 而Go是试图结合静态类型和动态类型(interface)各自的优势.

那么就从头谈起:什么是接口。其实通俗的讲,接口就是一个协议,规定了一组成员,例如.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协议的一部分。

原文观点:

  • 接口就是一个协议, 规定了一组成员.
  • 接口还规定了每个行为对应时间复杂度的"特征”.
  • 接口还规定了每个行为还包含是否会修改集合的隐藏要求.

我的观点:

  • 第一条: 没什么可解释的, 应该是接口的通俗含义.
  • 第二条: 但是接口还包含时间复杂度的"特征"就比较扯了. 请问这个特征是由语言特性来约束(语言如何约束?), 还只是由接口的文档作补充说明(这是语言的特性吗)?
  • 第三条: 这个还算是吐槽到了点子上. Go的接口确实不支持C++类似的const修饰, 除了接口外的method也不支持(Go的const关键字是另一个语义).

但是, C++中有了const就真的安全了吗?

class Foo {
    private: mutable Mutex mutex_;

    public: void doSomething()const {
        MutexLocker locker(&mutex_);
        // const 已经被绕过了
    }
};

C++中方法const修饰唯一的用处就是增加各种编译麻烦, 对使用者无法作出任何承诺. 使用者更关心的是doSomething的要做什么, 上面的方法其实和void doSomethingConst()要表达的是类似的意思.

不管是静态库还是动态库, 哪个能从库一级保证某个函数是不能干什么的? 如果C++的const关键字并不能
真正的保证const, 而类似的实现细节(也包括前面提到的和时间复杂度相关的性能特征)必须有文档来补充.
那文档应该以什么形式提供(代码注释?Word文档?其他格式文档?)? 这些文档真多能保证每个都会有人看吗?
文档说到底还只是人直接的口头约定, 如果文档真的那么好使(还有实现), 那么汇编语言也可以解决一切问题.

那在Go语言是如何解决const和性能问题?

首先, 对于C语言的函数参数传值的语义, const是必然的结果.
但是, 如果参数太大要考虑性能的话, 就会考虑传指针(还是传值的语义), 通过传指针就不能保证const的语义了. 如果连使用的库函数都不能相信, 那怎么就能相信它对于的头文件所提供的const信息呢?

因为, const和性能是相互矛盾的. Go语言中如果想绝对安全, 那就传值. 如果想要性能(或者是返回副作用),
那就传指针:

type Foo int

// 要性能
func (self *Foo)Get() int {
    return *self
}
// 要安全
func (self Foo)GetConst() int {
    return self
}

Go语言怎么对待性能问题(还有单元测试问题)? 答案是集成go test测试工具. 在Go语言中测试代码是pkg(包含package main)的一个组成部分. 不仅是普通的pkg可以go test, package main也可以用go test进行测试.

我们给前面的代码加上单元测试和性能测试.

// foo_test.go

func TestGet(t *testing.T) {
    var foo Foo = 0
    if v := foo.Get(); v != 0 {
        t.Errorf("Bad Get. Need=%v, Got=%v", 0, v)
    }
}
func TestGetConst(t *testing.T) {
    var foo Foo = 0
    if v := foo.GetConst(); v != 0 {
        t.Errorf("Bad GetConst. Need=%v, Got=%v", 0, v)
    }
}

func BenchmarkGet(b *testing.B) {
    var foo Foo = 0
    for i := 0; i < b.N; i++ {
        _ = foo.Get()
    }
}
func BenchmarkGetConst(b *testing.B) {
    var foo Foo = 0
    for i := 0; i < b.N; i++ {
        _ = foo.GetConst()
    }
}

当然, 最终的测试结果还是给人来看的. 如果实现者/使用者故意搞破坏, 再好的工具也是没办法的.

由此我们还可以解释另外一些问题,例如为什么.NET里的List 不叫做ArrayList ,当然这些都只是我的推测。我的想法是,由于List 与IList 接口是配套出现的,而像IList 的某些方法,例如索引器要求能够快速获取元素,这样使用IList 接口的方法才能放心地使用下标进行访问,而满足这种特征的数据结构就基本与数组难以割舍了,于是名字里的Array就显得有些多余。

假如List 改名为ArrayList ,那么似乎就暗示着IList 可以有其他实现,难道是LinkedList 吗?事实上,LinkedList 根本与IList 没有任何关系,因为它的特征和List 相差太多,它有的尽是些AddFirst、InsertBefore方法等等。当然,LinkedList 与List 都是ICollection ,所以我们可以放心地使用其中一小部分成员,它们的行为特征是明确的。

原文观点:

  • 推测: 因为为了和IList<T>接口配套出现的原因, 才没有将List<T>命名为ArrayList<T>.
  • 因为IList<T>(这个应该是笔误, 我觉得作者是说List<T>)索引器要求能够快速获取元素, 这样使用IList 接口的方法才能放心地使用下标进行访问(实现的算法复杂度特征向接口方向传递了).
  • 不能将List<T>改为ArrayList<T>的另一个原因是LinkedList<T>. 因为List<T>LinkedList<T>的时间复杂度不一样, 所以不能是一个接口(大概是一个算法复杂度一个接口的意思?).
  • LinkedList<T>List<T>都属于ICollection<T>这个祖宗接口.

我的观点:

  • 第一条: 我不知道原作者是怎么推测的. 接口的本意就是要和实现分离. 现在却完全绑定到一起了, 那这样还要接口做什么(一个Xxx<T>对应一个IXxx<T>接口)?
  • 第二条: 因为运行时向接口传递了某个时间复杂度的实现, 就推导出接口的都符合某种时间复杂度, 逻辑上根本就不通!
  • 第三条: 和前两个差不多的意思, 没什么可说的.
  • 第四条: 这个应该是Go非入侵接口的优点. C++/Java就是因为接口的入侵性, 才导致了接口和实现无法完全分离. 因为, C++/Java大部分时间都在整理接口间/实现间的祖宗八代之间的关系了(重要的不是如何分类, 而是能做什么). 可以参考许式伟给的Java的例子(了解祖宗八代之间的关系真的很重要吗): http://docs.oracle.com/javase/1.4.2/docs/api/overview-tree.html.

这方面的反面案例之一便是Java了。在Java类库中,ArrayList和LinkedList都实现了List接口,它们都有get方法,传入一个下标,返回那个位置的元素,但是这两种实现中前者耗时O(1)后者耗时O(N),两者大相近庭。那么好,我现在要实现一个方法,它要求从第一个元素开始,返回每隔P个位置的元素,我们还能面向List接口编程么?假如我们依赖下标访问,则外部一不小心传入LinkedList的时候,算法的时间复杂度就从期望的O(N/P)变成了O(N2/P)。假如我们选择遍历整个列表,则即便是ArrayList我们也只能得到O(N)的效率。话说回来,Java类库的List接口就是个笑话,连Stack类都实现了List,真不知道当年的设计者是怎么想的。

简单地说,假如接口不能保证行为特征,则“面向接口编程”没有意义。

原文观点:

  • Java的ArrayListLinkedList都实现了List接口, 但是get方法的时间复杂度不同.
  • 假如接口不能保证行为特征,则“面向接口编程”没有意义。

我的观点:

  • 第一条: 这其实是原作者列的一个前提, 是为了推出第二条的结论. 但是, 我觉得这里的逻辑同样是有问题的. 有这个例子只能说明接口有它的不足, 但是怎么就证明了 则“面向接口编程”没有意义?
  • 第二条: 我要反问一句, 为什么非要在这里使用接口(难道是被C++/Java的面向对象洗脑了)? 接口有它合适的地方(面向逻辑层面), 也有它不合适的地方(面向底层算法层面). 在这里为什么不直接使用ArrayListLinkedList?

而Go语言式的接口也有类似的问题,因为Structural Typing都只是从表面(成员名,参数数量和类型等等)去理解一个接口,并不关注接口的规则和含义,也没法检查。忘了是Coursera里哪个课程中提到这么一个例子:

nterface IPainter {
    void Draw();
}

nterface ICowBoy {
     void Draw();
}

在英语中Draw同时具有“画画”和“拔枪”的含义,因此对于画家(Painter)和牛仔(Cow Boy)都可以有Draw这个行为,但是两者的含义截然不同。假如我们实现了一个“小明”类型,他明明只是一个画家,但是我们却让他去跟其他牛仔决斗,这样就等于让他去送死嘛。另一方面,“小王”也可以既是一个“画家”也是个“牛仔”,他两种Draw都会,在C#里面我们就可以把他实现为:

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

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

因此我也一直不理解Java的取舍标准。你说这样一门强调面向对象强调接口强调设计的语言,还要求强制异常,怎么就不支持接口的显示实现呢?

原文观点:

  • 不同实现的Draw含义不同, 因此接口最好也能支持不同的实现.
  • Java/Go之类的接口都没有C#的接口强大.

我的观点:

  • 第一条: 不要因为自己有个锤子, 就把什么东西都当作钉子! 你这个是C#的例子(我不懂C#), 但是请不要往Go语言上套! 之前是C++搞出了个函数重载(语义还是相似的, 但是签名不同), 没想到C#还搞了个支持同一个单词不同含义的特性.
  • 第二条: 只能说原作者真的不懂Go语言.

Go语言为什么不支持这些花哨的特性? 因为, 它们太复杂且没多大用处, 写出的代码不好理解(如果原作者不提示, 谁能发现Darw的不同含义这个坑?). Go语言的哲学是: “Less is more!“.

看看Go语言该怎么做:

type Painter interface {
    Draw()
}
type CowBoyer interface {
    DrawTheGun()
}

type XiaoWang struct {
    // ...
}

func (self *XiaoWang)Draw() {
    // ...
}
func (self *XiaoWang)DrawTheGun() {
    // ...
}

XiaoWang需要关心的只是自己有哪些功能(method), 至于祖宗关系开始根本不用关心.
等到XiaoWang各种特性逐渐成熟稳定之后, 发现新来的XiaoMing也有类似的功能特征,
这个时候才会考虑如何用接口来描述XiaoWangXiaoMing共同特征.

这就是我更倾向于Java和C#中显式标注异常的原因。因为程序是人写的,完全不会因为一个类只是因为存在某些成员,就会被当做某些接口去使用,一切都是经过“设计”而不是自然发生的。就好像我们在泰国不会因为一个人看上去是美女就把它当做女人,这年头的化妆和PS技术太可怕了。

原文观点:

  • 接口是经过“设计”而不是自然发生的.
  • 接口有不足, 因为在泰国不能根据美女这个接口来推断这个人是女人这个类型.

我的观点:

  • Go的哲学是先构造具体对象, 然后再根据共性慢慢归纳出接口, 一开始不用关心祖宗八代的关系.
  • 那请问女人是怎么定义的, 难道这不是一个接口?

我这里再小人之心一把:我估计有人看到这里会说我只是酸葡萄心理,因为C#中没有这特性所以说它不好。还真不是这样,早在当年我还没听说Structural Typing这学名的时候就考虑过这个问题。我写了一个辅助方法,它可以将任意类型转化为某种接口,例如:

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

于是,我们就很快乐地将只懂画画的小明送去决斗了。其内部实现原理很简单,只是使用Emit在运行时动态生成一个封装类而已。此外,我还在编译后使用Mono.Cecil分析程序集,检查FromTo的泛型参数是否匹配,这样也等于提供了编译期的静态检查。此外,我还支持了协变逆变,还可以让不需要返回值的接口方法兼容存在返回值的方法,这可比简单通过名称和参数类型判断要强大多了。

原文观点:

  • C#接口的这个特性很NB…

我的观点:

我们看看Go是该怎么写(基于前面的Go代码, 没有Draw重载):

var xm interface{} = new(XiaoWang)
cb := xm.(Painter).(CowBoyer)

但是, 我觉得这样写真的很变态. Go语言是为了解决实际的工程问题的,
不是要像C++那样成为各种NB技术的大杂烩.

我始终认同一个观点: 任何语言都可以写出垃圾代码, 但是不能以这些垃圾代码来证明原语言也垃圾.

有了多种选择,我才放心地说我喜欢哪个。JavaScript中只能用回调编写代码,于是很多人说它是JavaScript的优点,说回调多么多么美妙我会深不以为然——只是没法反抗开始享受罢了嘛……

这篇文章好像吐槽有点多?不过这小文章还挺爽的。

这段不是接口相关, 懒得整理/吐槽了.


最后我只想说一个例子, 从C语言时代就很流行的printf函数.
我们看看Go语言中是什么样子(fmt.Fprintf):

func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)

在Go语言中, fmt.Fprintf只关心怎么识别各种a ...interface{}, 怎么format这些参数,
至于怎么写, 写到哪里去那完全是w io.Writer的事情.

这里第一个参数的w io.Writer就是一个接口, 它不仅可以写到File, 也可以写到net.Conn, 准确的说是可以写到任何实现了io.Writer接口的对象中.

因为, Go语言接口的非入侵性, 我们可以独立实现自己的对象, 只要符合io.Writer接口就行, 然后就可以和fmt.Fprintf配合工作.

后面的可变参数interface{}同样是一个接口, 它代替了C语言的void*, 用于格式化输出各种类型的值. (更准确的讲, 除了基础类型, 参数a必须是一个实现了Stringer接口的扩展类型).

接口是一个完全正交的特性, 可以将Fprintf从各种a ...interface{}, 以及各种w io.Writer完全剥离出来.
Go语言也是这样, struct等基础类型的内存布局还是和C语言中一样, 只是加了个method(在Go1.1中, method value就是一个普通闭包函数), 接口以及goroutine都是在没有破坏原有的类型语义基础上正交扩展(而不是像C++那样搞个构造函数, 以后又是析构函数的).

我到很想知道, 在C++/C#/Java之类的语言中, 是如何实现fmt.Fprintf的.


套用原作者的一句话作为结束: Go语言虽然有缺点, 即使老赵是牛人, 但是这篇吐槽也着实一般!

本文来自:开源中国博客

感谢作者:chai2010

查看原文:评: 为什么我不喜欢Go语言式的接口

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。