Skip to content

Latest commit

 

History

History
675 lines (514 loc) · 24.1 KB

17 go变长参数函数的妙用.md

File metadata and controls

675 lines (514 loc) · 24.1 KB

17 go变长参数函数的妙用

变长参数函数的妙用

在 Go 语言中,我们日常使用最多但又经常被”忽视“的一类函数就是变长参数函数

说变长参数函数被使用得最多是因为最常用的 fmt 包、log 包中的几个导出函数都是变长参数函数:

// $GOROOT/src/fmt/print.go
func Println(a ...interface{}) (n int, err error)
func Printf(format string, a ...interface{}) (n int, err error)
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
func Fprint(w io.Writer, a ...interface{}) (n int, err error)
func Sprintf(format string, a ...interface{}) string
func Sprintln(a ...interface{}) string

// $GOROOT/src/log/log.go
func Printf(format string, v ...interface{})
func Println(v ...interface{}) 
func Fatal(v ...interface{})
func Fatalf(format string, v ...interface{})
func Fatalln(v ...interface{}) 
func Panic(v ...interface{})
func Panicf(format string, v ...interface{})
func Panicln(v ...interface{}) 

并且 Go 内置的常用于切片类型操作的 append 函数也是变长参数函数:

// $GOROOT/src/builtin/builtin.go
func append(slice []Type, elems ...Type) []Type 

但日常我们却很少基于变长参数设计和实现自己的函数或方法。究其原因,笔者认为除了对变长参数函数的理解可能不足之外,更主要的原因是没有找到很好的变长参数函数应用模式。

但有些时候使用变长参数函数可以简化代码逻辑,使代码更易阅读和理解。在这一节中,我就和大家一起来了解一下变长参数函数以及它的几个典型应用模式。

1. 什么是变长参数函数

顾名思义,变长参数函数就是指函数调用时可以接受零个、一个或多个实际参数,就像下面对 fmt.Println 的调用那样:

fmt.Println() // ok
fmt.Println("Tony", "Bai") // ok
fmt.Println("Tony", "Bai", "is", "a", "gopher") // ok 

对照下面 fmt.Println 函数的原型:

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

我们看到:无论传入零个、两个还是多个实际参数,这些实参都传给了 Println 的形式参数 a。形参 a 的类型是一个比较”奇特“的组合:… interface{},这种接受**”…T“类型形式参数的函数就被称为变长参数函数**。

一个变长参数函数只能有一个**”…T“**类型形式参数,并且该形式参数应该为函数参数列表中的最后一个形式参数,否则 Go 编译器就会给出如下错误提示:

func foo(args ...int, s string) int // syntax error: cannot use ... with non-final parameter args
func bar(args1 ...int, args2 ...string) int // syntax error: cannot use ... with non-final parameter args1 

变长参数函数的**”…T“**类型形式参数在函数体内呈现为[]T 类型的变量,我们可以将其理解为一个 Go 语法甜头:

// variadic_function_1.go
func sum(args ...int) int {
        var total int

        // 下面的args的类型为[]int
        for _, v := range args { 
                total += v
        }

        return total
} 

但在函数外部,”…T“ 类型形式参数可匹配和接受的实参类型有两种:

  • 多个 T 类型变量;
  • t…(t 为 []T 类型变量);
// variadic_function_1.go

func main() {
        a, b, c := 1, 2, 3
        println(sum(a, b, c)) // 传入多个int类型的变量
        nums := []int{4, 5, 6}
        println(sum(nums...)) // 传入"nums...",num为[]int型变量
} 

但我们只能选择上述两种实参类型中的一种:要么是多个 T 类型变量,要么是t…(t 为[]T 类型变量)。如果两种放在一起混用,则会得到类似下面的编译错误:

println(sum(a, b, c, nums...)) // too many arguments in call to sum
	have (int, int, int, []int...)
	want (...int) 

这里将变长参数函数的形参和实参类型总结为下面的示意图:

