functional optionsのようなbuilderのような何かを考えてみた
functional options は便利なのだけれど、同じパッケージでオプションを取る関数を複数定義したくなったときに名前の衝突に悩まされる。
// 衝突の例
func NewFoo(options ...FooOption) *Foo {
...
}
func WithName(name string) FooOption {
...
}
func NewBar(options ...BarOption) *Bar {
...
}
// ここでWithNameが衝突してしまう
func WithName(name string) BarOption {
...
}
interfaceなどを定義して頑張って対応することはできるがめんどくさい[1]。
そんなわけで、builder patternのようなものを考えてみた。
functional builder
例えば以下のようなPersonというstructがあるとする。
type Person struct {
Name string
Age int
Nickname string
}
これを以下の様な形で生成できるようにしたい。
func main() {
{
ob := NewPerson()
fmt.Printf("%+#v\n", ob) // &main.Person{Name:"", Age:20, Nickname:""}
}
{
ob := NewPerson.WithAge(10).WithName("foo")()
fmt.Printf("%+#v\n", ob) // &main.Person{Name:"foo", Age:10, Nickname:""}
}
}
ここでgoでは自分で定義した型はすべてメソッドを持つことができる[2]。これを使って関数をビルダーとして扱ってみる。このようにすることで似たような関数の例えばNewTeam()のようなものが現れたときにもWithName()というメソッドを利用し続ける事ができる。メソッドは名前空間が分かれている(型毎に持つことができる)ため。
type PersonF func() *Person
var NewPerson PersonF = func() *Person {
return &Person{Age: 20}
}
func (f PersonF) WithName(name string) PersonF {
return func() *Person {
ob := f()
ob.Name = name
return ob
}
}
func (f PersonF) WithAge(age int) PersonF {
return func() *Person {
ob := f()
ob.Age = age
return ob
}
}
例えば、Personの他にTeamのための関数を定義したときに、そのような型のためのNewTeam()もWithName()というメソッドを持つ事ができる。
optionへの対応
さらに最初の関数をfunctional optionsのような形で定義するようにしてあげれば、実行時にoptionによって調整できるような状態にできる。
(ここでprependは単にslicesの先頭に追加するためのhelper関数)
type personOption = func(*Person)
type PersonF func(...personOption) *Person
var NewPerson PersonF = func(options ...personOption) *Person {
ob := &Person{Age: 20}
for _, opt := range options {
opt(ob)
}
return ob
}
func prepend[T any](xs []T, x T) []T {
return append([]T{x}, xs...)
}
func (f PersonF) WithName(name string) PersonF {
return func(options ...personOption) *Person {
return f(prepend(options, func(ob *Person) { ob.Name = name })...)
}
}
func (f PersonF) WithAge(age int) PersonF {
return func(options ...personOption) *Person {
return f(prepend(options, func(ob *Person) { ob.Age = age })...)
}
}
以下の様なコードが動くようになる。ここでNewPersonもNewTeamもWithName()を持っている。
func main() {
{
ob := NewPerson()
fmt.Printf("%#+v\n", ob)
}
{
ob := NewPerson.WithName("foo")()
fmt.Printf("%#+v\n", ob)
}
{
ob := NewPerson.WithName("foo")(func(p *Person) {
p.Name = "boo"
p.Nickname = "B"
})
fmt.Printf("%#+v\n", ob)
}
fmt.Println("----------------------------------------")
{
ob := NewTeam.WithName("foo")()
fmt.Printf("%#+v\n", ob)
}
{
ob := NewTeam.WithName("foo")(func(ob *Team) {
ob.Name = "bar"
ob.Nickname = "B"
})
fmt.Printf("%#+v\n", ob)
}
}
// &main.Person{Name:"", Age:20, Nickname:""}
// &main.Person{Name:"foo", Age:20, Nickname:""}
// &main.Person{Name:"boo", Age:20, Nickname:"B"}
// ----------------------------------------
// &main.Team{Name:"foo", Nickname:""}
// &main.Team{Name:"bar", Nickname:"B"}
このときのgo docは以下の様なもの。
package p
TYPES
type Person struct {
Name string
Age int
Nickname string
}
type PersonF func(...personOption) *Person
var NewPerson PersonF = func(options ...personOption) *Person {
ob := &Person{Age: 20}
for _, opt := range options {
opt(ob)
}
return ob
}
func (f PersonF) WithAge(age int) PersonF
func (f PersonF) WithName(name string) PersonF
type Team struct {
Name string
Nickname string
}
----------------------------------------
type TeamF func(...teamOption) *Team
var NewTeam TeamF = func(options ...teamOption) *Team {
ob := &Team{}
for _, opt := range options {
opt(ob)
}
return ob
}
func (f TeamF) WithName(name string) TeamF
何が手に入ったのか?
functional optionsで指定できることに嬉しさはあるんだろうか?そして何が解決したんだろうか?
functional optionsを使うことでpresetのようなものを気軽に定義しておく事ができる。例えば、NewLoggerに対してForHuman()とForMachine()のようなpresetを定義し、前者はカラフルなtextのログで出力し後者はJSONログで出力するといったこと。これらのpresetを至るところで共用したい場合には便利かもしれない[3]。一方で今回の例のようにstructのフィールドに対して1:1のオプションを馬鹿みたいに生やすのはあまり嬉しくない。
今回解決したのは、functional optionsを利用する関数が複数存在したときに、名前空間を汚染してしまう問題だった。これはビルダーを作り生成される値の調整をメソッドにより行うことに近い解決策。特徴的なのは定義した型がなんであってもメソッドが持てるgoの機能を利用した点。
gist
[1]: いろいろな方法で解決を図る案はある https://golang.design/research/generic-option/
[2]: たとえばnet/httpのHandlerFuncはServeHTTP()メソッドを定義しているのでHandlerとして扱える
[3]: ここでNewHumanLogger()やNewMachineLogger()ではだめなのか一度検討してからの方が良いという話はもちろんある。
Discussion