Goで関数のオプション引数を実装するパターン集

本記事は、NewsPicks Advent Calendar 2022 の 12/19 公開分の記事になります。

はじめまして、11月からAlphaDriveにJoinし、Webアプリケーションエンジニアをしている畠山(keyamin)です。

今回は、Goで関数のオプション引数を実現するための方法を紹介しようと思います!

お題

コーヒーショップを題材に、コーヒーインスタンスを作成するプログラムを考えます。コーヒーファクトリー関数の呼び出し側は、サイズ、ミルクの有無、角砂糖の数を指定することができます。

※こちらの題材はO'Reilly Japanより出版されている実用 Go言語を参考にしています。めちゃくちゃいい本なので是非読んでみてください...!

type Portion int

const (
    Regular Portion = iota
    Small
    Large
)

type Coffee struct {
    size  Portion
    milk  bool
    sugar uint
}

func NewCoffee(p Portion, m bool, s uint) *Coffee {
    return &Coffee{
        size:  p,
        milk:  m,
        sugar: s,
    }
}

// 注文するとき
var blackCoffee = NewCoffee(Regular, false, 0)
var sweetCoffee = NewCoffee(Regular, false, 3)

このように素直にオプションをそれぞれ引数で受け取ってもいいですが、ブラックコーヒー(blackCoffee)を注文する際、milkにfalse、sugarに0をわざわざ指定するのが冗長に感じますね。

また、これからコーヒーの機能拡張が行われてオプションが増えた際には、何のオプションを示すのかよくわからないbool値やuint値を引数に羅列していくことになります。もう少しスマートなやり方があればそちらを採用したいですよね。

別言語での実装例

TypeScriptで実装してみた例になります。

type Portion = "Regular" | "Small" | "Large";

type Coffee = {
  size: Portion;
  milk: boolean;
  sugar: number;
};

const getCoffee = ({
  size = "Regular",
  milk = false,
  sugar = 0,
}: Partial<Coffee> = {}) => {
  return { size, milk, sugar };
};

// 注文するとき
const blackCoffee = getCoffee();
const sweetCoffee = getCoffee({ sugar: 3 });

TypeScriptでは分割代入やデフォルト引数、Utility Typesといった仕組みを利用することでこのように簡単にオプション引数を実装することができますね!

Goはシンプル故にこのような機能がないため、実装方法を考える必要があります。

Goでの実装方法を考える

使われそうなパターン分のファクトリー関数を作る

シンプルに、ミルクコーヒーファクトリーや激甘コーヒーファクトリーといった関数を個別に用意する方法です。

実際はサイズくらいはオプションで受け付けるべきでしょうが、例としては以下のようになります。

func NewMilkCoffee() *Coffee {
    return NewCoffee(Regular, true, 0)
}

func NewSweetCoffee() *Coffee {
    return NewCoffee(Regular, false, 3)
}

func NewMaxCoffee() *Coffee {
    return NewCoffee(Large, true, 3)
}

この方法は、例えばosパッケージで使用されています。os.Create()os.Open()は、ソースコードを読んでみるとどちらもos.OpenFile()を呼び出しており、その際の権限の付け方が違うだけになっています。

github.com

シンプルな少数のユースケースが存在する場合、こちらの方法がベストなのかなと感じます。

オプション構造体を用意する

オプション用の構造体を用意し、それを引数として渡す方法です。

type CoffeeOption struct {
    Size  Portion
    Milk  bool
    Sugar uint
}

func NewCoffee(opt *CoffeeOption) *Coffee {
    // デフォルト値を設定
    // デフォルトでは、Regularサイズ、ミルクなし、角砂糖なし
    if opt == nil {
        opt = &CoffeeOption{}
    }

    return &Coffee{
        size:  opt.Size,
        milk:  opt.Milk,
        sugar: opt.Sugar,
    }
}

// 注文するとき
var blackCoffee = NewCoffee(nil)
var sweetCoffee = NewCoffee(&CoffeeOption{
    Sugar: 3,
})

この方法だと、最初のように冗長に多くの引数を渡す必要がなく、呼び出し側は必要なオプションだけを構造体に入れてコーヒーインスタンスを生成することができます。

ただし、上の例ではReglarサイズ(0)、ミルクなし(false)、角砂糖なし(0)がデフォルトでしたが、デフォルトで角砂糖を1ついれたミルクコーヒーにしたい場合はどうでしょうか。

func NewCoffee(opt *CoffeeOption) *Coffee {
    // デフォルト値を設定
    // デフォルトでは、Regularサイズ、ミルクあり、角砂糖1つ
    if opt == nil {
        opt = &CoffeeOption{}
    }

    if opt.Milk == false {
        opt.Milk = true
    }

    if opt.Sugar == 0 {
        opt.Sugar = 1
    }

    return &Coffee{
        size:  opt.Size,
        milk:  opt.Milk,
        sugar: opt.Sugar,
    }
}