17 go变长参数函数的妙用

图4-6-1: 变长参数函数的形参和实参

使用变长参数函数时最容易出现的一个问题就是实参与形参的不匹配,比如下面这个例子:

// variadic_function_2.go
package main

import "fmt"

func dump(args ...interface{}) {
	for _, v := range args {
		fmt.Println(v)
	}
}

func main() {
	s := []string{"Tony", "John", "Jim"}
	dump(s...)
} 

运行这段代码:

$ go run variadic_function_2.go
# command-line-arguments
./variadic_function_2.go:14:6: cannot use s (type []string) as type []interface {} in argument to dump 

我们看到:编译器给出了”类型不匹配“的错误。

dump 函数的变长参数类型为”…interface{}“,因此匹配该形参的要么是 interface{} 类型的变量,要么为t…(t 类型为[]interface{})。在例子中给 dump 传入的实参为s…,但 s 的类型为[]string,并非[]interface{},导致不匹配。这里要注意的是虽然 string 类型变量可以直接赋值给 interface{} 类型变量,但是 []string 类型变量并不能直接赋值给 []interface{} 类型变量

要消除编译错误,我们仅需将变量 s 的类型换为 []interface{}:

// variadic_function_2.go

... ...

func main() {
	s := []interface{}{"Tony", "John", "Jim"}
	dump(s...)
}

$ go run variadic_function_2.go 
Tony
John
Jim 

不过有个例外,那就是 Go 内置的 append 函数,它支持通过下面的方式将字符串附加到一个字节切片后面:

// variadic_function_3.go
package main

import "fmt"

func main() {
	b := []byte{}
	b = append(b, "hello"...)
	fmt.Println(string(b))
}

$ go run variadic_function_3.go
hello 

string 类型本是不满足类型要求的(append 本需要[]byte…), 这算是 Go 编译器的一个优化,编译器自动将 string 隐式转换为了 []byte。如果是我们自定义的函数,那么是无论如何都不能支持这样的用法的:

// variadic_function_3.go
package main

import "fmt"



func main() {
	b := []byte{}
	b = append(b, "hello"...)
	fmt.Println(string(b))

	foo("hello"...)
}

$ go run variadic_function_3.go
# command-line-arguments
./variadic_function_3.go:14:6: cannot use "hello" (type string) as type []byte in argument to foo 

2. 模拟函数重载

Go 语言不允许在同一个作用域下定义名字相同但函数原型不同的函数,如果定义这样的函数,Go 编译器会提示下面错误信息:

// variadic_function_4.go
package main

import (
	"fmt"
	"strings"
)

func concat(a, b int) string {
	return fmt.Printf("%d %d", a, b)
}

func concat(x, y string) string {
	return x + " " + y
}

func concat(s []string) string {
	return strings.Join(s, " ")
}

func main() {
	println(concat(1, 2))
	println(concat("hello", "gopher"))
	println(concat([]string{"hello", "gopher", "!"}))
}

$ go run variadic_function_4.go
# command-line-arguments
./variadic_function_4.go:9:2: too many arguments to return
	have (int, error)
	want (string)
./variadic_function_4.go:12:6: concat redeclared in this block
	previous declaration at ./variadic_function_4.go:8:23
./variadic_function_4.go:16:6: concat redeclared in this block
	previous declaration at ./variadic_function_4.go:12:26
./variadic_function_4.go:21:16: too many arguments in call to concat
	have (number, number)
	want ([]string)
./variadic_function_4.go:22:16: too many arguments in call to concat
	have (string, string)
	want ([]string) 

如果要修复上面的例子程序,我们需要将三个”concat“函数作分别命名,比如:

concatTwoInt
concatTwoString
concatStrings 

在其他一些主流编程语言中(比如:C++),这种在同一声明域中的名字相同但参数列表不同的函数被称为重载函数(overloaded function),编译器根据实际的参数传递来判断究竟该使用哪个函数。通过重载函数,你可以根据参数的类型和数量为同名函数提供不同的语义。

