返回介绍

3.2 Go

发布于 2023-05-19 13:36:37 字数 19522 浏览 0 评论 0 收藏 0

2009 年 11 月,Google 发布了一种名为 Go 的新语言,在世界范围内引发了轰动。下面我从一个编程语言设计者的角度,来展望一下 Go 这个新兴的编程语言。

作为一种新的编程语言,Go 宣扬了 图 1 中的这些关键字。首先,我们先来看看这些关键字到底是什么意思吧。

(1)New(新的)
(2)Experimental(实验性的)
(3)Concurrent(并发的)
(4)Garbage-collected (带垃圾回收的)
(5)Systems(系统)
(6)Language
图 1 Go 的关键字

几乎每一个新的编程语言在发布的时候都会被问及这样一个问题:“为什么要创造一个新语言呢?”Ruby 发布的当时也有很多人这样问过,我给出的是“只是因为想做做看而已啊”这么个不着调的回答。不过 Go 的开发者们说,这个新语言是由于对现有语言的不满才诞生出来的。

在这 10 年中,有很多新的编程语言相继诞生,并获得一定程度的应用,其中大多数都是以 Ruby 为代表的动态语言,但是,能触及 C 和 C++ 领域的新的系统编程语言却迟迟没有出现。

另一方面,编程语言所面临的状况也在不断发生变化,如网络的普及、多核、大规模集群等,而重视性能的系统编程语言却没有对这样的变化做出应对。Go 语言的开发者们主张,正是因为这样的局面,才使得创造一种开发效率更高的系统编程语言变得十分必要。

Experimental(实验性的)

一种编程语言从出现到实用化所要经历的时间之长,超乎普通人的想象。以 Ruby 为例,从开始开发到发布用了 3 年左右的时间,而到了在程序员圈子中拥有一定知名度则又花了 4 年的时间,而再到通过 Ruby on Rails 而走红,则又花了 5 年的时间。

相比之下,从 2007 年末开始开发的 Go,只经过了 2 年左右的开发,在发布之时就获得了全世界的关注,我表示实在是羡慕之极。但即便如此,Go 所吸收的那些新概念是否能真正被世界所接受,现在还是个未知数。从这个意义上来看,应该说它还只是一种实验性的语言。

Concurrent(并发的)

在 21 世纪的今天,并发编程变得愈发重要。需要同时处理大量并发访问的网络应用程序,本来就更加适合并发编程,而对于不断增大的处理信息量,分布式并发编程又是一个很好的解决方案,因而备受期待。

此外,要最大限度地利用多核,甚至是超多核(Many-core)环境的 CPU 性能,并发编程也显得尤为重要。

因此,为了实现更高开发效率的并发编程,编程语言本身也必须具备支持并发编程的功能,这已经成为一种主流的设计思路。近年来,像 Erlang 这样以并行计算为中心的编程语言受到了广泛的关注,也正是由于上述背景所引起的。

然而,当前主流的系统编程语言中,并没有哪种语言在语言规格层面上考虑到了并发编程。我想,正是这一点成为了 Go 开发的契机。

Garbage-collected(带垃圾回收的)

将不需要的对象自动进行回收,从而实现对内存空间的循环利用,这种垃圾回收(GC)机制在 40 多年前出现的 Lisp 等编程语言中已经是常识了。在需要大量操作对象的程序中,对于某个对象是否还要继续使用,是很难完全由人来把握和判断的。然而,如果对象的管理出现问题,便会导致十分严重的 bug。

如果忘记对不需要的对象进行释放,程序所占用的内存容量就会不断增大,从而导致内存泄漏(Memory leak)bug;反过来,如果释放了仍然在使用中的对象,就会导致内存空间损坏的悬空指针(Dangling pointer)bug。

这两种 bug 都有一个特点,就是出问题的地方,和实际引发问题的地方往往距离很远,因此很难被发现和修复。所以我认为,在具备一定面向对象功能的编程语言中,GC 是不可或缺的一个机制。

使 GC 走进普通的编程领域,并得到广泛的认知,不得不说是 Java 所带来的巨大影响。在 Java 之前,大家对 GC 的主流观点,要么认为它在性能上有问题,要么认为它在系统编程中是不需要的。像 C++ 这样的系统编程语言中,没有提供 GC 机制,应该也是出于这个原因吧。

然而,现在情况变了。作为 21 世纪的系统编程语言,Go 具备了 GC 机制,从而减轻了对象管理的消耗,程序员的负荷也跟着减轻,从而使得开发效率得到了提高。