// 角砂糖0個を明示的に指定したのに、微糖ミルクコーヒーを出されてしまう!
var noSugarMilkCoffee = NewCoffee(&CoffeeOption{
    Milk:  true,
    Sugar: 0,
})

このようにデフォルト値がゼロ値ではない場合、明示的に指定されたゼロ値を判断することができません。

この場合どうするのかというと、ポインタ型を使用します。ポインタ型であれば、ゼロ値はnil,明示的にゼロを指定したい場合0へのポインタを指定すれば良いので、ファクトリ側から判別ができます。

type CoffeeOption struct {
    Size  *Portion
    Milk  *bool
    Sugar *uint
}

func NewCoffee(opt *CoffeeOption) *Coffee {
    // デフォルト値を設定
    if opt == nil {
        opt = &CoffeeOption{}
    }

    if opt.Size == nil {
        *opt.Size = Regular
    }

    if opt.Milk == nil {
        *opt.Milk = true
    }

    if opt.Sugar == nil {
        *opt.Sugar = 1
    }

    return &Coffee{
        size:      *opt.Size,
        milk:      *opt.Milk,
        sugar:   *opt.Sugar,
    }
}

func Uint(i uint) *uint {
    return &i
}

func Bool(b bool) *bool {
    return &b
}

// 注文するとき
var defaultCoffee = NewCoffee(nil)
var sweetMilkCoffee = NewCoffee(&CoffeeOption{
    Sugar: Uint(3),
})
// ちゃんと無糖ミルクコーヒーが出てくる
var noSugarMilkCoffee = NewCoffee(&CoffeeOption{
    Milk:  Bool(true),
    Sugar: Uint(0),
})

こうすることで、ゼロ値以外がデフォルトでも問題なく実装することができますね!

この方法の欠点としては、

  • 指定するオプションがない場合にもnilを渡す必要があること

  • Goではリテラルのポインタを直接取ることができないため、例のUint()のような関数が必要になってきて呼び出しがやや冗長になること

が挙げられるかと思います。

例えばaws-sdk-go-v2ではこのパターンでオプション引数を指定しますが、ポインタを取るためのUtility関数が用意されていますね!

github.com

呼び出す際に1つ以上はオプションを指定する場合や、同じオプションを複数の関数呼び出しで使いまわしたりする場合、こちらの方法がベストなのかなと思います。

ビルダーを利用する

ビルダー関数をメソッドチェーンで呼び出し、それぞれでオプションを受け付ける方法です。

type CoffeeOption struct {
    size  Portion
    milk  bool
    sugar uint
}

func (o *CoffeeOption) Size(p Portion) *CoffeeOption {
    o.size = p
    return o
}

func (o *CoffeeOption) Milk(m bool) *CoffeeOption {
    o.milk = m
    return o
}

func (o *CoffeeOption) Sugar(s uint) *CoffeeOption {
    o.sugar = s
    return o
}

func NewCoffee() *CoffeeOption {
    return &CoffeeOption{
        size:  Regular,
        milk:  false,
        sugar: 0,
    }
}

func (o *CoffeeOption) Order() *Coffee {
    return &Coffee{
        size:  o.size,
        milk:  o.milk,
        sugar: o.sugar,
    }
}

// 注文するとき
var blackCoffee = NewCoffee().Order()
var sweetCoffee = NewCoffee().Sugar(3).Order()

オプションごとに関数を作成するため冗長にはなりますが、メソッドチェーン形式なのでエディタの補完がいい感じに効いたり、構造体パターンであったデフォルト値とゼロ値の判別なども問題なく行えるのがメリットです。

欠点としては、例の場合最後にOrder()を呼び出さないとコーヒーインスタンスが生成されないことでしょうか。

zerologなどのロガーライブラリなどでよく見ますね。*Eventをメソッドチェーンで編集していき、最後にMsg()Send()を使ってログを出力します。

github.com

Functional Optionパターン

最後に紹介するのは、Functional Optionパターンと呼ばれるものです。

type CoffeeOptionFunc func(*Coffee)

func WithSize(size Portion) CoffeeOptionFunc {
    return func(c *Coffee) {
        c.size = size
    }
}

func WithMilk(milk bool) CoffeeOptionFunc {
    return func(c *Coffee) {
        c.milk = milk
    }
}

func WithSugar(sugar uint) CoffeeOptionFunc {
    return func(c *Coffee) {
        c.sugar = sugar
    }
}

func NewCoffee(opts ...CoffeeOptionFunc) *Coffee {
    c := &Coffee{
        size:  Regular,
        milk:  false,
        sugar: 0,
    }

    for _, opt := range opts {
        opt(c)
    }

    return c
}

// 注文するとき
var blackCoffee = NewCoffee()
var sweetCoffee = NewCoffee(WithSugar(3))

パッと見ビルダーを利用したパターンに似ているかもしれませんが、オプション関数がメソッドではなく独立した関数になっているため、CoffeeOptionFuncを公開しておけば利用者側がオプションを自作できるのがメリットです。

