为什么 Go 不是一款好的编程语言

jopen 4年前

我喜欢 Go. 常用它实现各种功能(包括在写本文时的这个博客). Go 很实用,但不够好。 不是说它有多差, 只是没那么好而已。 

一门编程语言, 也许会用上一辈子, 所以选择的时候要注意。 

本文专注于 Go 的各种吐槽。 老生常谈的有之,鲜为人知的也有。 

我用 Rust 和Haskell 作为参照 (至少, 我以为, 这俩都很不错)。 本文列出的所有问题, 都有解决方案。 

常规编程

那么问题来了

我们写代码可以用于许多不同的事情。假如我写了一个函数用来对一列数字求和,如果我可以用该函数对浮点数、整数以及其他任何类型进行求和那该多棒。如果这些代码包含了类型安全并且可以快速的写出用于整型相加、浮点型相加等的独立函数就更完美了。

好的解决方案:基于限制的泛型和基于参数的多态

到目前为止,我遇到的最好的泛型编程系统是rust和haskell所共用的那个。它一般被称作”被限制的类型“。在haskell中,这个系统被称作”type class“。而在Rust中,它被称作”traits“。像这样:

(Rust, version 0.11)

fn id<T>(item: T) -> T {     item  }

(Haskell)

id :: t -> t  id a = a

在上面这个简单了例子中,我们定义了一个泛型函数id。id函数将它的参数原封不动传回来。很重要的一点是这个函数可以接受任何类型的参数,而不是某个特定的类型。在Rust和haskell中,id函数保留了它参数的类型信息,使得静态类型检查可以顺利工作,并且没有为次在运行期付出任何代价。你可以使用这个函数来写一个克隆函数。

同样,我们可以应用这种方式来定义泛型数据结构。例如:

(Rust)

struct Stack<T>{     items: Vec<T>  }

(Haskell)

data Stack t = Stack [t]

跟上面一样,我们在没有运行期额外消耗的情况下得到完全的静态类型安全。

现在,如果我们想写一个通用的函数,我们必须告诉编译器“这个函数只有在它的所有参数支持这个函数中所用用到的操作时,才有意义”。举个例子,如果我们想定义一个将它的三个参数相加,并返回其和的函数,我们必须告诉编译器:这三个参数必须支持加法运算。就象这样:

(Rust)

fn add3<T:Num>(a:T, b:T, c:T)->T{     a + b + c  }

(Haskell)

add3 :: Num t => t -> t -> t -> t  add3 a b c = a + b + c

在上面这个例子中,我们告诉haskell的编译器:“add3这个函数的参数必须是一个Num(算数数类型)“。因为编译器知道一个Num类型的参数支持加法,所以这个函数的表达式可以通过类型检查。在haskell中,这些限制也可应用于data关键字所做的定义中。这是一个可以优雅地定义百分之百类型安全的灵活泛型函数的方式。

go的解决方案:interface{}

Go的普通类型系统的结果是,Go对通用编程的支持很差。

你可以非常轻松的写通用方程。假如你想写一个可以打印被哈希的对象的哈希值。你可以定义一个拥有静态类型安全保证的interface,像这样:

(Go)

type Hashable interface {     Hash() []byte  }    func printHash(item Hashable) {     fmt.Println(item.Hash())  }

现在,你可以提供给printHash任何Hashable的对象,你也得到静态类型检查。这很好。

但如果你想写一个通用的数据结构呢?让我们写一个简单的链表。在Go里写通用数据结构的惯用方法是:

(Go)

type LinkedList struct {      value interface{}      next  *LinkedList  }    func (oldNode *LinkedList) prepend(value interface{}) *LinkedList {     return &LinkedList{value, oldNode}  }    func tail(value interface{}) *LinkedList {     return &LinkedList{value, nil}  }    func traverse(ll *LinkedList) {     if ll == nil {         return     }      fmt.Println(ll.value)     traverse(ll.next)  }    func main() {     node := tail(5).prepend(6).prepend(7)     traverse(node)  }