但 Go 语言并不支持函数重载,Go 语言官方常见问答(即:FAQ)中给出的不支持的理由如下:

其他语言的经验告诉我们,使用具有相同名称但函数签名不同的多种方法有时会很有用,但在实践中也可能会造成混淆和脆弱性。 在 Go 的类型系统中,仅按名称进行匹配并要求类型一致是一个主要的简化决策。

不可否认重载函数会增加语言的复杂性,笔者在早期使用 C++ 语言开发时已经深刻体会到了这一点。Go 语言的设计哲学也让最初的设计者们倾向于简化而放弃了对函数重载的支持,但他们也承认了有些时候函数重载是很有用的。那么我们在 Go 语言中怎么模拟重载函数呢?变长参数函数显然是最好的选择。

如果要重载的函数的参数都是相同类型的,仅参数的个数是变化的,那么变长参数函数可以轻松对应;如果参数类型不同且个数可变,那么我们还要结合 interface{}类型的特性。我们来看一个例子:

// variadic_function_5.go
package main

import (
	"fmt"
	"strings"
)

func concat(sep string, args ...interface{}) string {
	var result string
	for i, v := range args {
		if i != 0 {
			result += sep
		}
		switch v.(type) {
		case int, int8, int16, int32, int64,
			uint, uint8, uint16, uint32, uint64:
			result += fmt.Sprintf("%d", v)
		case string:
			result += fmt.Sprintf("%s", v)
		case []int:
			ints := v.([]int)
			for i, v := range ints {
				if i != 0 {
					result += sep
				}
				result += fmt.Sprintf("%d", v)
			}
		case []string:
			strs := v.([]string)
			result += strings.Join(strs, sep)
		default:
			fmt.Printf("the argument type [%T] is not supported", v)
			return ""
		}
	}
	return result
}

func main() {
	println(concat("-", 1, 2))
	println(concat("-", "hello", "gopher"))
	println(concat("-", "hello", 1, uint32(2),
		[]int{11, 12, 13}, 17,
		[]string{"robot", "ai", "ml"},
		"hacker", 33))
} 

在上面这个例子中,我们定义了一个 concat 函数,该函数支持接受任意数量的整型、字符串、整型切片、字符串切片参数,并将输入的参数通过分隔符(sep)连接在一起。看 main 函数中对 concat 的调用,是不是有一种调用重载函数的味道儿!我们运行一下该例子:

$ go run variadic_function_5.go 
1-2
hello-gopher
hello-1-2-11-12-13-17-robot-ai-ml-hacker-33

3. 模拟实现函数的可选参数与默认参数

如果参数在传入时有隐式要求的固定顺序(这点由调用者保证),我们还可以利用变长参数函数模拟实现函数的可选参数和默认参数。

我们来看下面例子:

// variadic_function_6.go 
package main

import "fmt"

type record struct {
	name    string
	gender  string
	age     uint16
	city    string
	country string
}

func enroll(args ...interface{} /* name, gender, age, city = "Beijing", country = "China" */) (*record, error) {
	if len(args) > 5 || len(args) < 3 {
		return nil, fmt.Errorf("the number of arguments passed is wrong")
	}

	r := &record{
		city:    "Beijing", // 默认值:Beijing
		country: "China",   // 默认值:China
	}

	for i, v := range args {
		switch i {
		case 0: // name
			name, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("name is not passed as string")
			}
			r.name = name
		case 1: // gender
			gender, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("gender is not passed as string")
			}
			r.gender = gender
		case 2: // age
			age, ok := v.(int)
			if !ok {
				return nil, fmt.Errorf("age is not passed as int")
			}
			r.age = uint16(age)
		case 3: // city
			city, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("city is not passed as string")
			}
			r.city = city
		case 4: // country
			country, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("country is not passed as string")
			}
			r.country = country
		default:
			return nil, fmt.Errorf("unknown argument passed")
		}
	}

	return r, nil
}

