40

Go创建对象时,如何优雅的传递初始化参数

 4 years ago
source link: http://www.pengrl.com/p/60015/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Go 创建对象时,如何优雅的传递初始化参数?这里所说的优雅,指的是:

  1. 支持传递多个参数
  2. 参数个数、类型发生变化时,尽量保持接口的兼容性
  3. 参数支持默认值
  4. 具体的参数可根据调用方需关心的程度,决定是否提供默认值

Go 并不像 c++python 那样,支持函数默认参数。所以使用 Go 时,我们需要一种方便、通用的手法来完成这件事。

Go 的很多开源项目都使用 Option 模式,但各自的实现可能有些许细微差别。

本文将通过一个渐进式的 demo 示例来介绍 Option 模式,以及相关的一些思考。 本文将内容切分为10个小模块,如果觉得前面的铺垫冗余,想直接看Option模式的介绍,可以从小标题七开始阅读。

先看 demo ,一开始我们的代码是这样的:

type Foo struct {
  num int
  str string

  // ...
}

func New(num int, str string) *Foo {
  // ...

  return &Foo{
    num: num,
    str: str,
  }
}

// ...

我们有一个 Foo 结构体,内部有 numstr 两个属性, New 函数传入两个初始化参数,构造一个 Foo 对象。

ok,一切都足够简单。

假设我们需要对 Foo 内部增加两个属性,同时构造函数也需要支持传入这两个新增属性的初始值。有一种修改方法是这样的:

func New(num int, str string, num2 int, str2 string)

可以看到,这种方式,随着初始化参数个数、类型的变化,我们 New 函数的函数签名也需随之改变。这带来两个坏处:

  1. 对调用方来说,函数不兼容
  2. 参数数量太多,可读性可能变差

有一种保持兼容性的解决方案,是保留之前的 New 函数,再创建一个新的构造函数,比如 New2 ,用于实现4个参数的构造方法。

这种解决方案在大部分时候会导致代码可维护性下降。

另一种解决方案,是把所有的参数都放入一个结构体中。就像这样:

type Foo struct {
  option Option

  // ...
}

type Option struct {
  num int
  str string
}

func New(option Option) *Foo {
  // ...

  return &Foo{
    option: option,
  }
}

这种方式,解决了上面提出的两个问题。但是,假设我们想为参数提供默认参数呢?

比如说当调用方不设置 num 时,我们希望它的默认值是 100 ;不设置 str 时,默认值为 hello

// 构造对象时只设置 str,不设置 num
foo := New(Option{
  str: "world",
})

这种做法可行的前提是,属性的默认值也为 0 值。

假设我们希望 option.num 属性默认值是 100 ,那么当内部接收到的 option.num0 时,我们没法区分是调用方希望将 option.num 设置为 0 ,还是调用方压根就没设置 option.num 。从而导致我们不知道将内部的 option.num 设置为 0 好,还是保持默认值 100 好。

事实上,这个问题不仅仅是传递 Option 时才会出现,即使所有参数都使用最上面那种直接传递的方式,也会存在这个问题,即 0 值无法作为外部是否设置的判断条件。

有一种解决方案,是使用 *Option 即指针类型作为初始化参数,如果外部传入为 nil ,则使用默认参数。代码如下:

func New(option *Option) *Foo {
  if option == nil {
    // 外部没有设置参数
  }
}

该方案存在的问题是,所有的参数要么全部由外部传入,要么全部使用默认值。

如何才能细化到每一个具体的参数,外部设置了使用外部设置的值,外部没有设置则使用默认值呢?

一种解决方案,是 Option 中的所有属性,都使用指针类型,如果特定参数为 nil ,则该参数使用默认参数。代码如下:

type Option struct {
  num *int
  str *string
}

func New(option Option) *Foo {
  if option.num == nil {
    // num 使用默认值
  } else {
    // option.num 即为调用方设置的初始值
  }
  // ...
}

该方案存在的问题是,对于调用方来说,使用起来有些反人类,因为你无法使用类似 &1 的写法对一个整型字面常量取地址,这意味着调用方必须格外定义一个变量保存他需要设置的参数的值,然后再对这个变量取地址赋值给Option的属性。代码如下:

// // 下面这种写法会造成编译错误
// option := {
//     num: &200,
//     str: &"world",
// }
//
// // 只能这样写
// num := 200
// str := "world"
// option := {
//     num: &num,
//     str: &str,
// }
// foo := New(option)

看起来有点,额,不太优雅。

