Go 版本新特性

版本新特性

Go 1.16

embed

embed 是在 Go 1.16 中新加包。它通过 //go:embed 指令,可以在编译阶段将静态资源文件打包进编译好的程序中,并提供访问这些文件的能力。

为什么需要 embed 包

  • 部署过程更简单。传统部署要么需要将静态资源与已编译程序打包在一起上传,或者使用 docker 和 dockerfile 自动化前者
  • 确保程序的完整性。在运行过程中损坏或丢失静态资源通常会影响程序的正常运行。
  • 您可以独立控制程序所需的静态资源。

embed 的基本语法

基本语法非常简单,首先导入 embed 包,然后使用指令 //go:embed 文件名 将对应的文件或目录结构导入到对应的变量上。 例如: 在当前目录下新建文件 version.txt,并输入内容 0.0.1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
    _ "embed"
    "fmt"
)

//go:embed version.txt
var version string

func main() {
    fmt.Printf("version: %q\n", version)
}

Go 1.18

any

any 作为一个新的关键字出现,any 有一个真身,本质就上是 interface{} 的别名

1
type any = interface{}

使用例子:

1
func Print[T any](s []T) {}

泛型

详见 “Go 泛型”

扩容

Go1.18 之前切片的扩容是以容量 1024 为临界点:

  • 当新 Slice 需要的容量大于原 Slice 容量的两倍,则直接按照新切片需要的容量扩容;
  • 当原 slice 容量 < 1024 的时候,新 slice 容量变成原来的 2 倍;
  • 当原 slice 容量 > 1024,进入一个循环,每次容量变成原来的 1.25 倍,直到大于期望容量。

然而这个扩容机制已经被 Go 1.18 弃用了,官方说新的扩容机制能更平滑地过渡。

Go1.18 不再以 1024 为临界点,而是设定了一个值为 256 的 threshold,以 256 为临界点;超过 256,不再是每次扩容 1/4,而是每次增加(旧容量 + 3*256)/4;

  • 当新 Slice 需要的容量大于原 Slice 容量的两倍,则直接按照新切片需要的容量扩容;
  • 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
  • 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。

https://markdown-1303167219.cos.ap-shanghai.myqcloud.com/image-20230713202331271.png

1.18 的切片扩容优化策略,让底层数组大小的增长更加平滑: 通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从 2 到 1.25 的突变,该 commit 作者给出了几种原始容量下对应的“扩容系数”:

oldcap扩容系数
2562.0
5121.63
10241.44
20481.35
40961.30

可以看到,Go1.18 的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存。

我们可以试着求一个极限,当 oldcap 远大于 256 的时候,扩容系数将会变成 1.25。

Go 1.21

Go 1.21中值得关注的几个变化 - 知乎 (zhihu.com)

min、max 和 clear

builtin 包是一个特殊包,里面放置了 Go 语言预定义的标识符,用户层代码无需也不能导入 builtin 包。

builtin 增加了三个预定义函数:min、max 和 clear。

min、max

顾名思义,min 和 max 函数分别返回参数列表中的最小值和最大值,它们都是泛型函数,原型如下:

1
2
3
4
5
6
7
8
9
func min[T cmp.Ordered](x T, y ...T) T
func max[T cmp.Ordered](x T, y ...T) T

type Ordered interface {
 ~int | ~int8 | ~int16 | ~int32 | ~int64 |
  ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
  ~float32 | ~float64 |
  ~string
}

通过原型我们看到,使用这两个函数时,参数的类型要相同,且至少要传入一个参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// lang/min_max.go

var x, y int = 5, 6
fmt.Println(max(x))                    // 5
fmt.Println(max(x, y, 0))              // 6
fmt.Println(max("aby", "tony", "tom")) // tony

// will raise error 👇
var f float64 = 5.6
fmt.Printf("%T\n", max(x, y, f))    // invalid argument: mismatched types int (previous argument) and float64 (type of f)
fmt.Printf("%T\n", max(x, y, 10.1)) // (untyped float constant) truncated to int

clear

clear 函数的原型如下

1
func clear[T ~[]Type | ~map[Type]Type1](t T)