func main() {
	r, _ := enroll("小明", "male", 23)
	fmt.Printf("%+v\n", *r)

	r, _ = enroll("小红", "female", 13, "Hangzhou")
	fmt.Printf("%+v\n", *r)

	r, _ = enroll("Leo Messi", "male", 33, "Barcelona", "Spain")
	fmt.Printf("%+v\n", *r)

	r, err := enroll("小吴", 21, "Suzhou")
	if err != nil {
		fmt.Println(err)
		return
	}
} 

在该例子中,我们要实现一个 enroll 函数,用于登记一些人员信息。人员信息包括:姓名(name)、性别(gender)、年龄(age)、城市(city)和国家(country)。其中城市(city)和国家(country)这两个字段是可选字段并且具有默认值。我们结合变长参数函数和 interface{} 类型的特点来实现这个函数,city 和 country 的默认值是在 record 类型实例创建时赋予的初值。实现这样的一个 enroll 函数的前提就是其调用方要负责按正确的顺序传入参数并保证参数类型满足函数要求。

我们运行一下上面的例子:

$ go run variadic_function_6.go 
{name:小明 gender:male age:23 city:Beijing country:China}
{name:小红 gender:female age:13 city:Hangzhou country:China}
{name:Leo Messi gender:male age:33 city:Barcelona country:Spain}
gender is not passed as string 

我们看到:

  • 在第一次 enroll 函数调用时,我们省略了 city 和 country 的传参,因此得到了记录中 city 和 country 都是默认值;
  • 在第二次 enroll 函数调用时,我们省略了 country 的传参,因此得到的记录中,country 是默认值;
  • 第三次 enroll 函数调用,我们传递了 city 和 country,因此得到的记录中,city 和 country 没有使用默认值,使用的是传入的参数值;
  • 第四次 enroll 函数调用时,调用者没有按照约定传入 gender 参数,因此,enroll 函数返回一个错误。

我们看到基于上述前提而用 Go 实现的可选参数和默认参数是有局限的:调用者只能从右侧的参数开始逐一做省略传递的处理,比如:可以省略 country,可以省略 country、city,但不能省略 city 而不省略 country 的传递。

4. 实现”功能选项“模式

日常 Go 编程时,我们经常会去实现一些带有设置选项的创建型函数,比如:我们要创建一个网络通信的客户端,创建客户端实例的函数需要提供某种方式可以让调用者设置客户端的一些行为属性,比如:超时时间、重试次数等。对于一些复杂的 Go 包中的创建型函数,它要提供的可设置选项有时多达数十种,甚至后续还会增加。因此,设计和实现这样的创建型函数时要尤为考虑使用者的体验:不能因选项较多而提供过多的 API,并且要保证选项持续增加后,函数的对外接口依旧保持稳定。

接下来就让我们用一个简单的示例来看看变长参数函数在这里究竟能发挥什么样的作用。我们先从一个简单的版本开始并对其进行持续优化,直到实现令我们满意的最终版本。

我们来设计和实现一个 NewFinishedHouse 函数,该函数返回一个”FinishedHouse(精装房)“实例。生活中精装房是由不同装修选项的,比如:

  • 装修风格:美式/中式/欧式;
  • 是否安装中央空调系统;
  • 地面材料:瓷砖/实木地板;
  • 墙面材料:乳胶漆/壁纸/硅藻泥。

可能还有很多装修配置选项,但这里使用上述这几个就足以满足示例的需要了。

1) 版本 1:通过参数暴露配置选项

一个最简单直接的实现方法就是通过函数参数暴露配置选项,让调用者可以自行设置自己所需要的精装房风格和使用的材料:

// variadic_function_7.go 
package main

import "fmt"

