0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

7.7 インタフェースを受け取り構造体を返す についての考察

Posted at

インタフェースを返さないほうがよい理由について

以下のようにインタフェースを返す関数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)
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?