Go 语言内部机制第二部分:命名返回值的妙处

您可能知道 Golang 提供了命名返回值的功能。到目前为止,在MinIO 中,我们并没有太多地使用此功能,但这种情况将会改变,因为正如我们将在本文中解释的那样,它有一些很好的隐藏优势。

如果您像我们一样,您可能拥有大量的代码,如下所示,对于每个return语句,您都会实例化一个新对象以返回一个“默认”值。

type objectInfo struct {
	arg1 int64
	arg2 uint64
	arg3 string
	arg4 []int
}
func NoNamedReturnParams(i int) (objectInfo) {

	if i == 1 {
		// Do one thing
		return objectInfo{}
	}

	if i == 2 {
		// Do another thing
		return objectInfo{}
	}

	if i == 3 {
		// Do one more thing still
		return objectInfo{}
	}

	// Normal return
	return objectInfo{}
}

如果您查看 Golang 编译器生成的实际代码,您最终会得到类似这样的结果。

"".NoNamedReturnParams t=1 size=243 args=0x40 locals=0x0
0x0000 	TEXT	"".NoNamedReturnParams(SB), $0-64
0x0000 	MOVQ	$0, "".~r1+16(FP)
0x0009 	LEAQ	"".~r1+24(FP), DI
0x000e 	XORPS	X0, X0
0x0011 	ADDQ	$-16, DI
0x0015 	DUFFZERO	$288
0x0028 	MOVQ	"".i+8(FP), AX
0x002d 	CMPQ	AX, $1
0x0031 	JEQ	$0, 199
0x0037 	CMPQ	AX, $2
0x003b 	JEQ	$0, 155
0x003d 	CMPQ	AX, $3
0x0041 	JNE	111
0x0043 	MOVQ	"".statictmp_2(SB), AX
0x004a 	MOVQ	AX, "".~r1+16(FP)
0x004f 	LEAQ	"".~r1+24(FP), DI
0x0054 	LEAQ	"".statictmp_2+8(SB), SI
0x005b 	DUFFCOPY	$854
0x006e 	RET
0x006f 	MOVQ	"".statictmp_3(SB), AX
0x0076 	MOVQ	AX, "".~r1+16(FP)
0x007b 	LEAQ	"".~r1+24(FP), DI
0x0080 	LEAQ	"".statictmp_3+8(SB), SI
0x0087 	DUFFCOPY	$854
0x009a 	RET
0x009b 	MOVQ	"".statictmp_1(SB), AX
0x00a2 	MOVQ	AX, "".~r1+16(FP)
0x00a7 	LEAQ	"".~r1+24(FP), DI
0x00ac 	LEAQ	"".statictmp_1+8(SB), SI
0x00b3 	DUFFCOPY	$854
0x00c6 	RET
0x00c7 	MOVQ	"".statictmp_0(SB), AX
0x00ce 	MOVQ	AX, "".~r1+16(FP)
0x00d3 	LEAQ	"".~r1+24(FP), DI
0x00d8 	LEAQ	"".statictmp_0+8(SB), SI
0x00df 	DUFFCOPY	$854
0x00f2 	RET

一切都很好,但如果这看起来有点重复,您是正确的。从本质上讲,对于每个return语句,要返回的对象或多或少都会被分配/初始化(更准确地说,是通过DUFFCOPY宏复制)。

毕竟,这就是我们通过在每种情况下使用return objectInfo{}返回所要求的。

命名返回值

现在看看如果我们进行一个非常简单的更改会发生什么,本质上只是给返回值一个名称(oi)并使用 Golang 的“裸”返回功能(删除return语句的参数,尽管这并非严格要求,稍后会详细介绍)。

func NamedReturnParams(i int) (oi objectInfo) {

	if i == 1 {
		// Do one thing
		return
	}

	if i == 2 {
		// Do another thing
		return
	}

	if i == 3 {
		// Do one more thing still
		return
	}

	// Normal return
	return
}

再次查看编译器生成的代码,我们得到以下结果。

"".NamedReturnParams t=1 size=67 args=0x40 locals=0x0
	0x0000 	TEXT	"".NamedReturnParams(SB), $0-64
	0x0000 	MOVQ	$0, "".oi+16(FP)
	0x0009 	LEAQ	"".oi+24(FP), DI
	0x000e 	XORPS	X0, X0
	0x0011 	ADDQ	$-16, DI
	0x0015 	DUFFZERO	$288
	0x0028 	MOVQ	"".i+8(FP), AX
	0x002d 	CMPQ	AX, $1
	0x0031 	JEQ	$0, 66
	0x0033 	CMPQ	AX, $2
	0x0037 	JEQ	$0, 65
	0x0039 	CMPQ	AX, $3
	0x003d 	JNE	64
	0x003f 	RET
	0x0040 	RET
	0x0041 	RET
	0x0042 	RET

