Functional Options

2021/01/04

需求场景

在 Go 中为某种类型增添选项(Options)很常见,随着需求迭代,程序包复杂度提升,最终可能有数十种选项。有很多方式可以用来处理这个问题。但是我们期望的是不暴露过多的 API (至少用户不必去消化过多的 API ),而且在实际需要时可灵活扩充选项。

一般实现

option 结构体,提供大量方法,多种构造函数,等等。

type Foo struct {
		A int
		b int
		c string
}

func NewFoo() *Foo {
		return &Foo {}
}

func NewFooWithA(a int) *Foo {
		return &Foo {A: a}
}

func (f *Foo) SetB(b int) {
		f.b = b
}

func (f *Foo) SetC(c string) {
		f.c = c
}

这些写多了就会发现,不够优雅,为了特殊需求出现大量功能类似的构造函数,且扩展性不足。

下面我们要介绍一种有趣的实现,在此之前可以先了解下 Self-referential functions (自指函数)。

使用配置结构体

为了使同一构造函数能够满足不同需求,也不至于需求变更导致参数伸缩,让构造函数接受一个配置结构体参数。

type Foo struct {
		A int
		b int
		c string
		dur time.Duration
}

type Config struct {
  duration string
}

func NewFoo(c Config) (*Foo, error) {
		dur, err := time.ParseDuration(c.duration)
		if err != nil {
			return nil, err
		}
		
		return &Foo {dur: dur}, nil
}

使用这种方法,随着需求增多,配置结构体也会更复杂,需要维护详尽文档。

同时结构体成员不受约束,用户也能传入不符合要求的值:

NewFoo(Config{duration: "-1"})

所以这也不够完美

自指

Ouroboros(衔尾蛇) ,一种古老的符号,它是一条不断消耗自己的蛇或龙,可以用来表示 “自指”。

程序中的自指,主要是为了递归。

举个例子

下面一步步来实现我们的需求:

首先定义一种 option 类型,它是接受一个参数的函数, Foo 就是我们要操作的对象:

type option func(*Foo)

此处核心思想是,将 option 实现为一个函数,我们通过调用这个函数以设置这个 option 的状态。似乎很怪,却也是一种疯狂的想法。

给定 option 类型了,下一步为 *Foo 添加一个 Option 方法,它接收多个 option 参数 ,将它们作为函数调用。这个方法可以定义在同一个包下,比如 Foo 所属的 pkg 包。

// Option sets the options specified.
func (f *Foo) Option(opts ...option) {
    for _, opt := range opts {
        opt(f)
    }
}

现在要实现一个 option ,在 pkg 包下给它合适的名称和签名。比如我们要设置一个整型数来控制 Foo 中信息的详细程度。我们可以用闭包实现:

// Verbosity sets Foo's verbosity level to v.
func Verbosity(v int) option {
    return func(f *Foo) {
        f.verbosity = v
    }
}

为什么返回一个闭包,而不是直接赋值呢?

因为我们不希望用户不得不写闭包,我们希望 Option 方法用起来方便。(后面还有更多骚操作~)

在客户端调用时可以这么写:

foo.Option(pkg.Verbosity(3))

这很简单,而且大多数场景都够用。但是我还希望通过 option 机制设置临时值,这就意味着,最好 Option 方法可以返回之前的状态。那也很简单:只要把它保存在 Option 和基础函数类型的一个空 interface 里就行了,那个值通过代码层层穿出就好了:

type option func(*Foo) interface{}

// Verbosity sets Foo's verbosity level to v.
func Verbosity(v int) option {
    return func(f *Foo) interface{} {
        previous := f.verbosity
        f.verbosity = v
        return previous
    }
}

// Option sets the options specified.
// It returns the previous value of the last argument.
func (f *Foo) Option(opts ...option) (previous interface{}) {
    for _, opt := range opts {
        previous = opt(f)
    }
    return previous
}

客户端可以像之前一样使用它,如果客户端要恢复之前保存的值,要做的就是保存第一次调用返回的值,然后还原它。

prevVerbosity := foo.Option(pkg.Verbosity(3))
foo.DoSomeDebugging()
foo.Option(pkg.Verbosity(prevVerbosity.(int)))

在 Option 的撤销调用中使用类型断言看起来很笨拙,我们还可以优化一下这里的设计。

首先,将 option 重新定义为接受一个值并返回另一个 option 用于还原之前的值。

type option func(f *Foo) option

这种自指函数定义让人想起了状态机(state machine)。在这里,我们使用它的方式略有不同:它是一个返回其反函数(reverse)的函数。

然后修改 Option 方法的返回值类型(和含义)

// Option sets the options specified.
// It returns an option to restore the last arg's previous value.
func (f *Foo) Option(opts ...option) (previous option) {
    for _, opt := range opts {
        previous = opt(f)
    }
    return previous
}

最后是实际 option 函数的实现。它们的内部闭包现在也必须返回一个 option ,而不是 interface 值,这意味着它需要返回一个闭包来撤销它自己。这也很简单:它可以重新调用来准备撤销操作!像这样:

// Verbosity sets Foo's verbosity level to v.
func Verbosity(v int) option {
    return func(f *Foo) option {
        previous := f.verbosity
        f.verbosity = v
        return Verbosity(previous)
    }
}

关键在最后一行从 return previous 变成了 return Verbosity(previous)

不像之前仅仅返回一个旧值,它现在调用周围的函数(Verbosity)创建撤销闭包,并返回该闭包。

现在客户端调用就很优雅了:

prevVerbosity := foo.Option(pkg.Verbosity(3))
foo.DoSomeDebugging()
foo.Option(prevVerbosity)

最后我们更进一步,用上 Go 的 defer 机制将客户端代码编写地更整洁:

func DoSomethingVerbosely(foo *Foo, verbosity int) {
    // Could combine the next two lines,
    // with some loss of readability.
    prev := foo.Option(pkg.Verbosity(verbosity))
    defer foo.Option(prev)
    // ... do some stuff with foo under high verbosity.
}

值得注意的是,由于返回的 “verbosity” 现在是闭包而不是值,因此先前的值是隐藏的。如果你要获取这个值,需要更多的魔法,目前也有足够的魔法。

所以这一切的实现似乎小题大做,但实际上每个 option 也就几行,并且具有很大的通用性。最最重要的是,从包的客户端角度看,使用它真的很不错,这样很优雅。

参考