发现什么了吗?value的类型是interface{}。interface{}就是所谓的“最高类型”,意味着所有其他的类型都是interface{}的子类型。这大致相当于Java中的Object。呀!(注意:对于Go中是否有最高类型还有争议,因为Go宣称没有子类型。不管这些,保留类比的情况。

在Go里面“正确”构建通用数据结构的方法是将对象设置为最高类,然后把它们放入到数据结构中。大约在2004年,Java就是这么做的。后来人们发现这完全违背了类型系统的本意。当你有这样的数据结构时,你完全消除了一个类型系统能提供的所有好处。比如,下面这个是完全有效的代码:

node := tail(5).prepend("Hello").prepend([]byte{1,2,3,4})

而这在一个良好结构化的程序里完全没有意义。你可能期望的时一个整数链表,但在某个情况下,一些疲惫、靠咖啡清醒的程序员在截止日期前偶然在某处加入了一个字符串。因为Go里面的 通用数据结构不知道它们值的类型,Go的编译器也不会改正,你的程序在你失去从interface{}里面捕获时将崩溃。

相同的问题在任何通用数据结构里都存在,无论是list、map、graph、tree、queue等。

语言可扩展性

问题

高级语言通常有复杂任务的关键字和符号简写。比如,在很多语言中,迭代一个如数组一样的数据集合中所有元素的简写:

(Java)

for (String name : names) { ... }

(Python)

for name in names: ...

如果我们能在任何集合类型上操作,不仅仅是那些语言内建类型(如数组),会很美好。

如果我们可以定义类型的相加也会很美好,那么我们可以这么做

(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)

enum List<T> {    Cons(T, Box<List<T>>),    End  }  let my_list = Cons(1, box Cons(2, box Cons(3, box End)));

(Haskell)

data List t = End | Cons t (List t)  let my_list = Cons 1 (Cons 2 (Cons 3 End))

每个类型都为递归操作这个数据结构的算法声明了一个基底(End)。。Rust和Haskell都不允许null指针的出现,所以我们永远都不会碰到null指针解引用所造成的bug(除非我们做一些很大胆的底层操作)。

这些代数数据结构通过像模式匹配(后面讲它)这样的技术,允许我们写出非常明了的代码。

那么,我们如何得到一个可能返回或者不返回给定类型的数据的函数,或是一个可能内部包含或者没有包含一个给定类型的数据的数据结构呢?也就是说,我们如何将错误状况(failure condition)封装到我们的类型系统中来呢?Rust使用Option,Haskell使用一个叫Maybe的类型来解决这个问题。

我们想象这样一个函数,它所作的事情是搜索一个非空字符串的数组,寻找一个以这‘H’开头的字符串,返回第一个找到的这样的字符串,如果没有找到,就返回某种错误状况。在Go语言中,我们可以通过返回nil来表示“没找到”这个错误。但是在Haskell和Rust中,不使用危险的指针,我们就可以安全地完成这个任务。

(Rust)

fn search<'a>(strings: &'a[String]) -> Option<&'a str>{    for string in strings.iter() {      if string.as_slice()[0] == 'H' as u8 {        return Some(string.as_slice());      }    }    None  }

(Haskell)

search [] = Nothing  search (x:xs) = if (head x) == 'H' then Just x else search xs

我们可以返回一个包含或者没有包含一个字符串的对象来代替返回一个字符串或者null指针的做法。使用search()函数的程序员也会很清楚地知道这个函数可能会失败(因为它返回的对象的类型已经这么说了),而且程序员必须处理这两种状况,否则报错。这样我们就跟null指针解引用所造成的bug说再见了。

类型推导(Type Inference)

问题

给程序中的每个值都指定类型, 有时看起来点过老土。 某些场合, 值的类型显而易见,如

int x = 5
y = x*2

这里的 y 明显就是整形。更复杂点的,我们甚至可以根据函数的参数类型推断出它的返回类型(反之亦然)。

出色的解决方案: 通用类型推导(General Type Inference)

Rust 和 Haskell 都基于 Hindley-Milner 类型系统, 他们都很擅长类型推导, 你可以实现像下面这样好玩的功能:

(Haskell)

