普段Go書いているときにそこまで気にしてなかったが、ふと気になったので色々パターンを挙げてみる。なおこの記事には「答え」が書かれてないので、みなさんの意見を聞かせてください。
複数の引数を取るパターン一覧
- そのまま引数を羅列する
- 複数の引数をまとめたstructを取る
- Functional Options Pattern
そのまま引数を羅列する
例えばHTTPリクエストを行うような関数があったとして、
func Request(ctx context.Context, method http.Method, _url string, query url.Values, formValues url.Values) error {
}
というシグネチャが考えられる。
実際にnet/http.NewRequsetWithContext
は func 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 {
}
実際に、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)
}
}
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)
}
}
こうすれば、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 {
}
また、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.Request
structについては歴史的経緯でcontext.Context
が含まれている。Go 1.7より前はcontext.Contextが存在しなかったが、net/http.Request
やnet/http.Client.Do
は存在したためである。
その他にもstructにまとめない方が良い引数はあるかもしれない。例えば拙作のORM sqllaを使う際にcontext.Context
とともに渡すsqlla.DB
は必ず2つ目の引数として渡すようにしている。この引数はDBの接続情報を持つものであるが、トランザクションを使う場合はある特定かつ、その呼び出しでしか使ってほしくないコネクションが含まれている。なのでstructに入れて使いまわされるよりは、その危険性が少ないシグネチャにしている。
テストでよく見かける*testing.T
もそのような対象に見える。こういった変数には何らかのパターンがあるかもしれない。