为什么选择 Go

122 阅读1小时+

起源¶ 项目的目的是什么?¶ Go 的诞生始于 2007 年,当时的编程世界与今天大不相同。生产软件通常用 C++ 或 Java 编写,GitHub 还不存在,大多数计算机还不是多处理器,除了 Visual Studio 和 Eclipse 外,几乎没有 IDE 或其他高级工具可用,更不用说互联网上免费的了。

与此同时,我们对使用的语言及其相关构建系统构建大型软件项目所需的过度复杂性感到沮丧。自 C、C++ 和 Java 等语言首次开发以来,计算机的速度已经大大提高,但编程本身的进步却没有那么大。而且,很明显,多处理器正在变得普遍,但大多数语言在有效和安全地编程方面几乎没有提供帮助。

我们决定退一步思考,随着技术的发展,在未来几年将主导软件工程的主要问题是什么,以及一种新的语言如何帮助解决这些问题。例如,多核 CPU 的兴起表明语言应该为某种并发或并行性提供一流支持。而且,为了在大型并发程序中使资源管理变得易于处理,需要垃圾收集,或者至少是某种安全的自动内存管理。

这些考虑导致了一系列讨论,从中产生了 Go,首先是一组想法和需求,然后是语言。一个总体目标是 Go 做更多的工作来帮助程序员,例如通过启用工具、自动执行诸如代码格式化等日常任务,以及消除处理大型代码库的障碍。

Go 的目标和实现方法的更详细描述可以在文章《Go 在 Google:为软件工程服务的语言设计》中找到。

项目的历史是什么?¶
罗伯特·格里斯默 (Robert Griesemer)、罗布·派克 (Rob Pike) 和肯·汤普森 (Ken Thompson) 于 2007 年 9 月 21 日开始在白板上勾勒出新语言的目标。几天之内,目标就确定为一个计划,并对其进行了一些设想。设计工作在与无关工作并行的情况下继续进行。到 2008 年 1 月,肯开始开发一个编译器来探索想法;它生成 C 代码作为输出。年中,这门语言已经成为一个全职项目,并且已经足够稳定,可以尝试生产编译器。2008 年 5 月,伊恩·泰勒 (Ian Taylor) 独立地开始使用草案规范为 Go 创建 GCC 前端。拉斯·考克斯 (Russ Cox) 于 2008 年末加入,帮助将语言和库从原型转化为现实。

Go 于 2009 年 11 月 10 日成为公开的开源项目。社区中的无数人贡献了想法、讨论和代码。

现在,全世界有数百万 Go 程序员——地鼠——而且每天都有更多。Go 的成功远远超出了我们的预期。

地鼠吉祥物的起源是什么?¶
吉祥物和徽标由 Renée French 设计,她还设计了 Plan 9 兔子 Glenda。一篇关于地鼠的博客文章解释了它是如何从她几年前为 WFMU T 恤设计的图案中衍生出来的。徽标和吉祥物受 Creative Commons Attribution 4.0 许可协议保护。

地鼠有一张模型图,展示了他的特点以及如何正确地表现出来。该模型图首次在 Renée 于 2016 年在 Gophercon 上的演讲中展示。他有独特的特征;他是 Go 的地鼠,不是普通的地鼠。

这个语言是叫 Go 还是 Golang?¶
这个语言叫 Go。“golang” 这个名字的出现是因为该网站最初是 golang.org(当时没有 .dev 域名)。许多人使用 golang 名称,这在标签中很方便。例如,该语言的社交媒体标签是“#golang”。语言的名字就是 Go,不管怎样。

顺便说一下,尽管官方徽标有两个大写字母,但语言名称写作 Go,而不是 GO。

为什么要创建一种新的语言?¶
Go 诞生于对现有语言和我们在 Google 工作中使用的环境的沮丧。编程变得太困难,部分原因是语言的选择。必须选择高效编译、高效执行或编程简便;这三者在同一种主流语言中都不可用。能够选择的程序员正在通过转向动态类型语言(如 Python 和

JavaScript)而不是 C++ 或(在较小程度上)Java,从而选择简便性而非安全性和效率。

我们并不是唯一关心这些问题的人。在编程语言领域相对平静的许多年之后,Go 是首批几个新语言之一——Rust、Elixir、Swift 等——它们使编程语言的发展再次成为一个活跃的、几乎主流的领域。

Go 试图通过将解释型、动态类型语言的编程简便性与静态类型、编译型语言的效率和安全性结合起来来解决这些问题。它还旨在更好地适应当前的硬件,支持网络和多核计算。最后,使用 Go 应该是快速的:在单台计算机上构建大型可执行文件最多只需几秒钟。为了实现这些目标,我们重新考虑了一些当前语言中的编程方法,导致了:组合而非分层的类型系统;支持并发和垃圾收集;依赖项的严格规范;等等。这些无法通过库或工具很好地处理;需要一种新的语言。

文章《Go 在 Google》讨论了 Go 语言设计的背景和动机,并提供了关于本常见问题解答中许多答案的更多详细信息。

Go 的祖先是什么?¶
Go 主要属于 C 家族(基本语法),并从 Pascal/Modula/Oberon 家族(声明、包)中汲取了重要输入,加上一些受 Tony Hoare 的 CSP 启发的语言(如 Newsqueak 和 Limbo)中的想法(并发)。然而,它在各个方面都是一种新的语言。语言设计的每个方面都考虑到了程序员在做什么以及如何使编程(至少是我们做的那种编程)更加有效,这意味着更有趣。

设计的指导原则是什么?¶
在设计 Go 时,Java 和 C++ 是编写服务器最常用的语言,至少在 Google 是这样。我们认为这些语言需要太多的簿记和重复。一些程序员通过转向更动态、流畅的语言(如 Python)来反应,但代价是效率和类型安全性。我们认为应该有可能在一种语言中同时拥有效率、安全性和流畅性。

Go 试图减少两种意义上的打字量。在整个设计过程中,我们努力减少杂乱和复杂性。没有前向声明和头文件;所有内容只声明一次。初始化是富有表现力的、自动的且易于使用。语法干净且关键字很少。通过使用 := 声明和初始化结构简化了重复(foo.Foo* myFoo = new(foo.Foo))。也许最重要的是,Go 没有类型层次结构:类型就是类型,它们不需要宣布它们的关系。这些简化使 Go 富有表现力且易于理解,而不会牺牲生产力。

另一个重要原则是保持概念的正交性。方法可以为任何类型实现;结构代表数据,而接口代表抽象;等等。正交性使得理解事物组合时发生的事情变得更容易。

使用
Google 内部使用 Go 吗? 是的。Go 在 Google 内部广泛用于生产。例如,Google 的下载服务器 dl.google.com 提供 Chrome 二进制文件和其他大型可安装文件,如 apt-get 包。

Go 不是 Google 唯一使用的语言,远非如此,但它是包括站点可靠性工程(SRE)和大规模数据处理在内的多个领域的关键语言。它也是运行 Google Cloud 的软件的关键部分。

还有哪些公司使用 Go? Go 的使用在全球范围内增长,尤其是在云计算领域。用 Go 编写的两个主要云基础设施项目是 Docker 和 Kubernetes,但还有很多其他项目。

不仅仅是云,您可以从 go.dev 网站上的公司列表以及一些成功案例中看到这一点。此外,Go 维基页面定期更新,列出了一些使用 Go 的公司。

维基页面还包含一个链接到更多公司和项目使用该语言的成功案例的页面。

Go 程序可以与 C/C++ 程序链接吗? 可以在同一个地址空间中使用 C 和 Go,但这并不自然,可能需要特殊的接口软件。此外,将 C 与 Go 代码链接会失去 Go 提供的内存安全性和堆栈管理属性。有时绝对有必要使用 C 库来解决问题,但这样做总是会引入一个在纯 Go 代码中不存在的风险因素,因此需要小心进行。

如果确实需要将 C 与 Go 一起使用,如何进行取决于 Go 编译器的实现。Google 团队支持的 Go 工具链中的“标准”编译器称为 gc。此外,还有基于 GCC 的编译器(gccgo)和基于 LLVM 的编译器(gollvm),以及一个用于不同目的的非常规编译器的增长列表,有时实现了语言子集,如 TinyGo。

Gc 使用不同于 C 的调用约定和链接器,因此无法直接从 C 程序调用,也无法直接从 C 程序调用。cgo 程序提供了一个“外部函数接口”机制,允许从 Go 代码安全地调用 C 库。SWIG 扩展了这种能力,以支持 C++ 库。

您还可以使用 gccgo 和 gollvm 使用 cgo 和 SWIG。由于它们使用传统的 ABI,因此在小心的情况下,也可以将这些编译器生成的代码与用 GCC/LLVM 编译的 C 或 C++ 程序直接链接。但是,安全地进行这些操作需要了解所有相关语言的调用约定,并关心在从 Go 调用 C 或 C++ 时的堆栈限制。

Go 支持哪些 IDE? Go 项目不包含自定义 IDE,但语言和库设计使得分析源代码变得容易。因此,大多数知名的编辑器和 IDE 都支持 Go,要么直接支持,要么通过插件支持。

Go 团队还支持名为 gopls 的 LSP 协议的 Go 语言服务器。支持 LSP 的工具可以使用 gopls 集成语言特定的支持。

提供良好 Go 支持的知名 IDE 和编辑器包括 Emacs、Vim、VSCode、Atom、Eclipse、Sublime、IntelliJ(通过名为 GoLand 的自定义变体)等。您喜欢的环境很可能是 Go 编程的一个高效工具。

Go 支持 Google 的协议缓冲区吗? 一个单独的开源项目提供了所需的编译器插件和库。可在 github.com/golang/protobuf/ 获得。

设计 Go 有运行时吗? Go 有一个广泛的运行时库,通常称为运行时,是每个 Go 程序的一部分。该库实现了垃圾收集、并发、堆栈管理以及 Go 语言的其他关键特性。尽管它对语言更为核心,但 Go 的运行时类似于 libc,C 库。

然而,重要的是要理解,Go 的运行时不包括虚拟机,例如 Java 运行时提供的虚拟机。Go 程序是在前面编译为本机机器代码(或 JavaScript 或 WebAssembly,一些变体实现)运行的。因此,尽管该术语通常用于描述程序运行的虚拟环境,但在 Go 中,“运行时”只是为语言提供关键服务的库的名称。

Unicode 标识符是怎么回事? 在设计 Go 时,我们希望确保它不会过于以 ASCII 为中心,这意味着将标识符的字符范围扩展到 7 位 ASCII 之外。Go 的规则——标识符字符必须是 Unicode 定义的字母或数字——简单易懂且易于实现,但有限制。例如,组合字符被设计排除,这样就排除了某些语言,如梵文。

这个规则还有另一个不幸的后果。由于导出的标识符必须以大写字母开头,因此某些语言的字符无法导出。现在,唯一的解决方案是使用类似 X日本语 的东西,这显然不令人满意。

从最早的语言版本开始,就对如何最好地扩展标识符空间以适应使用其他母语的程序员进行了大量思考。究竟该怎么做仍然是一个活跃的讨论话题,未来的语言版本可能会在标识符的定义上更加自由。例如,它可能会采用 Unicode 组织的标识符建议中的一些想法。无论发生什么,都必须兼容,同时保留(或扩展)字母大小写决定标识符可见性的方式,这是我们最喜欢的 Go 特性之一。

目前,我们有一个可以扩展的简单规则,而不会破坏程序,该规则避免了由允许模糊标识符的规则引起的错误。

为什么 Go 没有功能 X? 每种语言都包含新的功能,并省略了一些人的最爱功能。Go 的设计考虑了编程的便利性、编译速度、概念的正交性以及支持并发和垃圾收集等功能的需要。您的最爱功能可能缺失是因为它不适合,因为它会影响编译速度或设计清晰度,或者因为它会使基本系统模型变得过于困难。

如果缺少功能 X 让您感到困扰,请原谅我们,并调查 Go 的功能。您可能

会发现它们以有趣的方式弥补了 X 的缺失。

Go 是什么时候获得泛型类型的? Go 1.18 版本增加了类型参数。这允许一种多态或泛型编程形式。请参阅语言规范和提案了解详细信息。

为什么 Go 最初没有发布泛型类型? Go 旨在成为一种易于维护的服务器程序语言。(有关背景的更多信息,请参阅本文。)设计集中于可伸缩性、可读性和并发等方面的特性。多态编程在当时似乎对语言的目标并不是必不可少的,因此为了简单起见,最初被排除在外。

泛型是方便的,但它们会增加类型系统和运行时的复杂性。开发一个我们认为能够提供与复杂性成正比的价值的设计花了一些时间。

为什么 Go 没有异常? 我们认为将异常与控制结构耦合(例如 try-catch-finally 习语)会导致代码复杂化。它还往往鼓励程序员将太多普通错误(例如无法打开文件)标记为异常。

Go 采用了不同的方法。对于普通错误处理,Go 的多值返回使报告错误变得容易,而不会过载返回值。一个典型的错误类型,结合 Go 的其他特性,使错误处理愉快但与其他语言不同。

Go 还具有几个内置函数,用于标识和从真正的异常情况下恢复。恢复机制仅在函数状态在错误后被拆除时执行,这足以处理灾难,但不需要额外的控制结构,并且在良好使用时可以生成干净的错误处理代码。

有关详细信息,请参阅文章《Defer、Panic 和 Recover》。此外,博客文章《Errors are values》描述了一种在 Go 中清洁地处理错误的方法,通过演示错误只是值,Go 的全部力量都可以用于错误处理。

为什么 Go 没有断言? Go 不提供断言。它们确实很方便,但我们的经验是,程序员将它们用作避免考虑适当错误处理和报告的拐杖。适当的错误处理意味着服务器在非致命错误后继续运行,而不是崩溃。适当的错误报告意味着错误是直接的,简明扼要,从而避免程序员解释大量崩溃跟踪的麻烦。精确的错误特别重要,当程序员看到错误时并不熟悉代码。

我们理解这是一个争论点。Go 语言和库中有许多与现代实践不同的地方,因为我们认为有时值得尝试不同的方法。

为什么基于 CSP 的思想构建并发? 并发和多线程编程随着时间的推移被认为是困难的。我们认为这部分是由于复杂的设计,如 pthreads,部分是由于过分强调低级细节,如互斥锁、条件变量和内存屏障。即使底层仍有互斥锁等,高级接口也能使代码更简单。

为并发提供高级语言支持的最成功模型之一来自 Hoare 的 Communicating Sequential Processes(CSP)。Occam 和 Erlang 是源于 CSP 的两种著名语言。Go 的并发原语来自该家族树的不同部分,其主要贡献是将通道作为一等对象的强大概念。对几种早期语言的经验表明,CSP 模型非常适合过程语言框架。

为什么选择 goroutines 而不是线程? Goroutines 是为了使并发易于使用而设计的。这个想法已经存在了一段时间,将独立执行的函数——协程——多路复用到一组线程上。当协程阻塞时,例如通过调用阻塞的系统调用,运行时自动将同一操作系统线程上的其他协程移动到不同的可运行线程上,以免被阻塞。程序员看不到这一点,这正是重点。结果,我们称之为 goroutines,它们非常便宜:除了堆栈的内存外,几乎没有开销,堆栈仅为几 KB。

为了使堆栈变小,Go 的运行时使用可调整大小的有界堆栈。新创建的 goroutine 被分配几 KB,这几乎总是足够的。当不够用时,运行时会自动增加(和减少)用于存储堆栈的内存,允许许多 goroutine 在适量内存中共存。CPU 开销平均约为每次函数调用三条廉价指令。在同一个地址空间中创建成百上千的 goroutine 是可行的。如果 goroutine 只是线程,系统资源将在较小的数量时用尽。

为什么 map 操作没有定义为原子操作? 经过长时间的讨论,我们决定典型的 map 使用不需要从多个 goroutine 安全访问,在需要时,map 可能是某些更大数据结构或计算的一部分,该结构或计算已经同步。因此,要求所有 map 操作都获取互斥锁会减慢大多数程序的速度,并为少数程序增加安全性。然而,这不是一个容易的决定,因为它意味着不受控制的 map 访问可能会导致程序崩溃。

语言不排除原子 map 更新。在需要时,例如托管不受信任的程序时,实现可以联锁 map 访问。

只有在进行更新时,map 访问才是不安全的。只要所有 goroutine 都在读取——查找 map 中的元素,包括使用 for range 循环迭代——并且没有通过分配元素或进行删除来更改 map,就可以安全地在没有同步的情况下并发访问 map。

作为正确 map 使用的帮助,一些语言实现包含一个特殊检查,在并发执行不安全修改 map 时自动报告。此外,sync 库中有一个类型 sync.Map,对于某些使用模式(例如静态缓存)非常有效,尽管它不适合替代内置 map 类型。

你会接受我的语言更改吗? 人们经常建议改进语言——邮件列表中包含了丰富的此类讨论历史——但很少有这些更改被接受。

尽管 Go 是一个开源项目,但语言和库受兼容性承诺保护,禁止违反现有程序的更改,至少在源代码级别(程序可能需要偶尔重新编译以保持最新)。如果您的提案违反了 Go 1 规范,无论其优点如何,我们都不能考虑。未来的 Go 主要版本可能与 Go 1 不兼容,但关于该主题的讨论才刚刚开始,可以肯定的是:在这个过程中引入的不兼容性将非常少。此外,兼容性承诺鼓励我们提供自动迁移旧程序的方法,以适应这种情况。

即使您的提案与 Go 1 规范兼容,也可能不符合 Go 设计目标的精神。文章《Go 在 Google:为软件工程服务的语言设计》解释了 Go 的起源和设计背后的动机。

类型 Go 是一种面向对象的语言吗? 是,也不是。尽管 Go 有类型和方法,并允许面向对象的编程风格,但没有类型层次结构。Go 中的“接口”概念提供了一种不同的方法,我们认为这种方法易于使用,并且在某些方面更通用。还有一些方法可以将类型嵌入到其他类型中,以提供类似(但不相同)的子类化。此外,Go 中的方法比 C++ 或 Java 中的方法更通用:它们可以为任何类型的数据定义,甚至内置类型,如简单的“未装箱”整数。它们不限于结构(类)。

此外,缺乏类型层次结构使 Go 中的“对象”比 C++ 或 Java 中的感觉轻量得多。

如何获得方法的动态分派? 唯一获得动态分派方法的方法是通过接口。结构或任何其他具体类型上的方法始终是静态解析的。

为什么没有类型继承? 面向对象编程,至少在最知名的语言中,涉及太多关于类型之间关系的讨论,这些关系通常可以自动推导出来。Go 采取了不同的方法。

Go 不要求程序员提前声明两个类型是相关的,而是自动满足任何接口类型的子集。除了减少簿记外,这种方法还有实际的好处。类型可以同时满足多个接口,而不会有传统多重继承的复杂性。接口可以非常轻量——一个接口即使只有一个甚至没有方法也能表达一个有用的概念。如果出现新想法或进行测试,可以在不注释原始类型的情况下添加接口。由于类型和接口之间没有显式关系,因此无需管理或讨论类型层次结构。

可以使用这些想法构建类似于类型安全 Unix 管道的东西。例如,查看 fmt.Fprintf 如何使格式化打印输出到任何输出,而不仅仅是文件;或 bufio 包如何与文件 I/O 完全分离;或图像包如何生成压缩图像文件。这些想法都来自一个表示单个方法(Write)的接口(io.Writer)。这仅仅是开始。Go 的接口对程序结构有深远影响。

虽然这需要一些适应,但这种隐式类型依赖风格是 Go 最富有成效的方面之一。

为什么 len 是函数而不是方法? 我们对此问题进行了辩论,但决定将 len 和其他内置函数

实现为函数在实际操作中是可以接受的,并且不会使基本类型的接口(在 Go 类型的意义上)复杂化。

为什么 Go 不支持方法和操作符的重载? 方法调度在不需要进行类型匹配时更简单。其他语言的经验告诉我们,拥有一组名称相同但签名不同的方法偶尔有用,但在实际操作中也可能令人困惑和脆弱。仅通过名称匹配并要求类型一致性是 Go 类型系统的一个主要简化决定。

关于操作符重载,这似乎更多的是一种便利,而不是绝对要求。同样,没有它,事情会更简单。

为什么 Go 没有“implements”声明? Go 类型通过实现接口的方法来实现接口,仅此而已。这一属性允许接口在不修改现有代码的情况下定义和使用。它实现了一种结构化类型,可以促进关注点分离和提高代码重用,并使基于随代码开发而出现的模式的构建变得更容易。接口语义是 Go 轻量、灵活感觉的主要原因之一。

有关详细信息,请参阅类型继承问题。

如何保证我的类型满足某个接口? 您可以通过尝试使用 T 或指向 T 的零值(视情况而定)进行赋值来要求编译器检查类型 T 是否实现了接口 I:

type T struct{}
var _ I = T{}       // 验证 T 是否实现了 I。
var _ I = (*T)(nil) // 验证 *T 是否实现了 I。

如果 T(或 *T,相应地)未实现 I,则错误将在编译时被捕获。

如果希望接口的用户明确声明他们实现了该接口,可以在接口的方法集中添加一个描述性名称的方法。例如:

type Fooer interface {
    Foo()
    ImplementsFooer()
}

类型必须实现 ImplementsFooer 方法才能成为 Fooer,从而清楚地记录事实并在 go doc 的输出中宣布它。

type Bar struct{}
func (b Bar) ImplementsFooer() {}
func (b Bar) Foo() {}

大多数代码不使用此类约束,因为它们限制了接口概念的实用性。然而,有时它们在解决类似接口之间的歧义时是必要的。

为什么类型 T 不满足 Equal 接口? 考虑这个简单的接口,表示可以与另一个值进行比较的对象:

type Equaler interface {
    Equal(Equaler) bool
}

和这个类型 T:

type T int
func (t T) Equal(u T) bool { return t == u } // 不满足 Equaler

与某些多态类型系统中的类似情况不同,T 并不实现 Equaler。T.Equal 的参数类型是 T,而不是字面上的 Equaler 类型。

在 Go 中,类型系统不提升 Equal 的参数,这是程序员的责任,如类型 T2 所示,它实现了 Equaler:

type T2 int
func (t T2) Equal(u Equaler) bool { return t == u.(T2) }  // 满足 Equaler

即便如此,这仍与其他类型系统不同,因为在 Go 中,任何满足 Equaler 的类型都可以作为 T2.Equal 的参数传递,并且在运行时我们必须检查参数是否为 T2 类型。某些语言会安排在编译时进行保证。

一个相关的例子是:

type Opener interface {
   Open() Reader
}

func (t T3) Open() *os.File

在 Go 中,T3 并不满足 Opener,尽管在另一种语言中可能会。

虽然在此类情况下 Go 的类型系统为程序员提供的帮助较少,但缺乏子类型使接口满足的规则非常容易说明:函数的名称和签名是否完全与接口相同?Go 的规则也易于高效实现。我们认为这些好处弥补了缺乏自动类型提升的不足。

我可以将 []T 转换为 []interface{} 吗? 不能直接转换。语言规范禁止这种转换,因为两种类型在内存中的表示不同。必须将元素逐个复制到目标切片。此示例将 int 切片转换为 interface{} 切片:

t := []int{1, 2, 3, 4}
s := make([]interface{}, len(t))
for i, v := range t {
    s[i] = v
}

如果 T1 和 T2 有相同的底层类型,我可以将 []T1 转换为 []T2 吗? 这段代码的最后一行无法编译。

type T1 int
type T2 int
var t1 T1
var x = T2(t1) // OK
var st1 []T1
var sx = ([]T2)(st1) // NOT OK

在 Go 中,类型与方法密切相关,每个命名类型都有一个(可能为空)方法集。一般规则是可以更改要转换类型的名称(因此可能更改其方法集),但不能更改复合类型元素的名称(和方法集)。Go 要求您明确类型转换。

为什么我的 nil 错误值不等于 nil? 在底层,接口实现为两个元素,类型 T 和值 V。V 是具体值,如 int、struct 或指针,从不是接口本身,并且具有类型 T。例如,如果我们在接口中存储 int 值 3,生成的接口值架构上为 (T=int, V=3)。值 V 也称为接口的动态值,因为在程序执行期间,给定接口变量可能包含不同的 V 值(及相应的 T 类型)。

接口值仅当 V 和 T 都未设置时为 nil,即 (T=nil, V 未设置)。特别是,nil 接口将始终包含一个 nil 类型。如果我们在接口值内存储一个 nil 类型的指针 *int,内部类型将是 *int,无论指针值 V 如何:(T=*int, V=nil)。因此,这样的接口值将始终为非 nil,即使内部的指针值为 nil。

这种情况可能令人困惑,并且在将 nil 值存储在接口值(如错误返回)时出现:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // 始终返回非 nil 错误。
}

如果一切顺利,函数返回 nil p,因此返回值是包含 (T=*MyError, V=nil) 的错误接口值。这意味着如果调用者将返回的错误与 nil 进行比较,似乎始终存在错误,即使没有发生任何错误。要返回正确的 nil 错误给调用者,函数必须返回显式的 nil:

func returnsError() error {
    if bad() {
        return ErrBad
    }
    return nil
}

返回错误的函数在签名中始终使用错误类型(如上所示),而不是具体类型,如 *MyError,以帮助确保错误正确创建。例如,os.Open 返回错误,即使它不为 nil,始终为具体类型 *os.PathError。

每当使用接口时,类似于上述的情况可能会发生。请记住,如果接口中存储了任何具体值,则接口不会为 nil。有关详细信息,请参阅《反射法则》。

为什么零大小类型的行为怪异? Go 支持零大小类型,例如没有字段的 struct(struct{})或没有元素的数组([0]byte)。没有什么可以存储在零大小类型中,但这些类型有时在不需要值时很有用,如 map[int]struct{} 或具有方法但没有值的类型。

具有零大小类型的不同变量可以位于内存中的同一位置。这是安全的,因为在这些变量中无法存储值。

此外,语言并不保证指向两个不同零大小变量的指针是否相等。此类比较在程序的不同点可能返回 true,也可能返回 false,具体取决于程序的确切编译和执行方式。

另一个与零大小类型相关的问题是,指向零大小结构字段的指针不得与内存中不同对象的指针重叠。这可能会引起垃圾收集器的混淆。这意味着如果结构的最后一个字段是零大小,结构将填充以确保指向最后一个字段的指针不会与紧随结构的内存重叠。因此,此程序:

func main() {
    type S struct {
        f1 byte
        f2 struct{}
    }
    fmt.Println(unsafe.Sizeof(S{}))
}

在大多数 Go 实现中将打印 2,而不是 1。

为什么没有像 C 那样的无标签联合? 无标签联合会违反 Go 的内存安全保证。

为什么 Go 没有变体类型? 变体类型,也称为代数类型,提供了一种指定值可能取一组其他类型之一的方法,但仅限于这些类型。系统编程中的常见示例是指定错误是网络错误、安全错误或应用程序错误,并允许调用者通过检查错误的类型来区分问题的来源。另一个示例是语法树,其中每个节点可以是不同类型:声明、语句、赋值等。

我们考虑过将变体

类型添加到 Go 中,但讨论后决定将其排除,因为它们与接口重叠,令人困惑。如果变体类型的元素本身是接口,会发生什么?

此外,变体类型解决的一些问题已被语言涵盖。错误示例可以使用接口值来保存错误,并使用类型开关来区分情况。语法树示例也可以实现,尽管不如代数类型优雅。

为什么 Go 没有协变结果类型? 协变结果类型意味着像

type Copyable interface {
    Copy() interface{}
}

这样的接口可以通过方法

func (v Value) Copy() Value

来满足,因为 Value 实现了空接口。在 Go 中,方法类型必须完全匹配,因此 Value 不实现 Copyable。Go 将类型的概念与类型的实现分离。如果两个方法返回不同类型,它们就不是在做同一件事。希望协变结果类型的程序员通常试图通过接口表达类型层次。在 Go 中,在接口和实现之间保持清晰的分离更自然。

值 为什么 Go 不提供隐式数值转换? C 中自动转换数值类型的便利性被它引起的混淆所抵消。表达式何时是无符号的?值的大小是多少?是否溢出?结果是否与执行的机器无关?它还使编译器复杂化;C 的“常规算术转换”并不容易实现,并且在不同架构上不一致。出于可移植性的考虑,我们决定以代码中的一些显式转换为代价,使事情变得清晰简单。Go 中常量的定义——具有任意精度的值,没有符号和大小注释——极大地缓解了这一问题。

相关的细节是,与 C 不同,即使 int 是 64 位类型,int 和 int64 也是不同的类型。int 类型是通用的;如果您关心整数的位数,Go 鼓励您明确指出。

Go 中常量是如何工作的? 尽管 Go 对不同数值类型之间的转换严格要求,但语言中的常量更加灵活。字面常量如 23、3.14159 和 math.Pi 占据了一种理想数字空间,具有任意精度且没有溢出或下溢。例如,math.Pi 的值在源代码中被指定为 63 位小数,涉及该值的常量表达式保持超过 float64 能够容纳的精度。只有在常量或常量表达式被赋值给变量——程序中的内存位置时,它才会变成具有通常浮点属性和精度的“计算机”数值。

此外,由于它们只是数字,而不是类型化的值,Go 中的常量比变量可以更自由地使用,从而缓解了一些严格转换规则带来的尴尬。可以编写如下表达式:

sqrt2 := math.Sqrt(2)

编译器不会抱怨,因为理想数字 2 可以安全准确地转换为 float64 以调用 math.Sqrt。

博客文章《常量》更详细地探讨了这个主题。

为什么内置 map? 同样的原因是字符串:它们是如此强大和重要的数据结构,提供一个优秀的实现并支持语法,使编程更愉快。我们认为 Go 的 map 实现足够强大,能够满足大多数需求。如果某个特定应用程序可以受益于自定义实现,可以编写一个,但在语法上不会像内置的方便;这似乎是一个合理的权衡。

为什么 map 不允许切片作为键? Map 查找需要一个等号运算符,而切片没有实现。它们不实现等号运算符,因为在这些类型上等号运算符没有明确定义;涉及浅比较与深比较、指针比较与值比较、如何处理递归类型等问题。我们可能会重新审视这个问题——为切片实现等号运算符不会使现有程序无效——但在没有清楚地了解切片的等号运算符应意味着什么之前,暂时将其排除会更简单。

等号运算符对结构体和数组是定义的,因此它们可以用作 map 键。

为什么 map、切片和通道是引用而数组是值? 在这个话题上有很多历史。早期,map 和通道在语法上是指针,不可能声明或使用非指针实例。此外,我们也在努力确定数组应该如何工作。最终,我们决定指针和值的严格分离使语言更难使用。将这些类型更改为引用关联的共享数据结构解决了这些问题。这个改变为语言增加了一些令人遗憾的复杂性,但对可用性有很大影响:Go 变得更加高效和舒适。

编写代码 库是如何记录的? 要从命令行访问文档,go 工具有一个 doc 子命令,为声明、文件、包等提供文本界面。

全球包发现页面 pkg.go.dev/pkg/ 运行一个服务器,从 web 上的 Go 源代码中提取包文档,并以 HTML 格式提供,带有声明和相关元素的链接。这是了解现有 Go 库的最简单方法。

在项目早期,有一个类似的程序,godoc,可以运行以提取本地机器上文件的文档;pkg.go.dev/pkg/ 基本上是它的后继者。另一个后继者是 pkgsite 命令,像 godoc 一样,可以本地运行,尽管它尚未与 go doc 显示的结果集成。

有 Go 编程风格指南吗? 没有明确的风格指南,尽管有一种公认的“Go 风格”。

Go 建立了指导命名、布局和文件组织的约定。文档《有效的 Go》包含一些关于这些主题的建议。更直接地说,gofmt 程序是一个美化程序,其目的是强制执行布局规则;它取代了通常的 dos 和 don'ts 集合,允许解释。存储库中的所有 Go 代码,以及开源世界中的绝大多数,都经过了 gofmt。

名为《Go 代码审查评论》的文档是关于 Go 习惯用法的非常短的文章集合,通常被程序员忽视。这是 Go 项目代码审查的一个方便参考。

如何向 Go 库提交补丁? 库源代码位于存储库的 src 目录中。如果要进行重大更改,请在开始前在邮件列表中讨论。

请参阅文档《为 Go 项目做贡献》以获取更多关于如何进行的信息。

为什么“go get”在克隆存储库时使用 HTTPS? 公司通常仅允许标准 TCP 端口 80(HTTP)和 443(HTTPS)上的出站流量,阻止其他端口上的出站流量,包括 TCP 端口 9418(git)和 TCP 端口 22(SSH)。使用 HTTPS 而不是 HTTP 时,git 默认执行证书验证,提供对中间人、窃听和篡改攻击的保护。因此,go get 命令为安全起见使用 HTTPS。

可以将 Git 配置为通过 HTTPS 进行身份验证或使用 SSH 替代 HTTPS。要通过 HTTPS 进行身份验证,可以在 git 查阅的 $HOME/.netrc 文件中添加一行:

machine github.com login *USERNAME* password *APIKEY*

对于 GitHub 账户,密码可以是个人访问令牌。

还可以将 Git 配置为在匹配给定前缀的 URL 时使用 SSH 替代 HTTPS。例如,要将 SSH 用于所有 GitHub 访问,可以在 ~/.gitconfig 中添加这些行:

[url "ssh://git@github.com/"]
    insteadOf = https://github.com/

如何使用“go get”管理包版本? Go 工具链内置了一个管理版本相关包集合的系统,称为模块。模块在 Go 1.11 中引入,自 1.14 起已准备好用于生产。

要使用模块创建项目,请运行 go mod init。此命令创建一个 go.mod 文件,用于跟踪依赖版本。

go mod init example/project

要添加、升级或降级依赖,请运行 go get:

go get golang.org/x/text@v0.3.5

有关入门的更多信息,请参阅教程《创建模块》。

有关使用模块管理依赖的指南,请参阅《开发模块》。

模块中的包在演化过程中应保持向后兼容,遵循导入兼容性规则:

如果旧包和新包具有相同的导入路径, 新包必须与旧包向后兼容。

