Golang 函数可变参数/多返回值

5.1.2 可变参数

函数 fmt.Println 可将任意数量的输入参数打印到屏幕上。这是如何实现的呢?

func Println(a ...interface{}) (n int, err error)

与其他编程语言一样,Go 也支持可变参数,即允许函数接受任意数量的同类型参数。可变参数必须是函数参数列表中的最后一个(或唯一一个)参数。通过在参数类型前增加 ... 前缀来声明(如 ...int)。可变参数在函数内部会被当作切片([]T)处理,并且操作方式与普通切片完全一致。

接下来,通过一个示例程序来介绍可变参数的工作原理,该程序将一个基数与可变数量的参数相加,并将结果以 int 切片形式返回。

func addTo(base int, vals ...int) []int {
	out := make([]int, 0, len(vals))
	for _, v := range vals {
		out = append(out, base+v)
	}
	return out
}

现在,用多种形式调用 addTo 函数,并打印结果:

func main() {
	fmt.Println(addTo(3))
	fmt.Println(addTo(3, 2))
	fmt.Println(addTo(3, 2, 4, 6, 8))

	a := []int{4, 3}
	fmt.Println(addTo(3, a...))
	fmt.Println(addTo(3, []int{1, 2, 3, 4, 5}...))
}
  • 可以为可变参数提供任意个值,也可以不提供任何值,当不提供任何值时,可变参数为 nil。
  • 如果已经有一个切片,想传给可变参数函数,需要用 ... 展开

运行上述程序,将得到以下输出:

[]
[5]
[5 7 9 11]
[7 6]
[4 5 6 7 8]

性能考虑:每次调用可变参数函数时,如果不是直接传递已有切片,Go 会创建一个新的切片,存在一定开销。频繁调用且性能敏感的场景可以考虑直接传入切片参数。

此外,修改可变参数会影响原切片

func modify(nums ...int) {
    nums[0] = 100
}

data := []int{1, 2, 3}
modify(data...)
fmt.Println(data) // [100 2 3],原切片被修改

以下为去除水印后的全文整理版。少量被水印遮挡的位置,我根据上下文、Go/Python 语法及代码逻辑进行了补全。

5.1.3 多返回值

Go 与其他语言之间的第一个区别是,Go 函数支持多个返回值。

func divAndRemainder(num, denom int) (int, int, error) { // 返回值的类型需在小括号中列出
	if denom == 0 {
		return 0, 0, errors.New("cannot divide by zero") // 多个返回值必须全部返回,
		                                               // 并用逗号分隔
	}
	return num / denom, num % denom, nil // 多个返回值:商、余数和错误信息
}

当 Go 函数返回多个值时,返回值的类型需在小括号中列出,并用逗号分隔。同时,如果函数返回多个值,必须将它们全部返回,并用逗号分隔。不要在返回值周围加小括号,否则会导致编译时错误。

此处涉及一个新概念:创建并返回错误对象。现阶段,只需要知道,如果函数中发生了错误,可以用 Go 的多返回值特性来返回一个错误对象。如果函数成功完成,可返回 nil 作为错误值。

按照惯例,错误对象总是函数返回值列表的最后一个(或唯一一个)值。

多返回值函数的调用,如下所示:

func main() {
	result, remainder, err := divAndRemainder(5, 2) // 多返回值函数的调用示例
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	fmt.Println(result, remainder)
}

5.1.4 多返回值是多个值

对 Python 语言有所了解的读者,可能会将 Go 语言中的多返回值特性与 Python 中函数返回元组(tuple)的行为进行类比。在 Python 中,当函数返回一个元组时,可以选择将这个元组的各个值分别赋给多个变量,此过程被称为解构(destructured)。示例 5.2 展示了 Python 中多返回值的示例代码。

示例 5.2:Python 的多返回值特性是通过解构元组来实现的

>>> def div_and_remainder(n,d):
...     if d == 0:
...         raise Exception("cannot divide by zero")
...     return n / d, n % d    // Python 的多返回值是通过元组来实现的
>>> v = div_and_remainder(5,2)
>>> v
(2.5, 1)
>>> result, remainder = div_and_remainder(5,2)
>>> result
2.5
>>> remainder
1

在 Python 中,可以将函数的多个返回值赋给单个变量或多个变量,而 Go 语言必须为函数的多个返回值分别赋值。如果尝试将多个返回值赋值给单个变量,将会引发编译时错误。

5.1.5 忽略返回值

如果调用函数后,不想使用所有返回值,该怎么办?如“2.5 未使用的变量<27页>”一节所述,Go 语言不允许存在不使用的变量。如果函数返回多个值,但不想使用某个(或多个)值,可以将不使用的值赋给标识符 _。例如,如果不想使用 divAndRemainder 返回的余数,可以将函数调用的赋值表达式写成:

result, _, err := divAndRemainder(5, 2)

在 Go 语言中,有一个特性可能初学者会觉得意外,那就是可以完全忽略函数的所有返回值。具体来说,可以调用一个函数(如 divAndRemainder(5,2)),而不对它返回的任何值做任何处理,这些返回值就会被丢弃。