Systems(系统)

刚刚我们不断提到系统编程语言这个说法,那么系统编程语言到底指的是怎样一类编程语言呢?

至于严格的定义,其实我也不是十分清楚,不过从印象来说,应该指的是可以用来编写操作系统的,对性能十分重视的语言吧。从定位上来说,应该说是 C 和 C++ 所覆盖的那片领域。

的确,在这个领域最广泛使用的语言中,即便是最新的 C++(1983 年)也决不能算是“新”了。而无法编译出可直接运行的代码(原本在设计上就不会编译出这样的代码)的 Java,又很难用作系统编程语言,而且 Java 发布于 1995 年,到现在也已经过了 10 多年了。

更进一步说,由于 Java 本身就是设计为在 JVM 中运行的,因此即便通过 JIT 等最新技术实现了高速化,我觉得也很难称其为是一种系统编程语言。

在 Google 中,由于对海量数据和大规模集群处理有较大的需求,因此便愈发需要一种高性能的编程语言。然而,为了避免使用过多种编程语言所造成的管理成本上升,Google 公司对官方项目中能够使用的语言进行了严格的限制,只有 C、C++、Java、JavaScript 和 Python 这 5 种1

1 不过,在非官方的项目中就没有这样的限制,比如在 Google 公司内部也有很多 Ruby 程序员。貌似他们在官方项目中使用 Java 和 Python,而在非官方项目中则使用 Ruby。

用于基础架构等系统编程的 C 和 C++、兼具高开发效率和高性能的 Java、用于客户端编程的 JavaScript,再加上高开发效率的动态语言 Python,我认为这是一组十分均衡的选择。

不过,仔细看看的话,用于系统编程的 C 和 C++ 则显得有些古老,对于最近获得广泛认知的,从语言层面对开发效率的支持机制(如 GC 等)显得不足。Go 的出现,则为这一领域带来了一股清新的风,也可以说,Go 是 Google 表达对于系统编程语言不满的一个结果。

Go 的创造者们

领导 Go 项目的,主要有下面这些人:罗勃·派克(Rob Pike)、肯·汤普逊(Ken Thompson)、Robert Griesemer、Ian Lance Taylor、Russ Cox。其中,罗勃·派克和肯·汤普逊是超级名人。肯·汤普逊是曾经最早创造 UNIX 的人,也是参与过 B 语言和 Plan 9 2操作系统开发的传说中的黑客。

2 Plan 9,全称 Plan 9 from Bell Labs(贝尔实验室九号计划),是贝尔实验室从 20 世纪 80 年代中期到 2002 年为止,以研究 UNIX 后续可能性为主要目的而开发的操作系统。

对我来说,罗勃·派克和布莱恩·柯林汉3合作的名著《UNIX 编程环境》给我留下了很深的印象,除此之外,罗勃·派克在 AT&T 贝尔实验室也贡献了诸多成果,如在 Plan 9 的开发中扮演了重要的角色。要说他离我们最近的一项功绩,莫过于 UTF-8 的开发了,这也是和肯·汤普逊的共同成果。如果没有他们的话,估计现在世界已经被那个超烂的 UTF-16 占领了吧,想到这里,我不禁充满了感激之情。

3 布莱恩 • 柯林汉(Brian Kernighan,1942— )是一位加拿大计算机科学家,曾任职于贝尔实验室,是 UNIX 的开发者之一。现在普林斯顿大学任教。

虽然可能有私情的成分,但这样的开发者所创造出的 Go,一定是颇受 UNIX,特别是 C 语言影响的,甚至可以说它就是现代版的 C 语言。因此,下面我们就通过和 C 语言的对比,为大家介绍一下 Go。

Hello World

Hello World 可以说是介绍编程语言的文章所必需的,如图 2 所示。这里面“世界”两个字并不是我故意给写成汉字的,而是罗勃·派克原始的 Hello World 程序就是这么写的。也许是为了证明罗勃·派克是开发者之一的缘故吧,这段程序表明,只要使用 UTF-8 字符串,就可以自由驾驭 Unicode。不过,貌似标识符还是只能用英文和数字。

package main ←---这段程序属于名为“main”的包
import "fmt" ←---使用名为“fmt”的包

func main () {
 fmt.Printf("Hello, 世界\n"); ←---使用fmt包中的Printf函数
}
图 2 用 Go 编写的 Hello World

由于只能引用公有函数,因此包名的圆点后面跟着的标识符总是大写字母开头。

