我喜欢 Go. 常用它实现各种功能(包括在写本文时的这个博客). Go 很实用,但不够好。 不是说它有多差, 只是没那么好而已。 一门编程语言, 也许会用上一辈子, 所以选择的时候要注意。 本文专注于 Go 的各种吐槽。 老生常谈的有之,鲜为人知的也有。 我用 Rust 和Haskell 作为参照 (至少, 我以为, 这俩都很不错)。 本文列出的所有问题, 都有解决方案。 常规编程那么问题来了 我们写代码可以用于许多不同的事情。假如我写了一个函数用来对一列数字求和,如果我可以用该函数对浮点数、整数以及其他任何类型进行求和那该多棒。如果这些代码包含了类型安全并且可以快速的写出用于整型相加、浮点型相加等的独立函数就更完美了。 好的解决方案:基于限制的泛型和基于参数的多态 到目前为止,我遇到的最好的泛型编程系统是rust和haskell所共用的那个。它一般被称作”被限制的类型“。在haskell中,这个系统被称作”type class“。而在Rust中,它被称作”traits“。像这样: (Rust, version 0.11)
(Haskell)
在上面这个简单了例子中,我们定义了一个泛型函数id。id函数将它的参数原封不动传回来。很重要的一点是这个函数可以接受任何类型的参数,而不是某个特定的类型。在Rust和haskell中,id函数保留了它参数的类型信息,使得静态类型检查可以顺利工作,并且没有为次在运行期付出任何代价。你可以使用这个函数来写一个克隆函数。 同样,我们可以应用这种方式来定义泛型数据结构。例如: (Rust)
(Haskell)
跟上面一样,我们在没有运行期额外消耗的情况下得到完全的静态类型安全。 现在,如果我们想写一个通用的函数,我们必须告诉编译器“这个函数只有在它的所有参数支持这个函数中所用用到的操作时,才有意义”。举个例子,如果我们想定义一个将它的三个参数相加,并返回其和的函数,我们必须告诉编译器:这三个参数必须支持加法运算。就象这样: (Rust)
(Haskell)
在上面这个例子中,我们告诉haskell的编译器:“add3这个函数的参数必须是一个Num(算数数类型)“。因为编译器知道一个Num类型的参数支持加法,所以这个函数的表达式可以通过类型检查。在haskell中,这些限制也可应用于data关键字所做的定义中。这是一个可以优雅地定义百分之百类型安全的灵活泛型函数的方式。 go的解决方案:interface{} Go的普通类型系统的结果是,Go对通用编程的支持很差。 你可以非常轻松的写通用方程。假如你想写一个可以打印被哈希的对象的哈希值。你可以定义一个拥有静态类型安全保证的interface,像这样: (Go)
现在,你可以提供给printHash任何Hashable的对象,你也得到静态类型检查。这很好。 但如果你想写一个通用的数据结构呢?让我们写一个简单的链表。在Go里写通用数据结构的惯用方法是: (Go)
发现什么了吗?value的类型是interface{}。interface{}就是所谓的“最高类型”,意味着所有其他的类型都是interface{}的子类型。这大致相当于Java中的Object。呀!(注意:对于Go中是否有最高类型还有争议,因为Go宣称没有子类型。不管这些,保留类比的情况。 在Go里面“正确”构建通用数据结构的方法是将对象设置为最高类,然后把它们放入到数据结构中。大约在2004年,Java就是这么做的。后来人们发现这完全违背了类型系统的本意。当你有这样的数据结构时,你完全消除了一个类型系统能提供的所有好处。比如,下面这个是完全有效的代码:
而这在一个良好结构化的程序里完全没有意义。你可能期望的时一个整数链表,但在某个情况下,一些疲惫、靠咖啡清醒的程序员在截止日期前偶然在某处加入了一个字符串。因为Go里面的 通用数据结构不知道它们值的类型,Go的编译器也不会改正,你的程序在你失去从interface{}里面捕获时将崩溃。 相同的问题在任何通用数据结构里都存在,无论是list、map、graph、tree、queue等。 语言可扩展性问题高级语言通常有复杂任务的关键字和符号简写。比如,在很多语言中,迭代一个如数组一样的数据集合中所有元素的简写: (Java)
(Python)
如果我们能在任何集合类型上操作,不仅仅是那些语言内建类型(如数组),会很美好。 如果我们可以定义类型的相加也会很美好,那么我们可以这么做 (Python) point3 = point1 + point2 好的解决方案:把运算符视作函数 将内建的运算符和某个特别命名的函数对应起来,亦或将关键字视作特定函数的别名,这样做可以很好的解决该问题。 某些编程语言,像Python,Rust和Haskell允许我们重载运算符。我们只需要给我们自定义的类添加一个函数,自此,当我们使用某个运算符的时候(例如”+“),解释器(编译器)就会直接调用我们所添加的函数。在Python中,运算符”+“对应于__add__()函数。在Rust中,”+“运算符在Add这个trait中定义为add()函数。在Haskell中,”+“对应于Num这个type class中的(+)。 许多语言都有扩展关键字的方法,例如for-each循环。Haskell没有循环,但是像Rust,Java和Python这样的语言中都有”迭代器“这样的概念使得for-each循环可以应用于任何种类的数据集合结构。 某些人可能会用这个特性做一些很操蛋的事情,这是一个潜在的缺点。例如,某些疯狂的家伙使用”-“来代表两个向量之间的点乘。但这并不完全是运算符重载的问题。无论使用何种语言,都可以写出胡乱命名的函数。 Go的解决方案:没有 Go语言不支持操作符重载或者关键字扩展。 那么如果我们想给其他的东西(例如树,链表)实现range关键字的操作怎么办?太糟糕了。这不是语言的一部分。你这能在内建对象上使用range关键字。对于关键字make也一样,它不能给非内建数据结构申请内存和初始化。 最接近这个可以使用迭代器的关键字的方式是写一个包装函数,这个函数以目标数据结构为参数并返回一个可迭代的对象,我们通过使用这个对象在目标数据结构上迭代(译者注:参见设计模式中的迭代器模式或C++中的迭代器实现)。但是这样做可能会很慢并且复杂,而且无法保证不引入其他的bug。 对于这样一个问题,有人辩解道,“这样更容易让人理解代码,并且我看到的代码就是真正被执行的代码。”也就是说,如果Go语言允许我们扩展像range这样的东西,那么range本身的机制和实现就会变得复杂难以理解。我认为这样的说法没有什么营养,因为不管Go是否通过这种方式让其变得更简单,更易懂,人们总要进行这种在某些数据结构上进行迭代操作。如果我们不想把实现细节隐藏在range()函数里,我们就要把它隐藏在其他的工具函数里,没什么改进。所有的好代码都是易读的,大多数糟糕代码让人很难懂,很显然Go不能改变这个事实。 基础案例与失败条件那么问题来了 当遇到递归的数据结构(如链表和树)时,我们希望找到一个途径来指出我们到达数据结构的末端。 当遇到可能会执行失败的函数或包含缺失数据片的数据结构时,我们希望找到一个途径明示我们遇到的几种失败情况。 Go 的方解决案: Nil (和多个返回值)这回我先说 Go 的, 才好引出其他更好解决方案的讨论. Go 支持 null 指针(nil). 每次看到新的编程语言(如:tabula rasa), 实现这个导致 bug 满天飞的功能, 我替他们可惜. null 指针的历史, 满满的都是 bug. 无论是历史, 还是现实, 我都看不出来, 数据存在内存地址为 0x0 的地方有什么意义. 指向 0x0 的指针通常都有特定的含义. 比如, 返回类型是指针的函数出错, 会返回 0x0 . 递归数据结构把 0x0 当作基底(base case), 如: 树结构的页节点, 或链表的结尾. 这也是 null 指针在 Go 中的用法. 然而,这样使用null指针也是不安全的。事实上,null指针是类型系统的后门,它让你能够创造某个根本不是所属类型的实例。程序员有时候会忘记某个指针的值可能是null这个事实,这是一个很常见的情况。在最好的情况下,你的程序会挂掉,而在最坏的情况下,这会产生一个可以被人利用的漏洞。编译器无法轻易地阻止这种情况的发生,因为null指针破坏了语言的类型系统。 对于Go来说,使用多重返回值这个机制,利用它第二个返回值来返回一个代表“失败”的值是一个正确也被鼓励的做法。然而,这种机制很容易被忽略或者误用,并且在表示递归数据结构的时候没有什么用用处。 好的解决方案:代数数据类型和类型安全的错误模式 我们可以使用类型系统来包装错误状况,基底,而不是试图打破类型系统。 现在我们想要构建一个表示链表的类型。我们想表示两种情况:我们是否已经到达了链表的末尾,某个链表的节点上到底有没有被存放在那里的数据。一种类型安全的方式是分别使用不同的类型来表示这些情况,最后将它们组合成一个单独的类型(使用代数数据类型)。现在我们有一个叫做Cons的类型来表示一个存放有某些数据的链表,一个叫做End的类型来表示链表的末尾。我们可以这样写: (Rust)
(Haskell)
每个类型都为递归操作这个数据结构的算法声明了一个基底(End)。。Rust和Haskell都不允许null指针的出现,所以我们永远都不会碰到null指针解引用所造成的bug(除非我们做一些很大胆的底层操作)。 这些代数数据结构通过像模式匹配(后面讲它)这样的技术,允许我们写出非常明了的代码。 那么,我们如何得到一个可能返回或者不返回给定类型的数据的函数,或是一个可能内部包含或者没有包含一个给定类型的数据的数据结构呢?也就是说,我们如何将错误状况(failure condition)封装到我们的类型系统中来呢?Rust使用Option,Haskell使用一个叫Maybe的类型来解决这个问题。 我们想象这样一个函数,它所作的事情是搜索一个非空字符串的数组,寻找一个以这‘H’开头的字符串,返回第一个找到的这样的字符串,如果没有找到,就返回某种错误状况。在Go语言中,我们可以通过返回nil来表示“没找到”这个错误。但是在Haskell和Rust中,不使用危险的指针,我们就可以安全地完成这个任务。 (Rust)
(Haskell)
我们可以返回一个包含或者没有包含一个字符串的对象来代替返回一个字符串或者null指针的做法。使用search()函数的程序员也会很清楚地知道这个函数可能会失败(因为它返回的对象的类型已经这么说了),而且程序员必须处理这两种状况,否则报错。这样我们就跟null指针解引用所造成的bug说再见了。 |