インタフェースを返さないほうがよい理由について
以下のようにインタフェースを返す関数New()を定義したとする
package client
type DB interface {
Get(string) (string, error) // ← 将来メソッドを足すと破壊的
}
func New() DB {
/*
…
*/
return &dbImpl{}
}
この場合、interfaceへの変更が入るとdbImple{}の実装も変更しないといけなくなる、という問題が発生する。
反対に以下のように具象型を返すようにすると、Clientにメソッドを追加しても下位互換が保てる
package client
type Client struct { /* … */ }
func (c *Client) Get(string) (string, error) {
/* … */
}
func New() *Client {
return &Client{}
}
このバージョン管理に関する問題がインタフェースを返さないほうがよい1つ目の理由。
次に以下のように多数のメソッドからなるインタフェースが切られている場合。
package store
// 複数メソッドを含むインタフェース
type Store interface {
Get(key string) (string, error)
Put(key, val string) error
Close() error
}
func New() Store {
return &client{/*...*/}
}
type client struct{/*...*/}
func (c *client) Get(k string) (string, error) { /* ... */ return "", nil }
func (c *client) Put(k, v string) error { /* ... */ return nil }
func (c *client) Close() error { /* ... */ return nil }
一方でこれを利用する側は以下のようにGet()しか興味がないとする
package app
import "store"
type Service struct{
s store.Store
}
func NewService() *Service {
return &Service{s: store.New()} // New は Store を返す
}
func (svc *Service) ReadOnly(key string) (string, error) {
return svc.s.Get(key) // 使うのは Get だけ
}
にも関わらずテストしようとすると、以下のようにPut()
, Close()
も含めて実装する必要が出てくる
// app/app_test.go
type fakeStore struct{
data map[string]string
}
func (f fakeStore) Get(k string) (string, error) { return f.data[k], nil }
// ここでコンパイルエラー:fakeStore does not implement store.Store
// (missing method Put)
// func (f fakeStore) Put(k, v string) error { return nil }
// func (f fakeStore) Close() error { return nil }
func TestReadOnly(t *testing.T) {
svc := &Service{s: fakeStore{data: map[string]string{"x": "ok"}}}
// ↑ Store を満たさないとそもそも代入できない
}
このようにカップリングの度合いが増すため、テストの負担が増えてしまう。
これが2つ目の理由。
3つ目。Interfaceに定義していないメソッドを呼ぼうとすると型アサートが必要になる。
package store
type Store interface {
Get(key string) (string, error)
}
type Client struct{}
func (c *Client) Get(k string) (string, error) { /*...*/ return "", nil }
func (c *Client) Stats() map[string]int { /*独自の追加メソッド*/ return nil }
// インタフェースを返す
func New() Store { return &Client{} }
利用側がそのオブジェクトに実装されているStats()を使おうとしてもコンパイルエラーになる
s := store.New()
val, _ := s.Get("k")
s.Stats() // ← コンパイルエラー!
// なぜならStore には Stats が無いから
// 使いたければ型アサートが必要:
type hasStats interface{ Stats() map[string]int }
if hs, ok := s.(hasStats); ok {
_ = hs.Stats()
}
なのでやはり具象を返したほうが柔軟で良い。
4つ目はパフォーマンスの理由で、インタフェースで返してしまうと状況によってはヒープ割当が増え、ガベージコレクタの負荷が増える。これは本に書いてあるとおり。
例外
errorはインタフェース型。
一つの関数が返しうるerrorの実体は多様なのでインタフェースで宣言する。
逆にそうしないと(具象型で返してしまうと)、その型のエラーしか返せなくなる
func LoadConfig(ctx context.Context, path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
// 例: *os.PathError(ファイル無い/権限なし 等)
return nil, fmt.Errorf("read: %w", err) // %wでラップ
}
var cfg Config
if err := yaml.Unmarshal(b, &cfg); err != nil {
// 例: *yaml.TypeError(型不一致)
return nil, fmt.Errorf("parse: %w", err)
}
if err := cfg.Validate(); err != nil {
// 例: 独自の検証エラー型
return nil, fmt.Errorf("validate: %w", err)
}
// 例: 期限付きで何か外部アクセス
if deadline, ok := ctx.Deadline(); ok && time.Now().After(deadline) {
// 例: context.DeadlineExceeded(標準の sentinel error)
return nil, context.DeadlineExceeded
}
return &cfg, nil
}
そして、返ってきたオブジェクトをerrors.Is/errors.Asで仕分けする
cfg, err := LoadConfig(ctx, "config.yaml")
if err != nil {
// 1) ファイルが無いか?
if errors.Is(err, os.ErrNotExist) {
log.Println("設定ファイルが見つからない")
return
}
// 2) 期限切れか?
if errors.Is(err, context.DeadlineExceeded) {
log.Println("タイムアウトしました")
return
}
// 3) 具体的な型の情報が必要なら As で取り出す
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Printf("I/O失敗: op=%s, path=%s, err=%v",
pathErr.Op, pathErr.Path, pathErr.Err)
return
}
var vErr *ValidationError
if errors.As(err, &vErr) {
log.Printf("検証エラー: %v (field=%s)", vErr, vErr.Field)
return
}
// 4) その他はまとめて処理
log.Printf("その他のエラー: %v", err)
return
}
パフォーマンス
構造体を返すとヒープ割当が減少しますので、それはよいことです。
ところが、関数をインタフェース型の引数で呼び出すと、各インタフェース引数がヒープに割り当てられてしまいます。
後半については必ずしも真ではないケースもある
下記のようにインタフェース引数を受け取ってその場で使い切る場合には特にヒープは使われない
// 受け取ってすぐ使い切るだけ
func Use(i any) { fmt.Fprint(io.Discard, i) }
func call() {
var x int = 42
Use(x) // ← ここで interface に箱詰めはされるが、寿命は呼び出し中だけ
// コンパイラはスタックで間に合わせられる(ヒープ不要)ことが多い
}
一方で下記のように引数を外に保存する場合などはヒープが使われる
var saved []any
// 受け取った i をどこかに保存 ⇒ 呼び出しの外まで寿命が延びる
func Hold(i any) {
saved = append(saved, i) // ← ここで i(の中の具体値)が逃逸 → ヒープ化
}
func call() {
b := Big{}
Hold(b)
}