Jason Pan

Golang 空白标识符

黄杰 / 2023-07-26


Go 语言中,空白标识符 _ 最常用的方式是:作为无用变量的占位符,可以接受任何类型的值,而该值将被无害地丢弃。这有类似将Unix将输出重定向到 Unix/dev/null

本文除了介绍空白标识符作为变量赋值占位符之外,介绍一下空白标识符的其他用法,这些用法你可能见到过,但当时可能没理解是什么含义。

1. 多重赋值中的空白标识符

多重赋值是指一个函数有多个返回值,但程序可能不会使用其中某几个值,则赋值左侧的空白标识符能够避免创建变量,能明确说明该变量应该被丢弃。

常见的场景包括:

用和不用之间的变量或包

Go 中如果声明了一个变量,而后续没使用,是会报编译错误的。同样的,如果 import 一个包没有使用,也会报错或者被 go fmt 自动删掉。

但实际开发过程中,难免会有需要临时变更或者注释代码调试,这就会经常使用变量重新定义不使用变量删除变量定义之间来回切换,这是比较烦的一件事。

但是,如果使用 _ 来临时接收经常改的变量或者包中的内容,则可以避免这一烦恼:

package main

import (
    "fmt"
    "log"
    "os"
)

var _ = fmt.Printf // For debugging; delete when done.

func main() {
    fd, err := os.Open("test.go")
    if err != nil {
        log.Fatal(err)
    }
    // fmt.Println("commented temporarily")
    _ = fd  // TODO: use fd
}

与 Python 中 _ 的不同

其实,Python 中我们也常用 _ 来接收无用的变量,比如:

for _ in range(10):
  print("A")

但是,两种语言中的 _ 有本质的不同:

>>> _, _ = 1, 2
>>> print(_)
2

2. 用于匿名导入

导入有一定的副作用,这里说的副作用不是个贬义词,而是指一个包被 import 的时候,会被动执行其 init()函数。此时,如果代码中没有用到包的任何符号,则要使用 import _ path/to/package 的方式,保证包会被初始化。

import _ package 与上边提到的用和不用之间的变量或者包的用法是不同的:后者为临时接收方式,在最终程序完成之后,实际是会被删掉的;不然,引入无用的包会造成程序变大、增加编译耗时,引入无用的变量也是一种浪费。

pprof 的例子

比如在 pprof 的文档中就提到:

To use pprof, link this package into your program:

import _ "net/http/pprof"

一个最简单的使用 pprof 的完整代码:

package main

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    http.ListenAndServe("localhost:6060", nil)
}

在 pprof 的 init 函数中,会注册提供调试信息的 HTTP 处理函数:

image-20230726182241486

init() 函数的细节

To ensure reproducible initialization behavior, build systems are encouraged to present multiple files belonging to the same package in lexical file name order to a compiler.

A package with no imports is initialized by assigning initial values to all its package-level variables followed by calling all init functions in the order they appear in the source, possibly in multiple files, as presented to the compiler.

go-init-order

什么时候会使用 init()

init() 函数中通常是做与包级别的资源确认和创建、变量初始化等,比如:

除了上边的 pprof 的例子,使用 tRPC-go 的程序中,可通过 import _ 引入 opentelemetry 拦截器,然后在配置中增加 opentelemetry 相关的配置,就可以使可观测性功能启用。

直接引入main包

在 open-telemetry 的项目规范中,有个 tools 目录下有一个 tools.go 文件,去掉注释只有两行:

package tools

import _ "github.com/client9/misspell/cmd/misspell"

在该目录下直接使用 go build -o bin/misspell github.com/client9/misspell/cmd/misspell 命令可以构建出一个二进制文件,因为这个文件实际就是原封不动的使用了 misspell/cmd/misspell 的 main 包。

3. 用于接口检查

在 Go 语言中,类型不需要显式声明它实现哪些接口(interface),仅需实现接口的所有方法即可。实际上,大多数接口转换都是静态的——会在编译时进行检查。

  f, _ := os.OpenFile("notes.txt", os.O_RDWR|os.O_CREATE, 0755)
  lr := io.LimitReader(f, 4)

上述代码中,出现了将 *os.Filef 传递给期望 io.Reader 的函数 io.LimitReader 的表达。编译器自动验证前者实现了 io.Reader(有对应的 Read()函数),如果没有实现则会在编译器报错。

**某些接口检查确实会在运行时进行,多在涉及反射的时候会遇到。**比如在 encoding/json 包中, 编码器在运行时使用类型断言检查此属性:

import_blank_in_json

其中 if _, ok := val.(json.Marshaler); ok {,用于查询类型是否实现了一个接口,而不实际使用接口本身,可作为错误检查的一部分,使用空白标识符来忽略类型声明的值。


当必须保证实现了接口的场景中,可以使用 _ + 全局声明的方式。仍以 encoding/json 包中的代码为例,stream.go 文件中有以下两条声明(仅用于类型检查,而不是创建变量):

var _ Marshaler = (*RawMessage)(nil)
var _ Unmarshaler = (*RawMessage)(nil)

上面的赋值中,先将 nil 转换成 RawMessage 指针,再将 *RawMessage 转换成 json.Marshaler。其作用就是,检验 *RawMessage 是否实现了 json.Marshaler 接口。如果没有实现,会在编译期报错,而不是在运行时出错。

通常,使用这种方式的场景较少,仅仅在代码中没有静态转换的时候才会用到。

进一步思考一下,什么时候会没有静态转换,同时还需要确保某类型实现了某接口呢?通常是我们封装了一些库,给别人使用的时候,以本节前半部分的例子,如果我自己写一个类型,来实现某接口,为了确保自己实现没有遗漏,就可以使用这种方式来做保障。再回头看 RawMessage 的两个类型检查,就是为了确保其完整的实现了 MarshalerUnmarshaler

4. 其他语言中的 _

除了 Go 语言中会使用 _,上文还提到了 Python 中也会使用 _

不少其他语言也会用到 _,其用法大都是为了使代码更简练。

我最近在使用 Scala 语言,发现里边 _ 的应用场景更多,除了表示忽略变量外,还有 6 种明显不同的用法:

import cats.implicits._
var myVariable: String = _
val sumFunction: (Int, Int) => Int = _ + _
class MyHigherKindedJewel[M[_]]
val love = makeSentence(words: _*)
val incrementerFunction = incrementer _ // incrementer是个函数

欢迎大家留言,讨论更多有意思的用法。