建议:迁移到Go 2
本文翻译自《Proposal: Go 2 transition》。
作者:Ian Lance Taylor
最后更新时间:2018年10月15日
摘要
本文是关于如何在尽可能少地破坏的情况下,从Go 1迁移到Go 2进行不兼容更改的建议。
背景
目前Go语言和标准库都遵守Go 1兼容性保证。该文档的目标是承诺Go的新版本不会破坏现有的程序。
Go 2进展的目标之一是对会破坏兼容性保证的语言和标准库进行更改。由于Go是在分布式开源环境中使用的,因此我们不能依赖卖旗日(flag day)(译者注:指一种既不向前兼容也不向后兼容的软件更改,其制作成本高且撤消成本高)。我们必须允许使用不同版本的Go编写的不同包可以互相操作。
每种语言都会经历版本转换。作为背景,这里有一些关于其他语言所做的事情的记录。你可以随意跳过本节的其余部分。
C
C语言版本由ISO标准化过程驱动。C语言开发非常注重向后兼容性。在第一个ISO标准C90之后,每个后续标准都保持严格的向后兼容性。在引入新关键字的地方,它们被引入到C90保留的命名空间中(一个下划线后跟一个大写的ASCII字母),并且可以通过特定头文件中的#define
宏访问它们,这些头文件以前并不存在(例如_Complex
在<complex.h>
中定义为复数,而_Bool
在<stdbool.h>
中定义为bool
)。C90中定义的基本语言语义都没有改变。
此外,大多数C编译器都提供选项来精确定义代码应该针对哪个版本的C标准进行编译(例如,-std=c90
)。大多数标准库实现都支持在包含头文件之前使用#define
定义的特性宏,用来准确指定应该提供哪个版本的库(例如,_ISOC99_SOURCE
)。虽然这些特性以前存在过Bug,但它们相当可靠并且被广泛使用。
这些选项的一个关键特性是,能让使用不同的语言版本和库版本编写的代码,通常都可以链接在一起并按照预期工作。
第一个标准C90确实对以前的C语言实现进行了重大更改,可以非正式地认为以前的C语言就是K&R C。引入了新关键字,例如volatile
(实际上这可能是C90中唯一的新关键字)。 整数表达式中整数提升的精确实现从无符号保留(unsigned-preserving)更改为值保留(value-preserving)。幸运的是,很容易检测到由于使用了新关键字而编译错误的代码,并且很容易调整该代码。整数提升的变化实际上让新手用户没有那么难理解,有经验的用户大多使用显式转换,来确保在具有不同整数大小的系统之间的可移植性,因此虽然没有自动检测该问题,但在实践中并没有多少代码被破坏。
也有一些恼人的变化。C90引入了三字母词,它改变了一些字符串常量的行为。编译器适应了诸如-no-trigraphs
和-Wtrigraphs
之类的选项。
更严重的是,C90引入了未定义行为(undefined behavior)的概念,并声明调用未定义行为的程序可能会采取任何操作。在K&R C中,被C90描述为未定义行为的情况大多被视为C90中所谓的依赖具体实现的行为(implementation-defined behavior):程序将采取一些不可移植但可以预测行为的操作。编译器编写者吸收了未定义行为的概念,并开始编写假定该行为不会发生的编译优化。这造成了令不熟悉C标准的人感到惊讶的影响。我不会在这里详细介绍,但其中一个示例(来自我的博客)是有符号数的栈溢出。
当然C仍然是内核开发的首选语言和计算行业的胶水语言。尽管它已被更加新的语言部分取代了,但这并不是新版本的C做出的任何选择之过。
我在这里看到的教训是:
- 向后兼容性很重要。
- 小部分破坏兼容性是可以的,只要人们可以通过编译器选项或编译器错误发现这些破坏。
- 可以选择特定语言/库版本的编译器选项很有用,前提是使用不同选项编译的代码可以链接在一起。
- 没有限制的未定义行为会让用户感到困惑。
C++
C++语言的版本现在也由ISO标准化过程驱动。与C一样,C++也非常关注向后兼容性。历史上,C++在添加新关键字方面更加自由(C++ 11中有10个新关键字)。这很正常,因为较新的关键字往往相对较长(constexpr
、nullptr
、static_assert
),使得使用新关键字作为标识符的代码很容易找到编译错误。
C++使用与C中相同的选项来指定语言和库的标准版本。在未定义的行为方面,C++遇到与C相同的问题。
C++中一个突破性变化的例子是在for循环的初始化语句中声明的变量范围的变化。在C++的预标准版本中,该变量的范围扩展到for循环所在的封闭块的末尾,就好像它是在for循环之前声明的一样。在第一个C++标准C++ 98的开发过程中,对其进行了修改,使其范围仅限于for循环本身。编译器通过引入诸如-ffor-scope
之类的选项进行了调整,以便用户可以控制变量的预期范围(在一段时间内,当既不使用-ffor-scope
也不使用-fno-for-scope
进行编译时,GCC编译器使用了旧的范围,但警告任何依赖这一行为的代码)。
尽管向后兼容性相对较强,但用新版本的C++(如C++ 11)编写代码往往与用旧版本的C++编写代码有着非常不同的感觉。这是因为样式已更改为使用新的语言和库功能。原始指针不太常用,使用范围循环而不是迭代器,诸如右值引用和移动语义等新概念被广泛使用,等等。熟悉C++旧版本的人很难理解用新版本编写的代码。
C++当然是一种非常流行的语言,正在进行的语言修改过程并没有损害它的流行性。
除了C的教训,我还想补充一点:
- 在保持向后兼容的同时,新版本可能会有非常不同的感觉。
Java
与我讨论的其他语言相比,我对Java的了解较少,因此这里可能存在更多错误,当然也存在更多偏见。
Java在字节码级别很大程度上向后兼容,这意味着Java N+1版本的库可以调用由Java版本N(以及N-1、N-2等)编写和编译的代码。Java源代码也大多是向后兼容的,尽管它们会不时添加新的关键字。
Java文档非常详细地介绍了从一个版本迁移到另一个版本时可能出现的兼容性问题。
Java标准库非常庞大,每个新版本都会添加新包。包也会不时被弃用。使用已弃用的包将在编译时引发一个警告(警告可能会被关闭),并且在几次发布后,已弃用的包将被删除(至少在理论上是这样)。
Java似乎没有太多的向后兼容性问题。问题集中在JVM上:较旧的JVM通常不会运行较新版本的库,因此你必须确保你的JVM至少与你要使用的最新库所需的一样新。
Java按理说具有某种前向兼容性问题,因为JVM字节码提供了比CPU更高级别的接口,这使得引入不能使用现有字节码直接表示的新特性变得更加困难。
这种前向兼容性问题是Java泛型使用类型擦除的部分原因。更改现有字节码的定义会破坏已经编译成字节码的现有程序。扩展字节码以支持泛型类型需要定义大量额外的字节码。
从某种程度上说,这种前向兼容性问题对于Go来说并不存在。由于Go编译为机器代码,并通过生成额外的机器代码来实现所有必需的运行时检查,因此不存在类似的前向兼容性问题。
但总的来说:
Python
Python 3.0(也称为Python 3000)于2006年开始开发,最初于2008年发布。2018年过渡仍未完成。有些人继续使用Python 2.7(2010年发布)。这不是Go 2想要效仿的路径。
这种缓慢过渡的主要原因似乎是缺乏向后兼容性。Python 3.0故意与早期版本的Python不兼容。值得注意的是,print
从语句更改为函数,字符串更改为使用Unicode。Python通常与C代码结合使用,后者的变化意味着任何将字符串从Python传递到C的代码都需要调整C代码。
因为Python是一种解释型语言,并且因为没有向后兼容性,所以不可能在同一个程序中混合使用Python 2和Python 3代码。这意味着对于使用一系列库的典型的程序,每个库都必须先转换为Python 3,然后才能转换程序。由于程序处于各种转换状态,库必须同时支持Python 2和3。
Python支持from __future__ import FEATURE
形式的语句。像这样的语句以某种方式改变了Python对文件其余部分的解释。例如,from __future__ import print_function
将print
从语句(如在 Python 2中)更改为函数(如在Python 3中)。这是一种渐进地把代码更新到新的语言版本的方式,并使在不同的语言版本之间共享相同代码变得更加容易。
因此我们知道:
- 向后兼容性是必不可少的。
- 与其他语言的接口的兼容性很重要。
- 升级到新语言版本也受到你使用的代码库所支持的版本的限制。
Perl
Perl 6的开发过程始于2000年。Perl 6规范的第一个稳定版本于2015年发布。这不是Go 2想要效仿的路径。
这条道路如此缓慢有很多原因。Perl 6有意不向后兼容:它旨在修复语言中的缺陷。Perl 6旨在通过规范来表示,而不是像以前版本的Perl那样,通过实现来表示。Perl 6从一组更改建议开始,随着时间的推移会不断发展,再继续发展得更多。
Perl支持use feature
,类似于Python的from __future__ import
。它更改了文件其余部分的解释方式,以使用新语言的指定功能。
- 不要成为Perl 6。
- 设定并遵守最后期限。
- 不要一下子改变一切。
建议
语言更改
迂腐地说,我们必须使用一种方式来谈论特定的语言版本。Go语言的每个更改首先出现在Go的发行版中。我们将使用Go版本号来定义语言版本。这是唯一合理的选择,但它可能会造成混淆,因为标准库的更改也与Go版本号相关联。在考虑兼容性时,有必要在概念上将Go语言版本与标准库版本分开。
作为特定更改的一个示例,类型别名(type aliase)首先在Go语言1.9版本中可用。类型别名是向后兼容语言更改的一个示例。所有用Go语言1.0到1.8版本编写的代码在Go语言1.9版本继续与以前相同的方式工作。但使用类型别名的代码需要使用Go语言1.9或更高版本才能编译运行。
增加语言特性
类型别名是增加语言特性的一个示例。使用类型别名语法type A = B
的代码无法在1.9版本之前的Go中编译。
类型别名和自Go 1.0以来的其他向后兼容的更改向我们表明,对于语言特性的增加,包没有必要显式声明它们所需的最低语言版本。一些包使用类型别名这一新特性。当用Go 1.8工具编译这样的包时,编译失败。包作者可以简单地说:升级到Go 1.9,或者降级到包的早期版本。Go工具不需要知道这个要求;因为无法使用旧版本的工具进行编译暗示了这一点。
程序员当然需要了解语言特性的增加,但工具不需要。Go 1.8工具和Go 1.9工具都不需要明确知道Go 1.9中增加了类型别名,除了Go 1.9编译器可以编译类型别名而Go 1.8编译器不能,这个有限的意义之外。指定最低语言版本以获得更好的不支持语言特性的错误消息的可能性,将在下文讨论。
移除语言特性
我们还必须考虑从语言中删除特性的语言更改。例如,issue 3939建议我们删除string(i)
转换整数值i
。如果我们在Go版本1.20中进行此更改,那么使用此语法的包将在Go 1.20中停止编译。(如果你更愿意将向后不兼容的更改限制在新的主版本中,那么在此讨论中将1.20替换为2.0;问题仍然存在。)
在这种情况下,使用旧语法的包没有简单的修改方法。虽然我们可以提供将1.20之前的代码转换为可工作的1.20代码的工具,但我们不能强制包作者运行这些工具。一些软件包可能没有维护但仍然有用。一些组织可能希望升级到1.20而不必重新验证他们所依赖的软件包的版本。一些软件包作者可能希望使用1.20,他们的软件包现在已经损坏,但没有时间修复他们的软件包。
这些场景表明我们需要一种机制,来指定可以用来构建软件包的Go语言的最高版本。
重要的是,指定Go语言的最高版本不应被视为要使用的Go工具的最高版本。随Go 1.20版本发布的Go编译器必须能够构建Go 1.19版本编写的包。这可以通过模仿C编译器支持的-std
选项向编译器(以及,如果需要,汇编器和链接器)添加一个选项来完成。当编译器看到选项时,可能是-lang=go1.19
,它将使用Go 1.19语法来编译代码。
这需要编译器以某种方式支持所有以前的版本。如果证明支持旧语法很麻烦,则可以通过将代码从旧版本转换到当前版本来实现-lang
选项。这将使对旧版本的支持不在编译器的合理范围内,并且转换器对于想要更新其代码的人可能很有用。支持旧的语言版本不太可能会成为一个重大问题。
当然,即使包是用语言版本1.19的语法构建的,它在其他方面也必须是1.20版本的包:它必须与1.20版本的代码链接,能够调用和被1.20版本的代码调用,等等。
go工具需要知道最大语言版本,以便它知道如何调用编译器。我们继续考虑模块,此信息的逻辑位置位于go.mod文件。模块M的go.mod文件可以为其定义的包指定最大语言版本。当M作为其他模块的依赖项被下载时,它的最大语言版本将会被遵循。
最高语言版本不是最低语言版本。如果一个模块需要1.19版本的语言特性,但可以用1.20构建,我们可以说最大语言版本是1.20。如果我们使用Go 1.19版本来构建,我们低于最大版本值,但使用1.19语言版本来构建不是不可以。可以忽略大于当前工具支持的最大语言版本。如果我们稍后使用Go 1.21版本来构建该模块,我们可以使用-lang=go1.20
选项使用最高1.20版本的语言特性。
这意味着这些工具可以自动设置最大语言版本。当我们使用Go 1.30发布模块时,我们可以将模块标记为具有最大语言版本1.30。该模块的所有用户都会看到这个最高版本并做正确的事情。
这意味着我们将不得不无限期地支持该语言的旧版本。如果我们在1.25版本之后删除了一个语言特性,如果使用-lang=go1.25
选项(或-lang=go1.24
或任何其他包含支持功能)。当然,如果没有使用-lang
选项,或者选项是-lang=go1.26
或更高版本,则该功能将不可用。由于我们不希望大规模删除现有语言功能,因此这应该是一个可管理的负担。
我相信这种方法足以实现移除语言特性。
最小语言版本
为了获得更好的错误消息,允许模块文件指定最低语言版本可能很有用。但这不是必需的:如果一个模块使用了语言版本1.N中引入的特性,那么用1.N-1版本构建它将编译失败。这可能令人困惑,但在实践中,问题很可能是显而易见的。
也就是说,如果模块可以指定最低语言版本,那么在使用1.N-1构建时,go工具可以立即生成一条清晰的错误消息。
最低语言版本可能由编译器或其他工具设置。编译每个文件时,查看它使用的特性,并使用这些特性确定最低语言版本。不需要很精确。
这只是建议,不是要求。随着Go语言的变化,它可能会提供更好的用户体验。
语言重定义
Go语言也能够以不添加或删除的方式进行更改,即对特定语言结构的工作方式进行更改。例如,在Go 1.1中,64位主机上int
类型的大小从32位更改为64位。这一变化相对无害,因为该语言本就没有指定int
的确切大小。然而,一些Go 1.0程序使用Go 1.1进行编译后,可能会停止工作。
重新定义语言是这样一种情况,即我们的代码在版本1.N和版本1.M中都能成功编译,其中M>N,但两个版本中代码的含义不同。例如,issue 20733提出范围循环(range loop)中的变量应在每次迭代中重新定义。尽管在实践中,这种变化似乎更可能是一种修复程序而不是破坏程序,但从理论上来说,这种变化可能会破坏某些程序。
请注意,新关键字通常不会导致语言重新定义,但我们必须小心确保在引入新关键字之前确实如此。例如,如果我们按照错误处理草案设计中的建议引入关键字 check
,并且我们允许像check(f())
这样的代码,如果check
被定义为同一个包中的函数名,这可能看起来是一个语言重新定义。但是在引入check
关键字之后,任何定义这样一个函数名的尝试都将失败。因此,使用check
的代码无论在何种意义上都不可能同时使用1.N和1.M版本进行编译。新关键字可以作为移除语言特性(check
作为非关键字使用时)或添加语言特性(check
是关键字时)来处理。
为了让Go生态系统在向Go 2的过渡中幸存下来,我们必须尽量减少此类语言重新定义。如前所述,成功的语言通常基本上没有超出特定程度的重新定义。
当然,语言重新定义的复杂性在于我们不再能依赖编译器来检测问题。查看重新定义的语言结构时,编译器无法知道其含义。在存在重新定义的语言结构时,我们也无法确定最大语言版本。因为我们不知道该语言结构是打算用旧含义还是新含义进行编译。
唯一的可行性可能是让程序员设置软件包的语言版本。在这种情况下,它可能是最低或最高语言版本,视情况而定。它必须以不会被任何工具自动更新的方式设置。当然,设置这样的版本很容易出错。随着时间的推移,最大的语言版本会导致令人惊讶的结果,因为人们试图使用新的语言功能,但都失败了。
我认为唯一可行的安全方法是不允许重新定义语言。
我们被当前的语义所困扰。这并不意味着我们无法改进它们。例如,对于issue 20733,即range问题,我们可以更改range循环,以便禁止获取range参数的地址,或从函数字面值中引用它。这不是一个语言重新定义;这将是一个语言特性移除。这种方法可能会消除Bug,而不会意外破坏代码。
构建标签(Build tags)
构建标签是一种现有机制,程序可以使用它来根据要发布的程序版本选择要编译的文件。
构建标签可用于给出发布的程序的版本号,它们看起来就像Go语言的版本号,但是,学究式地说,它们是不同的。在上面的讨论中,我们讨论了使用Go版本 1.N的编译器来编译Go语言版本为1.N-1的代码。使用构建标签是不可能做到的。
构建标签可设置用于编译特定文件的最大版本号或最小版本号,或同时设置两者。这是一种方便的方式来利用只有在特定版本之后才可用的语言更改;也就是说,这可用于在编译文件时设置最低语言版本号。
如上所述,对于语言更改最有用的是可以根据这种更改设置最大语言版本。构建标签并没有以有用的方式提供这一点。如果使用构建标签将当前版本设置为最大版本,则你的包将不会再生成以后的版本。只有将最高语言版本号设置为当前版本之前的版本号时,才能设置最高语言版本,并且还需要一个用于发布后续版本的当前包的副本。也就是说,如果你使用1.N
进行构建,那么使用!1.N+1
构建标签是没有帮助的。你可以使用!1.M
构建标签,其中M<N
,并且在几乎所有情况下,你都还需要一个单独的文件,其构建标记为!1.M+1
。
构建标签可用于处理语言重新定义:如果语言版本1.N
有语言重新定义,程序员可以使用!1.N
构建标签,使用旧语义和使用一个1.N
构建标签的不同文件。然而,这些重复实现需要大量的工作,一般来说很难知道何时需要,而且很容易出错。构建标签的可用性不足以克服先前关于不允许任何语言重新定义的讨论。
导入“go2”
可以为Go添加一种机制,类似于Python的from __future_import
和Perl的use feature
。例如,我们可以使用一个特殊的导入路径,import "go2/type-aliases"
。这将把所需的语言功能放在使用它们的文件中,而不是隐藏在go.mod文件中。
这将提供一种方法来描述文件所需的语言特性添加集合。它更复杂,因为它不依赖于语言版本,而是将语言分解为单独的功能。没有明显的办法消除这些特殊import
,因此它们会随着时间的推移而积累。Python和Perl通过故意进行向后不兼容的更改来避免累积的问题。在转到Python3或Perl6之后,可以丢弃累积的特性。由于Go试图避免向后不兼容,因此没有明确的方法可以消除这些导入。
此机制无法处理移除语言特性的情况。我们可以引入删除导入,例如import "go2/no-int-to-string"
,但不清楚为什么会有人使用它。实际上,根本没有办法删除语言特性,即使是那些容易混淆和出错的特性。
这种方法似乎不适合Go。
标准库改变
迁移到Go 2的好处之一是有机会发布一些兼容Go 1的标准库包。另一个好处是有机会将许多(也许是大部分)软件包移出六个月的发布周期。如果模块实验成功,甚至有可能尽早开始做这件事,使一些包的发布周期更快。
我建议继续保持六个月的发布周期,但将其视为编译器/运行时的发布周期。我们希望Go发行版开箱即用,因此发行版将继续包含与今天大致相同的软件包集合的当前版本。然而,其中许多包实际上有它们自己的发布周期。使用给定Go版本的人将能够明确选择使用标准库包的新版本。事实上,在某些情况下,他们可能会使用旧版本的标准库包。
不同的发布周期需要包维护者投入更多的资源。只有当我们有足够的人手来管理它,有足够的测试资源来测试它时,我们才能做到这一点。
我们还可以继续对所有内容使用六个月的发布周期,但将可分离的包单独提供兼容的、不同的版本。
核心标准库
尽管如此,标准库的某些部分仍必须被视为核心库。这些库与编译器和其他工具紧密相关,必须严格遵循发布周期。不得使用这些库的旧版本或新版本。
理想情况下,这些库将保留在当前版本1上。如果似乎有必要将其中任何一个更改为版本2,则必须根据具体情况进行讨论。目前我看不出有什么理由这样做。
核心库的暂定清单是:
- os/signal
- plugin
- reflect
- runtime
- runtime/cgo
- runtime/debug
- runtime/msan
- runtime/pprof
- runtime/race
- runtime/tsan
- sync
- sync/atomic
- testing
- time
- unsafe
我可能乐观地从这个列表中省略了net、os和syscall包。我们将看看我们能管理什么。
伴生标准库
伴生标准库是那些包含在Go发行版中但独立维护的包。当前标准库中的大部分内容其实都是这种包。这些软件包将遵循与今天相同的规则,并可选择在适当的情况下迁移到v2。可以使用go get
升级或降级这些标准库包。特别是,这些包可以独立于每六个月的核心库发布周期,修复Bug发布自己的次版本。
go工具必须能够区分核心库和伴生库。我不知道这将如何运作,但它似乎是可行的。
将标准库包移动到v2时,必须规划好同时使用包的v1和v2版本的程序。这些程序必须按预期运行,如果不可能,就必须干净利落地迅速运行失败。在某些情况下,这将涉及修改v1版本以使用也由v2版本使用的核心库。
标准库包必须能够使用Go语言的旧版本进行编译,至少是我们目前支持的前两个发布周期的Go语言版本。
从标准库中移除包
标准库包支持的go get
能力将允许我们从发布中删除包。这些包将继续存在和维护,人们能够在需要时检索它们。但是,默认情况下它们不会随Go版本一起发布。
这将包括像以下这样的包:
- index/suffixarray
- log/syslog
- net/http/cgi
- net/http/fcgi
以及其他似乎没有被广泛使用的包。
我们应该在适当的时候为旧包制定弃用政策,将这些包设置为不再维护。弃用政策也适用于移至v2版本的v1版本的软件包。
或者这可能被证明是有问题的,我们不应该弃用任何现有的包,也不应该将它们从标准的Go发行版本中删除。
Go 2
如果上述过程按计划进行,那么在某种重要意义上永远不会有Go 2。或者,换句话说,我们将慢慢过渡到新的语言和库特性。我们可以在过渡期间的任何时候决定我们现在是Go 2,这可能是一个很好的营销方式。或者我们可以跳过它(从来没有C 2.0,为什么要有Go 2.0?)。
C、C++和Java等流行语言从来没有v2版本。实际上,它们始终处于版本1.N,尽管它们使用不同的名称来称呼该状态。我认为我们应该效仿它们。事实上,从不兼容的新版本语言或核心库的意义上说,完全意义上的Go 2对我们的用户来说不是一个很好的选择。不夸张地说,一个真正的Go 2版本可能是有害的。