其实,从本书的第一个示例开始,就已经在这样做了。fmt.Println 返回两个值(打印的字节数、错误对象),但按照惯例,通常会忽略这两个返回值。多数情况下(fmt.Println 这种特例除外),要忽略函数的某个(或多个)返回值,都应使用下划线 _,以明确意图并提升代码可读性。

如果不想使用函数返回的某个(或多个)值,可以将该返回值赋值给标识符 _

5.1.6 命名(具名)返回值

在函数声明时,可以直接用变量为返回值命名,函数体中可以直接使用这些变量,最后通过 return 关键字返回。这种变量被称为“命名返回值”或“命名返回参数”。如下所示,改写 divAndRemainder 函数,为函数的返回值指定名称:

// 为返回值指定名称
func divAndRemainder(num, denom int) (result int, remainder int, err error) {
	if denom == 0 {
		err = errors.New("cannot divide by zero")
		return result, remainder, err
	}

	result, remainder = num/denom, num%denom
	return result, remainder, err
}

当为返回值命名时,实际上是在函数内部预先声明了用于存储返回值的变量。命名返回值需要以逗号分隔的形式写在小括号内,即使只有一个返回值,也必须用小括号括起来。命名返回值在创建时会被初始化为相应类型的零值。这意味着,可以在任何显式使用返回值之前,直接返回它们。

如果只想为某些返回值命名,可以使用 _ 作为希望保持无名返回值的名称。

需要注意的一点是:用于命名返回值的名称仅在函数内部有效,不会影响函数外部的变量命名。在调用函数时,完全可以将返回的值赋给与函数内部命名返回值不同的变量名。

func main() {
	x, y, z := divAndRemainder(5, 2)
	fmt.Println(x, y, z)
}

虽然命名返回值有时能使代码更清晰,但它们也存在一些潜在的陷阱。首先,是遮蔽变量(Shadowing Variable)的问题。与其他变量一样,命名返回值也可能被遮蔽。需要确保正在对返回值变量赋值,而不是对返回值的遮蔽变量进行赋值。

命名返回值另一个问题是:并不一定要返回它们。接下来,看一下 divAndRemainder 的另一个变体。

// 命名的返回值参数,即 result、remainder、err
func divAndRemainder(num, denom int) (result int, remainder int, err error) {
	// assign some values
	result, remainder = 20, 30 // ❶ 为命名返回值赋值
	if denom == 0 {
		return 0, 0, errors.New("cannot divide by zero")
	}

	return num / denom, num % denom, nil // ❷ 返回不同的值,而非使用命名的返回参数
}

注意,在 divAndRemainder 函数中,首先为 resultremainder 赋值(❶),但随后的 return 语句(❷)返回了不同的值。在运行代码之前猜猜传入 5 和 2 后,会得到什么输出结果?结果可能会令你惊讶:

2 1

命名返回值只是提前声明变量名,不代表必须使用它们。return 语句永远以你显式给出的值为准。

即使函数签名声明了 resulterr,只要 return 后面显式写了值,这些值会直接覆盖命名变量,和有没有命名返回值没有任何关系。

只有在使用空 return 语句的情况下,命名返回值才真正发挥作用——编译器会自动把这些变量当前的值当作返回值

命名返回值本质上是函数体内可以直接使用的"预声明变量",而不代表函数一定返回这几个变量

部分开发者偏好使用命名返回值(Named Return Parameters),因其能为代码提供额外的说明性文档。但我认为其实际价值有限:首先,若在函数内部声明了与命名返回参数同名的变量时,命名返回参数会被函数内部变量遮蔽,从而引发混淆。其次,开发者可能直接忽略命名返回参数,导致代码意图不清晰。然而,命名返回值在一种特定场景下不可或缺——本章后续讲解 defer 时会详述其必要性。

5.1.7 空 return 语句——坚决别用

如果使用了命名返回值,需要注意 Go 中的一个严重缺陷:空 return(又称“裸(naked)return”)。如果使用命名返回值,可以直接写 return 关键字,而不需要指定返回的值。这将返回最后赋值给命名返回参数的值。最后一次修改 divAndRemainder 函数,这次使用“空 return”:

func divAndRemainder(num, denom int) (result int, remainder int, err error) {
	if denom == 0 {
		err = errors.New("cannot divide by zero")
		return // 空 return,返回最后赋值给命名返回参数的值
	}

	result, remainder = num/denom, num%denom
	return // 空 return,返回最后赋值给命名返回参数的值
}

使用空 return 对函数进行了一些额外的修改。当输入无效时,函数立即返回。由于没有为 resultremainder 赋值,因此将返回它们的零值。如果要为命名返回参数返回零值,请确保这个行为是有意义的。还要注意,在函数末尾必须加上 return 语句。即使函数使用了空 return,但该函数也会返回值。省略 return 关键字将导致编译时错误。

一开始,可能会觉得空 return 很方便,因为它可以减少输入。然而,大多数经验丰富的 Go 开发人员都认为空 return 是一个坏主意,因为空 return 会让人更难理解数据流。好的软件应该是清晰可读的,即发生了什么一目了然。当使用空 return 时,代码阅读者需要浏览程序代码,找到分配给返回值参数的最后一个值,以了解空 return 实际返回的是什么。

如果函数有返回值,千万不要使用空 return。这会让人很难搞清楚函数实际返回了什么值。

评论