在我的印象中,Go 和 C 语言果然还是很相似的。当然也有一些不同之处,例如 package 和 import 等对包系统的定义,以及末尾的分号可以省略等等。

Printf 中的 P 是大写字母,这一点挺引人注目的,其实它的背后代表了一条规则,即大写字母开头的名称表示可以从包外部访问的公有对象,而小写字母开头的名称表示只能从包内部访问的私有对象。由于有了这条规则,Go 中几乎所有的方法名都是大写字母开头的。

Go 的控制结构

下面我们来更加深入地了解一下 Go 吧。

首先我们从控制结构开始看。Go 主要的控制结构有 if、switch 和 for 三种,而并没有 while,while 用 for 代替了。我们先来讲一下 if 结构。

Go 的 if 结构语法规则如图 3 所示。

if 条件1 {程序体1}
  else if 条件2 {程序体2}
else (程序体3)
图 3 Go 的控制结构

其中 else if 的部分可以有任意个。Go 的 if 结构和 C 的 if 结构很相似,不过有几点区别。

首先,和 C 语言不同,Go 中 if 等结构中的条件部分并不用括号括起来,而相应地,程序体的部分则是必须用花括号括起来的。

还有一点不是很明显,那就是“必须括起来”这条规则实际上非常重要。C 语言的规则是这样的:当程序体包含多行代码时,需要用花括号括起来使其成为一体。但这样的规则下,if 结构的语法便会产生歧义。

例如:

if (条件) if (条件) 语句 else 语句
这样的 C 语言程序,到底是解释为:

if (条件) {
  if (条件) 语句
  else 语句
}
还是解释为:

if (条件) {
   if (条件) 语句
  }
else 语句
貌似很难抉择。

这个问题被称为“悬挂 else 问题”。在 C 语言中,虽然存在“有歧义的 else 属于距离较近的 if 语句”这样一条规则,但还是避免不了混乱的发生。

然而,如果有了“程序体必须用花括号括起来”这条规则,就不会产生这样的歧义了。从这个角度来看,不允许省略花括号着实是一个好主意。在其他广泛应用的主流语言中,Perl 的语法也不允许省略花括号。

说句题外话,Ruby 在控制结构的划分中,没有使用花括号,而是使用 end,理由也是一样的。像 Ruby 这样使用 end 的语言中,也可以避免因悬挂导致的歧义。

Go 的 if 语句还有一点与 C 语言不同,那就是在条件部分可以允许使用初始化语句,具体示例如图 4 所示。

if v = f(); v < 10 {
  fmt.Printf("%d < 10\n", v);
} else {
  fmt.Printf("%d >= 10", v);
}
图 4 条件部分可以使用初始化语句

将初始化语句移动到 if 结构的前面,意思也不会发生变化,但将初始化语句放在条件部分中,可以更加强调是对 f() 的返回值进行判断这一意图。我们后面要讲到的“逗号 OK”形式中,这种初始化方式也非常奏效。

switch 也是从 C 语言来的,但也有些微妙的差异。和 if 结构一样,条件表达式不用括号括起来,而程序体必须用花括号括起来(图 5)。

switch a {
 case 0: fmt.Printf("0");
 default: fmt.Printf("非0");
}
图 5 Go 的 switch 结构

当没有满足条件的 case 时则执行 default 部分,这一点和 C 语言是相同的。但是,和 C 语言相比,Go 的 switch 结构还存在下面这些差异:

· 即便没有 break,分支也会结束

· case 中可以使用任意的值

· 分支条件表达式可以省略

switch 中的 break 语法很诡异,堪称 C 语言中最大的谜,趁这个机会正好改一改。在上面的例子中,也不存在用于分隔的 break 呢。相应地,case 则可以并列多个条件。

虽说 Go 从 C 语言中继承了很多东西,但也没有必要连这些也一起继承过来。然而,Go 中却追加了一条新的语法,即 case 的程序体以 fallthrough 语句结束时,会进入下面一个分支。这样真的有必要吗?我觉得这只是一种对 C 语言单纯的怀念而已吧。

C 语言的 switch 中,case 可以接受的值只能是整数、字符、枚举等编译时已经确定的值,这应该是考虑了为用表实现的分支进行优化的结果,而 break 的存在也可以看成是由以前的汇编语言编程派生而来。

于是,在汇编语言已经十分罕见的现在,这种语法成为一个“谜”也是没有办法的事,但在 Go 中就没有这样的制约了。