这是一个非常大的差异,所有四个对象初始化和DUFFCOPY的实例都消失了(即使在这种微不足道的情况下)。它将函数的大小从243字节减少到67字节。此外,作为一个额外的优势,您将在退出时节省一些 CPU 周期,因为不再需要做任何事情来设置返回值。

请注意,如果您不喜欢或更喜欢 Golang 提供的裸返回,您可以使用return oi,同时仍然获得相同的好处,如下所示。

	if i == 1 {
		return oi
	}

Minio 服务器中的实际示例

MinIO 服务器 中进一步检查,我们采用了以下案例。

// parse credentialHeader string into its structured form.
func parseCredentialHeader(credElement string) (credentialHeader) {
    creds := strings.Split(strings.TrimSpace(credElement), "=")
    if len(creds) != 2 {
	return credentialHeader{}
    }
    if creds[0] != "Credential" {
	return credentialHeader{}
    }
    credElements := strings.Split(strings.TrimSpace(creds[1]), "/")
    if len(credElements) != 5 {
	return credentialHeader{}
    }
    if false /*!isAccessKeyValid(credElements[0])*/ {
	return credentialHeader{}
    }
    // Save access key id.
    cred := credentialHeader{
	accessKey: credElements[0],
    }
    var e error
    cred.scope.date, e = time.Parse(yyyymmdd, credElements[1])
    if e != nil {
	return credentialHeader{}
    }
    cred.scope.region = credElements[2]
    if credElements[3] != "s3" {
	return credentialHeader{}
    }
    cred.scope.service = credElements[3]
    if credElements[4] != "aws4_request" {
	return credentialHeader{}
    }
    cred.scope.request = credElements[4]
    return cred
}

查看汇编代码,我们得到以下函数头(我们将为您节省完整的列表…)。

"".parseCredentialHeader t=1 size=1157 args=0x68 locals=0xb8

如果我们将代码修改为使用命名返回值参数(下面的第二个源代码块),请查看函数的大小发生了什么变化。

"".parseCredentialHeader t=1 size=863 args=0x68 locals=0xb8 

它从总共 1150 字节中减少了大约 300 字节,对于对源代码进行如此小的更改来说,这还不错。根据您的情况,您可能也更喜欢源代码的“更简洁”外观。

// parse credentialHeader string into its structured form.
func parseCredentialHeader(credElement string) (ch credentialHeader) {
    creds := strings.Split(strings.TrimSpace(credElement), "=")
    if len(creds) != 2 {
	return
    }
    if creds[0] != "Credential" {
	return
    }
    credElements := strings.Split(strings.TrimSpace(creds[1]), "/")
    if len(credElements) != 5 {
	return
    }
    if false /*!isAccessKeyValid(credElements[0])*/ {
	return
    }
    // Save access key id.
    cred := credentialHeader{
	accessKey: credElements[0],
    }
    var e error
    cred.scope.date, e = time.Parse(yyyymmdd, credElements[1])
    if e != nil {
	return
    }
    cred.scope.region = credElements[2]
    if credElements[3] != "s3" {
	return
    }
    cred.scope.service = credElements[3]
    if credElements[4] != "aws4_request" {
	return
    }
    cred.scope.request = credElements[4]
    return cred
} 

请注意,实际上ch变量是一个普通的局部变量,就像在函数中定义的任何其他局部变量一样。因此,您可以更改其从默认“零”状态的值(但当然,修改后的版本将在退出时返回)。

命名返回值的其他用途

正如一些人指出的那样,命名返回值的另一个好处是在闭包(即 defer 语句)中使用。因此,可以在作为defer语句的结果调用的函数中访问命名返回值并相应地采取行动。

关于本系列

如果您错过了本系列的第一部分,这里有一个链接。

结论

因此,我们将逐渐越来越多地采用命名返回值,包括新代码和现有代码。

事实上,我们还在研究是否可以开发一个小工具来帮助或自动化此过程。想想gofmt,但随后自动修改源代码以进行上述更改。特别是在返回值尚未命名的情况下(因此实用程序必须为其命名),它必然不能更改现有源代码中此返回值的任何方式,因此使用return ch(在上面的列表中)将不会导致程序的任何功能性更改。敬请期待。

我们希望这篇文章对您有所帮助,并为您提供一些关于 Go 内部运行机制以及如何改进您的 Golang 代码的新见解。

更新

一个问题 已经提交给 Golang,要求优化编译器以针对上述情况生成相同的代码,这将是一件好事。