Go 的几种函数参数传递方式

一般传递

Go 语言支持通过顺序传递参数来调用函数,如以下示例函数所示。

// ListApplications Query Application List
func ListApplications(limit, offset int) []Application {
    return allApps[offset : offset+limit]
}

 

调用代码

ListApplications(5, 0)

 

当您想添加新参数时,只需更改函数签名即可。例如,以下代码ownerListApplications.

func ListApplications(limit, offset int, owner string) []Application {
    if owner != "" {
        // ...
    }
    return allApps[offset : offset+limit]
}

 

调用代码需要相应更改。

ListApplications(5, 0, "piglei")
// Do not use "owner" filtering
ListApplications(5, 0, "")

显然,这种常见的传递参数模型存在几个明显的问题。

  • 可读性差:仅支持位置,不支持区分参数的关键字,添加更多参数后,每个参数的含义难以一目了然。
  • 破坏性兼容性:添加新参数后,必须修改原来的调用代码,ListApplications(5, 0, "")如上例,在参数位置传入空字符串owner

为了解决这些问题,通常的做法是引入参数结构(struct)类型。

 

 

2. 使用参数结构

创建一个包含函数需要支持的所有参数的新结构类型

// ListAppsOptions is optional when querying the application list
type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
}

 

修改原始函数以直接接受此结构类型作为唯一参数。

// ListApplications Query the application list, using the structure-based query option.
func ListApplications(opts ListAppsOptions) []Application {
    if opts.owner != "" {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

 

调用代码如下所示。

ListApplications(ListAppsOptions{limit: 5, offset: 0, owner: "piglei"})
ListApplications(ListAppsOptions{limit: 5, offset: 0})

与普通模型相比,使用参数结构有几个优点。

  • 在构造参数结构时,可以显式指定每个参数的字段名,这样更具可读性。
  • 对于非必要的参数,您可以在不传递值的情况下构建它们,例如省略owner上面。

但是,有一个普通模式或参数结构都不支持的常见使用场景:真正的可选参数。

 

 

3.隐藏在可选参数中的陷阱

为了演示“可选参数”的问题,我们在ListApplications函数中添加了一个新选项:hasDeployed– 根据应用程序是否已部署来过滤结果。

参数结构调整如下。

// ListAppsOptions is optional when querying the application list
type ListAppsOptions struct {
    limit       int
    offset      int
    owner       string
    hasDeployed bool
}

 

查询功能也做了相应的调整。

// ListApplications Query application list, add filtering for HasDeployed
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed {
        // ...
    } else {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

 

当我们要过滤已部署的应用程序时,可以这样调用。

ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: true})

 

而当我们不需要通过“部署状态”进行过滤时,我们可以删除该hasDeployed字段并ListApplications使用以下代码调用该函数。

ListApplications(ListAppsOptions{limit: 5, offset: 0})

等等……好像有些不对劲。hasDeployed是布尔类型,这意味着当我们不为其提供任何值时,程序将始终使用布尔类型的零值:false.

因此,现在的代码实际上根本没有得到“未按部署状态过滤”的结果,hasDeployed要么是要么truefalse存在其他状态。

 

4.可选地引入指针类型支持

要解决上述问题,最直接的办法就是引入指针类型。与普通值类型不同,Go 中的指针类型有一个特殊的零值:nil. 因此,简单地hasDeployed从布尔类型 ( bool) 更改为指针类型 ( *bool) 就可以更好地支持可选参数。

type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
    // Enable pointer types
    hasDeployed *bool
}

 

查询功能也需要一些调整。

// ListApplications Query application list, add filtering for HasDeployed
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed == nil {
        // No filtering by default
    } else {
        // Filter by whether hasDeployed is true or false
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

 

调用函数时,如果调用者没有指定字段的值,则代码不经过任何过滤hasDeployed就转到分支。if opts.hasDeployed == nil

ListApplications(ListAppsOptions{limit: 5, offset: 0})

 

当调用者想要过滤时hasDeployed,可以使用以下。

wantHasDeployed := true
ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: &wantHasDeployed})

 

在 golang 中,实际上可以通过以下方式快速创建一个非 nil 指针变量。

ListAppsOptions{limit: 5, offset: 0, hasDeployed: &[]bool{true}[0]}

 

如您所见,由于hasDeployed现在是指针类型*bool,我们必须先创建一个临时变量,然后获取它的指针来调用函数。

不用说,这很麻烦,不是吗?有没有办法解决传递函数参数时的上述痛点,又不会让调用过程像“手动构建指针”那样繁琐?

然后是功能选项模式发挥作用的时候了。

 

 

5.“功能选项”模式

除了普通的传参模式外,Go 实际上还支持可变数量的参数,使用该特性的函数统称为“可变参数函数”。例如,append并且fmt.Println属于这一类。

nums := []int{}
// When calling append, multiple arguments can be passed
nums = append(nums, 1, 2, 3, 4)

 

为了实现“功能选项”模式,我们首先修改ListApplications函数的签名以采用可变数量的类型参数func(*ListAppsOptions)

// ListApplications Query the list of applications, using variable arguments
func ListApplications(opts ...func(*ListAppsOptions)) []Application {
    config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil}
    for _, opt := range opts {
        opt(&config)
    }
    // ...
    return allApps[config.offset : config.offset+config.limit]
}

 

 

然后,为调整选项定义了一系列工厂函数。

func WithPager(limit, offset int) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.limit = limit
        opts.offset = offset
    }
}

func WithOwner(owner string) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.owner = owner
    }
}

func WithHasDeployed(val bool) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.hasDeployed = &val
}

 

这些名为 的工厂函数通过返回闭包函数来With*修改函数选项对象。ListAppsOptions

调用时的代码如下。

// No arguments are used
ListApplications()
// Selectively enable certain options
ListApplications(WithPager(2, 5), WithOwner("piglei"))
ListApplications(WithPager(2, 5), WithOwner("piglei"), WithHasDeployed(false))

与使用“参数结构”相比,“功能选项”模型具有以下特点。

  • 更友好的可选参数:例如,不再手动获取hasDeployed.
  • 更大的灵活性:可以轻松地将附加逻辑附加到每个With*功能
  • 良好的前向兼容性:添加任何新选项而不影响现有代码
  • prettier API:当参数结构复杂时,该模式提供的 API 更漂亮,更可用

但是,直接使用工厂函数实现的“功能选项”模式并不是非常用户友好。因为每一个With*都是独立的工厂函数,可能分布在不同的地方,调用者很难在一个地方找出该函数支持的所有选项。

为了解决这个问题,对“功能选项”模式进行了一些小的优化:用接口类型替换工厂函数。

 

 

6. 使用接口实现“功能选项”

首先,定义一个名为 的接口类型Option,它只包含一个方法applyTo

type Option interface {
    applyTo(*ListAppsOptions)
}

 

然后,把这批With*工厂函数改成各自的自定义类型,实现Option接口。

type WithPager struct {
    limit  int
    offset int
}

func (r WithPager) applyTo(opts *ListAppsOptions) {
    opts.limit = r.limit
    opts.offset = r.offset
}

type WithOwner string

func (r WithOwner) applyTo(opts *ListAppsOptions) {
    opts.owner = string(r)
}

type WithHasDeployed bool

func (r WithHasDeployed) applyTo(opts *ListAppsOptions) {
    val := bool(r)
    opts.hasDeployed = &val
}

 

做好这些准备后,查询功能应该做相应的调整。

// ListApplications Query application list, using variable arguments, Option interface type
func ListApplications(opts ...Option) []Application {
    config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil}
    for _, opt := range opts {
        // Adjusting the call method
        opt.applyTo(&config)
    }
    // ...
    return allApps[config.offset : config.offset+config.limit]
}

 

调用代码和上一个类似,如下。

ListApplications(WithPager{limit: 2, offset: 5}, WithOwner("piglei"))
ListApplications(WithOwner("piglei"), WithHasDeployed(false))

一旦将选项从工厂功能更改为Option接口,就可以更轻松地找到所有选项并使用 IDEFind Interface Implementation轻松完成工作。

 

问:我应该优先考虑“功能选项”吗?

在查看了这些参数传递模式之后,我们发现“功能选项”似乎在各个方面都是赢家。它可读,兼容,似乎应该是所有开发者的首选。而且它在 Go 社区中确实很受欢迎,活跃在许多流行的开源项目中(例如,AWS 的官方 SDKKubernetes 客户端)。

“函数选项”确实比“正常传递”和“参数结构”有很多优点,但我们不能忽视缺点。

  • 需要更多不那么简单的代码来实现
  • 使用基于“功能选项”模式的 API 比使用简单的“参数结构”更难用户找到所有可用选项,并且需要更多的努力

总的来说,最简单的“普通参数传递”、“参数结构”和“函数选项”实现起来越来越困难和灵活,并且每种模式都有自己的适用场景。在设计 API 时,我们需要根据具体需求优先考虑更简单的方法,如果没有必要,不要引入更复杂的传递模式。

 

发表评论