map :: (a -> b) -> [a] -> [b]  let doubleNums nums = map (*2) nums  doubleNums :: Num t => [t] -> [t]

函数 (*2) 有一个 Num 类型参数, 返回也是一个Num 类型, Haskell 由此推断 a 和 b 也是 Num 类型. 最后推断出, 该函数有若干个 Num 类型参数, 返回若个 Num 类型的值.  这种方式比 Go 和 C++ 的简单类型推导强大多了. 有了它, 哪怕是结构复杂的程序, 就算我们不声明这么多显性类型, 编译器也能正确处理. 

Go 的解决方案 : :=

Go 支持 := 赋值操作符,  用法如下:

(Go)

foo := bar()

它的原理是: 查找 bar() 的返回类型, 然后赋给 foo. 下列代码的道理也一样:

(C++)

auto foo = bar();

没什么稀奇的, 无非省去了人工查找函数 bar() 的返回类型, 在键盘上多敲几个字声明 foo 的类型那点时间而已. 

不变性(Immutability)

问题

不变性是指,在程序生成的时候,设好的值,以后不会再变。 它的优势很明显, 能减少因程序某个地方的数据结构改变,导致另一个地方出现问题的概率。

此外对程序优化也有利。 

出色的解决方案: 默认使用不变性

程序员应当尽可能使用不可变数据结构。 不变性使得判断负面影响和安全性变得更简单。同时也能减少各种 Bug 。 

Haskell 默认情况下, 所有的值都是不可变的。改变数据结构就意味着, 在保证正确性的前提下, 重新创建一个新的数据结构。由于 Haskell 采用的是惰性求值(lazy evaluation)和永久性数据结构(persistent data structures), 所以运行的速度还是粉快的。Rust 属于系统级编程语言。不可能使用惰性求值,也就不能像 Haskell 那样始终使用不变性。 因此,虽然 Rust 默认情况下,变量的值是不可变的。 但是,在需要的时候, 还是可以将变量设置成可变的。这样挺好,因为它迫使程序员问自己, 底需不需要将这个变量设成可变的。 这是很好的变成习惯, 对编译器优化代码也有好处。  

Go 的方案: 无

Go 不支持这项功能。 

控制流结构(Control Flow Structures)

问题

控制流结构是高级编程语言有别于汇编的原因之一. 它允许我们在抽象层面, 有条理地控制程序流程. 毫无疑问, 所有高级语言都支持控制流结构, 否则, 我还说个毛啊. 可惜, 有那么几种相当不错的控制流结构 Go 不支持. 

出色的解决方案:模式匹配和复合表达式

模式匹配配合数据结构或值使用的时候, 效果相当好. 简直就是 case/switch 的加强版.  我们可以像这样对值进行匹配:

(Rust)

match x {    0 | 1  => action_1(),    2 .. 9 => action_2(),    _      => action_3()  };

或者像这样解构数据结构(deconstruct data structures):

(Rust)

deg_kelvin = match temperature {    Celsius(t) => t + 273.15,    Fahrenheit(t) => (t - 32)/1.8 + 273.15  };

上面的例子, 有时也称作复合表达式.  C 和 Go 中的 if 和 case/switch 语句只用来控制程序流程, 不会返回值; 而 Rust 和 Haskell 的 if 和 模式匹配语句则可以. 既然有值返回, 当然也能用来赋给其他东东. 这里给出一个 if 语句的例子:

(Haskell)

x = if (y == "foo") then 1 else 2

Go 的方案: C语言风格的无值语句( Valueless Statements)

不是我故意找 Go 的茬; 它确实有几个不错的的控制流元素, 如, 用于并行计算的 select. 可惜没有我钟爱的复合表达式和模式匹配.  Go 唯一支持赋值的语句, 是像这样的原子表达式 x := 5 或 x := foo().

嵌入式编程

给嵌入式系统编写程序与在一个有完整操作系统的计算机上编写程序有很大不同。某些语言相比而言更适合嵌入式编程的需要。

