为什么我不喜欢Go语言式的接口(即Structural Typing)
2013-04-10 18:37 by 老赵, 17483 visits所谓Go语言式的接口,就是不用显示声明类型T
实现了接口I
,只要类型T
的公开方法完全满足接口I
的要求,就可以把类型T
的对象用在需要接口I
的地方。这种做法的学名叫做Structural Typing,有人也把它看作是一种静态的Duck Typing。除了Go的接口以外,类似的东西也有比如Scala里的Traits等等。有人觉得这个特性很好,但我个人并不喜欢这种做法,所以在这里谈谈它的缺点。当然这跟动态语言静态语言的讨论类似,不能简单粗暴的下一个“好”或“不好”的结论。
那么就从头谈起:什么是接口。其实通俗地讲,接口就是一个协议,规定了一组成员,例如.NET里的ICollection
接口:
interface ICollection { int Count { get; } object SyncRoot { get; } bool IsSynchronized { get; } void CopyTo(Array array, int index); }
这就是一个协议的全部了吗?事实并非如此,其实接口还规定了每个行为的“特征”。打个比方,这个接口的Count
除了需要返回集合内元素的数目以外,还隐含了它需要在O(1)时间内返回这个要求。这样一个使用了ICollection
接口的方法才能放心地使用Count
属性来获取集合大小,才能在知道这些特征的情况下选用正确的算法来编写程序,而不用担心带来性能问题,这才能实现所谓的“面向接口编程”。当然这种“特征”并不单指“性能”上的,例如Count
还包含了“不修改集合内容”这种看似十分自然的隐藏要求,这都是ICollection
协议的一部分。
由此我们还可以解释另外一些问题,例如为什么.NET里的List<T>
不叫做ArrayList<T>
(当然这些都只是我的推测)。我的想法是,由于List<T>
与IList<T>
接口是配套出现的,而像IList<T>
的某些方法,例如索引器要求能够快速获取元素,这样使用IList<T>
接口的方法才能放心地使用下标进行访问,而满足这种特征的数据结构就基本与数组难以割舍了,于是名字里的Array就显得有些多余。
假如List<T>
改名为ArrayList<T>
,那么似乎就暗示着IList<T>
可以有其他实现,难道是LinkedList<T>
吗?事实上,LinkedList<T>
根本与IList<T>
没有任何关系,因为它的特征和List<T>
相差太多,它有的尽是些AddFirst
、InsertBefore
方法等等。当然,LinkedList<T>
与List<T>
都是ICollection<T>
,所以我们可以放心地使用其中一小部分成员,它们的行为特征是明确的。
这方面的反面案例之一便是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
,真不知道当年的设计者是怎么想的。
简单地说,假如接口不能保证行为特征,则“面向接口编程”没有意义。
而Go语言式的接口也有类似的问题,因为Structural Typing都只是从表面(成员名,参数数量和类型等等)去理解一个接口,并不关注接口的规则和含义,也没法检查。忘了是Coursera里哪个课程中提到这么一个例子:
interface IPainter { void Draw(); } interface ICowBoy { void Draw(); }
在英语中Draw同时具有“画画”和“拔枪”的含义,因此对于画家(Painter)和牛仔(Cow Boy)都可以有Draw这个行为,但是两者的含义截然不同。假如我们实现了一个“小明”类型,他明明只是一个画家,但是我们却让他去跟其他牛仔决斗,这样就等于让他去送死嘛。另一方面,“小王”也可以既是一个“画家”也是个“牛仔”,他两种Draw都会,在C#里面我们就可以把他实现为:
class XiaoWang : IPainter, ICowBoy { void IPainter.Draw() { // 画画 } void ICowBoy.Draw() { // 掏枪 } }
因此我也一直不理解Java的取舍标准。你说这样一门强调面向对象强调接口强调设计的语言,还要求强制异常,怎么就不支持接口的显示实现呢?
这就是我更倾向于Java和C#中显式标注异常的原因。因为程序是人写的,完全不会因为一个类只是因为存在某些成员,就会被当做某些接口去使用,一切都是经过“设计”而不是自然发生的。就好像我们在泰国不会因为一个人看上去是美女就把它当做女人,这年头的化妆和PS技术太可怕了。
我这里再小人之心一把:我估计有人看到这里会说我只是酸葡萄心理,因为C#中没有这特性所以说它不好。还真不是这样,早在当年我还没听说Structural Typing这学名的时候就考虑过这个问题。我写了一个辅助方法,它可以将任意类型转化为某种接口,例如:
XiaoMing xm = new XiaoMing(); ICowBoy cb = StructuralTyping.From(xm).To<ICowBoy>();
于是,我们就很快乐地将只懂画画的小明送去决斗了。其内部实现原理很简单,只是使用Emit在运行时动态生成一个封装类而已。此外,我还在编译后使用Mono.Cecil分析程序集,检查From
与To
的泛型参数是否匹配,这样也等于提供了编译期的静态检查。此外,我还支持了协变逆变,还可以让不需要返回值的接口方法兼容带有返回值的方法(现在甚至还可以为其查找扩展方法),这可比简单通过名称和参数类型判断要强大多了。
有了多种选择,我才放心地说我喜欢哪个。JavaScript中只能用回调编写代码,于是很多人说它是JavaScript的优点,说回调多么多么美妙我会深不以为然——只是没法反抗开始享受罢了嘛……
这篇文章好像吐槽有点多?不过这小文章还挺爽的。
于是,我们就很快乐地将只懂画画的小明送去决斗了。 不上gist嘛坏人