常见问题 (FAQ)
起源
这个项目的目的是什么?
Go 语言在 2007 年诞生时,编程世界与今天截然不同。生产软件通常用 C++ 或 Java 编写,GitHub 尚不存在,大多数计算机尚未多处理器化,除了 Visual Studio 和 Eclipse 之外,几乎没有其他 IDE 或高级工具可用,更不用说在互联网上免费获得。
与此同时,我们对使用现有语言及其关联的构建系统构建大型软件项目所需的过度复杂性感到沮丧。自 C、C++ 和 Java 等语言首次开发以来,计算机的速度已经大大加快,但编程行为本身并没有取得同样大的进步。此外,很明显多处理器正在普及,但大多数语言在高效安全地编程方面提供的帮助很少。
我们决定退一步思考,随着技术的发展,未来几年主导软件工程的主要问题是什么,以及一门新语言如何帮助解决这些问题。例如,多核 CPU 的兴起表明语言应该为某种并发或并行提供一流的支持。为了在大规模并发程序中使资源管理易于处理,需要垃圾回收,或者至少某种安全的自动内存管理。
这些考虑导致了一系列讨论,Go 语言由此而生,首先是一组想法和期望,然后是一种语言。一个首要目标是 Go 语言通过启用工具、自动化代码格式化等日常任务以及消除处理大型代码库的障碍,更好地帮助工作程序员。
关于 Go 语言目标的更详细描述以及它们如何实现或至少接近实现,请参阅文章《Google 的 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 程序员——Gopher——而且每天都在增加。Go 的成功远远超出了我们的预期。
Gopher 吉祥物的起源是什么?
吉祥物和标志由Renée French设计,她也设计了 Plan 9 的兔子Glenda。一篇关于 Gopher 的博客文章解释了它是如何从她多年前为WFMU T 恤设计所使用的一个派生出来的。标志和吉祥物受知识共享署名 4.0 许可协议的保护。
Gopher 有一个模型图,说明了它的特征以及如何正确地表现它们。模型图首次在 Renée 于 2016 年 Gophercon 上的一次演讲中展示。它具有独特的特征;它是 Go Gopher,而不是任何一只普通的 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 的目标是 快速:在单台计算机上构建一个大型可执行文件最多只需几秒钟。实现这些目标促使我们重新思考现有语言中的一些编程方法,从而产生了:一种组合而非分层的类型系统;对并发和垃圾回收的支持;严格的依赖规范;等等。这些不能通过库或工具很好地处理;因此需要一门新语言。
文章《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 有一个庞大的运行时库,通常简称为 运行时,它是每个 Go 程序的一部分。这个库实现了垃圾回收、并发、栈管理以及 Go 语言的其他关键特性。虽然它对语言更核心,但 Go 的运行时类似于 C 库 libc
。
然而,重要的是要理解,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 还有一些内置函数可以发出信号并从真正异常情况中恢复。恢复机制仅作为函数在错误后状态被拆除的一部分执行,这足以处理灾难,但不需要额外的控制结构,并且如果使用得当,可以产生清晰的错误处理代码。
有关详细信息,请参阅《Defer、Panic 和 Recover》一文。此外,错误是值这篇博客文章描述了一种在 Go 中干净处理错误的方法,通过演示由于错误只是值,Go 的全部功能可以用于错误处理。
Go 为什么没有断言?
Go 不提供断言。它们无疑很方便,但我们的经验是程序员将它们用作拐杖,以避免考虑正确的错误处理和报告。正确的错误处理意味着服务器在非致命错误后继续运行而不是崩溃。正确的错误报告意味着错误是直接而切中要害的,使程序员不必解释大量的崩溃跟踪。当看到错误的程序员不熟悉代码时,精确的错误尤为重要。
我们理解这是一个争议点。Go 语言和库中的许多内容都与现代实践不同,仅仅是因为我们认为有时尝试不同的方法是值得的。
为什么要基于 CSP 的思想构建并发?
并发和多线程编程随着时间的推移获得了难以掌握的声誉。我们认为这部分是由于复杂的设计,如pthreads,部分是由于过度强调互斥锁、条件变量和内存屏障等低级细节。更高级别的接口可以实现更简单的代码,即使底层仍然存在互斥锁等。
提供高级并发语言支持最成功的模型之一来自 Hoare 的通信顺序进程(CSP)。Occam 和 Erlang 是两种源于 CSP 的著名语言。Go 的并发原语来自家族树的不同部分,其主要贡献是作为一流对象的通道的强大概念。使用几种早期语言的经验表明,CSP 模型非常适合过程语言框架。
为什么是 Goroutine 而不是线程?
Goroutine 是使并发易于使用的一部分。这个想法已经存在了一段时间,即将独立执行的函数——协程——复用到一组线程上。当协程阻塞时,例如通过调用阻塞系统调用,运行时会自动将同一操作系统线程上的其他协程移动到另一个可运行的线程上,这样它们就不会被阻塞。程序员看不到这一切,这就是重点。我们称之为 Goroutine 的结果可以非常便宜:它们除了堆栈的内存(只有几千字节)之外,几乎没有额外的开销。
为了使堆栈小,Go 的运行时使用可调整大小的、有界堆栈。一个新创建的 Goroutine 会获得几千字节,这几乎总是足够的。当不够时,运行时会自动增长(和收缩)用于存储堆栈的内存,从而允许许多 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 的设计目标。文章《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
,方法是尝试使用 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
的参数;这是程序员的责任,如实现了 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
都未设置时才为 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
,因此返回值为一个持有 (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 那样的未标记联合体?
未标记联合会违反 Go 的内存安全保证。
Go 为什么没有变体类型?
变体类型,也称为代数类型,提供了一种指定值可能采用一组其他类型中某一种(但仅限于这些类型)的方式。系统编程中的一个常见示例是指定错误是网络错误、安全错误还是应用程序错误,并允许调用者通过检查错误的类型来区分问题的来源。另一个示例是语法树,其中每个节点可以是不同的类型:声明、语句、赋值等。
我们考虑过将变体类型添加到 Go 中,但经过讨论后决定将其排除,因为它们与接口以令人困惑的方式重叠。如果变体类型的元素本身是接口,会发生什么?
此外,变体类型所解决的一些问题已经由语言涵盖。错误示例使用接口值来保存错误和类型 switch 来区分情况很容易表达。语法树示例也可以实现,尽管不够优雅。
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
。
一篇题为《常量》的博客文章更详细地探讨了此主题。
为什么 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 和 slice 值表现得像指针:它们是描述符,包含指向底层 map 或 slice 数据的指针。复制 map 或 slice 值不会复制它所指向的数据。复制接口值会复制存储在接口值中的内容。如果接口值包含一个结构体,复制接口值会复制该结构体。如果接口值包含一个指针,复制接口值会复制该指针,但同样不会复制它所指向的数据。
请注意,此讨论是关于操作的语义。实际实现可能会应用优化以避免复制,只要这些优化不改变语义。
我什么时候应该使用指向接口的指针?
几乎从不。指向接口值的指针仅在涉及为延迟评估而伪装接口值类型的罕见、棘手情况下才会出现。
将指向接口值的指针传递给期望接口的函数是一个常见的错误。编译器会抱怨这个错误,但情况仍然可能令人困惑,因为有时需要指针来满足接口。关键是,尽管指向具体类型的指针可以满足接口,但除了一个例外,指向接口的指针永远不能满足接口。
考虑变量声明,
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 通过其 goroutine 和通道支持这种方法。例如,您可以构建程序,以便在任何时候只有一个 goroutine 负责特定数据。这种方法由最初的Go 箴言概括:
不要通过共享内存来通信。相反,通过通信来共享内存。
有关此概念的详细讨论,请参阅《通过通信共享内存》的代码演练及其相关文章。
大型并发程序可能会借鉴这两种工具包。
为什么我的程序在更多 CPU 的情况下没有运行得更快?
程序在更多 CPU 的情况下是否运行得更快,取决于它所解决的问题。Go 语言提供了并发原语,例如 goroutine 和通道,但并发只有在底层问题本质上是并行时才能实现并行。本质上是顺序的问题无法通过增加更多 CPU 来加速,而那些可以分解成可以并行执行的部分的问题则可以加速,有时甚至可以显著加速。
有时增加更多的 CPU 会减慢程序的速度。在实际操作中,花费更多时间同步或通信而不是进行有用计算的程序在使用多个操作系统线程时可能会出现性能下降。这是因为线程之间传递数据涉及上下文切换,这会产生显著的开销,而且这种开销会随着 CPU 的增加而增加。例如,Go 规范中的素数筛示例虽然启动了许多 goroutine,但没有显著的并行性;增加线程(CPU)数量更有可能使其变慢而不是加快。
有关此主题的更多详细信息,请参阅题为《并发不是并行》的演讲。
如何控制 CPU 数量?
可同时执行 Goroutine 的 CPU 数量由 GOMAXPROCS
shell 环境变量控制,其默认值是可用的 CPU 核心数。因此,具有并行执行潜力的程序在多 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
的值,但 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 // create a new 'v'. go func() { fmt.Println(v) done <- true }() }
这种语言行为,即不为每次迭代定义一个新变量,事后被认为是一个错误,并在 Go 1.22 中得到了解决,Go 1.22 确实为每次迭代创建了一个新变量,消除了这个问题。
控制流
Go 为什么没有 ?:
运算符?
Go 中没有三元测试操作。您可以使用以下方法实现相同的结果:
if expr {
n = trueVal
} else {
n = falseVal
}
Go 中没有 ?:
的原因是语言设计者发现该操作经常被用来创建难以理解的复杂表达式。if-else
形式虽然更长,但无疑更清晰。一种语言只需要一种条件控制流结构。
类型参数
Go 为什么有类型参数?
类型参数允许所谓的泛型编程,其中函数和数据结构是根据稍后在使用这些函数和数据结构时指定的类型来定义的。例如,它们使得编写一个函数成为可能,该函数返回任何有序类型的两个值中的最小值,而无需为每种可能的类型编写单独的版本。有关更深入的解释和示例,请参阅博文 Why Generics?。
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 中还是在定义类型的 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 的复杂性。
-
为每个泛型方法安排发出一个缓慢的 fallback,该方法使用一个函数表来处理类型参数上的每个可能的语言操作,然后将该 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
函数,构建一个测试二进制文件,并运行它。
有关更多详细信息,请参阅 How to Write Go Code 文档、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 发布计划 的约束,阻止错误修复快速提供给用户。
大多数新代码应该位于标准库之外,并通过 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 发行版,并且系统报告它已感染,那肯定是一个错误。要真正彻底,您可以通过将校验和与下载页面上的校验和进行比较来验证下载。
无论如何,如果您认为报告有误,请向您的病毒扫描程序供应商报告错误。也许随着时间的推移,病毒扫描程序可以学会理解 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]
)的混乱问题也随之消除。这种简化意义重大。至于后缀 vs. 前缀,两者都可以很好地工作,但后缀版本更传统;对前缀的坚持是随着 STL 而出现的,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 剖析工具的演示。