最后,Go 还有一种独有的,很有趣的语法,那就是 switch 语句中判断分支条件的表达式是可以省略的(图 6)。这其实可以看成是用一种更易读的方式来实现一个 if-else 结构,实际上编译出来的结果貌似也是一样的。

switch {
 case a < b: return -1;
 case a == b: return 0;
 case a > b: return 1;
}
图 6 分支条件表达式可以省略

有趣的是,Ruby 的 case 结构也可以写成同样的形式,用 Ruby 编写出来的程序如图 7 所示。那个,我并不是说 Go 是抄袭 Ruby 哦,没有证据证明这一点,而且我也觉得不大可能会抄 Ruby,不过说实话,我还真小小地动过一点这个念头,万一是真的呢?(笑)

case
  when a < b: return -1
  when a == b: return 0
  when a > b: return 1
end
图 7 Ruby 中也可以省略分支条件表达式

for 结构也和 C 语言非常相似。和 if 结构一样,条件表达式不需要用括号括起来,但循环体则必须用花括号括起来,除此之外,还有一些地方和 C 语言有所不同。

首先,Go 的 for 语句中,条件部分的表达式有两种形式:单表达式形式和三表达式形式。其中单表达式形式和 C 语言中的 while 语句功能是相同的。

for 条件 {循环体}
三表达式形式则和 C 语言的 for 语句是相同的。

for 初始化; 条件; 更新 {循环体}
因此,编写出来的循环代码和 C 语言几乎是一样的:

for i=0; i<10; i++ {
  ...
}
有趣的是,在 Go 中,++ 递增并不是表达式,而是作为语句来处理的。此外,由于没有类似 C 语言中逗号操作符的形式,因此 for 的条件部分无法并列多个表达式。如果要对多个变量进行初始化,可以使用多重赋值:

for i,j=0,1; i<10; i++ {
  ...
}
在 for 语句中,空白表达式表示真,因此:

for ;; {
  ...
}
就表示无限循环,在 C 语言中也是一样的。

循环可以通过 break 和 continue 来中断,这两个语句的意思和 C 语言是一样的,不过 Go 中可以通过标签来指定到底要跳出哪一个循环(图 8)。

Loop: for i = 0; i < 10; i++ {
  switch f(i) {
    case 0, 1, 2: break Loop
  }
  g(i)
}
图 8 用标签来指定要跳出的循环

这一点又有点像 Java 的风格了,看来 Go 的设计真是研究和参考了其他多种语言呢。

作为控制结构来说,还有一些像 go 语句这样与并发编程关系密切的方式,我们稍后再来详细介绍。

类型声明

正如我们刚才所举的这些例子,Go 是一种深受 C 语言(不是 C++)影响的语言。不过,Go 也有和其他一些 C 派生语言不同的地方,其中最具有特色的就是其类型声明了。简单来说,Go 的类型声明和 C 语言是“正好相反”的。

C 语言中,类型声明的基本方式是:

类型  变量名;
不过,由于存在用户自定义类型,因此当遇到某个“名称”开头的一行代码时,很难一眼就判断出来这到底是类型声明,还是函数调用,或者是变量引用。

对人类来说存在歧义的表达方式,对编译器来说也就意味着需要更复杂的处理才能区分。因此,Go 中规定:声明必须以保留字开头,且类型位于变量名之后。

根据这两条规则,Go 的声明如图 9 所示。从深受 C 语言影响这个印象来说,这还真是令人震惊,不过习惯之后也就不觉得那么特殊了。这样的描述方式可以减少歧义,无论是对人还是编译器来说都更加友好。

// 新类型的声明
type T struct {
  x, y int
}

// 常量的声明
const N = 1024

// 变量的声明
var t1 T = new(T)

// 变量声明的简略形
t2 := new(T)

// 还可以声明指针
var t3 *T = &t2

// 函数声明
func f(i int) float {
 ...
}

// 函数声明(多个返回值)
func f2(i int) (float, int) {
 ...
}
图 9 Go 的声明

“:=”赋值也颇具魅力。“:=”赋值语句表示在赋值的同时将左侧的变量声明为右侧表达式的类型。

采用静态类型的语言中,由于需要大量对类型的描述,因此程序会通常会显得比较冗长,但在 Go 中由于可以省略类型声明,因此可以让程序变得更加简洁。虽说如此,但这种类型推导并非像某种函数型语言一样完美,因此 Go 也并非完全不需要类型声明。