另一种值得一提的解决方案,是使用 Go 可变参数的特性。代码如下:

func New(num int, str string, num2 ...int) {
  if len(num2) == 0 {
    // 调用方没有设置 num2,内部的 num2 应使用默认值
  } else {
    // num2[0] 即为调用方设置的初始值
  }
}

该方案存在的问题是,只能有一个参数有默认值。

ok,说了这么多,是时候开始上主菜了。 Go 是支持头等函数的语言,即可以将函数作为变量传递。所以我们可以像下面这样写:

type Option struct {
  num int
  str string
}

type ModOption func(option *Option)

func New(modOption ModOption) *Foo {
  // 默认值
  option := Option{
    num: 100,
    str: "hello",
  }

  modOption(&option)

  return &Foo{
    option: option,
  }
}

我们的 New 函数不再直接接收 Option 的值,而是提供了一种类似于钩子函数的功能,使得在内部对 option 设置完默认值之后,调用方可以直接选择修改哪些属性。比如调用方只设置 num ,代码如下:

New(func(option *Option) {
  // 调用方只设置 num
  option.num = 200
})

那么假设有些时候,我们觉得某个参数是调用方必须关心的,不应该由内部设置默认值呢?我们可以这样写:

package main

type Foo struct {
  key string
  option Option

  // ...
}

type Option struct {
  num  int
  str  string
}

type ModOption func(option *Option)

func New(key string, modOption ModOption) *Foo {
  option := Option{
    num: 100,
    str: "hello",
  }

  modOption(&option)

  return &Foo{
    key: key,
    option: option,
  }
}

// ...

func main() {
  New("iamkey", func(option *Option) {
    // 调用方只设置 num
    option.num = 200
  })
}

最后再来一种常见的、高级点的写法。在上面代码的基础上,增加如下代码:

func WithNum(num int) ModOption {
  return func(option *Option) {
    option.num = num
  }
}

func WithStr(str string) ModOption {
  return func(option *Option) {
    option.str = str
  }
}

然后是调用方的代码:

// 可以这样写
foo := New("iamkey", WithNum(200))
// 还可以这样写
foo := New("iamkey", WithStr("world"))

能不能两个一起用呢?其实是可以的,结合我们上文讲到的可变参数,将 New 函数修改如下:

func New(key string, modOptions ...ModOption) *Foo {
  option := Option{
    num: 100,
    str: "hello",
  }

  for _, fn := range modOptions {
    fn(&option)
  }

  return &Foo{
    key: key,
    option: option,
  }
}

然后是使用方的代码:

New("iamkey", WithNum(200), WithStr("world"))

总结

至此,关于 Option 模式的介绍就结束啦。

事实上, Option 模式除了在创建对象时可以使用,里面的一些 API 设计思想, Go 的小技巧,在编写普通函数时也可以使用。

模式说白了就是一种套路。在实现功能的基础之上,大家都熟悉了某种固有套路的写法,都按着这个套路走,那么代码的可读性、可维护性就更高些。

对于一个特定场景,没有最好的模式,只有最适合的模式。不要过度设计,手里就一把锤子,瞅啥都是钉子。

举个例子,最后说的那种 WithXXX 写法,我个人认为在大部分时候都有点皮裤套棉裤,简单事情复杂化的感觉,不如只用一个 ModOption 直接修改 option 来得简单、直观,所以我几乎不用 WithXXX 的写法。但是在有些场景,你如果觉得提供 WithXXX 对调用方更友好,那么用用也挺好。

为了保持场景的纯粹性,上面的 demo 可能会有些抽象。如果你想进一步看看 Option 模式在实际项目中是如何使用的,可以看看我的这个开源项目: naza 。该项目在构造对象时大量使用了 Option 模式。比如 consistenthash.gobitrate.go 等等。并且做了一些私人化的风格规范。

最后,感谢阅读,如果觉得文章还不错,可以给我的 github 项目 naza 来个 star 哈。该项目是我学习 Go 时写的一些轮子代码集合,后续我还会写一些文章逐个介绍里面的轮子以及一些写 Go 代码的技巧。

naza 项目地址: https://github.com/q191201771/naza

naza 的其他的文章:

原文链接: https://pengrl.com/p/60015/

原文出处: yoko blog ( https://pengrl.com )

原文作者:yoko

版权声明:本文欢迎任何形式转载,转载时完整保留本声明信息(包含原文链接、原文出处、原文作者、版权声明)即可。本文后续所有修改都会第一时间在原始地址更新。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK