原文链接:Go is a Well-Designed Language, Actually
哈哈,没有泛型 —— 这是一句古老的程序员谚语。
从诸多方面来看,2009 年为我未来的职业生涯埋下了伏笔。那时我 13 岁,刚在一场足球赛里打进了人生中的第一粒进球。那是一次精彩的二过一配合,最后我一记大力抽射,球直入球门左上角。可惜的是,那天球探不知去向。当我还憧憬着踏入温布利球场的那一刻,Go 语言诞生了。
Go 语言很快就吸引了大批拥趸。大家钟情于它的简洁性,还有它对 Web 服务的优化,以及像 gofmt
这类实用工具。不过,凡事皆有两面,Go 语言也不例外。有人嫌弃它太过简单,抱怨它只能用来捣鼓蹩脚的 REST API,还吐槽那些过于 “热情” 的工具。
在过去的 15 年里,人们写下了大量对 Go 语言的批评,甚至是愤怒的吐槽。其中让我格外留意的,是有人认为 Go 语言设计得很糟糕。这一观点在两篇文章里体现得尤为明显 —— 《我想摆脱 Golang 先生的狂野之旅》 和 《我们告诉自己继续使用 Go 的谎言》,均出自 fastthanlime 之手。后者更是直言:
所以他们没有。他们没有设计语言。它就这么 “冒出来” 了。
设计究竟是什么?
在我看来,设计就是达成目标的计划或规范。打个比方,BBC 新闻网站的目标是向用户通报全球发生的、与他们切身相关的大事。为实现这一目标,网站会撰写新闻报道,再依据事件发生地和重要程度进行排序。毕竟,一枚朝我飞来的核弹,可比一只挂在树上的猫要紧要得多。
所以,判断一个设计好不好,要看它能在多大程度上实现既定的设计目标。
Go 语言的起源
Go 语言诞生于谷歌,Russ Cox、Rob Pike、Ken Thompson 等众多大咖都效力于谷歌。彼时,谷歌内部主要使用 Java 和 C++。Go 语言的设计者们觉得,这两门语言性能虽优,但用起来实在费劲。编译器慢吞吞的,工具还特别挑剔,而且它们的设计至少都是十年前的老黄历了。与此同时,云计算 —— 大量多核服务器协同作业,正变得日益普及。
于是,他们决定打造一门属于自己的语言,优先考虑让它能在大规模的计算任务以及人力协作方面游刃有余。Rob Pike 在 Go at Google 一文中解释道:
硬件规模庞大,软件亦是如此。软件动辄数百万行代码,服务器端大多用 C++ 编写,其余部分则大量采用 Java 和 Python。数千名工程师投身于代码编写工作。
在别的场合,Rob Pike 一如既往地以谦逊、含蓄的口吻谈及他所面向的那数千名工程师:
关键在于,我们的程序员是谷歌员工,而非科研人员。高深精妙的语言,他们可玩不转。
重要提示:要是你正在搞设计,千万要避免贬低、居高临下地对待你的设计受众。
尽管有这么一段引发争议的言论,不过我们还是能看出一个相当合理的设计目标:这门语言得让编写和维护大型并发服务器代码变得轻松容易,哪怕使用者是数千名技能水平参差不齐的开发人员。
针对 Go 语言的批评
咱们来瞧瞧人们对 Go 语言的一些怨言,再依据它的设计目标来评判一番。
文件系统 API
Go 语言的文件系统 API 常常遭人诟病,原因是它太偏向 Unix 系统了。Windows 系统不像 Unix 那样有文件权限一说,所以 Go 语言只能返回一些形同虚设的权限。而且,Go 对路径的处理相当简单粗暴。操作系统有自己的路径分隔符,而路径在 Go 里就是 string
类型 —— 仅仅是一串字节,没有任何实质性的检查或限制(译者注:在 go 1.24 版本中使用 os.ROOT
会改善不少)。
其他语言在这方面就严谨得多。比如 在 Rust 里获取文件修改时间的方法,有可能返回 None
。Zig 语言里 文件的元数据会因操作系统而异。
不过从设计目标的角度来看,这倒也情有可原。Go 语言本就是为谷歌量身打造的,和大多数服务器一样,谷歌的服务器 清一色用的是 Linux。要是你设计一门主打服务器应用的语言,以 Unix 为核心来打造文件系统 API,不失为一个明智之举。
无运算符或函数重载
在 Go 语言里,和 Java 不同,函数和方法只有单一的定义(一旦指定了构建标签和目标)。与 C++ 迥异的是,运算符是在编译器里预先实现好的,无法重载。在 time
包里,要是想把 Duration
类型的值加到 Time
类型上,得用 Add
方法。要是你想增加两天,可不能像这样调用 Add(0 /*years*/, 0 /*months*/, 2 /*days*/)
,而得用 AddDate
方法。
在有些人眼里,这显得不够优雅,但它胜在简洁明了。在 Go 代码里看到函数调用,你心里清楚只需查看一处定义就行。要是瞅见一个运算符,你也明白它是针对内置类型的,干的肯定是靠谱的事儿,绝不会是 铸造 NFT 这种奇葩操作。
费力的错误处理
公允地讲,当下编程语言的潮流是追求简洁。也难怪程序员们都反感 Go 语言里那种 if err!= nil
的错误处理风格。
然而,这也是深思熟虑后的抉择:
虽说相比之下,Go 语言检查错误的写法更啰嗦,但这种显式设计让控制流程一目了然 —— 就是字面意义上的清晰。
清晰明了的控制流程让代码的可读性更强。虽说支持异常处理的语言写起代码来可能更快,但生成的代码没那么简洁,而且控制流程藏得很深。
Go 语言常常因避开异常处理这类特性而饱受批评,有人觉得这简直是开倒车。曾经有人质问设计者:“为啥你们对 20 世纪 70 年代以来有关类型系统的研究成果一概无视?”。类似的论调 在别处也屡见不鲜。
首先,Rob Pike 可瞧不上这种傲慢,也压根儿不 care:
Go 旨在解决谷歌在软件开发过程中遭遇的难题,这就使得这门语言虽说算不上开创性的科研语言,但用来搞大型软件项目,那绝对是把好手。
其次,将错误设计成明确的值,已然成为一种(再度)引领潮流的做法。Go、Rust 和 Zig 都选用了这种方式。Swift 语言即便支持异常,也要求你在函数签名里标明哪些函数可能会出错。
可怜的 FFI 能力
译者注:FFI,中文名叫语言交互接口 (Foreign Function Interface),指的是能在某种计算机语言里调用其他语言的接口。
Go 语言与其他语言的兼容性欠佳。要是你想调用 C 函数,比如使用 SQLite,那就得通过 CGO。要知道,CGO 可不是纯正的 Go,还存在性能损耗。由于 goroutine(拥有由 Go 运行时设定的专属堆栈)是执行单元,Go 就得按照 C 语言的期望来做一些操作以获取堆栈,这成本可不低。
Go 语言的 FFI 表现不佳,还因为它有自己的编译器、链接器和调试器。Go 生态系统里的好多东西都是定制化的。
不过,考虑到设计目标的话,这也说得通。服务器软件必须支持并发,所以采用了 goroutine。这必然会让调用 C 代码变得复杂些,但这种权衡利弊,至少适配 Go 用于服务器间通信而非进程间通信的并发系统。
这些决策也让 Go 语言在工具方面占尽优势。编译器是专为 Go 打造的,这意味着它能一门心思地快速编译 Go 代码。调试器能够理解 goroutine 以及 Go 的所有内置类型。
那么 Go 语言很棒吗?
这就见仁见智了。就我个人而言,我挺喜欢它的。我经手过的 Go 代码,读起来、理解起来通常都不费劲。它没有那些花里胡哨的东西,逼着我一门心思写实在的代码,而不是构建些华而不实的抽象概念。我还成功地向一大帮刚从大学毕业的新人传授过 Go 语言。
但这并不意味着我对它的缺点视而不见。有一回,我跟一位客户通电话,他碰上一个错误,就因为没检查错误,我们费了好大劲才追踪到问题所在。要是开着 Linter
,这事儿本可轻松避免,可要是没开,那就麻烦了。Go 语言长久以来都不支持泛型,编写泛型数据结构的时候可费劲了。每次收到一份关于 Windows 系统的错误报告,我都得停下来琢磨琢磨,是不是 Go 语言让我产生了一种错误的安全感?
说到底,这些问题都是设计过程中有意权衡取舍的结果。你可以说不喜欢 Go 语言,或者它不适合某个应用场景,又或者它满足不了你的需求。甚至,你大可以直言讨厌它。但千万别断言它设计得糟糕。