functional optionsの整理。interfaceにすることの意味について。

goのfunctional optionsに対する自分の理解と、いわゆるoption部分をinterfaceにしておくことの意味について考えてみる。

functional optionsについて

まずはfunctional optionsについてのおさらい。functional optionsはoptionalな引数をいい感じに受け取る関数を作るための方法。

詳しい話は以下の記事が参考になる。

一応整理しておくと、ある処理に対してrequiredな引数とoptionalな引数を考えたとき、optionalな引数の取扱いをどうするかという話。例えば、host名をrequiredな引数として取り、portと最大接続数(maxConns)をoptionalな引数として取るNewServerというfactory関数を考えたときに、以下の様な定義して置くと柔軟なものになる。

type Config struct {
    Port     int
    MaxConns int
}

func NewServer(host string, options ...func(*Config)) *Server {
    c := &Config{
        Port:     8080,
        MaxConns: 1,
    }
    for _, opt := range options {
        opt(c)
    }
    return &Server{
        Host:     host,
        Port:     c.Port,
        MaxConns: c.MaxConns,
    }
}

以下のように自由に設定を渡せる様になる。

NewServer(host)
NewServer(host, WithPort(44444))
NewServer(host, WithMaxConns(10))
NewServer(host, WithPort(44444), WithMaxConns(10))

ここまではおさらい。

option部分の定義の仕方

option部分の定義の仕方にもいろいろある。一般的には簡略化して以下のどちらかになる。

// 直接使う
func (*Config)

// new typeしておく
type ServerOpt func (*Config)

前者と後者の違いはOptionを受け取る関数(e.g. WithTimeoutなどのこと)の数ではなく形状を変更しようとしたときに、手間が減るかどうかと言うような感じ。まぁぶっちゃけた話どちらでもそう変わらない。

これをinterfaceにしてはどうか?

本題はここからで、もう少しfunctional optionsに対して真面目に付き合ったときにinterfaceにすることに価値があるだろうか?というような事を考えてみる。例えば以下のような形で定義してみる。

type ServerOpt interface {
    Apply(*Config)
}

これに意味があるだろうか?

Config自体がinterfaceを満たすようにしてみる

例えば、受け取っていたConfigもまたこれを満たすようにしてみる。これに意味があるか?

少し考えてみると、Configをそのまま受け取るような記述も許すようにできるようになりそうだ。そしてそれがWithXXXの形式の関数と併用できる。

defaultConfig := &Config{Port: 4444, MaxConn}

NewServer(host, defaultConfig)
NewServer(host, defaultConfig, WithPort(44444))

ただし定義は少し煩雑になる。

func (c *Config) Apply(target *Config) {
    target.Port = c.Port
    target.MaxConns = c.MaxConns
}

type ServerOpt interface {
    Apply(*Config)
}

type ServerOptFunc func(*Config)

func (f ServerOptFunc) Apply(c *Config) {
    f(c)
}
func WithPort(port int) ServerOpt {
    return ServerOptFunc(func(c *Config) {
        c.Port = port
    })
}

とはいえ、形式にこだわらなければ、option部分の定義をinterfaceにしなくても、Configを受け取るWithXXXを定義してあげれば良いという話はあるかもしれない。

func WithConfig(override *Config) ServerOpt {
    return func(c *Config) {
        // zero値チェックをしたほうが自然かもしれない
        c.Port = override.Port
        c.MaxConns = override.MaxConns
    }
}

埋め込みを同時に使って共通のoptionをもたせる

もう少し考えてみよう。例えばXXXListというある型の値の一覧を返すAPIがあり、それの対象となる型が複数存在するときのことを考えてみる。

  • Foo に対して FooList()
  • Bar に対して BarList()
  • Boo に対して BooList()

そして、このAPIはLimitというパラメーターで返す一覧の長さを制限できるとする。このようなときについて考えてみる。

実装としては、Foo,Bar,BooのそれぞれがLimitというパラメーターを保持している。毎回似たような設定を記述するのはすこぶる悪い。埋め込みで定義してしまう。

type ListParams struct {
    Limit int
    Offset int // 何か他のパラメーター
}

type FooListParams struct {
    ListParams
 // 何か他のパラメーター
}
type BarListParams struct {
    ListParams
}
type BooListParams struct {
    ListParams
}

これに対するfunctional optionsについて考えてみる。WithLimitをそれぞれに対して定義していくのはだるくはないか?加えて名前空間を汚染してしまうのでWithFooListLimit, WithBarListLimit, WithBooListLimitのような関数を定義することになる?これは結構バカバカしい。Limit以外にパラメーターが増えた場合には特に。一般に、対象の種類m、パラメーターの種類nとしたときに、m * n分のoption関数を定義する必要がある。

対象となる型の数だけoption関数を定義する

埋め込んだListParamsそのものを値として取るoption関数を考えてみる。不格好ではあるが、対象の種類mの分だけの定義で良くなった。しかし、不格好。

func WithFooListParams(apply func(*ListParams)) func(p *FooParams) {
    func (p *FooParams){
        apply(p.ListParams)
    }
}

使うときはこうなる。

FooList(WithFooListParams(WithLimit(100))
BarList(WithBarListParams(WithLimit(100)))

不格好。

パラメーターの種類だけoption関数を定義する

理想の話をすれば、WithXXX, WithYYY, ...というパラメーターの種類nの分の定義だけで済ませられるようなことを考えたい。つまり今回で言えばWithLimit1つで済ませたい。

それぞれinterfaceが要求するmethodの名前を変えてあげると、これを達成する事はできる。

type LimitOptFunc func(*ListParams)

func WithLimit(limit int) LimitOptFunc {
    return func(p *ListParams) {
        p.Limit = limit
    }
}

type FooListOpt interface {
    ApplyFoo(*FooListParams)
}
func (f LimitOptFunc) ApplyFoo(p *FooListParams) {
    f(&p.ListParams)
}

type BarListOpt interface {
    ApplyBar(*BarListParams)
}
func (f LimitOptFunc) ApplyBar(p *BarListParams) {
    f(&p.ListParams)
}

定義自体は煩雑になるが、同じWithLimitを別の型に対しても使う事ができる。名前空間の汚染もない。

func main() {
    fmt.Printf("%[1]T    %+[1]v\n", FooList(WithLimit(100)))
    fmt.Printf("%[1]T    %+[1]v\n", BarList(WithLimit(100)))
}

まぁ、常用するとしたらマクロみたいな何かが欲しくなったりはする。

最後に

functional optionsのoptionの定義部分をinterfaceにしたときに何か良い効果を得る事ができるか考えたりしてみた。

色々書いたが用法用量守って正しく使いましょう。煩雑になるので。

gist