常见问题 (FAQ)
起源
这个项目的目的是什么?
在 Go 于 2007 年诞生时,编程世界与今日不同。生产软件通常用 C++ 或 Java 编写,GitHub 尚不存在,大多数计算机还不是多处理器,除了 Visual Studio 和 Eclipse 之外,几乎没有其他高级工具可用,更不用说互联网上的免费工具了。
与此同时,我们对使用我们当时使用的语言及其相关的构建系统构建大型软件项目所需的不必要复杂性感到沮丧。自 C、C++ 和 Java 等语言首次开发以来,计算机已经变得极其快,但编程行为本身却没有进步那么多。此外,很明显多处理器正在普及,但大多数语言在高效安全地编程方面提供的帮助很少。
我们决定退后一步,思考随着技术发展,未来几年主导软件工程的主要问题是什么,以及一门新语言如何帮助解决这些问题。例如,多核 CPU 的兴起表明一门语言应该为某种并发或并行提供一流支持。为了在一个大型并发程序中使资源管理变得可行,需要垃圾回收,或者至少某种安全自动内存管理。
这些考量催生了一系列讨论,Go 正是从中产生的,首先是一系列想法和需求,然后是一门语言。一个总目标是 Go 能够通过赋能工具、自动化格式化代码等枯燥任务以及消除处理大型代码库的障碍来更多地帮助程序员工作。
关于 Go 的目标以及如何实现或至少接近这些目标的更详尽描述,请参阅文章 Go 在 Google:软件工程服务中的语言设计。
这个项目的历史是怎样的?
Robert Griesemer、Rob Pike 和 Ken Thompson 于 2007 年 9 月 21 日在白板上开始勾勒一门新语言的目标。几天之内,目标就确定为一个计划,并对其内容有了相当清晰的概念。设计工作与不相关的其他工作并行进行。到 2008 年 1 月,Ken 开始开发一个编译器来探索想法;它的输出是 C 代码。到年中,这门语言成为一个全职项目,并稳定到足以尝试一个生产编译器。2008 年 5 月,Ian Taylor 独立地使用草案规范开始了 Go 的 GCC 前端工作。Russ Cox 于 2008 年底加入,帮助将语言和库从原型变为现实。
Go 于 2009 年 11 月 10 日成为一个公共开源项目。来自社区的无数人贡献了想法、讨论和代码。
现在全世界有数百万 Go 程序员——gophers——而且每天都在增加。Go 的成功远远超出了我们的预期。
地鼠吉祥物的起源是什么?
吉祥物和标志是由 Renée French 设计的,她也设计了 Glenda,Plan 9 的兔子。一篇关于地鼠的博客文章解释了它是如何从她几年前为 WFMU T 恤设计的一个地鼠演变而来的。标志和吉祥物受 知识共享署名 4.0 许可协议的保护。
地鼠有一个模型图,展示了他的特征以及如何正确地表现它们。该模型图首次在 2016 年的 Gophercon 大会上由 Renée 的演讲中展示。他拥有独特的特征;他是 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 旨在做到 快速:在单台计算机上构建一个大型可执行文件最多只需几秒钟。实现这些目标促使我们重新思考当前语言中的一些编程方法,从而产生了:组合而非分层的类型系统;支持并发和垃圾回收;严格规范依赖关系;等等。这些不能很好地通过库或工具来处理;因此需要一门新的语言。
文章《Google 的 Go》讨论了 Go 语言设计的背景和动机,并提供了本 FAQ 中许多答案的更多细节。
Go 的祖先是谁?
Go 主要属于 C 家族(基本语法),并受到 Pascal/Modula/Oberon 家族(声明、包)的重大影响,此外还有一些来自受 Tony Hoare 的 CSP 启发的语言的想法,例如 Newsqueak 和 Limbo(并发)。然而,它是一门全新的语言。在各个方面,这门语言都是通过思考程序员做什么以及如何使编程(至少是我们所做的编程类型)更有效(这意味着更有趣)来设计的。
设计中的指导原则是什么?
Go 设计之初,Java 和 C++ 是编写服务器最常用的语言,至少在 Google 是如此。我们觉得这些语言需要太多的繁琐操作和重复。一些程序员转向 Python 等更具动态性、更灵活的语言,但这牺牲了效率和类型安全。我们认为应该有可能在一门语言中同时拥有效率、安全性和灵活性。
Go 试图减少两种意义上的“键入”:敲击键盘和类型声明。在整个设计过程中,我们一直试图减少杂乱和复杂性。没有前向声明,没有头文件;所有内容都只声明一次。初始化表达性强、自动化且易于使用。语法简洁、关键字少。通过使用 :=
声明并初始化构造进行简单的类型推导,减少了重复(foo.Foo* myFoo = new(foo.Foo)
)。或许最激进的是,没有类型层次结构:类型就是类型,它们不必声明彼此之间的关系。这些简化使得 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 Wiki 上有一个定期更新的页面,列出了许多使用 Go 的公司。
Wiki 上还有一个页面,提供了更多关于使用该语言的公司和项目的成功案例的链接。
Go 程序可以链接到 C/C++ 程序吗?
在同一个地址空间中使用 C 和 Go 是可能的,但这并非自然契合,并且可能需要特殊的接口软件。此外,将 C 与 Go 代码链接会放弃 Go 提供的内存安全和栈管理特性。有时使用 C 库来解决问题是绝对必要的,但这样做总是会引入纯 Go 代码中不存在的风险因素,因此请谨慎行事。
如果您确实需要在 Go 中使用 C,具体操作取决于 Go 编译器实现。Go 团队在 Google 支持的 Go 工具链中的“标准”编译器称为 gc
。此外,还有一个基于 GCC 的编译器(gccgo
)和一个基于 LLVM 的编译器(gollvm
),以及不断增长的用于不同目的的非主流编译器列表,有时这些编译器实现了语言的子集,例如 TinyGo。
Gc
使用与 C 不同的调用约定和链接器,因此不能直接从 C 程序调用,反之亦然。cgo
程序提供了一种“外部函数接口”机制,允许从 Go 代码安全地调用 C 库。SWIG 将此功能扩展到 C++ 库。
您也可以将 cgo
和 SWIG 与 gccgo
和 gollvm
一起使用。由于它们使用传统的 ABI,因此也可以非常小心地将这些编译器编译的代码直接与 GCC/LLVM 编译的 C 或 C++ 程序链接。然而,这样做需要理解所有相关语言的调用约定,以及从 Go 调用 C 或 C++ 时对栈限制的关注。
Go 支持哪些 IDE?
Go 项目不包含定制 IDE,但语言和库的设计使得分析源代码变得容易。因此,大多数知名的编辑器和 IDE 都很好地支持 Go,无论是直接支持还是通过插件。
Go 团队还支持一个用于 LSP 协议的 Go 语言服务器,称为 gopls
。支持 LSP 的工具可以使用 gopls
来集成特定语言的支持。
提供良好 Go 支持的知名 IDE 和编辑器列表包括 Emacs、Vim、VSCode、Atom、Eclipse、Sublime、IntelliJ(通过一个名为 GoLand 的定制版本)等等。您喜欢的环境很可能是一个适合用 Go 编程的有效环境。
Go 支持 Google 的协议缓冲区吗?
一个独立的开源项目提供了所需的编译器插件和库。它可在 github.com/golang/protobuf/ 获得。
设计
Go 有运行时吗?
Go 有一个庞大的运行时库,通常简称为 runtime,它是每个 Go 程序的一部分。这个库实现了垃圾回收、并发、栈管理以及 Go 语言的其他关键特性。尽管它对语言更核心,但 Go 的运行时与 C 库 libc
类似。
然而,重要的是要理解,Go 的运行时不包含虚拟机,例如 Java 运行时提供的虚拟机。Go 程序会被提前编译成原生机器码(或者,对于某些变体实现,编译成 JavaScript 或 WebAssembly)。因此,尽管“运行时”这个词经常被用来描述程序运行的虚拟环境,但在 Go 中,“runtime”只是提供关键语言服务的库的名称。
Unicode 标识符是怎么回事?
在设计 Go 时,我们想确保它不会过于以 ASCII 为中心,这意味着将标识符的范围从 7 位 ASCII 的限制中扩展出来。Go 的规则——标识符字符必须是 Unicode 定义的字母或数字——简单易懂且易于实现,但也有限制。例如,组合字符被故意排除,这排除了某些语言,如天城文。
这条规则还有一个不幸的后果。由于一个可导出的标识符必须以大写字母开头,用某些语言的字符创建的标识符按定义不能被导出。目前唯一的解决方案是使用类似 X日本語
的东西,这显然不能令人满意。
自从语言的最初版本以来,人们一直在认真思考如何最好地扩展标识符空间以适应使用其他母语的程序员。具体该怎么做仍然是一个活跃的讨论话题,未来的语言版本可能会在标识符的定义上更加宽松。例如,它可能会采用 Unicode 组织关于标识符的建议中的一些想法。无论发生什么,都必须兼容,同时保留(或可能扩展)字母大小写决定标识符可见性的方式,这仍然是 Go 中我们最喜欢的特性之一。
目前,我们有一条简单的规则,将来可以在不破坏现有程序的情况下进行扩展,这条规则避免了承认模糊标识符的规则肯定会产生的错误。
Go 为什么没有特性 X?
每种语言都包含新颖的特性,并省略了某些人喜爱的特性。Go 的设计着眼于编程的便利性、编译速度、概念的正交性,以及支持并发和垃圾回收等特性的需求。您喜爱的特性可能缺失是因为它不契合,因为它影响编译速度或设计的清晰性,或者因为它会使基本系统模型过于复杂。
如果 Go 缺失特性 X 让您感到困扰,请原谅我们,并研究 Go 确实拥有的特性。您可能会发现它们以有趣的方式弥补了 X 的不足。
Go 是什么时候支持泛型类型的?
Go 1.18 版本在语言中添加了类型参数。这允许一种形式的多态或泛型编程。详情请参阅语言规范和提案。
Go 为什么最初发布时没有泛型类型?
Go 旨在成为一门用于编写易于长期维护的服务器程序的语言。(有关更多背景信息,请参阅此文章。)设计着重于可伸缩性、可读性和并发性等方面。多态编程当时似乎不是语言目标的核心,因此最初为了简单起见被省略了。
泛型虽然方便,但会增加类型系统和运行时的复杂性。我们花了一些时间才开发出一个我们认为其价值与复杂性相称的设计。
Go 为什么没有异常?
我们认为将异常与控制结构耦合,如同 try-catch-finally
惯例一样,会导致代码复杂。它也倾向于鼓励程序员将太多普通错误,例如打开文件失败,标记为异常情况。
Go 采取了不同的方法。对于普通的错误处理,Go 的多值返回使得在不给返回值增加负担的情况下报告错误变得容易。规范的错误类型,结合 Go 的其他特性,使得错误处理变得愉快,但与其它语言截然不同。
Go 还有几个内置函数来信号和从真正异常的条件中恢复。恢复机制只在函数状态因错误而被销毁时执行,这足以处理灾难,但不需要额外的控制结构,并且在正确使用时可以产生简洁的错误处理代码。
详见Defer, Panic, and Recover文章。此外,错误是值博客文章通过展示错误只是值,Go 的全部能力可以用于错误处理,从而描述了一种在 Go 中干净地处理错误的方法。
Go 为什么没有断言?
Go 不提供断言。它们无疑很方便,但我们的经验是程序员将它们当作拐杖,避免思考适当的错误处理和报告。适当的错误处理意味着服务器在发生非致命错误后继续运行而不是崩溃。适当的错误报告意味着错误是直接而切中要害的,避免了程序员解释大量的崩溃堆栈跟踪。精确的错误尤其重要,特别是当看到错误的程序员对代码不熟悉时。
我们理解这是一个争议点。Go 语言和库中有很多地方与现代实践不同,仅仅是因为我们认为有时尝试不同的方法是值得的。
为什么要在 CSP 的思想上构建并发?
并发和多线程编程随着时间的推移获得了难以掌握的名声。我们认为这部分是由于 pthreads 等复杂设计,部分是由于过度强调互斥锁、条件变量和内存屏障等低级细节。更高级别的接口能够实现更简单的代码,即使底层仍然有互斥锁等机制。
一种为并发提供高级语言支持的最成功模型来自 Hoare 的通信顺序进程(CSP)。Occam 和 Erlang 是两个源自 CSP 的著名语言。Go 的并发原语源自家族树的另一个分支,其主要贡献是将通道视为第一类对象这一强大概念。与几种早期语言的经验表明,CSP 模型非常适合过程式语言框架。
为什么是 goroutines 而不是线程?
Goroutines 是使并发易于使用的一部分。其思想,由来已久,是将独立执行的函数——协程——多路复用到一组线程上。当一个协程阻塞时,例如通过调用阻塞系统调用,运行时会自动将同一操作系统线程上的其他协程移动到不同的、可运行的线程上,这样它们就不会被阻塞。程序员看不到这一切,这正是其目的。我们称之为 goroutines 的结果可以非常廉价:它们除了栈的内存外几乎没有开销,而栈只需几千字节。
为了使栈变小,Go 的运行时使用了可伸缩、有界的栈。新创建的 goroutine 被分配几千字节,这几乎总是足够的。当不够时,运行时会自动增长(和缩小)用于存储栈的内存,允许许多 goroutine 占用适度的内存。CPU 开销平均每次函数调用约三条廉价指令。在同一个地址空间中创建数十万个 goroutine 是可行的。如果 goroutine 只是线程,系统资源将在数量远小于此的情况下耗尽。
为什么 map 操作没有被定义为原子操作?
经过长时间讨论,我们决定 map 的典型使用不需要从多个 goroutines 进行安全访问,并且在需要安全访问的情况下,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 的设计目标。文章《Google 的 Go:软件工程中的语言设计》解释了 Go 的起源及其设计背后的动机。
类型
Go 是一种面向对象语言吗?
是的,也不是。尽管 Go 有类型和方法,并允许面向对象的编程风格,但它没有类型层次结构。Go 中的“接口”概念提供了一种我们认为易于使用且在某些方面更通用的不同方法。还有一些方法可以将类型嵌入其他类型中,以提供类似于(但不完全相同于)子类化的功能。此外,Go 中的方法比 C++ 或 Java 更通用:它们可以为任何类型的数据定义,甚至包括基本的“非装箱”整数等内置类型。它们不限于结构体(类)。
此外,没有类型层次结构使得 Go 中的“对象”感觉比 C++ 或 Java 等语言中的轻便得多。
如何实现方法的动态调度?
实现动态调度方法的唯一途径是通过接口。结构体或任何其他具体类型上的方法总是静态解析的。
为什么没有类型继承?
面向对象编程,至少在最著名的语言中,涉及太多关于类型之间关系的讨论,而这些关系通常可以自动推导出来。Go 采取了一种不同的方法。
Go 没有要求程序员提前声明两个类型之间存在关系,而是允许一个类型自动满足任何指定其部分方法的接口。除了减少繁琐之外,这种方法还具有实际优势。类型可以一次满足多个接口,而没有传统多重继承的复杂性。接口可以非常轻量——一个具有一个甚至零个方法的接口可以表达一个有用的概念。如果出现新的想法或为了测试,可以在事后添加接口,而无需修改原始类型。由于类型和接口之间没有显式关系,因此没有类型层次结构需要管理或讨论。
利用这些想法,可以构建出类似于类型安全的 Unix 管道的东西。例如,看看 fmt.Fprintf
如何能够将格式化输出打印到任何输出,而不仅仅是文件;或者 bufio
包如何可以与文件 I/O 完全独立;或者 image
包如何生成压缩图像文件。所有这些想法都源于一个接口(io.Writer
),它表示一个方法(Write
)。而这仅仅是触及表面。Go 的接口对程序结构有着深远的影响。
虽然需要一些时间来适应,但这种隐式的类型依赖风格是 Go 最富有成效的特性之一。
为什么 len
是一个函数而不是方法?
我们讨论过这个问题,但决定将 len
及相关函数实现为普通函数在实践中是可行的,并且没有使基本类型的接口(在 Go 类型意义上)问题复杂化。
Go 为什么不支持方法和运算符重载?
如果方法分派不需要进行类型匹配,那么它就简化了。与其他语言的经验告诉我们,拥有多种同名但签名不同的方法有时很有用,但在实践中也可能令人困惑且脆弱。仅按名称匹配并要求类型一致性是 Go 类型系统中的一个重要简化决定。
关于运算符重载,它看起来更像是一种便利而非绝对要求。同样,没有它,事情会更简单。
Go 为什么没有“implements”声明?
Go 类型通过实现接口的方法来实现一个接口,仅此而已。此属性允许定义和使用接口而无需修改现有代码。它实现了一种结构化类型,促进了关注点分离,提高了代码重用,并使得基于代码开发过程中出现的模式进行构建更加容易。接口的语义是 Go 灵活、轻量感的主要原因之一。
详见关于类型继承的问题。
如何保证我的类型实现了某个接口?
您可以通过尝试使用类型的零值或类型指针(视情况而定)进行赋值来要求编译器检查类型 T
是否实现了接口 I
:
type T struct{}
var _ I = T{} // Verify that T implements I.
var _ I = (*T)(nil) // Verify that *T implements 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 } // does not satisfy Equaler
与某些多态类型系统中的类似情况不同,T
没有实现 Equaler
。T.Equal
的参数类型是 T
,而不是字面上要求的类型 Equaler
。
在 Go 中,类型系统不会提升 Equal
的参数;那是程序员的责任,正如实现了 Equaler
的类型 T2
所示
type T2 int
func (t T2) Equal(u Equaler) bool { return t == u.(T2) } // satisfies 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
都未设置时(T=nil
, V
未设置),接口值才为 nil
。特别是,一个 nil
接口总是持有一个 nil
类型。如果我们将一个类型为 *int
的 nil
指针存储在接口值中,内部类型将是 *int
,无论指针的值是什么:(T=*int
, V=nil
)。因此,这样的接口值将是非 nil
的,即使内部的指针值 V
是 nil
。
这种情况可能会令人困惑,并且当一个 nil
值存储在接口值(例如 error
返回值)中时会发生
func returnsError() error {
var p *MyError = nil
if bad() {
p = ErrBad
}
return p // Will always return a non-nil error.
}
如果一切顺利,函数返回一个 nil
p
,因此返回值为一个持有 (T=*MyError
, V=nil
) 的 error
接口值。这意味着如果调用者将返回的错误与 nil
进行比较,即使没有发生任何不良情况,看起来也总好像有错误。为了向调用者返回一个正确的 nil
error
,函数必须显式返回 nil
func returnsError() error {
if bad() {
return ErrBad
}
return nil
}
对于返回错误的函数,最好始终在函数签名中使用 error
类型(如我们上面所做),而不是具体的类型(如 *MyError
),以帮助确保错误被正确创建。例如,os.Open
返回一个 error
,即使它不是 nil
,它也总是具体的类型 *os.PathError
。
只要使用接口,就会出现与此处描述类似的情况。请记住,如果接口中存储了任何具体值,则该接口将不是 nil
。更多信息,请参阅 反射定律。
为什么零大小类型行为怪异?
Go 支持零大小类型,例如没有字段的结构体 (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 那样没有非标签联合体 (untagged unions)?
非标签联合体会违反 Go 的内存安全保证。
为什么 Go 没有变体类型 (variant types)?
变体类型,也称为代数类型,提供了一种方式来指定一个值可能属于一组其他类型中的某一个,但只能是这些类型。系统编程中的一个常见例子是指定一个错误可能是一个网络错误、安全错误或应用程序错误,并允许调用者通过检查错误类型来区分问题来源。另一个例子是语法树,其中每个节点可以是不同的类型:声明、语句、赋值等。
我们考虑过在 Go 中添加变体类型,但经过讨论后决定不添加,因为它们与接口以令人困惑的方式重叠。如果变体类型的元素本身是接口,会发生什么?
此外,变体类型解决的一些问题已经由语言本身涵盖。错误示例很容易使用接口值来持有错误,并通过类型切换来区分不同的情况。语法树示例也是可以实现的,尽管不如变体类型那样优雅。
为什么 Go 没有协变返回类型 (covariant result types)?
协变返回类型意味着像这样的接口
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/ 运行一个服务器,该服务器从网络上任何地方的 Go 源代码中提取包文档,并将其作为 HTML 提供,其中包含指向声明和相关元素的链接。这是了解现有 Go 库最简单的方法。
在项目早期,有一个类似的程序 godoc
,也可以运行它来提取本地机器上文件的文档;pkg.go.dev/pkg/ 本质上是它的一个后代。另一个后代是 pkgsite
命令,它像 godoc
一样可以在本地运行,尽管它尚未集成到 go
doc
显示的结果中。
有 Go 编程风格指南吗?
没有明确的风格指南,尽管确实存在一种可识别的“Go 风格”。
Go 建立了一套约定来指导命名、布局和文件组织方面的决策。Effective Go 文档包含了一些关于这些主题的建议。更直接地说,gofmt
程序是一个美化工具,其目的是强制执行布局规则;它取代了通常允许解释的“应该做”和“不应该做”的规则汇编。仓库中的所有 Go 代码,以及开源世界中的绝大多数 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。例如,要对所有 GitHub 访问使用 SSH,请将以下行添加到您的 ~/.gitconfig
中
[url "ssh://git@github.com/"]
insteadOf = https://github.com/
在使用私有模块但使用公共模块代理获取依赖项时,您可能需要设置 GOPRIVATE
。有关详细信息和其他设置,请参阅私有模块。
如何使用“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 和切片值 behave like 指针:它们是描述符,包含指向底层 map 或切片数据的指针。复制 map 或切片值不会复制它指向的数据。复制接口值会复制存储在接口值中的内容。如果接口值持有结构体,复制接口值会复制该结构体。如果接口值持有指针,复制接口值会复制该指针,但同样不复制它指向的数据。
请注意,这里的讨论是关于操作的语义。只要优化不改变语义,实际实现可能会应用优化以避免复制。
何时应该使用指向接口的指针?
几乎从不。指向接口值的指针只出现在涉及隐藏接口值类型以进行延迟求值的极少数、棘手的情况下。
将指向接口值的指针传递给期望接口的函数是一个常见的错误。编译器会报告此错误,但情况仍然可能令人困惑,因为有时需要指针来满足接口。关键在于,尽管指向具体类型的指针可以满足接口,但除了一个例外,指向接口的指针永远不能满足接口。
考虑变量声明,
var w io.Writer
打印函数 fmt.Fprintf
将满足 io.Writer
的值作为其第一个参数——也就是实现了规范的 Write
方法的东西。因此我们可以写
fmt.Fprintf(w, "hello, world\n")
然而,如果我们传递 w
的地址,程序将无法编译。
fmt.Fprintf(&w, "hello, world\n") // Compile-time error.
唯一的例外是,任何值,即使是指向接口的指针,都可以赋给空接口类型 (interface{}
) 的变量。即使如此,如果该值是指向接口的指针,也几乎肯定是一个错误;结果可能会令人困惑。
我应该在值还是指针上定义方法?
func (s *MyStruct) pointerMethod() { } // method on pointer
func (s MyStruct) valueMethod() { } // method on value
对于不习惯指针的程序员来说,这两个例子之间的区别可能会令人困惑,但实际情况非常简单。在类型上定义方法时,接收器(上面例子中的 s
)的行为就好像它是方法的参数一样。因此,将接收器定义为值还是指针,与函数参数应该是值还是指针是同一个问题。有几个方面需要考虑。
首先,也是最重要的一点,方法是否需要修改接收器?如果需要,接收器必须是指针。(切片和 map 充当引用,因此它们的情况稍微微妙一些,但例如,要在方法中更改切片的长度,接收器仍然必须是指针。)在上面的例子中,如果 pointerMethod
修改了 s
的字段,调用者将看到这些更改,但 valueMethod
是用调用者参数的副本调用的(这就是按值传递的定义),因此它所做的更改对调用者是不可见的。
顺便说一下,在 Java 中,方法接收器一直是指针,尽管它们的指针特性在某种程度上是隐藏的(最近的发展正在将值接收器引入 Java)。Go 中的值接收器是不寻常的。
其次是效率方面的考虑。如果接收器很大,例如一个大型 struct
,使用指针接收器可能会更划算。
接下来是一致性。如果类型的某些方法必须具有指针接收器,那么其余方法也应该具有,这样无论类型如何使用,方法集都是一致的。有关详细信息,请参阅关于方法集的部分。
对于基本类型、切片和小型 structs
等类型,值接收器的开销非常小,因此除非方法语义需要指针,否则值接收器是高效且清晰的。
new 和 make 有什么区别?
简而言之:new
分配内存,而 make
初始化切片、map 和通道类型。
有关更多详细信息,请参阅 Effective 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 通过其 goroutine 和通道支持这种方法。例如,您可以构建程序,使得在任何时候都只有一个 goroutine 负责处理特定的数据。这种方法总结在最初的Go 谚语中,
不要通过共享内存来通信;相反,通过通信来共享内存。
有关此概念的详细讨论,请参阅通过通信共享内存代码漫步及其相关文章。
大型并发程序可能会借鉴这两种工具集。
为什么我的程序在更多 CPU 上没有运行得更快?
程序在更多 CPU 上是否运行得更快,取决于它正在解决的问题。Go 语言提供了并发原语,例如 goroutine 和通道,但并发只有在底层问题本身是并行的情况下才能实现并行。本质上是顺序的问题无法通过增加 CPU 来加速,而那些可以分解成可并行执行的部分的问题则可以加速,有时甚至显著加速。
有时增加更多 CPU 反而会降低程序速度。实际上,那些花费更多时间进行同步或通信而不是进行有用计算的程序在使用多个 OS 线程时可能会遇到性能下降。这是因为线程间传递数据涉及上下文切换,这会带来显著开销,并且开销可能随着 CPU 数量的增加而增加。例如,Go 规范中的素数筛示例虽然启动了许多 goroutine,但并没有明显的并行性;增加线程数(CPU 数)更可能降低其速度而不是加快速度。
有关此主题的更多详细信息,请参阅题为并发不是并行的演讲。
如何控制 CPU 数量?
同时可用于执行 goroutine 的 CPU 数量由 GOMAXPROCS
shell 环境变量控制,其默认值为可用的 CPU 核数。因此,具有并行执行潜力的程序在多核机器上默认应能实现并行。要更改使用的并行 CPU 数量,可以设置环境变量或使用运行时包中同名的函数来配置运行时支持以使用不同数量的线程。将其设置为 1 会消除真正的并行可能性,迫使独立的 goroutine 轮流执行。
运行时可以分配比 GOMAXPROCS
值更多的线程来服务多个未完成的 I/O 请求。GOMAXPROCS
只影响一次可以实际执行多少个 goroutine;任意更多的 goroutine 可能会阻塞在系统调用中。
Go 的 goroutine 调度器在平衡 goroutine 和线程方面做得很好,甚至可以抢占 goroutine 的执行,以确保同一线程上的其他 goroutine 不会饥饿。然而,它并非完美无缺。如果您遇到性能问题,针对每个应用程序设置 GOMAXPROCS
可能会有所帮助。
为什么没有 goroutine ID?
Goroutine 没有名字;它们只是匿名工作者。它们不向程序员暴露唯一的标识符、名称或数据结构。有些人对此感到惊讶,期望 go
语句返回一些可以稍后用于访问和控制 goroutine 的项目。
goroutine 匿名的根本原因在于,在编写并发代码时,整个 Go 语言都是可用的。相比之下,当线程和 goroutine 被命名时,形成的用法模式可能会限制使用它们的库的功能。
这是一个困难的例子。一旦给 goroutine 命名并围绕它构建模型,它就变得特殊,人们就会倾向于将所有计算都与该 goroutine 相关联,而忽略了使用多个(可能共享的)goroutine 进行处理的可能性。如果 net/http
包将每个请求的状态与一个 goroutine 相关联,那么客户端在处理请求时将无法使用更多 goroutine。
此外,像图形系统库那样要求所有处理都在“主线程”上进行的经验表明,这种方法在并发语言中应用时会多么笨拙和受限。特殊线程或 goroutine 的存在本身就迫使程序员扭曲程序结构,以避免因意外操作在错误的线程上而导致的崩溃及其他问题。
对于那些特定 goroutine 确实特殊的情况,语言提供了诸如通道之类的特性,可以以灵活的方式与其交互。
函数和方法
为什么 T 和 *T 具有不同的方法集?
正如 Go 语言规范 所述,类型 T
的方法集包含所有接收者类型为 T
的方法,而对应的指针类型 *T
的方法集包含所有接收者为 *T
或 T
的方法。这意味着 *T
的方法集包含 T
的方法集,反之则不成立。
这种区别的产生是因为,如果接口值包含指针 *T
,方法调用可以通过解引用指针来获取值;但如果接口值包含值 T
,方法调用无法安全地获取指针。(这样做将允许方法修改接口内部值的内容,而语言规范不允许这样做。)
即使在编译器可以将值的地址传递给方法的情况下,如果方法修改了该值,这些更改也将在调用者中丢失。
举个例子,如果下面的代码有效
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 }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }
人们可能误以为会看到 a, b, c
作为输出。而你可能看到的却是 c, c, c
。这是因为循环的每次迭代都使用了变量 v
的同一个实例,所以每个闭包共享这个单一变量。当闭包运行时,它打印的是 fmt.Println
执行时 v
的值,但自从 Goroutine 启动以来,v
可能已经被修改了。为了帮助在问题发生之前检测到此类问题,请运行 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 // create a new '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<Integer>
的 Java 类型在运行时会变成非泛型类型List
。这意味着,例如,在使用 Java 形式的类型反射时,无法区分List<Integer>
类型的值和List<Float>
类型的值。在 Go 中,泛型类型的反射信息包含完整的编译时类型信息。Java 使用诸如
List<? extends Number>
或List<? super Number>
的类型通配符来实现泛型协变和逆变。Go 没有这些概念,这使得 Go 中的泛型类型更简单。 -
C++
传统的 C++ 模板对类型参数不强制执行任何约束,尽管 C++20 通过 concept 支持可选约束。在 Go 中,对所有类型参数强制要求约束。C++20 concept 表达为小型代码片段,必须与类型参数一起编译。Go 约束是定义所有允许的类型参数集合的接口类型。
C++ 支持模板元编程;Go 不支持。实际上,所有 C++ 编译器都会在模板实例化时编译每个模板;如上所述,Go 可以并且确实对不同的实例化使用不同的方法。
-
Rust
Rust 中的约束版本被称为 trait bound。在 Rust 中,trait bound 与类型之间的关联必须明确定义,无论是在定义 trait bound 的 crate 中还是在定义类型的 crate 中。在 Go 中,类型参数隐式满足约束,就像 Go 类型隐式实现接口类型一样。Rust 标准库定义了标准 trait 用于比较或加法等操作;Go 标准库没有这样做,因为这些可以通过用户代码通过接口类型来表达。唯一的例外是 Go 的
comparable
预定义接口,它捕获了类型系统中无法表达的属性。 -
Python
Python 不是静态类型语言,因此可以说所有 Python 函数默认总是泛型的:它们总是可以接受任何类型的值调用,任何类型错误都在运行时检测到。
Go 为什么使用方括号表示类型参数列表?
Java 和 C++ 使用尖括号表示类型参数列表,例如 Java 的 List<Integer>
和 C++ 的 std::vector<int>
。然而,Go 不能采用这种方式,因为它会导致语法问题:在解析函数内的代码时,例如 v := F<T>
,在看到 <
时,无法确定我们是在处理实例化还是在使用 <
运算符的表达式。如果没有类型信息,这很难解决。
例如,考虑一个语句
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 从纯粹的提前(AOT)编译带来的简单性和可预测性能中受益良多。我们不愿意仅仅为了实现一个语言特性而承担 JIT 的复杂性。
-
安排为每个泛型方法发出一个慢速回退(fallback),该回退使用一个表格,其中包含针对类型参数的每种可能语言操作的函数,然后将该回退实现用于动态测试。
这种方法将使得由意外类型参数化的泛型方法比由编译时观察到的类型参数化的同一方法慢得多。这将使得性能变得不那么可预测。
-
定义泛型方法根本不能用于满足接口。
接口是 Go 编程中不可或缺的一部分。从设计角度来看,不允许泛型方法满足接口是不可接受的。
这些选择没有一个是好的,所以我们选择了“以上皆非”。
与其使用带有类型参数的方法,不如使用带有类型参数的顶级函数,或将类型参数添加到接收者类型中。
更多细节,包括更多示例,请参阅 提案。
为什么我不能对参数化类型的接收者使用更具体的类型?
泛型类型的方法声明是用包含类型参数名称的接收者编写的。也许是因为在调用点指定类型的语法相似,有些人认为这提供了一种机制,通过在接收者中指定特定类型(例如 string
)来为某些类型参数定制方法
type S[T any] struct { f T }
func (s S[string]) Add(t string) string {
return s.f + t
}
这样做会失败,因为编译器将单词 string
视为该方法中的类型参数名称。编译器错误消息会是类似“operator + not defined on s.f (variable of type string)
”的内容。这可能会令人困惑,因为 +
运算符在预声明的 string
类型上工作得很好,但是此声明已为此方法覆盖了 string
的定义,并且该运算符对那个无关的 string
版本不起作用。像这样覆盖预声明的名称是有效的,但这是一种奇怪的做法,通常是一个错误。
为什么编译器无法推断我的程序中的类型参数?
在许多情况下,程序员可以轻易看出泛型类型或函数的类型参数必须是什么,但语言不允许编译器推断它。类型推断是有意限制的,以确保永远不会对推断出的类型产生任何混淆。使用其他语言的经验表明,意外的类型推断在阅读和调试程序时可能导致相当大的混乱。总是可以通过在调用中指定要使用的显式类型参数。将来可能会支持新的推断形式,只要规则保持简单清晰。
包和测试
如何创建一个多文件包?
将包的所有源文件单独放在一个目录中。源文件可以随意引用不同文件中的项;无需前向声明或头文件。
除了被分成多个文件外,该包的编译和测试将与单文件包完全相同。
如何编写单元测试?
在与包源文件相同的目录中创建一个以 _test.go
结尾的新文件。在该文件内,import "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 和网络。它还包含对 Web 编程重要的元素,包括加密以及对 HTTP、JSON 和 XML 等标准的支持。
没有明确的标准来定义包含什么,因为很长一段时间以来,这是唯一的 Go 库。然而,现在有定义新增内容的标准。
标准库的新增内容很少,并且收录门槛很高。标准库中包含的代码承担着巨大的持续维护成本(通常由非原作者承担),受 Go 1 兼容性承诺 的约束(阻碍对 API 中任何缺陷的修复),并受 Go 发布计划 的约束,这使得 bug 修复无法快速提供给用户。
大多数新代码应该位于标准库之外,并通过 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 安装,就像通常有一个工作的 C 安装一样。如何从源码启动一个新的 Go 环境的故事在这里 这里 和 这里 都有描述。
Gc
用 Go 编写,使用递归下降解析器,并使用一个定制加载器(也用 Go 编写,但基于 Plan 9 加载器)来生成 ELF/Mach-O/PE 二进制文件。
Gccgo
编译器是一个用 C++ 编写的前端,带有递归下降解析器,并与标准的 GCC 后端耦合。一个实验性的 LLVM 后端 正在使用相同的前端。
在项目开始时,我们考虑过为 gc
使用 LLVM,但认为它太大且太慢,无法满足我们的性能目标。回想起来更重要的是,从 LLVM 开始会更难引入 Go 所需的一些 ABI 和相关更改,例如堆栈管理,而这些不是标准 C 设置的一部分。
事实证明,Go 是一种非常适合实现 Go 编译器的语言,尽管这并非其最初目标。从一开始就不是自举的,这使得 Go 的设计能够专注于其最初的用例,即网络服务器。如果我们决定 Go 应该在早期自己编译自己,我们最终可能会得到一种更偏向于编译器构造的语言,这是一个值得的目标,但不是我们最初设定的目标。
虽然 gc
有自己的实现,但本地的词法分析器和解析器在 go/parser
包中可用,还有一个本地的 类型检查器。gc
编译器使用了这些库的变体。
运行时支持是如何实现的?
同样由于引导问题,运行时代码最初主要用 C 编写(带有一小部分汇编代码),但后来已翻译成 Go(除了一些汇编部分)。Gccgo
的运行时支持使用 glibc
。gccgo
编译器使用一种称为分段堆栈的技术来实现 Goroutine,该技术得到了最近对 gold 链接器的修改的支持。Gollvm
类似地构建在相应的 LLVM 基础设施上。
我的简单程序为什么二进制文件这么大?
gc
工具链中的链接器默认创建静态链接的二进制文件。因此,所有 Go 二进制文件都包含 Go 运行时,以及支持动态类型检查、反射甚至 panic 时堆栈跟踪所需的运行时类型信息。
一个简单的 C “hello, world” 程序在 Linux 上使用 gcc 静态编译和链接后大约为 750 kB,包括一个 printf
实现。一个使用 fmt.Printf
的等效 Go 程序重达几兆字节,但这包含了更强大的运行时支持、类型和调试信息。
使用 gc
编译的 Go 程序可以通过 -ldflags=-w
标志进行链接,以禁用 DWARF 生成,从而从二进制文件中移除调试信息,但不会丢失任何其他功能。这可以显著减小二进制文件大小。
我能阻止这些关于我的未使用变量/导入的抱怨吗?
存在未使用变量可能表明存在错误,而未使用的导入只会减慢编译速度,随着程序积累代码和程序员随时间增加,这种影响可能会变得很大。出于这些原因,Go 拒绝编译包含未使用变量或导入的程序,用短期便利性换取长期构建速度和程序清晰度。
不过,在开发代码时,暂时出现这些情况是很常见的,而且在程序编译之前不得不将它们删除可能会很烦人。
有人要求提供一个编译器选项来关闭这些检查或至少将其降级为警告。然而,由于编译器选项不应影响语言的语义,并且 Go 编译器不报告警告,只报告阻止编译的错误,所以尚未添加此类选项。
不设置警告有两个原因。首先,如果值得抱怨,就值得在代码中修复。(反之,如果不值得修复,就不值得提及。)其次,让编译器生成警告会鼓励实现警告一些弱情况,这会使编译变得嘈杂,掩盖那些应该修复的真正错误。
不过,解决这种情况很简单。使用空标识符 _
可以在开发时让未使用的东西保留。
import "unused"
// This declaration marks the import as used by referencing an
// item from the package.
var _ = unused.Item // TODO: Delete before committing!
func main() {
debugData := debug.Profile()
_ = debugData // Used only during debugging.
....
}
如今,大多数 Go 程序员使用一个工具,goimports,它可以自动重写 Go 源文件以具有正确的导入,实际上消除了未使用导入的问题。该程序很容易与大多数编辑器和 IDE 连接,以便在编写 Go 源文件时自动运行。此功能也内置在 gopls
中,如 上面讨论 的那样。
我的病毒扫描软件为什么认为我的 Go 发行版或编译的二进制文件被感染了?
这是常见现象,尤其是在 Windows 机器上,几乎总是误报。商业病毒扫描程序常常被 Go 二进制文件的结构所迷惑,因为它们不像其他语言编译的文件那样常见。
如果你刚安装了 Go 发行版,系统报告它被感染,那肯定是错误的。为了真正彻底,你可以通过将下载的校验和与 下载页面 上的校验和进行比较来验证下载。
无论如何,如果你认为报告有误,请向你的病毒扫描软件供应商报告 bug。也许病毒扫描程序会随着时间学会理解 Go 程序。
性能
为什么 Go 在基准测试 X 上表现不佳?
Go 的设计目标之一是使可比较程序的性能接近 C,但在某些基准测试中表现相当差,包括 golang.org/x/exp/shootout 中的几个。最慢的取决于 Go 中没有具有可比较性能的库版本。例如,pidigits.go 依赖于多精度数学包,而 C 版本不像 Go 的版本那样使用 GMP(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
声明 a
和 b
都是指针。这更清晰、更规范。此外,:=
短声明形式表明,完整的变量声明应该与 :=
具有相同的顺序,所以
var a uint64 = 1
与
a := uint64(1)
具有相同的效果。通过为类型提供一个独立的语法(不仅仅是表达式语法),解析也得到了简化;像 func
和 chan
这样的关键字使事情清晰明了。
有关更多详细信息,请参阅关于 Go 的声明语法 的文章。
为什么没有指针运算?
安全。没有指针运算,就可以创建一种永远不会派生出非法地址并错误地成功的语言。编译器和硬件技术已经发展到使用数组索引的循环可以像使用指针运算的循环一样高效。此外,缺乏指针运算可以简化垃圾收集器的实现。
为什么 ++
和 --
是语句而不是表达式?为什么是后缀而不是前缀?
没有指针运算,前缀和后缀增量运算符的便利性值就降低了。通过将它们完全从表达式层次结构中移除,简化了表达式语法,也消除了围绕 ++
和 --
求值顺序的混乱问题(考虑 f(i++)
和 p[i] = q[++i]
)。这种简化是显著的。至于后缀还是前缀,两者都可以正常工作,但后缀版本更传统;对前缀的坚持出现在 STL 中,这是一个语言的库,而该语言的名称(C++)讽刺地包含了后缀增量。
为什么有大括号但没有分号?为什么不能把开大括号放在下一行?
Go 使用大括号进行语句分组,这是熟悉 C 家族任何语言的程序员所熟悉的语法。然而,分号是为解析器准备的,而不是为人,我们希望尽可能地消除它们。为了实现这个目标,Go 借鉴了 BCPL 的一个技巧:分隔语句的分号存在于形式语法中,但由词法分析器在任何可能作为语句结尾的行末自动注入,无需向前看。这在实践中工作得很好,但也产生了强制使用某种大括号风格的效果。例如,函数的开大括号不能单独出现在一行。
有些人认为词法分析器应该向前看,以便允许大括号放在下一行。我们不同意。由于 Go 代码旨在由 gofmt
自动格式化,因此必须选择某种风格。这种风格可能与你在 C 或 Java 中使用的不同,但 Go 是一种不同的语言,而 gofmt
的风格与其他任何风格一样好。更重要——非常重要——所有 Go 程序采用单一的、由程序强制执行的格式的好处远远超过特定风格带来的任何感知上的缺点。另请注意,Go 的风格意味着 Go 的交互式实现可以使用标准语法逐行处理,而无需特殊规则。
为什么进行垃圾回收?会不会太昂贵?
系统程序中最大的簿记工作之一是管理分配对象的生命周期。在像 C 这样手动完成的语言中,这会消耗程序员大量时间,并且常常是恶性 bug 的原因。即使在像 C++ 或 Rust 这样提供辅助机制的语言中,这些机制也可能对软件设计产生显著影响,通常会增加自身的编程开销。我们认为消除这种程序员开销至关重要,并且近几年垃圾回收技术的发展使我们相信,它可以以足够低的成本和足够低的延迟来实现,从而成为网络化系统的一种可行方法。
并发编程的许多困难根源在于对象生命周期问题:当对象在线程之间传递时,安全释放它们变得繁琐。自动垃圾回收使并发代码更容易编写得多。当然,在并发环境中实现垃圾回收本身就是一项挑战,但只应对一次而不是在每个程序中都应对有助于所有人。
最后,撇开并发不谈,垃圾回收使接口更简单,因为它们不需要指定如何在它们之间管理内存。
这并不是说像 Rust 这样的语言最近在资源管理问题上带来的新想法是误导的;我们鼓励这项工作,并很高兴看到它的发展。但 Go 采取了一种更传统的方法,通过垃圾回收,而且只有垃圾回收来处理对象的生命周期。
当前的实现是一个标记-清除收集器。如果机器是多处理器,收集器会与主程序并行运行在单独的 CPU 核心上。近年来对收集器的重大工作已将暂停时间减少到通常在毫秒以下范围,即使对于大型堆也是如此,这几乎消除了网络服务器中对垃圾回收的主要反对意见之一。工作仍在继续改进算法,进一步减少开销和延迟,并探索新的方法。Go 团队的 Rick Hudson 在 2018 年的 ISMM 主题演讲 中描述了目前的进展并提出了一些未来的方法。
关于性能方面,请记住,Go 赋予程序员对内存布局和分配的相当大的控制权,这比垃圾回收语言通常提供的要多得多。一个细心的程序员可以通过很好地使用语言来大幅减少垃圾回收开销;请参阅关于 剖析 Go 程序 的文章,其中有一个实际示例,包括 Go 剖析工具的演示。