Go 中没有 C++ 的模板(Template),也没有 Java 的泛型(Generic),但仅靠内置的数组(Array)、切片(Slice)、字典(Map)、通道(Channel)等类型,就可以指定其他的类型。切片是 Go 特有的一种类型,粗略来说,也可以理解成是数组的指针;字典则类似 Ruby 中的 Hash;通道用于并发编程,因此稍后再进行介绍。

// 字符串数组
var a [5]string

// 字符串切片
var s []string

// int到字符串的字典
var m map [int]string
// int管道
var c chan int
目前,由于 Go 没有支持泛型,因此无法定义类型安全的用户自定义集合。

要取出用户自定义集合的元素,需要使用 Cast。

Cast 的语法如下:

f := stack.get.(float)
Cast 在执行时会进行类型检查,这让人不禁想起支持泛型之前的早期 Java 呢。

在 Go 的 FAQ 中,并没有否定将增加泛型的可能性,只不过优先级比较低,此外,随着对泛型的支持,类型系统会变得非常复杂,从这些因素来考虑的话,暂时还没有支持泛型的计划。

不过,个人推测既然早晚要支持泛型,那么现在是不是应该让现有的复合类型(数组、切片、字典等)的声明更具有统一性呢?

无继承式面向对象

了解了 Go 语言之后,从个人观点来看,我感触最深的,莫过于其面向对象功能了。Go 虽然是一种静态语言,但却拥有用起来感觉和动态语言相近的面向对象功能。

这其中最大的特征就是无继承,但它也不是基于原型(Prototype)那样的实现方式。Go 的面向对象机制和其他语言大相径庭,所以一开始很容易搞得一头雾水。

首先,Go 中几乎所有的值都是对象,而对象就可以定义方法。Go 的方法是一种“指定了接收器(Receiver)的函数”,具体如图 10 所示。

func (p *Point) Move(x, y float) {
 ...
}
图 10 Go 的方法定义中指定了接收器

函数名(Move)前面用括号括起来的部分“p *Point”就是接收器。接收器的名称也必须逐一指定,这一点挺麻烦的,不由得让人想到 Python。

有趣的是,方法的定义和类型的定义可以在完全不同的地方进行。这有点像 C# 中的扩展方法,即可以向现有类型中添加新的方法。

貌似像 int 这样的内置类型不能直接添加方法,不过我们可以给它起个别名叫 init,然后再向这个类型添加方法。

方法的调用方式还是比较普通的:

p.Move(100.0, 100.0)
和 C 语言不同,语言本身可以区分是否为指针,因此不需要自己判断是用“.”还是“->”。

由于 Go 没有继承,因此通常的变量没有多态性,方法调用的连接是静态的。换一种更加易懂的说法,也就是说,如果变量 p 是 Point 型的话,则 p.Move 必定表示调用 Point 型中的 Move 方法。

然而,如果只有静态连接的话,作为面向对象编程语言来说就缺少了一个重要的功能。因此在 Go 中,通过使用接口(Interface),就实现了动态连接。

Go 的接口和 Java 的接口相似,是不具备实现的方法的集合,具体定义如下:

type Writer interface {
  Write(p []byte) int
}
interface 正文中出现的只可能是方法的类型声明,因此不需要保留字 func 和接收器类型。“不写不需要的东西”正是 Go 的风格。

作为未实现的类(类型),接口中只定义了方法的类型,而它本身也是一个类型,因此可以用于变量和参数的声明中。于是,只有通过接口来调用方法时,才会进行动态连接。

虽然语法有些差异,但大体上和 Java 的接口还是非常相似的。由于没有继承,因此只能通过接口来实现动态连接,这样便增加了静态链接的几率,提升了运行效率,这一点很有意思,不过也没有什么更大的好处了。

Go 的接口中令人感到惊讶的一点,就是某个类型对于是否满足某个接口,不需要事先进行声明。

在 Java 中,如果某个类在定义时用 implements 子句对接口进行了声明,则表示该类一定满足这个接口。然而,在 Go 中,无论任何类型,只要是接口中定义的方法(群)所拥有的类型, 就都能满足该接口。

以上述 Writer 接口为例,只要一个对象拥有接受 byte 切片的 Write 方法,就可以进行代入。通过这个变量来调用方法的话,就会根据对象选择合适的方法来进行调用。

这不就是动态语言所推崇的鸭子类型吗?明明是一种静态语言,却如此轻易地实现了鸭子类型,让人情何以堪。

例如,我们前面经常提到的 fmt.Printf 方法,它的参数应该具有 String() 方法。

