Go 语言初见
一个机缘巧合的机会,让我有了一个写 QQ 机器人的需求。需求不是重点,重点在于这次上手的新语言 Go —— 一个听说过很多次但都擦肩而过的老朋友。QQ 官方提供了标准化的 API 文档,并且有 Go 语言的 SDK 作为参考,站在已经基本完成开发的现在,我准备把这几天的探索过程写下来。
在这之前,我只知道 Go 天生支持并发,某某大厂全线都在用,怎么怎么好使,此外就是听说过语言创始人追求简洁推荐大家叫缩写 Go 而不是 GoLang 的故事,直接后果就是现在搜索引擎极难检索,搜个「go for」竟然会跳出补全「go for it」,我问你语法怎么写你跟我说加油鸭~ (ง •_•)ง
第一印象
因为速成的需要,我并没有找个视频入门或者一页一页地翻文档。我先下载了个 GoLand,然后打开了一个示例项目,边看边尝试自己改改,等有头绪了就模仿着写两行。这种入门方法缺点是刚上手时极为困难,但如果除去下载完 IDE 调成自己喜欢的配色和字体所占用的必需的半个小时,还是十分高效的。
以下是我刚接触 Go 这门语言的真实反应:
刚打开 IDE,上下打量一眼:
这语言怎么函数变量全都大驼峰命名?struct 还要对齐了写?看着好不习惯不过也勉强还行吧
2 分钟后:
不熟悉但是也能看懂吧,不就是 null 换成了 nil,function 换成了 func,if 没括号,还有这 := 赋值感觉回到了十年前学 pascal 的时候
5 分钟后:
不是等等能看懂个锤子啊,什么叫 defer 啊?什么叫 make 啊?这关键字别的语言也没见过啊?什么叫 go 啊?哪有把自己语言名字当作关键字的?
1小时后,摸索着写了几行:
我好像开始逐渐理解一切
怎么全是
if err != nil { return nil, err }
啊,是不是我写的有问题?这应该能有个像 rust 里面问号那种语法糖吧(去翻文档)
啊?这语言没有面向对象?OOP 没了你我可怎么活啊?
怎么还大小写控制 public/private?这跟 python 的用缩进写控制结构简直并称为编程语言两大奇葩
应该说,前几个小时虽然进展缓慢,但是终归领悟了一些东西。有些语法我虽然不是特别理解,但是至少也算新知道了一种奇妙的设计。比如 defer
关键字,我觉得一大作用是防止忘写,有时开启了一些资源,但中途写 if
图省事就直接 return
了,忘记关资源是常有的事;在资源打开之初就接着 defer
一个关闭,确实有效防止了这种情况的发生,有点像是 Java 后来推出的 try-with-resource
语法。再比如其它语言的每个 switch-case
块都需要手动 break
,堪称反人类设计的典范,而 Go 语言中把这个设计反了过来,默认都认为你需要 break
,而不需要的时候也可以手动写 fallthrough
继续执行,终于填补了这一早年设计缺陷。
每个语言都有类似的别出心裁的语法糖,了解这个过程是相当有趣的。这也很大程度上决定了开发者体验,如同想找一个温柔体贴美丽大方的女朋友作为自己生活中的另一半一样,工作中这个另一半的相处感受,直接决定了你需要透过它大声嚷嚷的报错信息苦心琢磨它的内心想法,还是作为默契的合作伙伴携手向前。
然而,语言终究不会依赖语法糖存在。糖纸好看但是不能吃,只有把糖纸剥开之后,才能尝到这门语言的独特魅力。这是创造这门语言的根本动机。如同 Rust 的所有权,Java 的面向对象,Typescript 的集合论一样,Go 语言的核心是什么呢?
回调函数与冰激凌
与大多数人不同,我其实还挺喜欢 JavaScript 这门语言的。去网上随便搜一搜,你会惊讶于 JavaScript 堪称差评如潮,各种论坛充斥着对 JavaScript 的冷嘲热讽,类似 0.1+0.2 != 0.3 之类的 JavaScript 笑话和梗图层出不穷,「用一个词来形容这门语言」问题底下,得到最多的回复是 Evil。如果仔细想想这些笑话是由 JavaScript 在背后呈现给你,再由它把你的评论发送出去的,还挺有讽刺意味的。
而我对 JavaScript 的印象不算差,一方面是因为很多时候它会套上一层 TypeScript 的外壳,从而变得温顺很多;另一方面它的异步事件处理机制,能让你在不花费很多经历和付出还算良好的开发体验后,就能写出看上去很简洁优雅的代码。
举个例子来说,有一天你和你的好朋友一起去逛街。逛到一半你们两个突然想吃冰激凌,于是经过简单商量后决定你下楼去冰激凌店,而好朋友继续在周围溜达。等到你回来的时候,发现他已经不在原处了,你只好在周围找找,想要让你跟周围的陌生人打听他的去处对你这种社恐来说是不太可能的,你只好干等到冰激凌快化掉,赶上姗姗来迟还问你「你去哪儿了我也在找你」的朋友。
一种显而易见的解决方案是:让他在原地待着,哪儿也不要去。这固然能保证你回来时还能见到他,但是对他来说干等也是一种煎熬,更何况你还不一定去多久,你去排一个小时的队他只能傻站一个小时。你又向他提出了一种新的方案,去别处逛可以,但是每隔五分钟就要回到集合点一次,这样你回来最多等五分钟就能见面。但他又否决掉了,且不说他会不会去别处逛的过程中忘了五分钟的时间,单说来来回回跑就够折腾的。
就在你们商量不出对策的时候,JavaScript 中为你们提供了一种新的方案。在你出发去买冰激凌之前,你们先做好约定:如果回来见不到他的人,那就去二楼的衣服专卖店找他;如果还见不到人,那就再去旁边的运动商场找找;运动商场也没人,那他应该已经走到一楼的生活超市了。他也会按照约定的顺序去这几家店逛:你会发现,在这种策略下,对于他来说完全没有负担,对于你来说也完全不需要等待。这种在异步任务之初就指定任务结束时操作的策略,JavaScript 中称其为「回调」。
回调函数在 JavaScript 中被大规模应用,让这样一门只支持单线程的语言拥有了比许多多线程语言还要方便的异步事件处理方法。现代 ES6 标准提供的 Promise 对象和 async/await 关键字进一步降低了多个异步任务处理的复杂度,同时又一定程度上解决了回调函数传染的问题,看似已经尽善尽美。
很长时间以来,我都觉得这是最好的、可能也是唯一的一种异步事件处理方法。也因如此,我在其他语言中也试图模仿 JavaScript 中的写法,像是在 Java 中用 interface 作为参数,又或是其他语言中寻找各式各样花里胡哨的 lambda 表达式。
还有别的解决方案吗?
协程与通道
其实 Go 语言的解决方案十分简单,正如在之前的去买冰激凌的路中,2023 年竟然还会因为碰面这种问题引起争论。或许你早就发现了,有个显而易见的问题:为什么不用手机呢?
没错,如果用上面的例子来类比的话,Go 给出的解决方案就是给你们一人一部手机,等你买完冰激凌给他发个消息说买到了,他再给你回个约定的见面地点,你们在约好的地方碰头即可。
如果以「买冰激凌」为例,下面展示的是 JavaScript 与 Go 两种语言的实现:
function main() {
state = "loading";
result = null;
getIcecream({ flavor: "strawberry" }, (icecream) => {
state = "ready";
eatIcecream(icecream)
})
}
在 JavaScript 版本的实现中,第三句调用了 getIcecream
函数,这个函数需要提供两个参数:第一个参数包括一些买冰激凌时候的选项(例如这里指定了想要的风味),第二个则是一个函数类型的参数,这个内部函数接受 icecream
类型的参数,指定买到 icecream 之后需要用 icecream 来做什么(例如这里是使用 eatIcecream
方法吃掉了冰激凌)。这第二个参数就被称为「回调函数」,其中的内容并不是在执行到这一行时就立即执行,而是冰激凌被买到后才执行,这个具体时机由 getIcecream
函数内部决定。这里不过多介绍 JavaScript 中回调函数执行的具体机制,假定你是写过 JavaScript 代码的,我们一起来看看 Go 的写法是什么样的。
func BuyIcecream(flavor string, channel chan Message) {
icecream := getIcecream(flavor)
channel <- Message{
State: "ready",
Data: icecream
}
}
func main() {
channel := make(chan State)
go BuyIcecream(flavor, channel)
for {
select {
case message := <-channel:
EatIcecream(message.Data.(Icecream))
break
}
}
}
首先我们定义了一个函数 BuyIcecream
,接受「风味」和一个类型为「通道」的两个参数,执行买冰激凌的整个过程。在 Go 语言中,「通道」相当于一个先进先出的队列,可以通过 chan <- item
来将 item 放入队尾,或者用 head := <- chan
将队首的第一个元素取出来并赋值给 head。这个在众多语言中绝无仅有的左箭头写法,其实是为了贴合赋值操作中将等号右面赋值给等号左边的操作习惯,其实熟悉了感觉还挺合理的。而这个队列在这里可不单单是一种数据结构,它就是我们之前说的「手机」,这就是线程与线程之间的信使,通过写入和读取队列中的消息块实现通讯功能。
BuyIcecream
函数中,当执行完 getIcecream
函数拿到了返回值之后,就会通过 channel 这个通道将 icecream 作为 Data 发送出去。相应的,在 main 函数中的 select-case
语句块会「监听」通道中传来的信号,如果 channel 中有信号传来,那就执行对应的 case 块,完成 EatIcecream
的逻辑。剩余的都是一些连接部分,例如 main 函数一开始的 go BuyIcecream
语句,相当于新建一个线程去执行 BuyIcecream
函数;这个没有循环条件的 for
相当于无条件循环 while (true)
,但也不用担心 CPU 空转,select-case
块在接收不到信号时会原地阻塞,释放 CPU 资源。
追本溯源
抛开语法细节,如果要说这两种语言最大的不同,就在于 getIcecream
的阻塞与否。JavaScript 中的 getIcecream
函数并不会阻塞接下来流程的运行,如果其后还有其它逻辑,即使现在 icecream 还没有买到,它也会立即执行。与之相反,Go 语言中的 getIcecream
函数一旦开始执行,直到 icecream 被买到之前, 一句也不会往下执行。
如果你接触过 Go 语言,你会发现它在这一点上与其它语言有着惊人的不同:几乎所有异步函数都会阻塞——文件读入会阻塞,输出会阻塞,网络请求会阻塞,Json、Yaml 文件格式的解析会阻塞,收不到信号会阻塞,大大小小的事情说阻塞就阻塞。
异步是不得已的操作。编程语言的执行顺序天然就是串行的,执行完第一行紧接着第二行,第二行之后第三行,这种逻辑从你敲下第一行代码那天就深入脑海,通俗又直观。然而,随着对效率提升的追求,逼迫着我们在同一段时间内去做两件事,将硬件资源充分利用。但是,并行是很难用代码描述的一件事,固然有回调函数的包装和简化,但「第一行执行期间先开始第三行,第一行结束之后再执行第二行」的逻辑不管怎么说,都比串行难以理解得多。
Go 语言的「协程」和「通道」,说到底其实就是提供了一种用同步的方式写异步代码的方法。它不再要求你把回调逻辑包装进函数里作为参数写进去,你只需要接着任务本身继续写下去就可以了。你可能已经习惯了回调函数的写法,你写了回调函数多少年,以至于都忘记了这才是最直观的写法。同时,Go 语言也没有舍弃异步带来的高效率,它需要让你将需要同时执行的任务分配到几个 goroutine 上,你只需要指定分配策略,由 Go 语言的底层去把这些 goroutine 分配到物理 CPU 上。
你当然可以把它当作回调函数来写,它能完成回调函数的所有工作。但它又是回调函数的延申,从扩展性上来看,相比起回调函数的即时处理,它还提供了「等」的空间。消息被发出只是宣告我的事情已经处理完成,而如果你真的很忙,你可以等一会儿回。这「等一会儿」的时间不光提供了多样的处理端策略,还提供了一定的削峰作用。