常见问题解答 (FAQ)
起源
该项目的目的是什么?
在 Go 于 2007 年诞生时,编程世界与今天不同。生产软件通常是用 C++ 或 Java 编写的,GitHub 还不存在,大多数计算机还没有多处理器,除了 Visual Studio 和 Eclipse 之外,几乎没有 IDE 或其他高级工具可用,更不用说在互联网上免费使用。
与此同时,我们对使用我们正在使用的语言及其相关构建系统构建大型软件项目所需的过度的复杂性感到沮丧。自 C、C++ 和 Java 等语言首次开发以来,计算机的速度已经变得快得多,但编程本身的进步却远远不如。此外,很明显,多处理器正在变得普遍,但大多数语言在有效且安全地对其进行编程方面几乎没有提供任何帮助。
我们决定退后一步,考虑随着技术发展,未来几年哪些主要问题将主导软件工程,以及一种新语言如何帮助解决这些问题。例如,多核 CPU 的兴起表明,一种语言应该为某种并发或并行性提供一流的支持。为了在大规模并发程序中使资源管理变得易于处理,需要垃圾收集,或者至少需要某种安全的自动内存管理。
这些考虑因素导致了 一系列讨论,从中产生了 Go,首先是一套想法和愿望,然后是一种语言。一个总体的目标是 Go 能够通过启用工具、自动化诸如代码格式之类的平凡任务以及消除处理大型代码库的障碍来帮助工作的程序员。
在文章 Go at Google: Language Design in the Service of Software Engineering 中,您可以找到对 Go 目标及其如何实现(或至少是接近实现)的更为详尽的描述。
该项目的历史是什么?
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 的成功远远超出了我们的预期。
吉祥物 gopher 的起源是什么?
吉祥物和徽标由 Renée French 设计,她还设计了 Glenda,Plan 9 的兔子。一篇关于 gopher 的 博客文章 解释了它是如何从她几年前为 WFMU T 恤设计使用的一个设计衍生出来的。徽标和吉祥物受 知识共享署名 4.0 许可证的约束。
gopher 有一个 模型表,说明了它的特征以及如何正确地表现它们。模型表首次出现在 2016 年 Gophercon 上 Renée 的 演讲 中。它具有独特的特征;它是Go gopher,不仅仅是任何普通的地鼠。
该语言叫做 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 at 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 能够在不牺牲生产力的前提下具有表现力,同时又易于理解。
另一个重要的原则是保持概念正交。方法可以为任何类型实现;结构表示数据,而接口表示抽象;等等。正交性使更容易理解当事物组合在一起时会发生什么。
用法
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 代码中不存在的风险,因此要谨慎操作。
如果您确实需要使用 C 与 Go 语言,如何进行取决于 Go 编译器的实现。“标准”编译器是 Google Go 团队支持的 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 拥有一个广泛的运行时库,通常称为 *运行时*,它是每个 Go 程序的一部分。此库实现了垃圾收集、并发、堆栈管理以及 Go 语言的其他关键功能。尽管它对于语言来说更为核心,但 Go 的运行时类似于 libc
,即 C 库。
但是,重要的是要了解,Go 的运行时不包含虚拟机,例如 Java 运行时提供的虚拟机。Go 程序在运行时被提前编译为本地机器代码(或 JavaScript 或 WebAssembly,针对某些变体实现)。因此,尽管该术语通常用于描述程序运行的虚拟环境,但在 Go 中,“运行时”一词仅仅是指提供关键语言服务的库的名称。
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 还提供了一些内置函数来发出信号并从真正异常的条件中恢复。恢复机制仅作为函数状态在错误后被拆除的一部分执行,这足以处理灾难,但不需要额外的控制结构,并且如果使用得当,可以产生干净的错误处理代码。
有关详细信息,请参阅 延迟、恐慌和恢复 文章。此外,错误是值 博客文章描述了一种在 Go 中干净地处理错误的方法,它表明,由于错误只是值,因此可以在错误处理中部署 Go 的全部功能。
为什么 Go 没有断言?
Go 不提供断言。它们无疑很方便,但我们的经验表明,程序员使用它们作为一种拐杖来避免考虑适当的错误处理和报告。适当的错误处理意味着服务器在发生非致命错误后继续运行,而不是崩溃。适当的错误报告意味着错误是直接且重点突出的,使程序员无需解释大量的崩溃跟踪。精确的错误在程序员看到错误时不熟悉代码时尤其重要。
我们理解这是一个争论点。Go 语言和库中的许多东西与现代实践不同,仅仅是因为我们觉得有时值得尝试不同的方法。
为什么在 CSP 的思想上构建并发性?
并发和多线程编程随着时间的推移而获得了难以驾驭的名声。我们认为这部分是由于诸如 pthreads 这样的复杂设计,以及部分是由于过度强调低级细节,例如互斥体、条件变量和内存屏障。更高级别的接口可以实现更简单的代码,即使底层仍然存在互斥体等等。
为并发提供高级语言支持的最成功模型之一来自 Hoare 的通信顺序进程 (CSP)。Occam 和 Erlang 是两种源于 CSP 的知名语言。Go 的并发原语源于家族树的不同部分,该部分的主要贡献是将通道作为一等公民的强大概念。在几种早期语言中的经验表明,CSP 模型很好地契合了过程化语言框架。
为什么使用 goroutine 而不是线程?
goroutine 是使并发易于使用的部分。这个想法已经存在了一段时间,就是将独立执行的函数(协程)多路复用到一组线程上。当协程被阻塞时,例如通过调用阻塞系统调用,运行时会自动将同一操作系统线程上的其他协程移动到另一个可运行的线程,这样它们就不会被阻塞。程序员看不到这些,这也是重点。结果是我们称为 goroutine 的东西可能非常便宜:它们除了堆栈的内存以外几乎没有开销,堆栈的内存只有几 KB。
为了使堆栈变小,Go 的运行时使用可调整大小的有界堆栈。一个新创建的 goroutine 会获得几 KB,这几乎总是足够的。当它不够时,运行时会自动增加(和缩小)用于存储堆栈的内存,允许许多 goroutine 在少量内存中生存。CPU 开销平均每函数调用大约 3 个廉价指令。在同一个地址空间中创建数十万个 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 at Google: Language Design in the Service of Software Engineering 解释了 Go 的起源及其设计背后的动机。
类型
Go 是一种面向对象的语言吗?
是,也不是。虽然 Go 具有类型和方法,并允许面向对象的编程风格,但没有类型层次结构。Go 中的“接口”概念提供了一种不同的方法,我们认为它易于使用,并且在某些方面更通用。还有一些方法可以将类型嵌入其他类型,以提供类似于——但并不相同——子类化的功能。此外,Go 中的方法比 C++ 或 Java 中更通用:它们可以为任何类型的数据定义,甚至包括内置类型,如普通的“未装箱”整数。它们不限于结构体(类)。
此外,缺乏类型层次结构使 Go 中的“对象”感觉比 C++ 或 Java 等语言中的对象轻得多。
我如何获得方法的动态分派?
唯一能够动态分派方法的方式是通过接口。结构体或任何其他具体类型的上的方法始终是静态解析的。
为什么没有类型继承?
面向对象编程,至少在最著名的语言中,涉及太多关于类型之间关系的讨论,而这些关系通常可以自动推断出来。Go 采用了不同的方法。
Go 不需要程序员提前声明两种类型之间存在关系,而是在 Go 中,类型会自动满足任何指定其方法子集的接口。除了减少簿记工作外,这种方法还具有实际优势。类型可以同时满足多个接口,而无需传统多重继承的复杂性。接口可以非常轻量级——一个具有一个甚至零个方法的接口可以表达一个有用的概念。如果出现新的想法或用于测试,可以在事后添加接口——无需注释原始类型。由于类型和接口之间没有明确的关系,因此没有类型层次结构需要管理或讨论。
可以使用这些想法来构建类似于类型安全的 Unix 管道的结构。例如,看看 fmt.Fprintf
如何能够将格式化的内容打印到任何输出,而不仅仅是文件,或者 bufio
包如何与文件 I/O 完全分离,或者 image
包如何生成压缩图像文件。所有这些想法都源于一个表示单个方法(Write
)的单一接口(io.Writer
)。而这仅仅是触及了表面。Go 的接口对程序的结构方式有深远的影响。
这种隐式类型的依赖关系需要一些时间来适应,但它也是 Go 最有效率的东西之一。
为什么 len
是一个函数而不是一个方法?
我们对此问题进行了辩论,但最终决定将 len
及其同类函数实现为函数在实践中是可以的,而且不会使基本类型的接口(在 Go 类型意义上)问题复杂化。
为什么 Go 不支持方法和运算符的重载?
如果方法分派不需要进行类型匹配,则可以简化方法分派。对其他语言的经验告诉我们,拥有具有相同名称但不同签名的多种方法有时很有用,但它在实践中也可能令人困惑且脆弱。只根据名称进行匹配,并要求类型一致是 Go 类型系统中一项主要的简化决策。
关于运算符重载,它似乎更像是一种便利,而不是绝对的必要性。同样,没有它会更简单。
为什么 Go 没有“实现”声明?
Go 类型通过实现该接口的方法来实现接口,仅此而已。此属性允许定义和使用接口,而无需修改现有代码。它实现了一种 结构化类型系统,该系统促进了关注点分离并改善了代码重用,并且使在代码开发过程中出现的模式上构建变得更加容易。接口的语义是 Go 灵活、轻量级感觉的主要原因之一。
有关更多详细信息,请参阅 有关类型继承的问题。
我如何保证我的类型满足一个接口?
你可以让编译器检查类型 T
是否实现了接口 I
,方法是尝试使用 T
的零值或 T
的指针进行赋值,具体情况视情况而定。
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
的参数;这是程序员的责任,如类型 T2
所示,该类型确实实现了 Equaler
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
均未设置时为 nil
,(T=nil
, V
未设置)。特别是,nil
接口始终包含一个 nil
类型。如果我们在接口值内存储类型 *int
的 nil
指针,则内部类型将为 *int
,无论指针的值如何:(T=*int
, V=nil
)。因此,即使内部的指针值 V
为 nil
,此类接口值也将非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
,因此返回值是一个 error
接口值,保存 (T=*MyError
, V=nil
)。这意味着,如果调用者将返回的错误与 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 语言中的那样没有无标记的联合?
未标记的联合会违反 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
和int64
是不同的类型,即使int
是 64 位类型。int
类型是通用的;如果您关心整数占用的位数,Go 鼓励您明确表示。
Go 中的常量是如何工作的?
尽管 Go 对不同数值类型的变量之间的转换很严格,但语言中的常量要灵活得多。诸如23
、3.14159
和math.Pi
之类的字面量常量占据一种理想的数字空间,具有任意精度,没有溢出或下溢。例如,math.Pi
的值在源代码中指定为 63 位小数,并且涉及该值的常量表达式保持超出float64
所能容纳的精度。只有当常量或常量表达式被赋给变量——程序中的内存位置——时,它才会成为具有通常浮点属性和精度的“计算机”数字。
此外,由于它们只是数字,而不是类型化的值,因此 Go 中的常量可以比变量更自由地使用,从而缓解了围绕严格转换规则的一些尴尬。人们可以写出像
sqrt2 := math.Sqrt(2)
这样的表达式,而不会引起编译器的抱怨,因为理想数字2
可以安全准确地转换为float64
以调用math.Sqrt
。
一篇题为常量的博客文章更详细地探讨了这个主题。
为什么内置了映射?
与字符串相同的原因:它们是如此强大和重要的数据结构,以至于提供一个具有语法支持的出色实现使编程更加愉快。我们相信 Go 中的映射实现足够强大,足以满足大多数用途。如果特定应用程序可以从自定义实现中受益,可以编写一个,但它在语法上不会那么方便;这似乎是一个合理的权衡。
为什么映射不允许切片作为键?
映射查找需要一个相等运算符,而切片没有实现。它们没有实现相等,因为在这些类型上相等没有明确定义;有涉及浅层比较与深层比较、指针比较与值比较、如何处理递归类型等等的多个考虑因素。我们可能会重新审视这个问题——实现切片的相等不会使任何现有程序失效——但在没有明确了解切片的相等应该意味着什么的情况下,暂时将其排除在外更简单。
结构体和数组定义了相等性,因此它们可以作为映射键使用。
为什么映射、切片和通道是引用,而数组是值?
关于这个主题有很多历史。在早期,映射和通道在语法上是指针,并且不可能声明或使用非指针实例。此外,我们还努力解决数组应该如何工作。最终我们决定,严格区分指针和值会使语言更难使用。将这些类型更改为充当与关联的共享数据结构的引用的操作解决了这些问题。这种变化为语言添加了一些令人遗憾的复杂性,但对可用性产生了重大影响:Go 在引入时成为一种更具生产力、更舒适的语言。
编写代码
库是如何记录的?
为了从命令行访问文档,go工具有一个doc子命令,它为声明、文件、包等等提供文档的文本界面。
全局包发现页面pkg.go.dev/pkg/运行一个服务器,从网络上的任何地方的 Go 源代码中提取包文档,并将其作为包含指向声明和相关元素的链接的 HTML 提供。这是了解现有 Go 库的最简单方法。
在项目的早期,有一个类似的程序godoc
,它也可以运行来提取本地机器上文件的文档;pkg.go.dev/pkg/本质上是它的后代。另一个后代是pkgsite
命令,它与godoc
一样,可以在本地运行,尽管它还没有集成到go
doc
显示的结果中。
是否有 Go 编程风格指南?
没有明确的风格指南,尽管肯定有一种可识别的“Go 风格”。
Go 已经建立了约定来指导围绕命名、布局和文件组织的决策。文档有效使用 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
也可以配置为使用 SSH 代替 HTTPS,用于匹配给定前缀的 URL。例如,要对所有 GitHub 访问使用 SSH,请将以下几行添加到您的~/.gitconfig
中
[url "ssh://[email protected]/"]
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/[email protected]
有关入门更多信息,请参阅教程:创建模块。
有关使用模块管理依赖项的指南,请参阅开发模块。
模块中的包在发展过程中应该保持向后兼容性,遵循导入兼容性规则
如果旧包和新包具有相同的导入路径,
新包必须向后兼容旧包。
Go 1 兼容性指南在这里是一个很好的参考:不要删除导出的名称,鼓励标记的复合字面量,等等。如果需要不同的功能,请添加一个新名称,而不是更改旧名称。
模块使用语义版本控制和语义导入版本控制来对这一点进行编码。如果需要打破兼容性,请在新的主版本中发布模块。主版本 2 及更高版本的模块需要主版本后缀作为其路径的一部分(如/v2
)。这保留了导入兼容性规则:模块的不同主版本中的包具有不同的路径。
指针和分配
何时按值传递函数参数?
与 C 家族的所有语言一样,Go 中的一切都是按值传递的。也就是说,函数始终获取被传递事物的副本,就好像存在一个将值分配给参数的赋值语句一样。例如,将一个 int
值传递给函数会创建一个 int
的副本,而传递一个指针值会创建一个指针的副本,但不会复制它指向的数据。(有关这如何影响方法接收者的讨论,请参阅后面的部分。)
映射和切片值的行为类似于指针:它们是包含指向底层映射或切片数据的指针的描述符。复制映射或切片值不会复制它指向的数据。复制接口值会创建一个存储在接口值中的事物的副本。如果接口值包含一个结构体,则复制接口值会创建一个结构体的副本。如果接口值包含一个指针,则复制接口值会创建一个指针的副本,但同样不会复制它指向的数据。
请注意,此讨论是关于操作的语义。实际实现可能会应用优化来避免复制,只要优化不会改变语义即可。
我什么时候应该使用指向接口的指针?
几乎从不。指向接口值的指针仅在涉及隐藏接口值的类型以进行延迟评估的罕见、棘手的情况下出现。
将指向接口值的指针传递给期望接口的函数是一个常见错误。编译器会抱怨此错误,但这种情况仍然令人困惑,因为有时需要指针来满足接口。关键在于,尽管指向具体类型的指针可以满足接口,但除了一个例外,指向接口的指针永远无法满足接口。
考虑变量声明,
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
) 的行为与它是方法的参数完全相同。那么,是否将接收器定义为值或指针与函数参数应该是值还是指针是同一个问题。有几个考虑因素。
首先,也是最重要的是,方法是否需要修改接收器?如果是,接收器必须是一个指针。(切片和映射充当引用,所以它们的故事有点微妙,但例如要更改方法中切片的长度,接收器仍然必须是一个指针。)在上面的例子中,如果 pointerMethod
修改了 s
的字段,调用者将看到这些更改,但 valueMethod
是使用调用者参数的副本调用的(这是传递值的定义),所以它所做的更改对于调用者来说是不可见的。
顺便说一句,在 Java 中,方法接收器一直都是指针,尽管它们的指针性质被掩盖了(最近的发展正在将值接收器引入 Java)。Go 中的值接收器是不寻常的。
其次是效率的考虑。如果接收器很大,例如一个大的 struct
,使用指针接收器可能会更便宜。
接下来是一致性。如果类型的某些方法必须具有指针接收器,那么其余方法也应该具有指针接收器,以便无论如何使用类型,方法集都一致。有关详细信息,请参阅方法集 部分。
对于基本类型、切片和小 struct
这样的类型,值接收器非常便宜,所以除非方法的语义要求指针,否则值接收器是高效且清晰的。
new
和 make
有什么区别?
简而言之:new
分配内存,而 make
初始化切片、映射和通道类型。
有关更多详细信息,请参阅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 通过其协程和通道支持这种方法。例如,您可以构建程序,以便一次只有一个协程负责特定数据。这种方法由原始的Go 格言总结,
不要通过共享内存进行通信。相反,通过通信共享内存。
有关此概念的详细讨论,请参阅通过通信共享内存 代码示例及其相关文章。
大型并发程序很可能借鉴这两个工具包。
为什么我的程序使用更多 CPU 不会运行得更快?
程序使用更多 CPU 是否运行得更快取决于它要解决的问题。Go 语言提供了并发原语,例如协程和通道,但只有当底层问题本质上是并行时,并发才能实现并行性。本质上是顺序的问题无法通过添加更多 CPU 来加速,而那些可以分解成可以并行执行的部分的问题可以加速,有时甚至可以显著加速。
有时添加更多 CPU 会使程序运行得更慢。从实际角度来看,花费更多时间进行同步或通信而不是进行有用计算的程序在使用多个操作系统线程时可能会遇到性能下降。这是因为线程之间传递数据涉及切换上下文,这有很大的开销,而这种开销会随着 CPU 的增加而增加。例如,Go 规范中的素数筛选示例 没有明显的并行性,尽管它启动了许多协程;增加线程(CPU)的数量更有可能使其运行得更慢而不是更快。
有关此主题的更多详细信息,请参阅名为并发不是并行 的演讲。
我如何控制 CPU 的数量?
GOMAXPROCS
shell 环境变量控制着同时可用于执行协程的 CPU 数量,其默认值为可用的 CPU 内核数量。因此,具有并行执行潜力的程序应该在多 CPU 机器上默认情况下实现并行性。要更改要使用的并行 CPU 数量,请设置环境变量或使用运行时包中的同名函数 来配置运行时支持以利用不同的线程数量。将其设置为 1 会消除真正并行的可能性,迫使独立的协程轮流执行。
运行时可以分配比 GOMAXPROCS
值更多的线程来服务多个未完成的 I/O 请求。GOMAXPROCS
仅影响一次可以实际执行的协程数量;任意数量的协程可能被阻塞在系统调用中。
Go 的协程调度器在平衡协程和线程方面做得很好,甚至可以抢占协程的执行以确保同一线程上的其他协程不会被饿死。但是,它并不完美。如果您遇到性能问题,在每个应用程序的基础上设置 GOMAXPROCS
可能会有所帮助。
为什么没有协程 ID?
协程没有名称;它们只是匿名工作者。它们不会向程序员公开任何唯一的标识符、名称或数据结构。有些人对此感到惊讶,他们期望 go
语句返回一些可以用来访问和控制协程的项目。
协程匿名化的根本原因是,在编写并发代码时,可以完全使用 Go 语言。相比之下,在命名线程和协程时产生的使用模式会限制使用它们的库可以做什么。
这是一个关于困难的例子。一旦命名一个协程并围绕它构建一个模型,它就变得特殊起来,人们就会倾向于将所有计算与该协程相关联,而忽略了使用多个(可能共享的)协程进行处理的可能性。如果 net/http
包将每个请求的状态与一个协程相关联,那么客户端在服务请求时将无法使用更多协程。
此外,使用诸如图形系统库之类的库的经验表明,当在并发语言中部署时,这种方法是多么笨拙和有限,因为这些库要求所有处理都在“主线程”上执行。特殊线程或协程的存在迫使程序员扭曲程序,以避免因无意中在错误线程上操作而导致的崩溃和其他问题。
对于确实需要特殊协程的情况,语言提供了诸如通道之类的功能,这些功能可以以灵活的方式与之交互。
函数和方法
为什么 T 和 *T 具有不同的方法集?
如 Go 规范 所述,类型 T
的方法集包含所有接收器类型为 T
的方法,而对应指针类型 *T
的方法集包含所有接收器为 *T
或 T
的方法。这意味着 *T
的方法集包含 T
的方法集,但反之则不然。
这种区别的产生是因为,如果接口值包含指针 *T
,方法调用可以通过解除指针引用来获取值,但如果接口值包含值 T
,则方法调用没有安全的方法来获取指针。(这样做将允许方法修改接口内部值的內容,这在语言规范中是不允许的。)
即使在编译器可以获取值的地址以传递给方法的情况下,如果方法修改了值,更改也会在调用者中丢失。
例如,如果下面的代码有效
var buf bytes.Buffer
io.Copy(buf, os.Stdin)
它将把标准输入复制到 buf
的副本 中,而不是复制到 buf
本身中。这几乎从来不是期望的行为,因此在语言中被禁止。
作为协程运行的闭包会发生什么?
由于循环变量的工作方式,在 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
的同一个实例,因此每个闭包都共享该单个变量。当闭包运行时,它会打印 v
在执行 fmt.Println
时时的值,但 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 通过 概念 支持可选约束。在 Go 中,约束对于所有类型参数都是强制性的。C++20 概念以必须与类型参数一起编译的小代码片段表示。Go 约束是定义所有允许的类型参数集的接口类型。
C++ 支持模板元编程;Go 不支持。实际上,所有 C++ 编译器都会在实例化模板的地方编译每个模板;如上所述,Go 可以并且确实对不同的实例化使用不同的方法。
-
Rust
Rust 版本的约束称为特质边界。在 Rust 中,必须在定义特质边界或定义类型的 crate 中显式定义特质边界与类型之间的关联。在 Go 中,类型参数隐式地满足约束,就像 Go 类型隐式地实现接口类型一样。Rust 标准库为比较或加法等操作定义了标准特质;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 从纯粹的提前编译带来的简单性和可预测的性能中受益匪浅。我们不愿意为了实现一种语言特性而承担 JIT 的复杂性。
-
安排为每个使用类型参数的泛型方法发出一个缓慢的回退,该方法为类型参数上的每个可能的语言操作使用一个函数表,然后对动态测试使用该回退实现。
这种方法将使以意外类型为参数的泛型方法比以编译时观察到的类型为参数的相同方法慢得多。这将使性能难以预测。
-
定义泛型方法根本不能用于满足接口。
接口是 Go 编程中必不可少的一部分。从设计角度来看,不允许泛型方法满足接口是不可接受的。
这些选择都不好,所以我们选择了“以上都不是”。
不要使用带类型参数的方法,而是使用带类型参数的顶级函数,或者将类型参数添加到接收器类型。
有关更多详细信息,包括更多示例,请参阅提案。
为什么我不能对参数化类型的接收器使用更具体的类型?
泛型类型的 method 声明是用包含类型参数名称的接收器编写的。也许是因为指定调用站点类型语法的相似性,有些人认为这提供了一种机制,可以通过在接收器中命名一个特定类型(例如 `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 程序所需的 key 功能,例如格式化 I/O 和网络。它还包含对 Web 编程很重要的元素,包括加密和对 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 安装,就像通常使用可以工作的 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”程序,使用 gcc 在 Linux 上静态编译和链接,大约为 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 发行版,并且系统报告它被感染了,那肯定是个错误。为了真正彻底,你可以通过将校验和与 下载页面上的校验和进行比较来验证下载。
无论如何,如果你认为报告有误,请向你的病毒扫描软件供应商报告错误。也许随着时间的推移,病毒扫描程序可以学会理解 Go 程序。
性能
为什么 Go 在基准测试 X 上表现不佳?
Go 的设计目标之一是为类似的程序接近 C 的性能,但在某些基准测试中,它的表现相当差,包括 golang.org/x/exp/shootout 中的几个。最慢的基准测试依赖于 Go 中没有提供性能相当版本的库。例如,pidigits.go 依赖于一个多精度数学包,而 C 版本(与 Go 版本不同)使用 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 中出现的,STL 是一个库,用于一种语言,其名称具有讽刺意味,包含一个后缀递增。
为什么有花括号但没有分号?为什么我不能将左花括号放在下一行?
Go 使用花括号用于语句分组,这是一种熟悉 C 家族中任何语言的程序员的语法。然而,分号是为解析器设计的,而不是为人们设计的,我们希望尽可能地消除它们。为了实现这一目标,Go 从 BCPL 中借用了一个技巧:用于分隔语句的分号在正式语法中,但在任何可能成为语句结尾的行的末尾,由词法分析器在没有前瞻的情况下自动注入。这在实践中工作得很好,但具有强制使用花括号风格的效果。例如,函数的左花括号不能单独出现在一行上。
有些人认为词法分析器应该执行前瞻,以允许花括号放在下一行。我们不同意。由于 Go 代码应该通过 gofmt
自动格式化,因此必须选择某种风格。这种风格可能与你在 C 或 Java 中使用的风格不同,但 Go 是一种不同的语言,而gofmt
的风格与其他任何风格一样好。更重要的是——更重要的是——对所有 Go 程序使用单一、程序化强制格式的优势大大超过了对特定风格的任何感知缺点。还要注意,Go 的风格意味着 Go 的交互式实现可以使用标准语法逐行,无需特殊规则。
为什么进行垃圾收集?它不会太贵吗?
系统程序中最大的簿记来源之一是管理已分配对象的生存期。在像 C 这样的语言中,它是手动完成的,它会消耗大量的程序员时间,而且通常是造成恶性错误的原因。即使在像 C++ 或 Rust 这样的提供辅助机制的语言中,这些机制也会对软件的设计产生重大影响,通常会增加自身的编程开销。我们认为消除此类程序员开销至关重要,近年来垃圾收集技术的进步让我们相信,它可以以足够低的成本和足够低的延迟实现,从而成为一种可行的网络系统方法。
并发编程的大部分难度都源于对象生存期问题:当对象在线程之间传递时,保证它们安全释放变得很麻烦。自动垃圾收集使并发代码更容易编写。当然,在并发环境中实现垃圾收集本身就是一个挑战,但一次性解决它,而不是在每个程序中都解决它,可以帮助每个人。
最后,除了并发之外,垃圾收集使接口更简单,因为它们不需要指定如何在接口之间管理内存。
这并不是说 Rust 等语言中最近在管理资源方面引入的新思想是错误的;我们鼓励这项工作,并且很高兴看到它如何发展。但是 Go 通过垃圾收集来解决对象生存期,并且只通过垃圾收集来解决对象生存期,从而采用了更传统的方法。
当前实现采用标记-清除收集器。如果机器是多处理器,收集器将在单独的 CPU 内核上与主程序并行运行。近年来,收集器上的主要工作已将暂停时间缩短到毫秒以下的范围,即使对于大型堆也是如此,这几乎消除了网络服务器中对垃圾收集的主要反对意见之一。工作仍在继续改进算法、进一步降低开销和延迟,并探索新的方法。Rick Hudson 在 2018 年的 ISMM 主题演讲 中描述了到目前为止取得的进展,并提出了一些未来的方法。
关于性能,请记住,Go 为程序员提供了对内存布局和分配的相当大的控制权,远超垃圾收集语言的典型水平。细心的程序员可以通过充分利用该语言来显着降低垃圾收集开销;请参阅关于 分析 Go 程序 的文章,其中包含一个操作示例,包括 Go 分析工具的演示。