欠点としては、実装がやや直感的ではないことや、外部パッケージから呼び出そうとするとオプション関数それぞれにパッケージ名がつくためコードが長くなることでしょうか。

このパターンはGoogleやUberのGoスタイルガイドにも記載がありますね!

google.github.io

github.com

オプションがない場合はわざわざnilを渡さなくて良いので、デフォルト値で使用されることが多い場合はこちらの方法が良さそうです。

また、Uberのスタイルガイドを見てみると、上の例とはちょっと違った書き方をしているのがわかります。上の例をUberと同じ書き方で書くと下のようになります。

type CoffeeOption interface {
    apply(*Coffee)
}

type sizeOption Portion

func (s sizeOption) apply(c *Coffee) {
    c.size = Portion(s)
}

func WithSize(size Portion) CoffeeOption {
    return sizeOption(size)
}

type milkOption bool

func (m milkOption) apply(c *Coffee) {
    c.milk = bool(m)
}

func WithMilk(milk bool) CoffeeOption {
    return milkOption(milk)
}

type sugarOption uint

func (s sugarOption) apply(c *Coffee) {
    c.sugar = uint(s)
}

func WithSugar(sugar uint) CoffeeOption {
    return sugarOption(sugar)
}

func NewCoffee(opts ...CoffeeOption) *Coffee {
    c := &Coffee{
        size:  Regular,
        milk:  false,
        sugar: 0,
    }

    for _, opt := range opts {
        opt.apply(c)
    }

    return c
}

// 注文するとき
var blackCoffee = NewCoffee()
var sweetCoffee = NewCoffee(WithSugar(3))

呼び出し方は同じなのに、CoffeeOptionの型がinterfaceになってコード量が増えただけでは?と思われるかもしれません。

しかし、呼び出し方は同じですが、NewCoffeeに渡しているWithSugar(3)の型を見てみると、最初の例ではtype CoffeeOptionFunc func(*Coffee)、つまり関数型ですが、2つ目の例ではtype CoffeeOption interface { apply(*Coffee) }、動的な型としてはtype sugarOption uintでuint型になっています。

Goでは関数は比較できないため、最初の例ではNewCoffeeの引数が比較不可能、2つ目の例では比較可能ということですね。

だからなんだといいますと、このNewCoffeeをモックしたテストをする際などに、引数を比較できる2つ目の例のほうが、NewCoffeeに意図した値が渡されているかを確認できるためテストしやすい、ということになります!

コード量は増えてしまいますが、こちらの書き方のほうがテスト容易性では優れていますね。

ただし、interface型同士の比較では実行時panicを起こす可能性があるので、注意しましょう。例えば下のコードでは実行時にpanicが起こります。

type fooOption []string

func (s fooOption) apply(c *Coffee) {
    fmt.Println("foo")
}

func WithFoo(strings ...string) CoffeeOption {
    var arr []string
    for _, str := range strings {
        arr = append(arr, str)
    }
    return fooOption(arr)
}

func main() {
    // panic: runtime error: comparing uncomparable type main.fooOption
    if WithFoo("a", "b") == WithFoo("a", "b") {
        fmt.Println("いっしょです")
    }
}

これはinterface型の比較を行う際、比較対象の動的な型が同じでかつ比較不可能な場合に発生するものです。ここまで深堀りするとこの記事の趣旨を逸脱してしまうので、気になる方はGoのinterfaceやinterface型の比較について調べてみてください!ちなみに、上の例ではWithFoofooOptionを返すように型を修正するとこのpanicは起こらず、コンパイルエラーになります。

research.swtch.com

まとめ

若干横道にそれてしまいましたが、挙げた方法をまとめます。

方法 メリット デメリット ユースケース
使われそうなパターン分のファクトリー関数を作る 最もシンプル オプションが増えるとファクトリーの数も増えていく シンプルな少数のユースケースが存在する場合
オプション構造体を用意する 直感的にオプションを指定できる デフォルト値がゼロ値でない場合、値をポインタで渡す手間が増える 呼び出す際に1つ以上はオプションを指定する場合や、同じオプションを複数の関数呼び出しで使いまわしたりする場合
ビルダーを利用する エディターの補完が効く、視覚的に前後関係をもたせてオプションを指定できる オプション提供側のコードが冗長になる・Apply関数を最後に呼び出す必要がある ロガーなど、流れのあるオプション指定をする場合
Functional Optionパターンを利用する デフォルト値で呼び出す場合にすっきり書ける オプション提供側のコードが冗長になる デフォルト値で使用されることが多い場合

オプション引数を実装するために様々な方法がありましたが、どれにもメリット・デメリットがありますね。

公式のスタイルガイドにあるからといって常にFunctional Optionパターンを使えば良いわけでもなく、利用者側の使い勝手やコードの規模感を踏まえて選択するのが良いと思います。

是非、参考にしてみてください!

Page top