反过来说,只要对 String() 方法进行重新定义,就可以控制 fmt.Printf 方法的输出。其实,在我上学的时候,曾经对静态类型的面向对象语言十分着迷,也曾经模模糊糊地设想过类似这样的一个机制,但当时的我还没有能力将它实现出来。

Go 所提供的面向对象功能十分简洁,但却兼具了类型检查和鸭子类型(虽然当时还没有这么一个专有名词)两者的优点,这是何等优秀的设计啊!我非常感动。

那么,动态连接就通过接口这一形式实现了。然而,接口却无法实现继承所具备的另一项功能,即“实现的共享”。在 Java 中,即便使用接口也无法共享实现,因此大家普遍使用结合体(composite)这一技术。

对于这一点,Go 也考虑到了。在 Go 中,如果将结构体的成员指定为一个匿名类型,则该类型就被嵌入到结构体中。在这里很重要的一点是,嵌入的类型中所拥有的成员和方法也被一并包含到结构体中,事实上这相当于是多重继承呢。这样一来,大家可能会想,成员和方法的名称会不会发生重复呢? Go 是通过下列这些独特的规则来解决这一问题的:

· 当重复的名称位于不同层级时,外层优先

· 当位于相同层级时,名称重复并不会引发错误

· 只有当拥有重复名称的成员被访问时,才会出错

· 访问名称重复的成员时,需要显式指定嵌入的类型名称

最后一条规则好像不是很容易看懂,我们来看一个示例(图 11)。

type A struct {
  x, y int
}
type B struct {
  y, z int
}
type C struct {
  A           // x, y
  B           // y, z --y与A重复
  z int       // z与B重复
}
图 11 重复时的优先级示例

在图 11 中,结构体 C 和嵌入在其中的结构体 B 都拥有 z 这一名称重复的成员。然而,由于 z 位于外层,因此是优先的。如果要访问在 B 中定义的 z,则需要使用 B.z 这样的名称。

结构体 A 和结构体 B 都拥有 y 这一名称重复的成员。因此,包含 A 和 B 两个嵌入类型的结构体 C 中,两个重复的 y 成员位于同一层级。

于是,当引用结构体 C 的 y 成员时,就会出错。在这种情况下,就需要显式指定结构体的名称,如 A.y、B.y,这样来访问成员才不会出错。这种设计真是相当巧妙。

多值与多重赋值

如前所述,Go 的函数和方法是可以返回多个值(即多值)的。

返回一个值需要使用 return 语句,但如果在声明返回值时指定了变量名,则可以自动在遇到 return 语句时返回该指定变量的当前值,而不必在 return 语句中再指定返回值(图 12)。

// 函数定义(多个返回值)
func f3(i int) (r float, i int) {
  r = 10.0;
  i = i;
  return; // 返回10.0和i
}
图 12 return 返回 r 和 i

接受返回值采用的是多重赋值的方法:

a, b := f3(4); // a=10.0; b=4
Ruby 也可以通过返回数组的方式实现和多值返回类似的功能,但返回数组说到底依然只是返回了一个值而已,而 Go 是真正返回多个值,在这一点上做得更加彻底。

Go 的错误处理也使用了多值机制。相比之下,C 语言由于只能返回单值,且又不具备异常机制,因此当发生错误时,需要返回一个特殊值(如 NULL 或者负值等)来将错误信息传达给调用方。

UNIX 的系统调用(system call)和库调用(library call)大体上也采用了类似的规则。然而,在这样的规则下,正常值也可能和错误值发生重复,因此总有碰钉子的时候。

例如,UNIX 的 mktime 函数,在正常时返回从 1970 年 1 月 1 日 00:00:00UTC 开始到指定时间所经过的秒数,而出错时则返回 -1。然而在最近的系统平台中,也开始支持负值的秒数了,即 -1 变成了一个正常值,代表 1969 年 12 月 31 日 23:59:59。

Go 也没有异常处理机制。但通过多值,就可以在原本返回值的基础上,同时返回错误信息值,这被称为“逗号 OK”形式。

例如,在 Go 中打开文件的程序如图 13 所示。

f,ok := os.Open(文件名,os.O_RDONLY,0);
if ok != nil {
 ... open失败时的处理...
}
图 13 文件的打开

这样的程序中,错误值和正常值可能会发生混淆。

和泛型一样,异常处理也是一个被搁置的功能,理由是会让语言变得过于复杂。不过,有了“逗号 OK”形式,在一定程度上就可以弥补缺少异常处理的不足。