Go 1 兼容性指南是一个很好的参考:不要删除导出的名称,鼓励标记组合文字,等等。如果需要不同的功能,请添加一个新名称而不是更改旧名称。

模块通过语义版本控制和语义导入版本控制对其进行规范。如果需要破坏兼容性,请以新主要版本发布模块。主要版本 2 及以上的模块需要作为其路径的一部分包含主要版本后缀(如 /v2)。这保留了导入兼容性规则:模块

的不同主要版本中的包具有不同的路径。

指针和分配 函数参数何时按值传递? 如所有 C 家族语言中一样,Go 中的一切都按值传递。也就是说,函数总是获得传递对象的副本,就像有一个赋值语句将值赋给参数一样。例如,将一个 int 值传递给函数会生成 int 的副本,将指针值传递会生成指针的副本,但不会复制它指向的数据。(参见后面关于方法接收器的讨论。)

Map 和切片值的行为类似于指针:它们是包含指向底层 map 或切片数据指针的描述符。复制一个 map 或切片值不会复制它指向的数据。复制接口值会复制存储在接口值中的对象。如果接口值包含结构体,复制接口值会生成结构体的副本。如果接口值包含指针,复制接口值会生成指针的副本,但同样不会复制它指向的数据。

请注意,这个讨论是关于操作的语义。实际实现可以应用优化以避免复制,只要优化不会更改语义。

何时应使用指向接口的指针? 几乎从不。指向接口值的指针仅在涉及延迟求值的类型掩饰的罕见复杂情况下出现。

将指向接口值的指针传递给期望接口的函数是一个常见错误。编译器会对这个错误提出警告,但情况仍然令人困惑,因为有时需要指针来满足接口。关键在于,尽管指向具体类型的指针可以满足接口,但指向接口的指针几乎不能满足接口。

考虑变量声明:

var w io.Writer

打印函数 fmt.Fprintf 的第一个参数是满足 io.Writer 的对象——实现了标准 Write 方法的对象。因此,我们可以编写:

fmt.Fprintf(w, "hello, world\n")

但是,如果我们传递 w 的地址,程序将无法编译:

fmt.Fprintf(&w, "hello, world\n") // 编译时错误。

唯一的例外是任何值(即使是指向接口的指针)都可以分配给空接口类型(interface{})的变量。即便如此,如果值是指向接口的指针,结果几乎肯定是一个错误。

我应该在值或指针上定义方法吗?

func (s *MyStruct) pointerMethod() { } // 方法在指针上
func (s MyStruct)  valueMethod()   { } // 方法在值上

对于不熟悉指针的程序员,这两个例子的区别可能令人困惑,但情况实际上非常简单。在类型上定义方法时,接收器(上述例子中的 s)行为完全像方法的参数一样。因此,是否定义接收器为值或指针与函数参数是否应该是值或指针是同一个问题。有几个考虑因素。

首先也是最重要的,方法是否需要修改接收器?如果需要,接收器必须是指针。(切片和 map 作为引用,它们的故事更为微妙,但例如要在方法中更改切片的长度,接收器仍然必须是指针。)在上面的例子中,如果 pointerMethod 修改 s 的字段,调用者将看到这些变化,但 valueMethod 调用者看不到(这就是按值传递的定义),因此修改将对调用者不可见。

顺便提一下,在 Java 中,方法接收器始终是指针,尽管它们的指针性质有些被掩盖(最近的发展正在为 Java 带来值接收器)。Go 中的值接收器才是不寻常的。

其次是效率考虑。如果接收器很大,例如大结构体,使用指针接收器可能更便宜。

接下来是一致性考虑。如果某些类型的方法必须有指针接收器,其余方法也应该如此,以便无论类型如何使用,方法集都一致。有关详细信息,请参阅方法集部分。

对于基本类型、切片和小结构体等类型,值接收器非常便宜,因此除非方法的语义需要指针,否则值接收器高效且清晰。

new 和 make 之间有什么区别? 简而言之:new 分配内存,而 make 初始化切片、map 和通道类型。

有关更多详细信息,请参阅《有效的 Go》的相关部分。

在 64 位机器上 int 的大小是多少? int 和 uint 的大小是特定于实现的,但在给定平台上彼此相同。为了便于移植,依赖特定大小的值的代码应使用显式大小的类型,如 int64。在 32 位机器上,编译器默认使用 32 位整数,而在 64 位机器上,整数具有 64 位。(历史上并非总是如此。)

另一方面,浮点标量和复数类型始终具有固定大小(没有 float 或 complex 基本类型),因为程序员在使用浮点数时应注意精度。用于(无类型)浮点常量的默认类型是 float64。因此,foo := 3.0 声明一个类型为 float64 的变量 foo。要使用(无类型)常量初始化 float32 变量,必须在变量声明中显式指定变量类型:

var foo float32 = 3.0

或者,必须通过转换为常量指定类型,如 foo := float32(3.0)。

如何知道变量是在堆上还是栈上分配的? 从正确性的角度来看,您不需要知道。Go 中的每个变量只要有引用就存在。实现选择的存储位置与语言语义无关。

存储位置确实对编写高效程序有影响。Go 编译器在可能的情况下将在函数本地的变量分配在该函数的栈帧中。然而,如果编译器无法证明函数返回后变量未被引用,则编译器必须将变量分配在垃圾回收堆上,以避免悬挂指针错误。此外,如果局部变量非常大,存储在堆上可能比存储在栈上更有意义。

在当前的编译器中,如果变量被取址,则该变量是分配在堆上的候选者。然而,基本逃逸分析识别了一些变量在函数返回后不会存活的情况,可以驻留在栈上。

为什么我的 Go 进程使用这么多虚拟内存? Go 内存分配器保留了一个大区域的虚拟内存作为分配的竞技场。此虚拟内存是特定于 Go 进程的;保留不会剥夺其他进程的内存。

要找到分配给 Go 进程的实际内存量,请使用 Unix top 命令,并查阅 RES(Linux)或 RSIZE(macOS)列。

并发 哪些操作是原子操作?互斥锁呢? Go 中操作的原子性描述可以在《Go 内存模型》文档中找到。

低级同步和原子原语可在 sync 和 sync/atomic 包中找到。这些包适用于简单任务,如增加引用计数或保证小规模互斥。

对于更高级的操作,如并发服务器之间的协调,更高级的技术可以使程序更优雅,Go 通过其 goroutines 和通道支持这种方法。例如,可以结构化程序,使得同一时间只有一个 goroutine 负责某个特定的数据。这种方法由最初的 Go 谚语总结:

不要通过共享内存来通信。相反,通过通信来共享内存。

有关此概念的详细讨论,请参阅《通过通信共享内存》代码演示及其相关文章。

大型并发程序可能会同时借鉴这两种工具包。

为什么我的程序在有更多 CPU 时运行得不快? 程序是否在有更多 CPU 时运行得更快取决于其解决的问题。Go 语言提供并发原语,如 goroutines 和通道,但只有当底层问题本质上是并行的,才能实现并行性。本质上是顺序的问题不能通过增加 CPU 来加速,而可以分成可以并行执行的部分的问题可以加速,有时显著加速。

有时增加更多 CPU 会使程序变慢。实际上,如果程序花费更多时间进行同步或通信而不是进行有用的计算,那么使用多个 OS 线程时可能会出现性能下降。这是因为在线程之间传递数据涉及上下文切换,其成本很高,并且这种成本可能随着 CPU 增加而增加。例如,Go 规范中的素数筛选示例没有显著的并行性,尽管它启动了许多 goroutine;增加线程(CPU)的数量更有可能使其变慢而不是加速。

有关此主题的更多详细信息,请参阅《并发不是并行》的演讲。

如何控制 CPU 的数量? 同时可用来执行 goroutine 的 CPU 数量由 GOMAXPROCS shell 环境变量控制,默认值为可用 CPU 内核数。因此,具有并行执行潜力的程序应在多 CPU 机器上默认实现并行性。要更改要使用的并行 CPU 数量,请设置环境变量或使用 runtime 包中的同名函数配置运行时

支持以使用不同数量的线程。将其设置为 1 消除了真正并行的可能性,强制独立 goroutine 轮流执行。

运行时可以分配比 GOMAXPROCS 更多的线程来处理多个未完成的 I/O 请求。GOMAXPROCS 只影响一次可以实际执行的 goroutine 数量;可能会有更多的 goroutine 被系统调用阻塞。

Go 的 goroutine 调度器在平衡 goroutine 和线程方面表现良好,甚至可以抢占 goroutine 的执行以确保其他在同一线程上的 goroutine 不会被饿死。然而,它并不完美。如果出现性能问题,为每个应用程序设置 GOMAXPROCS 可能会有所帮助。

