评: 为什么我不喜欢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 testpackage 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语言虽然有缺点, 即使老赵是牛人, 但是这篇吐槽也着实一般!

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
我为什么放弃go语言? 对于为什么放弃go语言,每个人的原因可能不尽相同。以下是我个人对放弃go语言的一些主要原因: 1. 生态系统相对较小:相较于其他主流编程语言,比如Java、Python和JavaScript,Go语言的生态系统相对较小。这意味着在使用Go语言开发项目时,可能需要自己编写很多常见功能的库,而不能直接从现有的包中获取。这对于开发速度和效率会有一些影响。 2. 编译速度较慢:与一些其他编程语言相比,Go语言的编译速度较慢。这对于频繁进行代码调试和测试的开发者来说可能是一个不小的问题,特别是在项目规模较大时。 3. 静态类型限制:尽管静态类型可以提供更高的代码安全性和执行效率,但相对于一些动态语言来说,Go语言的类型系统相对较为繁琐。这意味着相同的功能可能需要更多的代码来实现,也会增加开发时间和复杂度。 4. 缺乏一些常用工具和库:在某些领域,Go语言的生态系统相对较弱,缺乏一些常用的工具和库。这可能需要开发者自己去重复造轮子,不仅增加了开发成本,也可能导致项目质量和稳定性的问题。 总的来说,每个编程语言都有其优点和缺点,适用于不同的场景和需求。放弃一门编程语言通常是基于自身的需求和优先级而做出的决策。以上只是我个人对放弃go语言的一些理解和想法,可能并不适用于所有人。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值