ぱいぱいにっき

Pythonが好きすぎるけれど、今からPerlを好きになりますにっき

Goで複数の引数を取る関数やメソッドをどう書くのがいいのか

普段Go書いているときにそこまで気にしてなかったが、ふと気になったので色々パターンを挙げてみる。なおこの記事には「答え」が書かれてないので、みなさんの意見を聞かせてください。

複数の引数を取るパターン一覧

  • そのまま引数を羅列する
  • 複数の引数をまとめたstructを取る
  • Functional Options Pattern

そのまま引数を羅列する

例えばHTTPリクエストを行うような関数があったとして、

func Request(ctx context.Context, method http.Method, _url string, query url.Values, formValues url.Values) error {
    // do something
}

というシグネチャが考えられる。

実際にnet/http.NewRequsetWithContextfunc NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error)というシグネチャになっている。

このやり方のメリットとしては、引数の記述を呼び出し側に強制できることである。例えば、methodという引数は必ず呼び出す時に書かないといけないため、http.MethodPostなのか、http.MethodGetなのかを呼び出し側が指定することを強制できる。これにより呼び出し側コードに現れない暗黙の挙動の出現を抑えられる。記述は冗長になるが、暗黙の挙動が少なければ、コードレビューのレビュワーからすると嬉しい。

一方で、引数は記述順が固定であり、それぞれ記述が必須になるため、呼び出す側は関数の定義を覚えておかなければならない。ただ、現代はgoplsによる関数シグネチャの補完が効くので、コードを書く側の環境さえ整っていれば、この心配もいらないと思われる。

複数の引数をまとめたstructを作る

先ほどと同じような関数を以下のように変形する。

type RequestInput struct {
    Method     http.Method
    URL        string
    Query      url.Values
    FormValues url.Values
}

func Request(ctx context.Context, input RequestInput) error {
    // Do Something
}

実際に、net/http.Client.Doというメソッドは、このようなパターンのメソッドであると言える。

func (c *Client) Do(req *Request) (*Response, error)

このパターンのメリットは、呼び出し側で引数を列挙する順番が固定でないことである。また、RequestInput自体が呼び出し側で入れ物として使えるので、以下のように、適宜判断しながら引数を詰めていくこともできるだろう。

var input RequestInput
if isPost {
    input.Method = http.MethodPost
}
input.URL = something.URL()

if err := Request(ctx, input); err != nil {
    return fmt.Errorf("error Request: %w", err)
}

また、RequestInput自体にメソッドを生やすことも可能である。例えば引数にはURLを取るが中身はそのbasenameしか利用しない場合は、RequestInput自体にメソッドを生やすとRequest関数の中身の量を抑えられる。

func (r RequestInput) urlBasename() (string, error) {
    u, err := url.Parse(r.URL)
    if err != nil {
        return "", fmt.Errorf("error url.Parse: %w", err)
    }
    return path.Base(u.Path), nil
}

func Request(ctx context.Context, input RequestInput) error {
    basename, err := input.urlBasename()
    if err != nil {
        return fmt.Errorf("error RequestInput.urlBasename: %w", err)
    }
    // Do something
}

RequestInputのような引数のためのstructは他の関数やメソッドにも使いまわせる。個人的には、使いまわさない方が良い気がしているが、この言語化はうまくできていない。

また、Goにおいて、短い識別子は有限な資源である。「そのまま引数を羅列する」で述べたときの関数シグネチャ内のURLを取る仮引数の名前はあえて、_urlとしている。これは、packageであるnet/urlとの重複を避けるためである。実用的には一文字でuとしてしまうことが多いが、型がstringであるゆえ、uに何を入れればいいかをコメント等で指示しなければならない。urlという名前が使えれば、そこまでの配慮はいらないはずである。 一方、structを使う場合は、関数内ではinput.URLという名前を使えるため、このケースではフィールド名でわかるため、何を 入るべきかをあまり記述しなくて良いと思われる。また、structであれば、フィールド自体にコメントを詳細に書くことも可能である。