为什么没有 goroutine ID? Goroutines 没有名称;它们只是匿名的工作者。它们没有向程序员公开唯一标识符、名称或数据结构。一些人对此感到惊讶,期望 go 语句返回一些可以用于访问和控制 goroutine 的项目。

Goroutines 是匿名的根本原因是使 Go 语言的全部功能在编写并发代码时可用。相比之下,当线程和 goroutines 被命名时,会发展出限制库功能的使用模式。

以下是困难的说明。一旦命名一个 goroutine 并围绕它构建模型,它就变得特别,并且人们倾向于将所有计算与该 goroutine 关联,而忽略了使用多个可能共享的 goroutine 进行处理的可能性。如果 net/http 包将每个请求的状态与 goroutine 关联,客户端将无法在处理请求时使用更多的 goroutine。

此外,图形系统等库要求所有处理都在“主线程”上进行的经验表明,当在并发语言中部署时,这种方法是多么尴尬和有限。特殊线程或 goroutine 的存在迫使程序员扭曲程序以避免在错误线程上操作而导致崩溃和其他问题。

对于那些确实特别的 goroutine,语言提供了通道等特性,可以灵活地与它们交互。

函数和方法 为什么 T 和 *T 的方法集不同? 正如 Go 规范所述,类型 T 的方法集包括所有接收器类型为 T 的方法,而相应指针类型 *T 的方法集包括接收器类型为 *T 或 T 的所有方法。这意味着 *T 的方法集包括 T 的方法集,但反之不然。

这种区别是因为如果接口值包含指针 *T,则方法调用可以通过解引用指针获取值,但如果接口值包含值 T,则没有安全方法可以获取指针。(这样做会允许方法修改接口中的值,这是语言规范不允许的。)

即使编译器可以获取值的地址以传递给方法,如果方法修改了值,修改也会在调用者中丢失。例如,如果 bytes.Buffer 的 Write 方法使用值接收器而不是指针接收器,此代码:

var buf bytes.Buffer
io.Copy(buf, os.Stdin)

会将标准输入复制到 buf 的副本中,而不是 buf 本身。这几乎从不是期望的行为。

使用闭包运行 goroutine 时会发生什么? 由于循环变量的工作方式,在 Go 1.22 版本之前(参见本节结尾的更新),使用闭包与并发时可能会引起混淆。考虑以下程序:

func main() {
    done := make(chan bool)

    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // 等待所有 goroutine 完成后再退出
    for _ = range values {
        <-done
    }
}

人们可能错误地期望输出为 a、b、c。而实际输出可能是 c、c、c。这是因为循环的每次迭代使用相同的 v 变量实例,因此每个闭包共享该单个变量。当闭包运行时,它会打印 fmt.Println 执行时 v 的值,但 v 可能在 goroutine 启动后被修改。为了在问题发生前帮助检测到这一点,请运行 go vet。

要在每次迭代启动闭包时绑定 v 的当前值,必须修改内部循环以在每次迭代中创建新变量。一种方法是将变量作为参数传递给闭包:

    for _, v := range values {
        go func(u string) {
            fmt.Println(u)
            done <- true
        }(v)
    }

在这个示例中,v 的值作为参数传递给匿名函数。该值在函数内部作为变量 u 可访问。