从原型来看,clear 的操作对象是切片和 map 类型,不过其执行语义因依操作的对象类型而异。

  • 针对 slice,clear 保持 slice 的长度和容量,但将所有 slice 内已存在的元素(len 个)都置为元素类型的零值;
  • 针对 map,clear 则是清空所有 map 的键值对,clear 后,我们将得到一个 empty map。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// lang/clear.go

var sl = []int{1, 2, 3, 4, 5, 6}
fmt.Printf("before clear, sl=%v, len(sl)=%d, cap(sl)=%d\n", sl, len(sl), cap(sl))
clear(sl)
fmt.Printf("after clear, sl=%v, len(sl)=%d, cap(sl)=%d\n", sl, len(sl), cap(sl))

var m = map[string]int{
    "tony": 13,
    "tom":  14,
    "amy":  15,
}
fmt.Printf("before clear, m=%v, len(m)=%d\n", m, len(m))
clear(m)
fmt.Printf("after clear, m=%v, len(m)=%d\n", m, len(m))

这段代码的输出结果如下:

1
2
3
4
before clear, sl=[1 2 3 4 5 6], len(sl)=6, cap(sl)=6
after clear, sl=[0 0 0 0 0 0], len(sl)=6, cap(sl)=6
before clear, m=map[amy:15 tom:14 tony:13], len(m)=3
after clear, m=map[], len(m)=0

明确包初始化顺序算法

在 Go 中,包既是功能单元,也是构建单元,Go 代码通过导入其他包来复用导入包的导出功能(包括导出的变量、常量、函数、类型以及方法等)。G

o 程序启动时,程序会首先将依赖的包按一定顺序进行初始化,但长久以来,Go 语言规范并没有明确依赖包初始化的顺序,这可能会导致一些对包初始化顺序有依赖的 Go 程序在不同 Go 版本下出现行为的差异。

为了消除这些可能存在的问题,Go 核心团队在 Go 1.21 中明确了包初始化顺序的算法。

对包的初始化顺序有依赖,这本身就不是一种很好的设计,大家日常编码时应该注意避免。如果你的程序对包的初始化顺序存在依赖,那么升级到Go 1.21时你的程序行为可能会受到影响。

这个算法比较简单,其步骤如下:

  • 将所有依赖包按照导入路径排序,放入一个 list;
  • 从 list 中按顺序找出第一个自身尚未初始化,但其依赖包已经全部初始化了的包,然后初始化该包,并将该包从 list 中删除;
  • 重新执行上面步骤,直到 list 为空。

https://pic1.zhimg.com/80/v2-10a06cc15da36e2e934f5141c30d209c_1440w.webp

上图的包导入顺序,即为 c, d, e, f, z, a, main

type inference 的增强

此次的类型推断增强主要包含以下三个方面:

  • 部分实例化的泛型函数 (Partially instantiated generic functions)
  • 接口赋值推断 (Interface assignment inference)
  • 对无类型常量的类型推断 (Type inference for untyped constants)

TODO: 具体例子待补充

loop var per-loop -> loop var per-iteration

不过 Go 的 for 循环语句,尤其是 for range 语句有着很容易让程序出现错误的语义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// lang/loopvar/loopvar_per_loop.go

func main() {
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
}

运行结果

1
2
3
4
5
6
$go run loopvar_per_loop.go
4 5
4 5
4 5
4 5
4 5

我们看到:goroutine 中输出的 i、v 值都是 for range 循环结束后的 i、v 的最终值,而不是各个 goroutine 启动时的 i、v 值。这是因为

  1. goroutine 执行的闭包函数引用了它的外层包裹函数中的变量 i、v,这样变量 i、v 在主 goroutine 和新启动的 goroutine 之间实现了共享。
  2. 而 i, v 值在整个循环过程中是重用的,即仅有一份。在 for range 循环结束后,i = 4, v = 5,因此各个 goroutine 在等待 3 秒后进行输出的时候,输出的是 i, v 的最终值。

这里的 i 和 v 被称为 loop var per loop,即一个循环语句定义一次的变量

一种解决这个问题的典型方法是这样的:

1
2
3
4
5
6
// lang/loopvar/loopvar_per_iteration_classic.go
for i, v = range m {
 i := i
 v := v
 //... ...
}

我们在每个迭代中用短变量声明重新定义了在这次迭代中使用的 i 和 v,这里的 i 和 v 就是loop var per-iteration的了。不过这个方法也存在问题,比如不能解决所有场景下的 loop var per-iteration 问题,另外就是需要手工创建。

Go 团队决定在 Go 1.22 版本移除这个“坑”,并在 Go 1.21 版本中以实验语义 (GOEXPERIMENT=loopvar) 提供了默认采用 loop var per-iteration 语义的 for 循环(包括 for range)。

新语义仅在 GOEXPERIMENT=loopvar 且在for语句(包括for range)的前置条件表达式中使用短变量声明循环变量时才生效。

下面是 for range 的新语义的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// lang/loopvar/loopvar_per_iteration.go
package main

import (
 "fmt"
 "time"
)

func main() {
 var m = [...]int{1, 2, 3, 4, 5}

 for i, v := range m {
  go func() {
   time.Sleep(time.Second * 3)
   fmt.Println(i, v)
  }()
 }

 time.Sleep(time.Second * 10)
}

使用新语义运行该示例:

1
2
3
4
5
6
$GOEXPERIMENT=loopvar go run loopvar_per_iteration.go
2 3
1 2
4 5
0 1
3 4

我们看到,新 loopvar 语义就相当于我们在每次迭代时手动重新定义 i := i 和 v := v。

对于经典的 3 段式 for 循环语句:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for i := 0; i < 5; i++ {
 // 使用i 
}

在新语义下等价于

for i := 0; i < 5; i++ {
 i' := i
 // 使用i'
 i = i'
}

我们看到:新语义相当于 Go 编译器在每次 iteration 的前后各插入一行代码,在迭代(iteration)开始处插入 i’ := i,然后迭代过程中使用的是 i’,而在迭代的末尾则将 i’的最新值赋值给 i,后续 i 继续参与到 loop 是否继续的条件判定以及后置语句的操作中去。

新增的标准库

log/slog

slog 是一个高质量、高性能的结构化日志实现,这里建议大家在启动新 Go 项目时,尽量采用 log/slog 作为日志输出的方案。

slices、maps 和 cmp

在 Go 实验库“孵化”了一年多的几个泛型包 slices、maps 和 cmp 终于在 Go 1.21 版本中正式加入到标准库中了。

slices 切片包提供了针对切片的常用操作,slices 包使用了泛型函数,可处理任何元素类型的切片。同理,maps 包与 slices 包地位相似,只不过操作对象换成了 map 类型变量,它可以处理任意类型键和元素类型的 map。

cmp 包是 slices 包依赖的包,这个包非常简单且内聚,它仅提供了与 compare 和 ordered 相关的约束类型定义与简单泛型函数。

以上三个包没有太多可说的,都是一些 utils 类的函数,大家在日常开发中记得用就 ok 了,基于泛型的实现以及 unified 中间代码的优化,这些函数的性能相对于基于 interface 实现的通用工具函数要高出一些。

在Go 1.21正式版发布之前,Go team删除了maps包中原有的Keys和Values函数,其原因是要在后续版本中提供iter包。

有修改的标准库

context

新增 WithoutCancel、WithDeadlineCause、WithTimeoutCause 和 AfterFunc

新增的 WithoutCancel、WithDeadlineCause、WithTimeoutCause 函数可以让你通过 Cause 函数获得导致 cancel/timeout 的真因:

1
2
3
4
ctx, cancel := context.WithCancelCause(parent)
cancel(myError)
ctx.Err() // returns context.Canceled
context.Cause(ctx) // returns myError

AfterFunc 函数是一个高级函数,与 time.AfterFunc 的机制和用法都类似,官方文档中有三个使用 AfterFunc 的例子

其他

  • sync:增加 OnceFunc, OnceValue 和 OnceValues 等语法糖函数

  • 增加 errors.ErrUnsupported

  • testing: 新增 Testing 函数

  • runtime/trace:收集跟踪信息成本大幅降低

  • unicode: 升级到 Unicode 15.0.0 版本