本文是 “2022 InfoQ 年度技术盘点与展望” 系列文章之一,由 InfoQ 编辑部制作呈现,重点聚焦 Go 语言在 2022 年的重要进展、动态,希望能帮助你准确把握 2022 年 Go 语言的核心发展脉络,在行业内始终保持足够的技术敏锐度。
“InfoQ 年度技术盘点与展望”是 InfoQ 全年最重要的内容选题之一,将涵盖操作系统、数据库、AI、大数据、云原生、架构、大前端、编程语言、开源安全、数字化十大方向,后续将聚合延展成专题、迷你书、直播周、合集页面,在 InfoQ 媒体矩阵陆续放出,欢迎大家持续关注。
之所以在这样一篇年度解读文章的标题直接提到“泛型”,是因为 Go 语言的泛型在 2022 年终于浮出了水面!并且,它在功能方面已经大致完整了。圈内的开发者们都知道,这是 Go 社区(当然包括中国的 Go 社区)多年请愿的结果。
虽然 Go 语言是 Google 公司出品并主导开发的,但它毫无疑问也是广大 Go 语言爱好者的心头肉。可以说,对于 Go 语言泛型的正式登场,至少有一半功劳属于 Go 语言的爱好者们。
肯定会有一些程序员,尤其是那些不以 Go 为主要语言的程序员会说“Go 语言的进展太慢了,其他众多新的编程语言甚至那些较老的编程语言早就支持泛型了,可 Go 语言墨迹了这么多年才搞出泛型”。确实,单就泛型这方面来说,Go 语言的脚步确实很慢,可这主要是因为 Go 语言想要做到完全向后兼容,并力求让 Go 语言的泛型完美契合其自身的精炼的编程哲学和简约的编码风格。要知道,做加法容易,而做减法却很难。不过不管怎样,Go 语言的泛型终于落地了!就连现如今清心寡欲的作者也忍不住心中的喜悦!
好了,感慨完毕,我们还是回归正题吧。下面,我们就一起从头至尾捋一捋 Go 语言在 2022 年的发展和变化,以及它将会在 2023 年如何继续前行。
趋势概览
Go 在今年的 11 月 10 日刚满 13 岁。它已经长大并进入了少年期,不算是一门新的编程语言了。但 Go 依然保持着非常好地向后兼容。很显然,这是一件很不容易的事。
从业界知名的编程语言排行榜 TIOBE Index 绘制的折线图来看,Go 语言的采用趋势在过去三年间基本上保持平稳。
更具体地说,根据使用情况统计,在 2020 年下半年到 2021 年上半年的这段时间里,Go 语言的使用率有略微提升,但时至今日又差不多回到了 2019 年的水平。与其在 2016 年到 2017 年间的大爆发相比,如今 Go 语言的使用率已经基本上稳定下来了。
图 1 - TIOBE Index 之 Go 语言趋势
不过,就作者个人而言,感觉是有些奇怪的。按理说,Go 语言泛型的落地应该能够引起一波采用 Go 语言的小高潮,但事实上这一情况并没有出现。这可能就是“波澜不惊”的最佳诠释吧?可能因为大家都等得太久了,真到了重大的新特性来临之时却不太能兴奋得起来了。
当然了,这也可能是因为 Go 语言在应用领域方面的攻城掠地已经基本完成,而且在其优势领域的使用率已经趋于饱和。而对于那些新兴领域,比如机器学习、人工智能、机器人、元宇宙等,Go 语言还没有显著的优势,尤其是还没有杀手级框架或工具出现。在这种情况下,Go 语言在使用统计上的平稳趋势也是一种必然。
现在换一个维度,我们横向来看,Go 语言的排名相较于前两年有比较明显的提升。如下图所示。
图 2 - TIOBE Index(2022 年 11 月)
我们可以看到,Go 语言在 2021 年年底的 TIOBE Index 排名中位于第 18 位。这低于它在 2020 年年底的排名(第 16 位),以及它在 2019 年年底的排名(第 15 位),也算是一个小谷底了。然而,在今年的年底,Go 语言的排名却大幅提升了,甚至,它已经摸到了 Top10 的边界!如果作者没记错的话,Go 语言最近一次进入 Top10 是在 2018 年的年底。如今,4 年过去了,Go 语言终于又有希望重回 Top10 了。
我们还可以看到,至今常年占据 Top10 的依然是那些老生常谈,甚至是那些老得不能再老的编程语言。相比之下,Go 语言真真切切算是年轻一派的优秀编程语言了。当然,除了 Go 语言之外,还有图形化的儿童编程语言 Scratch、Apple 公司出品的编程语言 Swift,以及近年来出尽风头的系统级编程语言 Rust。它们都很年轻,却都已经进入了 Top20,也是非常优秀的。作者也很看好它们。
在简单分析了 Go 语言在 TIOBE Index 中的排名之后,我们再来看看新晋出炉的 StackOverflow Developer Survey 2022 吧。众所周知,StackOverflow 是全球最大的编程社区和专业问答网站。它的 Developer Survey 报告属于年度总结性报告,已经有好多年的历史了,并且深受全球软件开发者的关注和喜爱。
我们下面重点说说其中与 Go 语言强相关的内容。至于其他的内容,大家如果有兴趣的话,可以点开前面的超链接仔细阅读完整报告。
在“使用率最高的编程语言(专业组)”这一栏中,Go 语言的排名是第 13 位(如下图)。
图 3 – Stackoverflow Developer Survey 2022 之使用率最高的编程语言(专业组)
如果只考虑通用的跨平台编程语言的话,Go 语言仅次于 Python、Java、C++ 和 C。这与它在 TIOBE Index 中的情况是相同的。有的程序员可能会说“JavaScript 和 TypeScript 现如今也可以算是通用的跨平台编程语言”。当然,从某种角度看,我们可以这样说。但是,它们终归不是原生的通用编程语言,都需要额外的工具或框架才能够实现“通用”。因此,我们稍微严格一点,暂且不把它们计算在内。
在应用级框架和工具栏目中,作者发现 Docker 和 Kubernetes 今年依然非常火爆。作为常用的基础工具,它们深受广大软件开发者的追捧。
图 4 – Stackoverflow Developer Survey 2022 之最受欢迎的基础工具(全体组)
从上面这幅截图我们可以看出,Docker 和 Kubernetes 在当前的排名中都已经进入了 Top5。甚至,如果只考虑专业的软件开发者的话,Docker 甚至超越了 npm,拔得头筹!
图 5 – Stackoverflow Developer Survey 2022 之最受欢迎的基础工具(专业组)
毫无疑问,npm 是 JavaScript 世界中 Top1 的工具,其应用是相当相当广泛的。更何况,在全世界的软件开发者当中,不论是专业的开发者还是业余的开发者,使用 JavaScript 语言的人都是最多的。所以,Docker 能够超越它真的是一件非常值得骄傲的事!
大家应该都知道,Docker 和 Kubernetes 都是使用 Go 语言开发出来的。在云计算领域,尤其是容器技术领域,它们都是绝对的王者!因此,如果你想进入云计算领域,或者想使用 Go 语言开发基于云的应用程序,那么最起码应该学会甚至熟练使用 Docker。
好啦,以上算是一个令人激动的小插曲。现在,让我们把焦点再转回到编程语言的排名上来。
在 Stackoverflow Developer Survey 的报告中,有一个非常有特色的栏目。我们经常戏称它为“爱恨交织”栏目,其原名是“Most loved, dreaded, and wanted”。
在这个栏目里,共有七万一千多名开发者回答了相应的问题。在他们当中,有 64.58% 的人喜爱 Go 语言,而有另外的 35.43% 的人憎恨 Go 语言(或者说对 Go 语言有恐惧感)。在所有为大众所熟知的编程语言当中,Go 语言排在了第 8 位。
图 6 – Stackoverflow Developer Survey 2022 之让人“爱恨交织”的编程语言
在这份排名中的 Rust、TypeScript 和 Julia 作为更加年轻的编程语言后来居上。不过,如果我们查看相邻的“最想学习的编程语言”排名的话,就会发现 Go 语言的位置靠前了很多,处于第 4 位,仅次于 Rust、Python 和 TypeScript,并且从百分比数值上来看相差无几。
图 7 – Stackoverflow Developer Survey 2022 之大家最想学习的编程语言
看起来,程序员们应该都很喜欢追新,不是吗?这起码与作者的所见所闻是比较吻合的。当然了,只有那些优秀的新兴编程语言才有进入 Top10 的机会。
可以想象,Docker 和 Kubernetes 在这个“爱恨交织”栏目中肯定是名列前茅的。事实也确实如此,它们分列“Loved vs. Dreaded”一栏中的前两位。
图 8 – Stackoverflow Developer Survey 2022 之让人“爱恨交织”的基础工具
而且,在“Want”一栏,它们拥有着一骑绝尘的态势。
图 9 – Stackoverflow Developer Survey 2022 之大家最想学习的基础工具
顺便说一句,在“爱恨交织”栏目里,同样拥有“一骑绝尘”态势的还有 Visual Studio Code(以下简称 VSCode)。
图 10 – Stackoverflow Developer Survey 2022 之大家最想学习的集成开发环境
作者为什么会在这里提到 VSCode 呢?因为,在 2022 年订阅我的技术专栏的读者当中,非 gopher(gopher 的意思是 Go 语言粉丝)的人明显增多。他们问的最多的一个问题就是:“要是使用 Go 语言开发程序的话,有哪些好用的代码编辑器或者 IDE 吗?”
我的回答一般都是 VSCode 和 GoLand。
微软公司出品的 VSCode 是 Go 语言爱好者最常用的免费代码编辑器。我们使用它时,外加 Go 语言官方团队出品的 vscode-go 插件就基本上可以满足平常的需求了。
而 GoLand 是 JetBrains 公司出品的,但它收费而且价格不菲。作者在这里绝不是给 JetBrains 公司做广告,但是 GoLand 真的很贵、很好用。如果你对于配置编程工具非常不耐烦,并且预算很充足的话,那么我会强烈推荐你使用 GoLand。
顺便说一句,如果你是 Vim 那一派的话,Go 社区中也有相应的 vim-go 插件。
好了,关于 Go 语言发展趋势的整体解读,我们就暂告一段落吧。下面,我们从更新迭代的角度来看看 Go 语言在 2022 年都有哪些进展。
年度回顾
支持泛型
Go 语言在 2022 年最大的变化莫过于添加了对泛型(generics)的支持。这对于 Go 语言的广大使用者来说,也是感受最明显的变化。
早在 2022 年 2 月正式发布的 1.18 版本,Go 语言就包含了类型参数提案(即 Type Parameters Proposal)中描述的通用功能的实现。这包括了需要对 Go 语言本身进行的主要的更改。注意,这些更改是完全向后兼容的。这也是 Go 语言官方对于 Go 1.x 版本更新的一贯原则和保证。
Go 语言官方团队为此编写和修改了大量的代码。虽然这些代码并未经过超大规模的验证,但是 Go 语言团队对它们的质量和功效是非常有信心的。不过,也不排除这种可能:一些使用泛型的代码适用于 1.18 版本,但在以后的版本中会变得无效,或者说需要稍加修改才能使其有效。因为,这次为了支持泛型的改动实在是太大了。按照 Go 官方的说法,这种不完全兼容的可能性会被尽可能减小,但是无法对此做出 100% 保证。
虽然我们都喜欢 100% 确定的东西,但是万事万物都不可能有 100% 的稳定性和可预测性。尤其是,对软件开发有一定理解的朋友们肯定都知道,没有哪一个软件是没有 bug 的,也没有哪一个软件功能可以保证 100% 的正确。所以,我们需要用更加宽容的心态来看待 Go 语言的这次超大级别的更新。
实际上我们也不用太过担心。因为从 Go 语言的 issue 列表上看,泛型相关的 bug 如今也已经发现和修复得差不多了。Go 语言的泛型已经趋于稳定,我们已经可以放心地将其用在生产级代码上了。
从语法上说,Go 语言的类型参数(可以理解为“泛型”的另一种称谓)并未使用那个在其他编程语言中常见的尖括号(即“<”和“>”),而用的是方括号(即“[”和“]”)。这也是程序员们感受的最大一点不同。
请注意,我们在这里所说的“对泛型的支持”实际上是“对自定义泛型的支持”。因为 Go 语言内置的一些数据类型从一开始就是支持泛型的,比如:
或者
然而,在 Go 1.18 之前,使用者们自己编写的函数或者类型是无法添加泛型定义的。
下面重点来了。在 Go 1.18 中,对泛型的具体支持都有哪些呢?请看下文。
1. 自定义的函数声明或类型声明都可以包含类型参数。
请看如下代码:
所谓的类型参数声明,其实就是对一个类型所涉及到的关联类型的泛化定义。例如,对于结构体类型中的每个字段,我们都必须分别定义其类型。这些字段的类型就是它们所属的结构体类型的关联类型。
这里的泛化定义的意思是,我们在声明一个类型的时候,并不指定它的关联类型具体是哪一个或哪几个,而只定义相应的范围。等到这个类型被实例化的时候,再由那个编写实例化代码的程序员去设定类型参数的具体信息。这样的话,我们就可以定义出更加抽象的类型,而把具体化的工作留给使用它的人。
这与“声明接口类型,并把其作为某些函数参数或变量的类型”的编程手法有着异曲同工之妙,都是为了提升代码的灵活性,并增强其扩展的能力。不过需要注意的是,类型参数的值与函数参数的值或变量的值是不同的。一个类型参数的值必须是代表了某个已存在类型的标识符(或者说类型字面量)。另外,类型参数值代表的既可以是结构体这样的具体类型,也可以是接口那样的抽象类型。而函数参数或变量的值,则必须是某个具体类型的确切值。
另一方面,Go 语言的类型参数声明与它的函数参数声明是类似的。在上述代码的方括号中,K 和 E 分别是两个类型参数的标识符,类似于函数参数的名称。而~int 和~string 则分别是两个类型参数的类型约束(type constraint),类似于函数参数的类型声明。至于在 int 和 string 的前面为什么会有“~”这个符号,我们稍后再说。
正因为结构体类型 Pair 的声明里包含了类型参数 K 和 E 的声明,所以在它的主体当中,我们自然可以自由地使用 K 和 E。如代码所示,我们把 Pair 的字段 Key 的类型声明为 K,并把字段 Elem 的类型声明为 E。这样一来,Pair 的类型参数就与其主体实现了联动。这种联动将会在我们对 Pair 类型进行实例化的时候得以体现。
2. 对于带有类型参数的函数或类型,可以通过在它们的名称后面追加方括号并指定相应的类型参数值来进行实例化。
示例如下:
我们在这里声明了一个 Pair[int, string] 类型的变量 pair1,并把一个 Pair[int, string] 类型的值赋给了它。请注意,我们在对一个带有类型参数的类型进行实例化的时候,也必须对它的类型参数进行实例化。在这里,Pair[int, string] 中的 int 和 string 就是分别对 Pair 的类型参数 K 和 E 的实例化。
还记得吗?我们当初在声明 Pair 类型的时候,把它的类型参数列表编写成 [K ~int, E ~string]。其中,~int 是类型参数 K 的类型约束,而~string 则是类型参数 E 的类型约束。那么,这里的 Pair[int, string] 中的 int 和 string,分别作为 K 和 E 的值就是合法的,可以通过编译。至于为什么,我们马上就会说到。
先接着看其余的代码。因为在 Pair 类型的声明当中,字段 Key 的类型声明是 K,字段 Elem 的类型声明是 E。所以,在实例化 Pair[int, string] 的时候,我们自然就可以把某个 int 类型的值(这里是 1)赋给 Key,并把某个 string 类型的值(这里是"a")赋给 Elem。
3. 新符号“~”已被添加到了运算符和标点符号的集合中。
我们再看 Pair 类型的声明:
我们大可以把这里的符号“~”理解为“潜在”。代码“K ~int”的意思是,只要一个类型(假定为 A)的潜在类型是 int,那么就可以满足我们在这段代码中对 K 所做的类型约束,这就意味着 A 类型的字面量可以作为类型参数 K 的值。同样的道理,代码“E ~string”的意思是,只要一个类型(假定为 B)的潜在类型是 string,那么就可以满足我们在这段代码中对 E 所做的类型约束,这就意味着 B 类型的字面量可以作为类型参数 E 的值。也正因为如此,类型 Pair[int, string] 才是合乎语法规则的,它的类型参数都已通过了有效的实例化。
至于什么是“潜在类型”,Go 语言规范对此有明确的解释。具体内容是:每个类型 T 都有一个潜在类型。如果 T 是 Go 语言内置的布尔类型、数字类型、字符串类型之一,或者是某个类型字面量,那么相应的潜在类型就是 T 本身。否则,T 的潜在类型就是 T 在其声明中引用的类型的潜在类型。
下面举个例子。如果我们又编写了如下代码:
那么,对于当前的 Pair 类型声明来说,下面的代码也是合法的:
更确切的说,类型 Pair[MyInt, MyStr] 是合乎语法规则的。因为,从前面的说明和代码可知,MyInt 的潜在类型是 int,而 MyStr 的潜在类型是 string。它们分别符合 Pair 类型的声明里对类型参数 K 和 E 的定义。
4. 接口类型的声明中现已允许嵌入任意类型,以及由符号“|”联结的联合类型和由~T 代表的类型元素,而不只是之前允许的其他接口类型。不过,这样的接口只能用于泛型中的类型约束。
这段话是什么意思呢?我们来详细解析一下。
为了配合 Go 语言对泛型的支持,官方团队对接口类型的声明语法做了很多的增强。
使用 Go 语言的程序员们都知道,以前的接口声明只能像下面这样:
或者这样:
也就是说,在接口类型声明的主体中,我们可以嵌入任意数量的非重复的方法声明,也可以嵌入任何其他非重复的接口类型(用接口名称来代表)。我们称这两者为合法的接口元素。但除此之外,我们就不能添加任何东西了。
然而,从 Go 1.18 开始,合法的接口元素又多了一种。Go 官方把这种接口元素称为类型集合(type set)。
一个类型集合可以仅包含单独的类型(由类型的名称代表,如:T),也可以包含代表了某个潜在类型的~T,还可以是联合类型(如:T1|T2|T3,其中的符号“|”可以被理解为“并集”),甚至可以是它们的混合(如:T1|~T2|~T3)。而且,对此我们可以分多行来写,只要它们所代表的类型是具体的且不存在重复即可。
不过要注意,包含了类型集合的接口类型只能被用在泛型相关的类型约束当中。例如,有如下代码:
可以看到,含有类型集合的接口 FloatUnion 是可以被嵌入到其他接口类型的声明里面的(或者说,其他的接口类型可以扩展 FloatUnion 接口)。但如此一来,不但 FloatUnion 接口不能被用作任何值、变量、字段、参数、结果的类型,而且 FloatMaker 接口也会是这样。换句话说,对这种接口的用途限制具有传递性。
5. 新的内置标识符 any 是空接口的别名。它可以代替 interface{}。
这一条说得很直白。单词 any 现在属于关键字了。它代表了空接口,也就是 interface{}。但是,空接口本身的含义却因泛型支持的出现而增多了。
从 Go 1.18 开始,空接口自带类型集合,并且它的类型集合包含了所有的非接口类型。注意,这里的“所有”不但代表当前已存在的所有非接口类型,而且还囊括了将来会在程序中出现的所有非接口类型。也就是说,空接口的类型集合拥有无限多的非接口类型。
这与空接口的设立初衷是一致的,即:空接口是包罗万象的,也是类型之树的唯一树根。在 Go 语言中,任何接口都是空接口的扩展接口,任何类型都是空接口的实现类型。这样来看,任何类型,不论是抽象类型还是具体类型,都是对空接口所代表的类型空间的进一步圈定。
对于类型参数中的类型约束来说也是这样。空接口的类型集合包括了无限多的非接口类型,这使得任何类型约束所代表的类型集合都将是空接口的类型集合的一个子集。这是“进一步圈定”的另一种表现形式。因此,空接口在 Go 语言全面支持泛型之后,依然能够作为其类型系统的根基。
6. 新的内置标识符 comparable 也代表一个接口类型。
顾名思义,comparable 接口的含义是“可比较的”。只要一个类型符合以下两种情况中的一种,我们就可以断定它实现了 comparable 接口:
这个类型不是接口类型,并且它的值可以通过使用操作符 == 或 != 进行比较。
这个类型是接口类型,而且其类型集合中的每一个类型都实现了 comparable 接口。
比如,像 int、float32、rune、string 这样的基本类型肯定都实现了 comparable 接口,而切片(slice)类型、字典(map)类型以及任何的函数类型肯定就不是 comparable 接口的实现类型。
再比如,我们在前面声明过的 FloatUnion:
可以确定它肯定实现了 comparable 接口。但如果我们把其中的~float64 替换为~[]float64,那么它就不再是 comparable 接口的扩展接口了。
请注意,comparable 接口,以及任何直接或间接地嵌入了(或者说扩展了)comparable 的接口都只能被用于类型约束。它们不能被用作任何值、变量、字段、参数、结果的类型。
显而易见,与 any 接口一样,comparable 接口也是专门为了类型参数(或者说泛型)才引入的。同样的,comparable 接口也自带了类型集合。它的类型集合包含了所有可以被比较的类型。这样的类型既可以是已经存在的,也可以是尚未出现的。
除了上述 6 个很重要的改动之外,Go 团队还为使用者们准备了 3 个实验性质的代码包。这些代码包并不在 Go 标准库当中,而是位于 Go 语言官方专门设立的实验包 golang.org/x/exp 里。这意味着,它们的 API 并不在 Go 1.x 的兼容性保证范围之内。并且,随着 Go 团队对泛型支持的进一步深入,这些代码包也可能会发生非向后兼容的变化。具体如下:
代码包 golang.org/x/exp/constraints:其中包含了对泛型编程非常有用的一些类型约束,如 constraints.Ordered 接口等等。
代码包 golang.org/x/exp/slices:其包含了不少对于切片操作非常有用的函数。而且,对于这些函数所操作的切片,其元素类型可以是任意的。比如,泛型函数 func BinarySearch(x []E, target E) (int, bool)、func CompactS ~[]E, E comparable S、func SortE constraints.Ordered 等等。从这些函数的签名上我们就可以看出,它们的通用性都得益于泛型。这样的通用性在 Go 语言支持泛型之前都是不可能存在的。
代码包 golang.org/x/exp/maps:与 golang.org/x/exp/slices 包类似,其中包含了一些针对字典的非常通用的泛型函数,如:func ClearM ~map[K]V, K comparable, V any、func CloneM ~map[K]V, K comparable, V any M、func KeysM ~map[K]V, K comparable, V any []K 等。
真正了解 Go 语言的程序员们肯定都知道,Go 团队经常会向 golang.org/x/exp 包中放入一些实验性的代码。他们往往会通过这种方式来实现一些或新鲜或激进的想法。如果某些代码在这里通过了使用者们的检验,并被认为已经足够成熟,那么它们就有希望被添加到 Go 语言的标准库当中。Go 团队正是依靠这种渐进式升级的方式,在保证标准库稳定的同时,使其创新性得以延续。
再说回泛型。尽管 Go 语言团队为了泛型做了如此多的工作,但到目前为止,Go 语言的泛型实现仍然存在一些小限制(主要体现更加细致的编程语法、值成员访问等方面)。不过,这些小限制在大多数情况下并不会妨碍我们在应用程序中使用泛型。而且,Go 语言团队也已经预告将在未来的版本中对此进行改进。所以,作者就不在这里一一列举了。
到这里,相信大家已经有所体会,“支持泛型”可以说是 Go 语言正式发布以来最大、最复杂且最重要的一项变化了。很显然,Go 语言本身的泛型支持工作离彻底完成还有一小段距离。而对于 Go 语言的技术社区来讲,更加重要的是,这项变化将意味着 Go 语言生态系统的大规模翻新。
到目前为止,Go 语言的生态系统已经非常庞大。因此,Go 语言的这项变化将会给 Go 社区带来很可观的压力。那些 Go 程序员们常用的第三方开发框架和工具必然需要一定的时间才能够跟进这项变化,而完美契合这项变化也许还需要更多的时间。这其实也是 Go 团队当初在考虑“是否添加泛型支持”的时候,涉及到的一个很重要的负面因素。
但无论如何,Go 语言在这件事情上的第一步(也是非常重要的一步)已经迈出并平稳落地了。我们现在只希望,Go 语言以及 Go 语言技术社区能够在这个良好的基础之上继续稳步前行、平滑过渡。
模糊测试
我们都知道,Go 语言原生支持的测试已有三种,即:功能测试、基准测试(或称性能测试),以及示例测试。而从 1.18 版本开始,Go 语言本身支持的测试又多了一种,那就是模糊测试(fuzz test)。
所谓模糊测试是一种自动化测试技术,这种测试可以通过不断地调整应用程序的输入值来试图查找应用程序内部可能存在的错误,尤其是那些我们平常不太能注意到(或称边缘情况)的错误。正因为不太能注意到,所以在编写测试代码的时候,我们往往会有意或无意地忽视掉它们。这个时候,模糊测试就可以被用来查缺补漏了。而且,这种查缺补漏也是非常重要的,特别是在面向安全的测试当中。
这里所说的模糊,其含义是对应用程序的输入值(或者说参数值)的模糊。更确切地讲,模糊测试程序会对应用代码(如函数、结构体、接口方法等)进行若干次调用。而每一次调用都会在预先准备好的语料库(corpus,此为模糊测试的专用术语)当中随机地挑选出一个条目(corpus entry),并把该条目作为参数值输入给应用代码。
请注意,模糊测试的语料库与我们熟知的普通单元测试(如功能测试、基准测试等)的测试用例在生成方面有一个很大的不同。在普通的单元测试中,测试用例都是需要我们专门去准备的。这也算是普通单元测试的一种局限性。因为测试用例的规模与我们的测试工作量是成正比的,而且起码是线性的关系。我们越想全面地测试应用代码,我们的工作量就越大。由于我们的精力和时间是有限的,而且也不可能无限期地测试下去,因此普通的单元测试在测试覆盖度方面就会存在天然的限制,而且很难突破。
而在模糊测试中,语料库的生成是自动化完成的。模糊测试程序会自动生成一定规模的语料库。而我们只需事先向它提供一个规模非常小的种子语料库(seed corpus),甚至只包含一两个条目就可以。
这个种子语料库存在的目的,只是为了指导模糊测试引擎对语料库的自动生成,比如,单个语料库条目里需要包含几个输入值,以及每个输入值都是什么类型的,诸如此类。假设,作为测试目标的函数 A 只有一个参数,那么种子语料库中的每个条目只包含一个值即可。如果作为测试目标的函数 B 有两个参数,那么种子语料库里的单个条目就应该包含两个值。以此类推。
当然了,没有任何一个工具是完美的。虽然模糊测试可以大大地节省我们的测试工作量,但它也有一个小缺点,那就是:我们无法对语料库提供的参数值进行精细的控制。更确切地说,在模糊测试的过程中,我们不能确定应用代码每次被调用时接收到的输入值具体是什么。在某些情况下,这可能会妨碍我们对应用代码返回的输出值(或者说结果值)的正确性判断。倘若确实需要,我们可能就要额外添加一些测试代码来做专门的判断了。如果这样的情况很多,那么我们可能还需要重新权衡是否一定要使用模糊测试,而不是用普通的功能测试或基准测试。
好了,到了这里,想必大家已经大致了解模糊测试是什么,以及我们大概应该如何运用它。至于具体怎样给 Go 程序做模糊测试,其实还是很简单的。它与之前已经存在的功能测试和基准测试的编写方式大同小异,我们稍微迁移一下以前的测试经验就基本上可以搞定。因此,作者在这里就不再赘述了。如果你想对此做深入了解,那么可以去访问 fuzzing landing page 以及 the fuzzing proposal。
工作区模式
Go 语言的工作区模式(go workspace)是建立在 Go 模块模式(go module)之上的一种项目管理模式,主要体现在 go 标准命令之中。
Go 模块模式在 2021 年发布的 Go 1.17 中已经完全稳定下来了。我们可以通过 go mod 命令来创建和管理我们的 Go 模块。如果我们开发的 Go 项目已经达到了一定的规模,那么往往就需要把它拆分成多个模块,以便让它继续保持高内聚、低耦合的状态。这对于代码和项目的维护都是相当有利的。
比如,我们有一个名叫 MyProject 的 Go 项目(同时也是一个 Go 模块)。它的存放路径是 /path/go /haolin/Demo/MyProject。现在,我们想把其中的一些基础代码分离出来,以便进行独立的开发和维护。我们仔细地抽出那些基础代码,并它们放入到了一个名叫 MyLib 的模块中,并同样存放在 /path/go/haolin/Demo 目录之下。
这两个模块分别有自己的 go.mod 文件。MyProject 模块在它的 go.mod 文件中的名称为 github.com/hyper0x/MyProject,而 MyLib 模块在它的 go.mod 文件中的名称为 github.com/hyper0x/MyLib。不过请注意,这两个模块并不实际存在于网址 https://github.com/hyper0x 之下。这里只是作为示意而已。
在这之后,我们在 MyProject 模块里导入并使用 MyLib 中的代码包。但当我们想编译 MyProject 模块的时候却发现,go 命令无法正确编译 MyProject,提示找不到 MyLib 里的那个代码包。碰到这种情况,我们需要在 MyProject 所在的路径下运行:
这条命令的主要功能是,在 MyProject 模块的 go.mod 文件中加入如下内容:
其含义是,将本模块中的源码文件里的 github.com/hyper0x/MyLib 定位到本地的路径(即 /path/go/haolin/Demo/MyLib)之上。
做好以上准备工作后,我们再在同样的路径下运行:
其功能是,在对应的 go.mod 文件中添加相应的依赖包信息。
好了,现在我们再次编译 MyProject 模块就不会有问题了。因为我们已经把本地的 MyLib 模块正确地设置为了 MyProject 模块的依赖包。
不过,这里有一个问题,上述的 replace 指令中存在一个本地的路径。如果开发这两个模块的人不止我一个,也就是说需要通过多人协作来开发项目,那么,我这里的本地路径在别人那里就是不正确的了。因为不同的开发者几乎不可能确保把同一个项目存放在自己的计算机中的相同路径之下。我们常常称这类问题为“本地路径问题”。
这个时候,Go 语言的 workspace 模式(也就是工作区模式)就派上用场了。具体的做法是,我们在 /path/go/haolin/Demo 这个路径(也就是 MyProject 模块和 MyLib 模块的上一级目录的路径)之下运行:
这里的第一条命令的功能是,把路径 /path/go/haolin/Demo 所代表的目录设置为 Go 工作区。它会在这个路径下添加一个名为 go.work 的文件。第二条命令的功能是,让子目录./MyLib 所代表的模块成为该工作区的一个共用模块。如此一来,该工作区中的其他 Go 模块就可以直接导入并使用 MyLib 模块中的代码包了。
此外,我们还需要在 MyProject 模块所在的路径下运行命令:
并以此删除掉对应的 go.mod 文件中包含的那个带有本地路径的 replace 指令。由于我们前面针对(/path/go/haolin/Demo 路径所代表的)相应 Go 工作区的设置,这里的这个 replace 指令的消失并不会造成任何问题。MyProject 模块依然可以通过编译。
至此我们可以看到,由于 Go 工作区模式的出现,我们的 Go 项目无论包含有多少个模块,也无论需要多少人进行怎样的协作调试和开发,都不用担心出现“本地路径问题”。这给团队级别的项目开发,尤其是大规模的协作开发,带来了非常大的便利。我们终于不用再为此频繁地修改 go.mod 文件了。这个 Go 项目管理上的痛点终于被消除了!
内存模型更新
在 1.19 版本中,Go 语言的内存模型已经过了修改(详见 the Go memory model)。这使得它与其他编程语言如 C、C++、Java、JavaScript、Rust 和 Swift 使用的内存模型保持了一致。
Go 语言只提供可以保证访问顺序一致性的原子操作,而不是像其他编程语言那样使用更加宽松的形式。更确切地说,在 Go 语言中,除非直接或间接地使用了相应的原子操作,否则并发地读写共享的数据必会引发数据竞争(data race)。顺便说一句,从根本上讲,Go 语言标准库中提供的其他同步或异步的数据访问工具基于的其实都是原子操作。
随着内存模型的更新,Go 1.19 在 sync/atomic 包中加入了一些新的类型,如 atomic.Bool、atomic.Int32、atomic.Int64、atomic.Uint32、atomic.Uint64,以及 atomic.Uintptr 和 atomic.Pointer[T]。很显然,与 sync/atomic 包中原有的诸如 AddXXX、CompareAndSwapXXX、LoadXXX 之类的原子函数相比,新的原子类型可以帮助我们更加方便和彻底地运用原子操作,同时也可以避免很多容易发生的编程失误。不得不说,我们已经等待这些原子类型很久了。
到这里,作者已经对 Go 语言在 2022 年的重大更新进行了相应的说明。这其中的重中之重肯定是泛型,所以作者对此的着墨也明显更多。至于那些在作者看来不那么重要或相对较小的更新,大家可以去浏览 Go 语言官方出具的版本说明(Go 1.18 Release Notes 和 Go 1.19 Release Notes),这里就不一一列出了。
未来展望
实际上,与 Go 1.18 相比,Go 1.19 算是一个更新量很小的版本迭代。这主要是因为,在影响巨大的特性加入之后,Go 语言及其团队和社区确实需要一段时间去消化它。这里其实有很多的工作要做,比如:进一步评估和验证、功能调整和修补、社区推广和反馈收集、代码优化和改进、文档补全和细化,等等。
不过,在明年即将发布的 Go 1.20 当中,随着上述工作的稳步推进和趋于完善,还是有一些值得关注的新东西的。下面只列举作者认为最喜闻乐见且可能最常用的几个更新:
支持同时囊括多个其他错误值的单个错误值:届时 errors 包中会提供一个新的函数 Join,用来把多个错误值包装成一个单一的错误值。另外 fmt.Errorf 函数也将允许在其模版字符串(即第一个参数值)中出现多个(用于包装错误值的占位动词)%w。函数 errors.Unwrap 到时候也会以 []error(即 error 的切片)作为结果值的类型,而不是之前的 error,以便一次性地返回所有被包装的错误值。这无疑大大增加了错误包装代码的灵活性,并间接地加强了错误判断代码的功能。
sync.Map 的增强:sync.Map 类型里将会出现几个新方法,如 Swap、CompareAndSwap、CompareAndDelete 等。这些方法将如 sync/atomic 包里的相应函数那样,可以让单一的原子操作中包含多个动作。在 sync.Map 的场景下,这将支持针对键值对的更加复杂的原子操作,如上述方法所对应的比较并更新、比较并删除等。使用过 sync.Map 的程序员们应该都知道,对于这种操作,我们在之前不得不使用额外的同步工具来保证其并发的安全性。
支持从切片到数组的转换:我们都知道,把数组转换成切片很容易,只要在一个数组上执行切片操作就可以得到一个指向那个数组的切片。这样的切片就相当于一个架设在数组之上的窗口。然而,现在的 Go 语言却不支持对应的反向操作。虽然我们通过少许的魔法代码就可以实现这样的操作,但是它肯定没有“array2 := [5]int(slice1)”这样的代码方便。后者正是将会在 Go 1.20 中合法化的代码,只一行就能完成从切片到数组的转换。不过要注意,上面这小段代码只会返回 slice1 的底层数组的副本,而不是这个底层数组本身。所以,我们之后对 slice1 中元素的修改将不会影响到 array2(但肯定会继续影响到 slice1 的底层数组)。
顺便提一下。已经在 Go 社区中引发热议的代码包 arena 及其配套代码可能会在 1.20 版本中以实验特性的方式出现。作者很看好 arena,因为它可以在某些情况下大大地减少内存分配和释放的次数,从而显著减轻 GC 的压力。
在作者看来,arena 包的功能类似于 Zig 编程语言中的内存分配器(allocator)。不过相比之下,Go 语言的实现肯定是大大简化的,因为它在 Go 语言中只需起到一定的辅助作用即可。整体来看,其做法是,在需要使用大块内存时先通过 arena 包中的 API 进行显式的内存空间申请,然后再根据实时的需要对其中的子空间进行取用,最后当无需再使用这块内存的时候再利用 arana 中的 API 进行整体释放。简单来说,这属于一种“整体化分配、碎片化使用、整体化释放”的内存空间使用范式。关于它更详细的说明可参看 Proposal: arena: new package providing memory arenas。
除了上述这些,Go 1.20 还会对编译器、标准工具、标准库等方面进行诸多的改进,详情可查看 Go 1.20 Release Notes(DRAFT)。如果在你阅读这篇文章的时候,Go 1.20 已经正式发布了,那么就可以直接去看 Go 1.20 Release Notes。
总结
好了,我们现在来快速地总结一下。
Go 语言在 2022 年的最大更新莫过于对自定义泛型类型和泛型函数的支持。新增的模糊测试 API 和工具将显著减少程序员们的测试工作量。工作区模式让我们在同时开发多个 Go 模块,尤其是多人协作开发的时候方便了许多(解决了一个很明显的痛点)。而在内存模型方面的修改则进一步增强了 Go 语言在数据访问方面的一致性保证。
在 2023 年,Go 语言将继续全方位地优化和改进其在各个方面的功能和性能。我们在前面已经列举了 Go 官方团队在错误处理、并发编程、便捷语法、内存管理方面的计划。作者相信,这些计划将会有条不紊地进行,并在将来让 Go 语言成为更加优秀、更加流行的编程语言。
希望大家继续关注 Go 语言和 Go 技术社区,尤其是国内的 Go 技术社区。同时,作者也希望能有更多的小伙伴参与到 Go 社区的建设甚至 Go 语言本身的改进当中去。如果正在阅读本文的你还没有尝试过 Go 语言,那么别再犹豫了,赶快到这里下载 Go 语言,并开始尝试编写 Go 程序吧!
1. Go 1.18 Release Notes: https://go.dev/doc/go1.18
2. Go 1.19 Release Notes: https://go.dev/doc/go1.19
3. Go 1.20 Release Notes(DRAFT): https://tip.golang.org/doc/go1.20
4. 其他相关的 Go 语言官方文档
5. Go 语言源码及其修改记录
往期盘点文章:
1. 解读 2015 之 Golang 篇:Golang 的全迸发时代
2. 解读 2016 之 Golang 篇:极速提升,逐步超越
4. 解读 2018 之 Go 语言篇(上):为什么 Go 语言越来越热?
5. 解读 2018 之 Go 语言篇(下):明年有哪些值得期待?
6. 解读 Go 语言的 2019:如果惊喜不再 还有哪些值得关注?
作者简介:
郝林,国内知名编程布道者、技术社群 GoHackers 的发起人和组织者,微信公众号“螺旋码”(视频号同名)主理人。发布过多个 Go 语言技术教程,包括开源的《Go 命令教程》、极客时间的付费专栏《Go 语言核心 36 讲》,以及图灵原创图书《Go 并发编程实战》,等等。其中专栏和图书拥有数万订阅者或购买者,开源教程 star 数也有数千。另外,他还在 2020 年出版了一本名为《Julia 编程基础》的技术图书。
如果你觉得本文对你有帮助,或者你对数据库领域的技术发展有自己的思考,欢迎在文末留言告诉我们!你也可以加入 InfoQ 写作平台撰文发表自己的观点:https://xie.infoq.cn/
【年度技术盘点与展望】专题已发布于 InfoQ 官网,将 InfoQ 添加进收藏夹,精彩不错过。
另外,InfoQ 年度展望直播周将于 2023 年 1 月 3 日首场开播,并持续输出精彩内容,关注 InfoQ 视频号,与行业技术大牛连麦~
评论