更简单的方法是只创建一个新变量,使用一种在 Go 中可能看起来奇怪但有效的声明方式:

    for _, v := range values {
        v := v // 创建一个新的 'v'。
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

语言的这种行为(未为每次迭代定义新变量)在回顾时被认为是一个错误,并且在 Go 1.22 中已得到解决,该版本确实为每次迭代创建了新变量,消除了这个问题。

控制流 为什么 Go 没有 ?: 运算符? Go 中没有三元测试操作。您可以使用以下方法实现相同结果:

if expr {
    n = trueVal
} else {
    n = falseVal
}

Go 中没有 ?: 运算符的原因是,语言设计者认为该操作被用于创建难以理解的复杂表达式的情况太多。if-else 形式虽然更长,但无疑更清晰。语言只需要一种条件控制结构。

类型参数 为什么 Go 有类型参数? 类型参数允许所谓的泛型编程,其中函数和数据结构是根据稍后指定的类型定义的。例如,它们使得编写一个返回任何有序类型的最小值的函数成为可能,而无需为每种可能的类型编写单独版本。有关更深入的解释和示例,请参阅博客文章《为什么要泛型?》。

泛型在 Go 中是如何实现的? 编译器可以选择分别编译每个实例或将相似实例编译为单个实现。单个实现方法类似于具有接口参数的函数。不同的编译器将在不同情况下做出不同选择。标准 Go 编译器通常为每个具有相同形状的类型参数发出一个实例,其中形状由类型的属性(例如包含的指针的位置和大小)确定。未来版本可能会在编译时间、运行时效率和代码大小之间进行权衡。

Go 中的泛型与其他语言的泛型如何比较? 所有语言中的基本功能相似:可以编写使用稍后指定类型的类型和函数。尽管如此,也有一些差异。

Java

在 Java 中,编译器在编译时检查泛型类型,但在运行时删除类型。这被称为类型擦除。例如,在编译时称为 List 的 Java 类型在运行时将变成非泛型类型 List。这意味着,例如,在使用 Java 类型反射时,不可能区分类型为 List 和 List 的值。在 Go 中,泛型类型的反射信息包括完整的编译时类型信息。

Java 使用类型通配符(例如 List<? extends Number> 或 List<? super Number>)实现泛型协变和逆变。Go 没有这些概念,使得 Go 中的泛型类型更加简单。

C++

传统上,C++ 模板不强制类型参数上的任何约束,尽管 C++20 支持通过概念的可选约束。在 Go 中,所有类型参数的约束都是强制的。C++20 概念表达为必须使用类型参数编译的小代码片段。Go 的约束是定义所有允许类型参数集合的接口类型。

C++ 支持模板元编程;Go 不支持。实际上,所有 C++ 编译器都在实例化模板的地方编译每个模板;如上所述,Go 对不同实例使用不同方法。

Rust

Rust 的约束版本称为 trait bounds。在 Rust 中,trait bound 和类型之间的关联必须显式定义,无论是在定义 trait bound 的 crate 中还是在定义类型的 crate 中。在 Go 中,类型参数隐式满足约束,就像 Go 类型隐式实现接口类型一样。Rust 标准库为比较或加法等操作定义标准 trait;Go 标准库没有,因为这些可以通过接口类型在用户代码中表示。唯一的例外是 Go 的 comparable 预定义接口,它捕获了类型系统中不可表达的属性。

Python

Python 不是静态类型语言,因此可以合理地说,所有 Python 函数默认始终是泛型的:它们始终可以与任何类型的值一起调用,任何类型错误在运行时检测到。

为什么 Go 使用方括号表示类型参数列表? Java 和 C++ 使用尖括号表示类型参数列表,如 Java List 和 C++ std::vector。然而,这个选项对 Go 不可用,因为它导致语法问题:在函数内解析代码时,如 v := F,在看到 < 时,它不明确

是实例化还是使用 < 运算符的表达式。没有类型信息,这很难解决。

例如,考虑这样一个语句:

    a, b = w < x, y > (z)

没有类型信息,不可能决定赋值的右侧是两个表达式(w < x 和 y > z),还是一个返回两个结果值的泛型函数实例化和调用((w<x, y>)(z))。

Go 的一个关键设计决策是解析在没有类型信息的情况下可能,这在使用尖括号表示泛型时似乎不可能。

Go 并非唯一或原始使用方括号的语言;还有其他语言(如 Scala)也使用方括号表示泛型代码。

为什么 Go 不支持带有类型参数的方法? Go 允许泛型类型具有方法,但除接收器外,这些方法的参数不能使用参数化类型。我们预计 Go 永远不会添加泛型方法。

问题是如何实现它们。具体来说,考虑检查接口中的值是否实现了具有附加方法的另一个接口。例如,考虑这个类型,一个空结构体,具有返回其参数的泛型 Nop 方法,无论可能的类型如何:

type Empty struct{}

func (Empty) Nop[T any](x T) T {
    return x
}

现在假设一个 Empty 值存储在 any 中,并传递给其他检查它可以做什么的代码:

func TryNops(x any) {
    if x, ok := x.(interface{ Nop(string) string }); ok {
        fmt.Printf("string %s\n", x.Nop("hello"))
    }
    if x, ok := x.(interface{ Nop(int) int }); ok {
        fmt.Printf("int %d\n", x.Nop(42))
    }
    if x, ok := x.(interface{ Nop(io.Reader) io.Reader }); ok {
        data, err := io.ReadAll(x.Nop(strings.NewReader("hello world")))
        fmt.Printf("reader %q %v\n", data, err)
    }
}

如果 x 是 Empty,该代码如何工作?看起来 x 必须满足所有三个测试,以及任何其他类型的任何其他形式。

这些方法调用时运行什么代码?对于非泛型方法,编译器生成所有方法实现的代码,并将它们链接到最终程序中。但是对于泛型方法,可以有无限数量的方法实现,因此需要不同的策略。

有四种选择:

在链接时,列出所有可能的动态接口检查,然后查找满足这些检查但缺少编译方法的类型,然后重新调用编译器以添加这些方法。

这会显著减慢构建速度,因为需要在链接后停止并重复一些编译。特别是会减慢增量构建。更糟糕的是,新编译的方法代码可能本身具有新的动态接口检查,必须重复这个过程。可以构造一些示例,过程永远不会结束。

实现某种 JIT,在运行时编译所需的方法代码。

Go 大大受益于纯提前编译的简单性和可预测的性能。我们不愿意仅为了实现一个语言特性而承担 JIT 的复杂性。

安排为每个泛型方法发出慢速回退,使用每种可能语言操作的函数表,然后对动态测试使用该回退实现。

这种方法会使使用意外类型参数的泛型方法比编译时观察到的类型参数的方法慢得多。这会使性能不那么可预测。

定义泛型方法根本不能用于满足接口。

接口是 Go 编程的基本部分。从设计角度看,不允许泛型方法满足接口是不可接受的。

这些选择都不是好选择,所以我们选择了“以上皆非”。

不要使用带有类型参数的方法,而是使用带有类型参数的顶级函数,或者将类型参数添加到接收器类型。

有关更多详细信息,包括更多示例,请参阅提案。

为什么不能为参数化类型的接收器使用更具体的类型? 泛型类型的方法声明使用包括类型参数名称的接收器编写。也许是因为在调用点指定类型的语法相似,一些人认为这提供了一种通过命名特定类型(如 string)来生成为某些类型参数自定义的方法的机制:

type S[T any] struct { f T }

func (s S[string]) Add(t string) string {
    return s.f + t
}

这是无效的,因为编译器将 string 视为方法中的类型参数名称。编译器错误消息可能是“在 s.f 上未定义操作符 +(类型为 string 的变量)”。这可能令人困惑,因为 + 运算符对预定义类型 string 有效,但声明覆盖了 string 的定义,运算符对那个无关的 string 无效。覆盖预定义名称是合法的,但通常是一个错误。

为什么编译器不能推断我程序中的类型参数? 在许多情况下,程序员可以很容易地看到泛型类型或函数的类型参数必须是什么,但语言不允许编译器推断它。类型推断故意受限,以确保永远不会对推断的类型感到困惑。其他语言的经验表明,意外的类型推断在阅读和调试程序时会导致相当大的混淆。始终可以指定要在调用中使用的显式类型参数。将来可能会支持新形式的推断,只要规则保持简单和清晰。

包和测试 如何创建多文件包? 将包的所有源文件放在一个目录中。源文件可以任意引用不同文件中的项目;不需要前向声明或头文件。

除了分成多个文件外,包将像单文件包一样编译和测试。

如何编写单元测试? 在与包源文件相同的目录中创建一个以 _test.go 结尾的新文件。在该文件中,导入 "testing" 并编写形式为

func TestFoo(t *testing.T) {
    ...
}

的函数。在该目录中运行 go test。该脚本找到 Test 函数,构建测试二进制文件并运行它。

有关更多详细信息,请参阅《如何编写 Go 代码》文档、testing 包和 go test 子命令。

我最喜欢的测试助手函数在哪里? Go 的标准 testing 包使编写单元测试变得容易,但缺乏其他语言测试框架提供的功能,如断言函数。本文前面的一部分解释了为什么 Go 没有断言,同样的论点适用于测试中的 assert 使用。适当的错误处理意味着在一个测试失败后让其他测试继续运行,以便调试失败的程序员可以获得完整的错误信息。报告 isPrime 在 2、3、5 和 7(或 2、4、8 和 16)上的错误答案比报告 isPrime 在 2 上错误且不再运行更多测试更有用。触发测试失败的程序员可能不熟悉失败的代码。现在花时间编写好的错误消息,在测试失败时会得到回报。

相关的一点是,测试框架往往发展成自己的迷你语言,带有条件和控制以及打印机制,但 Go 已经具备所有这些功能;为什么要重新创建它们?我们更愿意用 Go 编写测试;学习一种更少的语言,并且这种方法使测试简洁易懂。

如果编写好的错误消息所需的额外代码似乎重复且压倒性,可以考虑将测试转换为表驱动,遍历定义在数据结构中的输入和输出列表(Go 对数据结构文字有出色支持)。编写好的测试和错误消息的工作将分摊到多个测试用例上。标准 Go 库中有许多示例,如 fmt 包的格式化测试。

为什么 X 不在标准库中? 标准库的目的是支持运行时库,连接操作系统,并提供许多 Go 程序所需的关键功能,如格式化 I/O 和网络。它还包含网络编程的重要元素,包括加密和对 HTTP、JSON 和 XML 等标准的支持。

没有明确的标准定义包含什么,因为很长时间以来,这是唯一的 Go 库。然而,今天有关于新增内容的标准。

对标准库的新添加很少,标准很高。包括在标准库中的代码需要承担大量的持续维护成本(通常由非原作者承担),受 Go 1 兼容性承诺的约束(阻止修复 API 中的任何缺陷),并且受 Go 发布周期的约束,阻止快速提供错误修复。

大多数新代码应在标准库外部存活,并通过 go 工具的 go get 命令访问。此类代码可以有自己的维护者、发布周期和兼容性保证。用户可以在 pkg.go.dev 上找到包并阅读其文档。

尽管标准库中确实有不太合适的部分,如 log/syslog,但我们继续维护标准库中的所有内容,因为 Go 1 兼容性承诺。但我们鼓励大多数新代码在其他地方存活。

实现 用于构建编译器的编译技术是什么? 有几个 Go 的生产编译器,以及一些为各种平台开发的编译器。

默认编译器 gc 是 Go 发行版的一部分,作为 go 命令的支持。gc 最初是用 C 编写的,因为引导

的困难——需要一个 Go 编译器来设置 Go 环境。但事情已经发展,自 Go 1.5 版本以来,编译器已是 Go 程序。编译器是通过使用自动翻译工具从 C 转换为 Go 的,如本设计文档和演讲所述。因此,编译器现在是“自举的”,这意味着我们需要解决引导问题。解决方案是像通常那样拥有一个工作 Go 安装。如何从源代码创建新 Go 环境的故事在这里和这里描述。

gc 是用 Go 编写的,使用递归下降解析器,并使用一个自定义加载器(也是用 Go 编写的,但基于 Plan 9 加载器)生成 ELF/Mach-O/PE 二进制文件。

Gccgo 编译器是一个用 C++ 编写的前端,带有递归下降解析器,结合标准 GCC 后端。一个实验性的 LLVM 后端使用相同的前端。

项目开始时,我们考虑过使用 LLVM 作为 gc 的后端,但决定它太大和太慢,无法满足我们的性能目标。更重要的是,从后视角看,使用 LLVM 后端会使引入一些 Go 需要但不是标准 C 设置的一部分的 ABI 相关更改更困难,如堆栈管理。

Go 成为实现 Go 编译器的优秀语言,尽管这不是其最初目标。最初没有自举允许 Go 的设计专注于其原始用途,即网络服务器。如果我们早期决定 Go 应该编译自己,我们可能会得到一个更适合编译器构造的语言,这是一个有价值的目标,但不是我们最初的目标。

尽管 gc 有自己的实现,本地词法分析器和解析器可在 go/parser 包中找到,还有一个本地类型检查器。gc 编译器使用这些库的变体。

运行时支持是如何实现的? 同样由于引导问题,运行时代码最初主要用 C 编写(还有一点汇编),但后来被翻译成 Go(除了某些汇编部分)。Gccgo 的运行时支持使用 glibc。gccgo 编译器使用称为分段栈的技术实现 goroutine,支持最近对 gold 链接器的修改。Gollvm 类似地建立在相应的 LLVM 基础设施上。

为什么我的简单程序这么大的二进制文件? gc 工具链中的链接器默认创建静态链接的二进制文件。因此,所有 Go 二进制文件都包含 Go 运行时,以及支持动态类型检查、反射甚至在 panic 时堆栈跟踪所需的运行时类型信息。

一个简单的 C“hello, world”程序用 gcc 在 Linux 上静态编译和链接约 750 KB,包括 printf 的实现。使用 fmt.Printf 的等效 Go 程序约为几兆字节,但包括更强大的运行时支持和类型和调试信息。

用 gc 编译的 Go 程序可以用 -ldflags=-w 选项链接,以禁用 DWARF 生成,删除二进制文件中的调试信息,但不损失其他功能。这可以大大减少二进制文件大小。

如何停止这些关于未使用变量/导入的抱怨? 未使用变量的存在可能表明一个错误,而未使用的导入只是减慢编译速度,随着程序累积代码和程序员时间的推移,影响可能会变得显著。出于这些原因,Go 拒绝编译带有未使用变量或导入的程序,交换短期便利以换取长期构建速度和程序清晰度。

然而,在开发代码时,通常会暂时创建这些情况,必须在编译前编辑它们是令人讨厌的。

一些人要求编译器选项来关闭这些检查或至少将它们降级为警告。尽管如此,尚未添加这样的选项,因为编译器选项不应影响语言的语义,并且 Go 编译器不报告警告,只报告阻止编译的错误。

没有警告有两个原因。首先,如果值得抱怨,就值得在代码中修复。(相反,如果不值得修复,就不值得提及。)其次,让编译器生成警告会鼓励实现者对弱情况发出警告,这会使编译噪声变大,掩盖应修复的真实错误。

很容易解决这个问题。使用空标识符允许未使用的内容在开发过程中保留。

import "unused"

// 此声明通过引用包中的某个项目标记导入为已使用。
var _ = unused.Item  // TODO: 提交前删除!

func main() {
    debugData := debug.Profile()
    _ = debugData // 仅在调试期间使用。
    ....
}

如今,大多数 Go 程序员使用工具 goimports,它会自动重写 Go 源文件以具有正确的导入,实际上消除了未使用导入问题。这个程序可以轻松连接到大多数编辑器和 IDE,以在编写 Go 源文件时自动运行。这个功能也内置在 gopls 中,如上所述。

为什么我的病毒扫描软件认为我的 Go 发行版或编译的二进制文件感染了病毒? 这是一种常见情况,尤其是在 Windows 机器上,几乎总是误报。商业病毒扫描程序经常被 Go 二进制文件的结构搞糊涂,因为它们不像用其他语言编写的二进制文件那么常见。

如果您刚安装了 Go 发行版并且系统报告它感染了病毒,那肯定是个错误。为了彻底检查,您可以通过将校验和与下载页面上的校验和进行比较来验证下载。

无论如何,如果您认为报告有误,请向您的病毒扫描软件供应商报告错误。也许将来病毒扫描软件可以学习理解 Go 程序。

性能 为什么 Go 在基准测试 X 上表现不好? Go 的设计目标之一是与类似程序的 C 性能接近,但在某些基准测试上表现不佳,包括 golang.org/x/exp/shootout 中的几个。最慢的依赖于 Go 中未提供相同性能版本的库。例如,pidigits.go 依赖于多精度数学包,而 C 版本使用 GMP(用优化汇编编写)。依赖于正则表达式的基准测试(例如 regex-dna.go)本质上是在将 Go 的原生 regexp 包与成熟的高性能正则表达式库(如 PCRE)进行比较。

基准游戏通过广泛的调优获胜,而大多数基准测试的 Go 版本需要关注。如果您测量真正可比的 C 和 Go 程序(reverse-complement.go 是一个示例),您会看到两种语言在原始性能上更接近这个套件所显示的。

尽管如此,仍有改进空间。编译器很好,但可以更好,许多库需要进行重大性能工作,垃圾回收器还不够快。(即使它足够快,不生成不必要的垃圾可以对性能产生巨大影响。)

无论如何,Go 往往具有很强的竞争力。随着语言和工具的发展,许多程序的性能已显著提高。请参阅关于 Go 程序剖析的博客文章,了解一个信息丰富的示例。尽管它已经相当旧,但仍包含有用的信息。

从 C 的变化 为什么语法与 C 如此不同? 除了声明语法,差异并不大,源于两个愿望。首先,语法应感觉轻松,没有太多的强制关键字、重复或奥秘。其次,语言被设计为易于分析,并且可以在没有符号表的情况下解析。这使得构建调试器、依赖分析器、自动文档提取器、IDE 插件等工具变得容易。C 及其后代在这方面非常困难。

为什么声明是反向的? 只有习惯 C 才会觉得是反向的。在 C 中,变量的声明像表示其类型的表达式,这个想法不错,但类型和表达式语法不太兼容,结果可能令人困惑;考虑函数指针。Go 大部分将表达式和类型语法分开,简化了问题(使用前缀 * 表示指针是一个例外,证明规则)。在 C 中,声明

    int* a, b;

声明 a 是指针,但 b 不是;在 Go 中

    var a, b *int

声明两者都是指针。这更清晰,更规则。此外,:= 短声明形式表明完整变量声明应与 := 表示相同顺序,因此

    var a uint64 = 1

    a := uint64(1)

具有相同效果。

通过具有不只是表达式语法的类型的单独语法,也简化了解析;关键字如 func 和 chan 保持清晰。

有关更多详细信息,请参阅关于《Go 声明语法》的文章。

为什么没有指针算术? 安全性。没有指针算术,就有可能创建一个永远不会成功非法地址的语言。编译器和硬件技术已经发展到使用数组索引的循环

可以与使用指针算术的循环一样高效。此外,缺乏指针算术可以简化垃圾收集器的实现。

为什么 ++ 和 -- 是语句而不是表达式?为什么是后缀而不是前缀? 没有指针算术,前缀和后缀增量运算符的便利性降低。通过将它们从表达式层次结构中完全删除,表达式语法被简化,并消除了与 ++ 和 -- 的计算顺序相关的问题(考虑 f(i++) 和 p[i] = q[++i])。简化是显著的。至于后缀和前缀,任何一种都可以很好地工作,但后缀版本更传统;对前缀的坚持源于 STL,一个名称具有讽刺意味地包含后缀增量的库。

为什么有大括号但没有分号?为什么不能将大括号放在下一行? Go 使用大括号来进行语句分组,这种语法对使用 C 家族语言的程序员很熟悉。然而,分号是给解析器而不是给人的,我们希望尽可能消除它们。为实现这一目标,Go 借用了 BCPL 的一个技巧:分隔语句的分号在形式语法中,但由词法分析器在任何可以是语句结尾的行末自动注入,无需前瞻。这在实践中非常有效,但有一个影响,即强制使用特定的大括号样式。例如,函数的开头大括号不能单独放在一行上。

一些人认为词法分析器应该进行前瞻以允许大括号放在下一行。我们不同意。由于 Go 代码旨在通过 gofmt 自动格式化,必须选择一种样式。这个样式可能与您在 C 或 Java 中使用的不同,但 Go 是不同的语言,gofmt 的样式和其他任何样式一样好。更重要的是——非常重要——为所有 Go 程序规定单一、程序化的格式的优势远远超过特定样式的任何感知缺点。还要注意,Go 的样式意味着 Go 的交互式实现可以逐行使用标准语法而无需特殊规则。

为什么要进行垃圾回收?会不会太贵? 系统程序中最大的簿记来源之一是管理分配对象的生命周期。在 C 语言等手动完成的语言中,这可能会占用大量程序员时间,并且经常导致难以发现的错误。即使在提供机制来辅助的语言(如 C++ 或 Rust)中,这些机制也会显著影响软件设计,通常增加自己的编程开销。我们认为消除这种程序员开销是至关重要的,近年来垃圾回收技术的进步使我们有信心可以以足够低的成本实现,并且延迟足够低,可以成为网络系统的可行方法。

并发编程的许多难题根源于对象生命周期问题:当对象在线程之间传递时,确保它们安全释放变得繁琐。自动垃圾回收使编写并发代码变得更容易。当然,在并发环境中实现垃圾回收本身是一个挑战,但一次解决这个问题比在每个程序中解决有利于所有人。

最后,除了并发,垃圾回收使接口更简单,因为它们不需要指定内存管理方式。

这并不是说最近在管理资源问题上带来新想法的语言(如 Rust)的工作是错误的;我们鼓励这种工作,并对其演变感到兴奋。但 Go 采用更传统的方法,通过垃圾回收单独解决对象生命周期问题。

当前实现是标记和清除收集器。如果机器是多处理器,收集器在一个单独的 CPU 核心上与主程序并行运行。近年来对收集器的主要工作将暂停时间减少到毫秒以下,即使对于大堆,也消除了对网络服务器中垃圾回收的主要反对意见。工作继续改进算法,进一步减少开销和延迟,并探索新方法。Go 团队的 Rick Hudson 在 2018 ISMM 上的主题演讲描述了迄今为止的进展,并提出了一些未来的方法。

关于性能,记住 Go 给程序员提供了比典型垃圾回收语言更多的内存布局和分配控制。一个仔细的程序员可以通过正确使用语言大大减少垃圾回收开销;请参阅关于剖析 Go 程序的文章,了解一个工作示例,包括展示 Go 的剖析工具。