1. err != nil失效
下面两种case是我们经常遇到的代码,它的执行结果如何?
代码1-1
如果仅从代码想表达的意图理解,err != nil的结果应该是false,但实际上与我们要求正好相反,它进入了异常处理逻辑。这种预期与实际上的偏差很容易造成线上事故。
2. 疑问
我们知道error类型实际是一个内建(in-build)的接口,其定义如下:
golang
type error interface {
Error() string
}
那么,当一个error类型值赋值时,golang是如何实现的?nil赋值到error类型变量时,其内存结构是怎样?nil赋值到具体类型时,是做了什么?在回答这些问题前,我们先看下interface是怎么实现的?nil又是什么?
3. golang如何实现interface
在golang中有两个关于interface的结构体:runtime.iface和runtime.eface,其区别如下:
- runtime.iface表示有方法的接口
- runtime.eface表示没有方法的接口
本文只涉及有方法的接口runtime.iface。
runtime.iface的定义
golang
type iface struct { // 16 字节
tab *itab // 表示支持的方法和真实数据类型
data unsafe.Pointer // 表示对象数据地址
}
其中,iface.tab表示支持的方法和真实数据类型,用于实现类型转换和函数成员调用。data表示真实对象数据地址。
4. nil是什么
nil在golang中是一个预声明的标识符,表示指针、channel、函数、接口、map、slice的零值。它和true和false有些类似,但它没有明确的类型,需要在编译时,由编译器推导,然后转成明确类型的零值。
golang
// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type
你可以理解它是一条变色龙,当一个对象被nil赋值时,编译器会生成对应类型的零值,所以nil赋值给不同类型变量时,其大小有可能不一样。具体而言:
代码2-1
同时,由于不同的类型的结构不一样,导致其判断是否为零值的逻辑也不一样。
5. 真实的样子
我们下面来看看代码1-1中,关键的代码到底是如何执行的
5.1 error = nil
当一个nil赋值类error类型时,由于error本身是一个带方法的interface,其类型是runtime.iface,其类型和data字段均是空指针。
5.2 err != nil
当error类型或者其他interface类型与nil进行比较时,由于我们不可能直接操作interface的类型字段(tab),其等效于err.tab != 0。比如:代码1-1中
golang
err := ReturnStandardError()
if err != nil
编译器根据ReturnStandardError的声明判断err是error接口类型。那么,err != nil实际是error接口类型是否为空值的判断;同时,由于我们不能直接操作变量的类型字段(tab),编译器在进行interface是否为空值比较时,直接通过tab指针是否为空值即可;针对代码1-1的场景,ReturnStandardError实际返回的类型是SomeError,err变量在存储时,tab字段存储为SomeError,自然不是指针空值。
6. 解法
我们理解err != nil为什么在空值的情况下结果还是true的原因,那我们如何安全表达异常判断逻辑呢?
6.1 解法1
全部错误类型都是error类型,不定义新的错误类型。由于错误都是同一个类型,自然不存在类型不一致导致类型转换后err != nil判断问题,但是这种方法局限性很大,导致我们不能自己定义业务错误,表达错误码、错误描述、堆栈信息等等对业务和问题排查有益的信息。
6.2 解法2
全部函数的错误返回类型都是error。这种方式能够支持业务自定义业务错误,且不存在err != nil判断问题,因为你在有异常的时候,就会创建error,在没有错误时,返回nil。自然而然地,只要你的类型不是空值,那么就代表存在错误,符合err != nil要表达的语义
代码3-1
6.3 解法3 其实,err != nil实际想表达的是,err是否为空值,无论其类型是什么。那么我们实现判断一个interface变量是否为空值的函数达到我们的目标。这种实现方式不会扩展性和可用性问题,不用小心翼翼注意函数声明的错误类型,也不需要对全团队成员在创建自己的错误类型和函数声明上严格要求。
7. 汇编代码分析(选读)
上面都是根据语法和定义的推理,真实代码是否与推理一致,我们可以通过汇编代码来确认。
golang转成汇编代码指令:
css
> go build -gcflags all="-N -l" main.go
> go tool objdump -S main > main.objdump
scss
var someErr error
0x100097678 a9087fff STP (ZR, ZR), 128(RSP)
someErr = ReturnSomeError()
0x10009767c 97ffffa5 CALL main.ReturnSomeError(SB)
0x100097680 f94007e0 MOVD 8(RSP), R0
0x100097684 f90033e0 MOVD R0, 96(RSP)
0x100097688 f0000221 ADRP 290816(PC), R1
0x10009768c 9110a021 ADD $1064, R1, R1
0x100097690 f90043e1 MOVD R1, 128(RSP)
0x100097694 f90047e0 MOVD R0, 136(RSP)
if someErr != nil {
0x100097698 f94043e0 MOVD 128(RSP), R0
0x10009769c b5000040 CBNZ R0, 2(PC)
0x1000976a0 1400005e JMP 94(PC)
汇编代码
7.1. 声明变量 someErr
scss
0x100097678 a907ffff STP (ZR, ZR), 120(RSP)
- 指令含义 :
STP
是 ARM64 架构中用于存储一对寄存器值到内存的指令。ZR
是零寄存器,其值恒为 0。此指令将两个零寄存器的值存储到栈指针RSP
偏移 120 字节的位置。在 Go 语言里,error
是接口类型,通常由类型指针和数据指针组成,这里将这两个指针初始化为 0,也就是把someErr
初始化为nil
。 - 目的 :初始化
someErr
变量。
7.2. 调用 ReturnSomeError
函数
css
0x10009767c 97ffffa5 CALL main.ReturnSomeError(SB)
- 指令含义 :
CALL
指令用于调用函数,main.ReturnSomeError(SB)
表示调用main
包下的ReturnSomeError
函数。SB
是 Go 汇编里的全局符号表。 - 目的 :调用函数以获取返回值并赋值给
someErr
。
7.3. 处理函数返回值
scss
0x100097680 f94007e0 MOVD 8(RSP), R0
- 指令含义 :
MOVD
指令将栈指针RSP
偏移 8 字节处的内存值加载到寄存器R0
中。在 ARM64 架构中,R0
常用来存放函数的返回值。这里可能是从栈上获取函数返回的某个值。 - 目的:获取函数返回的相关数据。
scss
0x100097684 f90037e0 MOVD R0, 104(RSP)
- 指令含义 :将寄存器
R0
的值存储到栈指针RSP
偏移 104 字节的位置,可能是为了临时保存函数返回的某个数据。 - 目的:临时存储函数返回的部分数据。
7.4. 计算类型指针地址
bash
0x100097688 f0000221 ADRP 290816(PC), R1
0x10009768c 9110a021 ADD $1064, R1, R1
- 指令含义 :
ADRP
指令根据当前程序计数器PC
的值加上偏移量 290816,将结果的高 48 位存储到寄存器R1
中,用于计算页对齐地址。ADD
指令将R1
的值加上 1064,得到最终的类型指针地址。 - 目的 :计算
error
类型的类型指针地址。
7.5. 赋值给 someErr
scss
0x100097690 f9003fe1 MOVD R1, 120(RSP)
0x100097694 f90043e0 MOVD R0, 128(RSP)
- 指令含义 :将寄存器
R1
存储的类型指针值存储到栈指针RSP
偏移 120 字节处(someErr
的类型指针位置),将寄存器R0
存储的数据指针值存储到栈指针RSP
偏移 128 字节处(someErr
的数据指针位置)。 - 目的 :将
ReturnSomeError
函数返回的类型指针和数据指针赋值给someErr
。
7.6. 判断 someErr
是否为 nil
scss
0x100097698 f9403fe0 MOVD 120(RSP), R0
- 指令含义 :将栈指针
RSP
偏移 120 字节处的内存值(即someErr
的类型指针)加载到寄存器R0
中。 - 目的 :获取
someErr
的类型指针,用于后续判断。
scss
0x10009769c b5000040 CBNZ R0, 2(PC)
- 指令含义 :
CBNZ
(Compare and Branch if Not Zero)指令用于比较寄存器R0
的值是否为 0,如果不为 0,则跳转到相对当前程序计数器PC
偏移 2 条指令的位置执行。这里是判断someErr
的类型指针是否为 0,如果不为 0 说明someErr
不为nil
。 - 目的 :判断
someErr
是否为nil
。
scss
0x1000976a0 14000035 JMP 53(PC)
- 指令含义 :
JMP
(Jump)指令用于无条件跳转到相对当前程序计数器PC
偏移 53 条指令的位置执行。如果CBNZ
判断someErr
的类型指针为 0,说明someErr
为nil
,则执行此跳转,跳过someErr != nil
条件成立时的代码块。 - 目的 :根据
someErr
是否为nil
决定程序的执行流程。
从上述汇编代码可以可知,interface的零值和条件判断的处理方式是不同。对于interface的零值包含类型零值和数据零值,其判断逻辑是判断类型是否为零值,而不是判断数据是否为零值。
8. 总结
本文总结interface的结构、nil标识符的定义和实现方式,进而说明两者结合之后,golang的处理方式,期望各位在开发过程中以安全的方式判断是否存在异常,避免线上出现类似问题。