然而,没有异常处理,也有不方便的地方。就是 Java 的 finally,或者 Ruby 的 ensure 的部分,即无论是正常结束还是发生异常,都要保证执行的后处理程序。

在 Go 中,是通过 defer 语句来实现后处理的。defer 语句所指定的方法,在该函数执行完毕时一定会被调用。

例如,为了保证打开的文件最终被关闭,可以像图 14 这样使用 defer 语句来实现。

f,ok := os.Open(文件名,O_RDONLY,0);
defer f.Close();
图 14 使用 defer 关闭文件

在 Ruby 中,open 方法是完全不需要进行 close 的,而 Go 的抽象度虽然不如 Ruby 那样高,但也提供了可以避免文件忘记被关闭所需要的基本框架。

并发编程

如果要举出 Go 作为最新的系统编程语言最重要的一个特征,恐怕大多数人都会说——并发编程。

近年来,虽然像 Erlang 这些以并发编程为卖点的编程语言受到了广泛的关注,但在系统编程领域还没有出现这样的语言。要在系统编程领域实现并发编程,只能用 C、C++ 和线程(thread)做艰苦卓绝的斗争。

然而,线程这东西,在并发编程上绝对算不上是好用的工具。

Go 则在语言中内置了对并发编程的支持,这一功能参考了 CSP(Communicating Sequential Processes,通信顺序进程)4模型。

4 CSP(通信顺序进程)是一种用来描述并行系统交互模式的形式语言,最早由托尼 • 霍尔(C.A.R.Hoare, 1934— )在其 1979 年的一篇论文中提出。CSP 对并发编程语言的设计有深远影响,受其影响的编程语言包括 Limbo 和 Go 等。

具体的方法是使用 go 语句。go 是 Go 特有的一个语句,也许这才是 Go 这个命名的来源吧。通过这个语句,可以创建新的控制流程。

go f(42);
f 函数在独立的控制流程中执行,和 go 语句后面的程序是并行运作的。

这里所说的独立的控制流程被成为 goroutine,而控制流程还有其他一些表现方式,表 1 对它们的差异进行了比较。

表1 “控制流程”的实现方法一览

表现控制流程的术语

内存空间共享

轻 型

上下文切换

process(OS)

no

no

自动

process(Erlang)

no

yes

自动

thread

yes

no

自动

fiber/coroutine

yes

yes

手动

goroutine

yes

yes

自动

其中,内存空间共享指的是某个控制流程是否可以访问其他控制流程的内存状态。

如果不共享的话,就可以避免如数据访问冲突等并发编程所特有的难题。但另一方面,为了共享信息,则需要对数据进行复制,但这样存在性能下降的风险。

“轻型”是指一个程序是否可以创建大量的控制流程。例如,操作系统提供了进程(process)和线程(thread),但由一个程序创建上千个进程或线程是不现实的。然而,如果是轻型控制流程的话,不要说上千个,某些情况下就是创建上百万个也毫无问题。

最后,“上下文切换”是指在程序中是否需要显式地对控制流程进行切换。例如 fiber(也叫 coroutine)就需要进行显式切换。

除此之外,可以在等待输入暂停运行时,或者以一定时间间隔的方式自动进行切换。此外,支持自动上下文切换的方式(在大多数情况下)都支持在多核 CPU 中的多个核心上同时运行。

Go 的 goroutine 支持内存空间共享,是轻型的,且支持自动上下文切换,因此可以充分利用多核的性能。在实现上,是根据核心数量,自动生成操作系统的线程,并为 goroutine 的运行进行适当的分配。

此外,通过使用自动增长栈的 segmented stack 技术,可以避免栈空间的浪费,即便生成大量的 goroutine 也不会对操作系统带来过大的负荷。

在内存空间共享的并发编程中,如果同时对同样的数据进行改写就会发生冲突,最坏的情况下会导致数据损坏,甚至程序崩溃,因此必须要引起充分的注意。在 Go 中,为了降低发生问题的几率,采取了下面两种策略。

第一种策略是,作为 goroutine 启动的函数,不推荐使用带指针的传址参数,而是推荐使用传值参数。这样一来,可以避免因共享数据而产生的访问冲突。

第二种策略是,利用通道(channel)作为 goroutine 之间的通信手段。通过使用通道,就基本上不必考虑互斥锁等问题,且以通道为通信方式的并发编程,其有效性已经通过 Erlang 等语言得到了证实。