type FinishedHouse struct {
	style                  int    // 0: Chinese, 1: American, 2: European
	centralAirConditioning bool   // true or false
	floorMaterial          string // "ground-tile" or ”wood"
	wallMaterial           string // "latex" or "paper" or "diatom-mud"
}

func NewFinishedHouse(style int, centralAirConditioning bool,
	floorMaterial, wallMaterial string) *FinishedHouse {

	// here: you should do some check to the arguments passed

	h := &FinishedHouse{
		style:                  style,
		centralAirConditioning: centralAirConditioning,
		floorMaterial:          floorMaterial,
		wallMaterial:           wallMaterial,
	}

	return h
}

func main() {
	fmt.Printf("%+v\n", NewFinishedHouse(0, true, "wood", "paper"))
} 

运行该例子:

$ go run variadic_function_7.go 
&{style:0 centralAirConditioning:true floorMaterial:wood wallMaterial:paper} 

上述这个设计的唯一优点就是能够快速实现,但不足之处却有很多,最致命的是该接口没法扩展。如果我们此时应用户要求增加一个室内门型设置的选项(可选实木门/板材套装门),那么该接口无法满足。考虑兼容性原则,该接口一但发布就成为了 API 的一部分,我们不能随意变更。于是我们唯一能做的就是新增一个创建函数,比如:NewFinishedHouseWithDoorOption。如果后续要增加其他设置选项,API 中很大可能会充斥着 NewFinishedHouseWithXxxOption1、NewFinishedHouseWithYyyOpiton、… NewFinishedHouseWithZzzOption 等新接口。

2) 版本 2:使用结构体封装配置选项

软件设计中的一个比较重要的原则就是“封装变化”,既然我们无法控制将来要加入的配置选项的个数和内容,但还要尽可能保持提供单一接口,我们就把“配置选项”这个变量抽取出来封装到一个结构体中,这也是目前比较常见的作法。

下面是我们的第二个版本:

// variadic_function_8.go 
package main

import "fmt"

type FinishedHouse struct {
	style                  int    // 0: Chinese, 1: American, 2: European
	centralAirConditioning bool   // true or false
	floorMaterial          string // "ground-tile" or ”wood"
	wallMaterial           string // "latex" or "paper" or "diatom-mud"
}

type Options struct {
	Style                  int    // 0: Chinese, 1: American, 2: European
	CentralAirConditioning bool   // true or false
	FloorMaterial          string // "ground-tile" or ”wood"
	WallMaterial           string // "latex" or "paper" or "diatom-mud"
}

func NewFinishedHouse(options *Options) *FinishedHouse {
	// use default style and materials if option is nil
	var style int = 0
	var centralAirConditioning = true
	var floorMaterial = "wood"
	var wallMaterial = "paper"

	if options != nil {
		// here: you should do some check to the options passed

		style = options.Style
		centralAirConditioning = options.CentralAirConditioning
		floorMaterial = options.FloorMaterial
		wallMaterial = options.WallMaterial
	}

	h := &FinishedHouse{
		style:                  style,
		centralAirConditioning: centralAirConditioning,
		floorMaterial:          floorMaterial,
		wallMaterial:           wallMaterial,
	}

	return h
}

func main() {
	fmt.Printf("%+v\n", NewFinishedHouse(nil)) // use default options
	fmt.Printf("%+v\n", NewFinishedHouse(&Options{
		Style:                  1,
		CentralAirConditioning: false,
		FloorMaterial:          "ground-tile",
		WallMaterial:           "paper",
	}))
} 

我们运行一下这个例子:

$ go run variadic_function_8.go 
&{style:0 centralAirConditioning:true floorMaterial:wood wallMaterial:paper}
&{style:1 centralAirConditioning:false floorMaterial:ground-tile wallMaterial:paper} 