デメリットとしては、呼び出し側にフィールドに値を明示的に入れることを強制できない。つまり、

Request(ctx, RequestInput{})

という呼び出しもコンパイルは通ってしまう。この場合、各フィールドにはゼロ値が入ってしまう。また、Goにおいて、ゼロ値をあえて指定した場合と、何も指定しなかった場合の区別は関数側ではできない。このケースではあまり考えられないだろうが、あえてURLに空文字を指定した場合と、何も入れなかった場合(もしくは記述し忘れた場合)の区別はできない。

Functional Options Pattern

Functional Options Patternは必須ではない引数を指定する際に用いられるが、指定した場合と指定しなかった場合の区別にも使える。

type requestInput struct {
    hasMethod bool
    method     http.Method
    hasURL bool
    url        string
    hasQuery bool
    query      url.Values
    hasFormValues bool
    formValues url.Values
}

type RequestOption interface {
    Apply(*requestInput)
}

type WithURL string

func (w WithURL) Apply(input *requestInput) {
    input.url = string(w)
    input.hasURL = true
}

func Request(ctx context.Context, options ...RequestOption) error {
    input := &requestInput{}
    for _, o : = range options {
        o.Apply(o)
    }
    // Do something
}

こうすれば、URLが指定されたかどうかをhasURLフィールドで区別できる。ただ、この区別は実行時の話であり、指定しなかったらコンパイルに失敗するという挙動を実現するには「そのまま引数を羅列する」を使わないといけないだろう。

番外編

関数を分けてしまう

上記の関数でmethodは数パターンしかなく、またRequest関数自体がGetとPostしかサポートしないのであれば、引数で取るのではなく、関数自体を分けてしまうことも考えられる。

func GetRequest(ctx context.Context, _url string, query url.Values, formValues url.Values) error
func PostRequest(ctx context.Context, _url string, query url.Values, formValues url.Values) error

中身の記述が冗長であるのであれば、別のプライベート関数に委譲すれば問題がなさそうである。

func GetRequest(ctx context.Context, _url string, query url.Values, formValues url.Values) error {
    return request(ctx, http.MethodGet, _url, query, formValues)
}

func PostRequest(ctx context.Context, _url string, query url.Values, formValues url.Values) error {
    return request(ctx, http.MethodPost, _url, query, formValues)
}

func request(ctx context.Context, method http.Method, _url string, query url.Values, formValues url.Values) error {
    // Do something
}

また、GETとPOST以外のメソッドは記述すらないわけであるから、サポートしていないDELETEやPUTなどを投げようとしても記述しようがないし、無理やり書いてもコンパイルエラーになるだけである。なので一種のバリデーションとしても機能している。

私はコードレビューでこういった状況で、パターンが少なく、またif文でバリデーションを行うぐらいであれば、関数でわけてしまった方が良いという旨のコメントをよく行う。

まとめる引数、まとめない引数

この記事の例では、context.Contextは一貫してまとめない対象である。context.Contextはドキュメントで明示的にstructに含めずにひとつ目の引数として関数に渡しなさいと書かれている。

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx

pkg.go.dev

よって、この例でもそのようにした。net/http.Requeststructについては歴史的経緯でcontext.Contextが含まれている。Go 1.7より前はcontext.Contextが存在しなかったが、net/http.Requestnet/http.Client.Doは存在したためである。

その他にもstructにまとめない方が良い引数はあるかもしれない。例えば拙作のORM sqllaを使う際にcontext.Contextとともに渡すsqlla.DBは必ず2つ目の引数として渡すようにしている。この引数はDBの接続情報を持つものであるが、トランザクションを使う場合はある特定かつ、その呼び出しでしか使ってほしくないコネクションが含まれている。なのでstructに入れて使いまわされるよりは、その危険性が少ないシグネチャにしている。

テストでよく見かける*testing.Tもそのような対象に見える。こういった変数には何らかのパターンがあるかもしれない。