通道是一种类似队列的机制,只能从一侧写入,从另一侧读出。写入和读出操作使用 <- 操作符来完成(图 15)。

// 创建通道
 c := make(chan int);
 c <- 42     // 向通道添加值
 v := <- c   // 取出值并赋给变量
图 15 <- 操作符的使用示例

图 16 是一个用 goroutine 和通道编写的简单的示例程序。这个程序中,通过通道将多个 goroutine 连接起来,这些 goroutine 分别将值加 1,并传递给下一个 goroutine。

向最开始的通道写入 0,则返回由 goroutine 链所生成的 goroutine 个数。这个程序中我们生成了 10 万个 goroutine。

在我的配备 Core2 Duo T7500 2.20GHz CPU 的电脑上运行这个程序,只需要不到 1 秒的时间就完成了。生成 10 万个控制流只用了这么短的时间,还是相当不错的。

package main
import "fmt"

const ngoroutine = 100000
func f(left, right chan int) { left <- 1 + <-right }
func main() {
   leftmost := make(chan int);
   var left, right chan int = nil, leftmost;
   for i := 0; i < ngoroutine; i++ {
     left, right = right, make(chan int);
     go f(left, right);
     }
   right <- 0; // bang!
   x := <-leftmost; // wait for completion
   fmt.Println(x);  // 100000
}
图 16 Go 编写的并行计算示例程序

小结

Go 是一种比较简洁的语言,但我们在这里依然无法网罗其全部方面。因此,我在这里对 Go 的介绍,是从某种语言的设计者在看待另一种编程语言的时候,会被哪些点所吸引这个角度出发的。

我认为 Go 是一种考虑十分周全的语言。我做了很多年的 C 程序员(还有一段做 C++ 程序员的历史)。作为一种系统编程语言,让我稍许产生“也许可以换它用用看”这样念头的,从上学时用上 C 语言以来到现在,这还是头一次。

当然,Go 也不是一种十全十美的语言,它也有诸多不足。如数组、字典等特殊对待的部分,以及作为一种静态语言,总归还是需要对泛型做出一定的支持等。

此外,异常处理也是很有必要的。即便有可能会让语言变得复杂,我认为最好还是应该加上对方法重载和操作符重载的支持。而对于是否可以省略分号这样的规则,对我来说并没有什么直观的感受。

在实现方面,Go 的目标是做到压倒性的高速编译,以及将运行所需时间控制在比同等 C 语言程序多 10% 到 20% 的范围内。但就目前来看,先不要说编译时间,貌似连运行时间也尚未达到当初的目标。

不过,回过头来想想,Go 还只是一种非常年轻的语言。从 2007 年项目启动算起,也只是仅仅过了几年的时间而已。

一种编程语言从出现到被广泛关注和使用,大多都需要 10 年以上的时间,而 Go 只用了短短几年的时间就走到这一步,着实令人惊叹。

Go 是下一代编程语言中我最看好的一个,今后也会继续关注它的发展。

说句题外话,其实在 Go 出现很久之前,就已经存在一种叫做“Go!”的语言了。由于 Google 奉行“不作恶”(Don't be evil)的信条,因此网上有很多人认为 Go 应该改名。

话说,语言名称撞车也不是什么新鲜事(用 Ruby 这个名字的编程语言也有好几个),不过网上有人推荐将 Go 语言改成 Golang 或者 Issue-9。前者是来自 Go 官方网站的域名(golang.org),后者则是来自“已经有一个叫 Go! 的语言了,请改名”这个问题报告的编号5

5 Issue-9 来源于 Google Code 中 Go 语言 Issue tracker 中的第 9 号问题。该问题提交于 2009 年 11 月 10 日(即 Go 刚刚发布几天之后),标题为“I have already used the name for *MY* programming language”(我已经为 * 我自己的 * 编程语言用了这个名字),发帖人称他自己一个已经开发了 10 年的语言也叫这个名字(有人指出其实发帖人开发的语言叫“Go!”,多一个感叹号),并且发表了论文还出版了书。2010 年 10 月,Google 正式作出回应,称叫 Go 的计算机相关产物有很多,过去 11 个月中这两种语言命名的类似性也没有引起很大的歧义,因此决定关闭该问题。(Issue-9 原帖链接:http://code.google.com/p/go/issues/detail?id=9

就我个人来说,我会给“不改名,撞车就撞车”这个选项投上一票。如果非要改的话,我比较喜欢 Golang 吧。无论如何,我对 Google 今后会做出怎样的抉择十分关注。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文