以下の文について具体的なコードとともに考察してみる
Goの関数は第一級のオブジェクトですから、別の引数として渡されることもよくあります。
一方で、Goは小さなインタフェースを推奨していますが、メソッドが一つしかないインタフェースは関数型の引数を簡単に置き換えられます。
そこで疑問が生じます。
どのような場合に関数やメソッドに関数型の引数を指定するべきで、どのような場合にインタフェースを使うべきなのでしょうか。
そのたった一つの関数が他の多くの多くの関数や、引数に指定されていない他の状態変数に依存する場合は、インタフェースを引数として使い、関数をそのインタフェースにつなぐための関数型を定義します。
パッケージhttpではこれが行われています。
Handlerは一連の呼び出しの入り口に過ぎず、呼び出される関数の方に手を入れると考えればよいでしょう。
しかし、その関数が単純なものであれば(sort.Sliceで使われている関数のように)、関数型の引数を選択するとよいでしょう
小さいインタフェース
小さいインタフェースの利点としては
- 実装が楽
- 合成が効く
- 差し替え・テストが簡単
など。でかいインタフェースはその逆が発生する。
ここまではどの言語も同じ。
JavaやC#では明示的にimplementsなどを使って実装する必要があるので、大きめのインタフェースでも頑張って実装する、ということが普通に起こる。
また、フレームワークも「全部まとめてケアする」という発想で出来ているものが多いため、インタフェースが比較的大きくなりがちだった(最近は小さくする傾向もある)。
TypeScriptやRustなどは、インタフェースを小さく分ける、というプラクティスが存在するが、型合成などがもうちょっと柔軟なので、Go言語ほど小さいインタフェースへの要望は強くない(らしい)
Goで小さいインタフェースを推奨する理由としては主に以下の4つがある
- 暗黙実装なので、小さいほど偶然でも適用されやすくなり、テストもしやすくなる
- 「必要な能力だけ要求する」という思想があり、これが依存の分離やテスト容易性に直結する
- 小さいインタフェース、という考えが浸透していればあとから変更を加えにくく(加えると壊れるから)、安定して公開APIにしやすい
- 大きいインタフェースを設計するのではなく、小さいインタフェースを合成して大きくするのが自然に出来るから
以上の理由でGoのベストプラクティスとなっている。
関数型の引数を指定するべきケース
以下のSlice()の例のように、
- その場の「一つの動作」だけを渡したい (つまりシンプルな述語や比較関数、変換処理など)
- 外から追加機能を検出したり、再入・長寿命で使う前提がない
- クロージャで必要な状態を閉じ込められる
などの条件がある場合は関数型が良い
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})
インタフェース引数を選ぶべきケース
-
7.12 関数型とインタフェース についての考察 その1で触れたように「入口は1つでも、裏側でいろいろな強調や追加能力がありうる」ような場合 - 任意の追加能力を型アサーションで扱いたい
- 長寿命・複数回呼び・ライフサイクル管理がある
- テストでモックを差し替えたい
例えばhttp.Handler()はServeHTTP1つが入口となるが、中で他のメソッドや状態に依存しうる。
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }
一つの方法としては以下のように関数をそのまま入口にする方法
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello\n"))
}
mux := http.NewServeMux()
mux.Handle("/hello", http.HandlerFunc(hello))
当然自前の状態、ほかメソッドを好きに使うことも可能
type UserHandler struct {
Repo *UserRepo // 依存(DB等)
Log *log.Logger // ロガー
}
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u, err := h.Repo.Find(r.Context(), r.URL.Query().Get("id"))
if err != nil { h.Log.Printf("err: %v", err); http.Error(w, "ng", 500); return }
renderUser(w, u) // ← 補助メソッド・テンプレート等、なんでも呼べる
}
引数の拡張能力に依存することも出来る
func stream(w http.ResponseWriter, r *http.Request) {
// 例: ストリーミング可能か?
if f, ok := w.(http.Flusher); ok {
for i := 0; i < 3; i++ {
fmt.Fprintf(w, "chunk %d\n", i)
f.Flush() // 即時送出
time.Sleep(200 * time.Millisecond)
}
return
}
w.Write([]byte("no streaming"))
}
またはミドルウェアをラップすることも可能
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t := time.Now()
next.ServeHTTP(w, r) // ← 次の入口へ
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(t))
})
}
このように様々な角度から利用するうえでもインタフェースが小さいことがキーとなる