我们看到:

  • 使用这种方法,即便后续添加新配置选项,Options 结构体可以随着时间变迁而增长,但 FinishedHouse 创建函数本身的 API 签名是保持不变的;
  • 这种方法还使得调用者可以使用 nil 来表示他们希望使用默认配置选项来创建 FinishedHouse;
  • 这种方法还带来了额外收获:更好的文档记录(文档重点从对 NewFinishedHouse 函数的大段注释描述转移到了对 Options 结构体各字段的说明)。

当然这种方法也有其不足的地方:

  • 调用者可能会有如此疑问:传递 nil 和传递&Options{}之间有区别吗?
  • 每次传递 Options 都要将 Options 中的所有字段做正确显式的赋值,即便调用者想使用某个配置项的默认值,赋值动作依然不可少;
  • 调用者还可能有如此疑问:如果传递给 NewFinishedHourse 的 options 中的字段值在函数调用后发生了变化会发生什么情况?

带着这些疑问,我们进入 NewFinishedHouse 的下一个版本。

3) 版本 3:使用“功能选项”模式

Go 语言之父 Rob Pike 早在 2014 年就在其一篇博文“自引用函数与选项设计”中论述了一种被后人称为“功能选项(functional option)”的模式,这种模式应该是目前进行功能选项设计的最佳实践方案。

接下来我们就来看看使用“功能选项”模式实现的 NewFinishedHouse 是什么样的:

// variadic_function_9.go 
package main

import "fmt"

type FinishedHouse struct {
	style                  int    // 0: Chinese, 1: American, 2: European
	centralAirConditioning bool   // true or false
	floorMaterial          string // "ground-tile" or ”wood"
	wallMaterial           string // "latex" or "paper" or "diatom-mud"
}

type Option func(*FinishedHouse)

func NewFinishedHouse(options ...Option) *FinishedHouse {
	h := &FinishedHouse{
		// default options
		style:                  0,
		centralAirConditioning: true,
		floorMaterial:          "wood",
		wallMaterial:           "paper",
	}

	for _, option := range options {
		option(h)
	}

	return h
}

func WithStyle(style int) Option {
	return func(h *FinishedHouse) {
		h.style = style
	}
}

func WithFloorMaterial(material string) Option {
	return func(h *FinishedHouse) {
		h.floorMaterial = material
	}
}

func WithWallMaterial(material string) Option {
	return func(h *FinishedHouse) {
		h.wallMaterial = material
	}
}

func WithCentralAirConditioning(centralAirConditioning bool) Option {
	return func(h *FinishedHouse) {
		h.centralAirConditioning = centralAirConditioning
	}
}

func main() {
	fmt.Printf("%+v\n", NewFinishedHouse()) // use default options
	fmt.Printf("%+v\n", NewFinishedHouse(WithStyle(1),
		WithFloorMaterial("ground-tile"),
		WithCentralAirConditioning(false)))
} 

运行一下该新版例子:

$ go run variadic_function_9.go 
&{style:0 centralAirConditioning:true floorMaterial:wood wallMaterial:paper}
&{style:1 centralAirConditioning:false floorMaterial:ground-tile wallMaterial:paper} 

我们看到在该方案中,FinishedHouse 的配置选项不是通过存储在结构体中的配置参数传入的,而是通过对 FinishedHouse 值本身进行操作的函数调用(利用函数的“一等公民“特质)实现的,并且通过使用变长参数函数,我们可以随意扩展传入的配置选项的个数。

功能选项模式使得我们在设计和实现类似 NewFinishedHouse 这样带有配置选项的函数或方法时可以收获如下好处:

  • 更漂亮的、不随时间变化的公共 API
  • 参数可读性更好
  • 配置选项高度可扩展
  • 提供使用默认选项的最简单方式
  • 使用更安全(不会像版本 2 那样在创建函数被调用后,调用者仍然可以修改 options)

5. 小结

本节要点:

  • 了解变长参数函数的特点和约束;
  • 变长参数函数可以在有限情况下模拟函数重载、可选参数和默认参数,但要谨慎使用,不要造成混淆;
  • 利用变长参数函数实现”功能选项(Functional Options)“模式。