err != nil ?

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,说明 someErrnil,则执行此跳转,跳过 someErr != nil 条件成立时的代码块。
  • 目的 :根据 someErr 是否为 nil 决定程序的执行流程。

从上述汇编代码可以可知,interface的零值和条件判断的处理方式是不同。对于interface的零值包含类型零值和数据零值,其判断逻辑是判断类型是否为零值,而不是判断数据是否为零值。

8. 总结

本文总结interface的结构、nil标识符的定义和实现方式,进而说明两者结合之后,golang的处理方式,期望各位在开发过程中以安全的方式判断是否存在异常,避免线上出现类似问题。

9. 相关资料

go101.org/article/nil...

nil是什么

相关推荐
彭岳林19 小时前
nil是什么?
go
浮尘笔记19 小时前
go-zero使用elasticsearch踩坑记:时间存储和展示问题
大数据·elasticsearch·golang·go
杰克逊的黑豹19 小时前
不再迷茫:Rust, Zig, Go 和 C
c++·rust·go
DemonAvenger2 天前
深入剖析 sync.Once:实现原理、应用场景与实战经验
分布式·架构·go
一个热爱生活的普通人3 天前
Go语言中 Mutex 的实现原理
后端·go
孔令飞3 天前
关于 LLMOPS 的一些粗浅思考
人工智能·云原生·go
小戴同学3 天前
实时系统降低延时的利器
后端·性能优化·go
Golang菜鸟4 天前
golang中的组合多态
后端·go
Serverless社区4 天前
函数计算支持热门 MCP Server 一键部署
go