对于不少人赞成Go语言可以给机器人编程这件事我很疑惑。基于一些原因,Go语言并不适合用来为嵌入式系统编写程序。这一节并不是对Go语言的指责,Go语言并不是被设计用来编写嵌入式程序的语言。这一章节针对那些吹捧Go语言可以胜任嵌入式编程的人。

子问题 #1:堆和动态内存分配

堆是一块在运行期创建的可以存储任意数量对象的内存区域。我们将对堆的使用称作”动态内存分配“。

通常,在嵌入式系统中使用堆存储空间是不明智的。较大的内存开销和需要管理复杂的数据结构是主要的原因,尤其是当你在一块主频只有8MHz,RAM只有2KB的MCU上写程序的时候。

在实时系统(因为某一操作耗时过长就可能会跪的系统)中使用堆也是不明智的,因为对堆上空间的申请和释放所消耗的时间有很大的不确定性。举个例子,如果你的MCU正在控制一个火箭的引擎,就在这时,如果一个对栈空间的申请比平常多消耗了几百毫秒,导致对阀门的错误计时,就会发生大爆炸。

还有一些原因致使动态内存分配对嵌入式编程没有多大用。例如,许多使用堆的语言同时也拥有垃圾收集机制。垃圾收集机制经常会暂停整个程序一会儿,在堆上寻找垃圾(不再被程序使用的内存)并清除它们。这比单纯的堆空间申请更加具有不确定性。

好的解决方案:让动态内存分配成为可选项

Rust语言的标准库中有很多特性依赖于堆。然而,Rust语言的编译器支持完全关闭这些有关堆的语言特性,并且能够静态地确保这些特性在程序中不被使用。写出完全不使用堆的Rust程序是完全可行的。

Go语言的解决方案:没有

Go语言严重依赖于对堆的运用。没有可行的方式让Go程序完全不使用堆。这不是Go语言的问题。这在Go语言的目的应用领域完全没有问题。

Go并不是一门实时的语言,通常我们不能担保合理复杂的Go程序的执行时间。这可能有点费解,我来解释一下:Go相对而言很快,但不是实时的,这两个概念非常不同。执行速度快对嵌入式程序来说很重要,但是真正重要的是能否担保某些操作的最大执行时间,而这恰恰是Go不能预测的。这个问题有很大一部分是Go语言对于堆空间和垃圾收集机制的使用造成的。

Haskell也有相似的问题。Haskell同样由于对堆的大量使用而不能胜任嵌入式或者实时编程。然而,我没有看见任何人推荐使用Haskell对机器人编程,所以我不用指出这点。

子问题#2:不安全的底层代码

当我们写嵌入式程序的时候,写一些不安全的代码(不安全的的类型转换,或者指针运算)是不可避免的。在C或C++中,做这样的事情是很简单的。如果我需要向0x1234这个内存地址写入0xff这个值来点亮一个LED,我可以这样写:

(C/C++)

*(uint8_t*)0x1234 = 0xFF;

这样做很危险,只有当我们写非常底层的系统代码的时候才有意义。这就是Go和Haskell没有简单的方式来做这样的事的原因:它们不是系统编程语言。

好的解决方案:将不安全的代码孤立开来

注重安全和系统编程的Rust语言有一个非常好的解决方案:unsafe代码块。unsafe代码块是一种显示地将不安全的代码分离出来的方式。我们通过如下的方式在Rust语言中向0x1234地址写入0xff:

(Rust)

unsafe{ 
  *(0x1234 as *mut u8) = 0xFF; 
}

如果我们在unsafe代码块外面做这样的事情,Rust的编译器会警告我们。这样允许我们在满足嵌入式编程需要的同时,保持了程序的安全和稳定。

Go的解决方案:没有

Go语言本来就不是为了做这样的事而出现的,所以没有任何内建的支持。

总结

现在你可能会说,“那么为什么你说Go语言不好?这只是一大堆你的抱怨而已。你可以针对任何语言发牢骚。“没有语言是完美的,这很正确。然而,我希望我的抱怨能在某种程度上说明:

  • Go语言本质上没有干了什么新的事情

  • Go语言本身没有被良好地设计

  • Go语言是其他现代编程语言的退化