Go のインターフェース: 小さく、暗黙的で、正しい使い方

1. 概要

Go のインターフェースは Java や C# と異なる重要な点があります: implements キーワードがありません。型が必要なメソッドをすべて持っていれば、自動的にインターフェースを満たします。この特性により、独自の設計方法が生まれました: インターフェースは小さく、実装する側ではなく、使用する側で宣言されます。

この記事を読み終えると、Go のスタイルに合ったインターフェースの書き方、mock ライブラリなしでテストする方法、および「nil のように見えるが nil ではない」というような一般的なエラーを回避する方法がわかります。

2. インターフェースは暗黙的に満たされる

顧客に通知を送るインターフェースから始めましょう:

package main

import "fmt"

type Notifier interface {
	Notify(customer, message string) error
}

type EmailNotifier struct {
	From string
}

func (e EmailNotifier) Notify(customer, message string) error {
	fmt.Printf("[メール送信元 %s] 宛て %s: %s\n", e.From, customer, message)
	return nil
}

type SMSNotifier struct{}

func (SMSNotifier) Notify(customer, message string) error {
	fmt.Printf("[sms] 宛て %s: %s\n", customer, message)
	return nil
}

func main() {
	notifiers := []Notifier{
		EmailNotifier{From: "shop@nonla.dev"},
		SMSNotifier{},
	}
	for _, n := range notifiers {
		_ = n.Notify("An", "注文 #42 が確認されました")
	}
}

結果:

[メール送信元 shop@nonla.dev] 宛て An: 注文 #42 が確認されました
[sms] 宛て An: 注文 #42 が確認されました

EmailNotifier または SMSNotifier のどこにも Notifier への言及はありません。コンパイラが自動的にチェックします: Notify(string, string) error メソッドを持つ型なら、Notifier 型の変数に割り当てることができます。

重要な結果として、実装側がインターフェースの存在を知る必要がないということです。標準ライブラリまたはサードパーティライブラリの既存の型に対して新しいインターフェースを宣言でき、彼らのコードを変更する必要がありません。

3. インターフェースを小さく保つ

Go の標準ライブラリが最良の例です。io.Reader と io.Writer はそれぞれ正確に 1 つのメソッドを持っています:

type Writer interface {
	Write(p []byte) (n int, err error)
}

小さいため、多くの型がそれを満たします: *os.File、*bytes.Buffer、*strings.Builder、http.ResponseWriter、*gzip.Writer… io.Writer を受け取る関数は、これらすべての型で使用できます:

package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

type Order struct {
	ID       int
	Customer string
	Total    int64
}

func WriteReceipt(w io.Writer, o Order) error {
	_, err := fmt.Fprintf(w, "注文 #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
	return err
}

func main() {
	order := Order{ID: 42, Customer: "An", Total: 350000}

	_ = WriteReceipt(os.Stdout, order) // ターミナルに出力

	var sb strings.Builder
	_ = WriteReceipt(&sb, order) // メモリに書き込み
	fmt.Printf("レシートの長さ: %d バイト\n", sb.Len())
}
注文 #42 - An - 350000 VND
レシートの長さ: 26 バイト

WriteReceipt は単に「バイトを書く」能力が必要なため、正確にそれだけを要求します。ファイル、HTTP response、または圧縮ファイルに書き込みたい場合、関数を変更する必要はありません。

複数の動作が必要な場合は、最初から大きなインターフェースを書くのではなく、小さなインターフェースを組み合わせるようにしてください:

type OrderFinder interface {
	FindOrder(id int) (Order, error)
}

type OrderSaver interface {
	SaveOrder(o Order) error
}

type OrderStore interface {
	OrderFinder
	OrderSaver
}

注文を表示する関数は OrderFinder のみを受け取ります。ステータスを更新する関数は OrderStore を受け取ります。各関数は明確に必要なものを宣言し、テスト用の fake を書く方がはるかに簡単です。

4. インターフェースを使用側で宣言する

Java では通常、PaymentGateway を書いてから StripePaymentGateway implements PaymentGateway と書き、両方が並んでいます。Go ではその逆が一般的です: 実装パッケージは具体的な struct を返し、使用パッケージが必要なインターフェースを自分で宣言します。

payment パッケージが多くのメソッドを持つクライアントを提供しているとしましょう:

// package payment
type Client struct {
	apiKey string
}

func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }

func (c *Client) Charge(customer string, amount int64) (string, error) {
	return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}

func (c *Client) Refund(txnID string) error         { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }

checkout パッケージは料金を徴収するだけが必要なため、1 つのメソッドを持つインターフェースを宣言します:

// package checkout
var ErrInvalidTotal = errors.New("合計金額は 0 より大きい必要があります")

type PaymentGateway interface {
	Charge(customer string, amount int64) (string, error)
}

type Service struct {
	gateway PaymentGateway
}

func NewService(gw PaymentGateway) *Service {
	return &Service{gateway: gw}
}

func (s *Service) Checkout(o Order) (string, error) {
	if o.Total <= 0 {
		return "", ErrInvalidTotal
	}
	txnID, err := s.gateway.Charge(o.Customer, o.Total)
	if err != nil {
		return "", fmt.Errorf("注文 #%d の支払い: %w", o.ID, err)
	}
	return txnID, nil
}

main で初期化するとき:

svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>

*payment.Client は暗黙的に checkout.PaymentGateway を満たします。payment パッケージは checkout をインポートせず、それが存在することも知る必要がありません。

この原則は通常 「インターフェースを受け入れ、構造体を返す」 と要約されます: 関数は柔軟性のためにインターフェースを受け入れますが、呼び出し元がすべてのメソッドとフィールドを使用できるように具体的な型を返します。

5. フェイクでテストする。mock ライブラリは不要です

使用側の小さなインターフェースはテストを非常にコンパクトにするのに役立ちます。正確に 1 つのメソッドを持つ struct を書くだけです:

package checkout

import (
	"errors"
	"testing"
)

type fakeGateway struct {
	charged []int64
	err     error
}

func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
	if f.err != nil {
		return "", f.err
	}
	f.charged = append(f.charged, amount)
	return "txn-test", nil
}

func TestCheckout_ChargesOrderTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})

	if err != nil {
		t.Fatalf("予期しないエラー: %v", err)
	}
	if txn != "txn-test" {
		t.Errorf("txn = %q、期待値 %q", txn, "txn-test")
	}
	if len(gw.charged) != 1 || gw.charged[0] != 200000 {
		t.Errorf("charged = %v、期待値 [200000]", gw.charged)
	}
}

func TestCheckout_RejectsZeroTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})

	if !errors.Is(err, ErrInvalidTotal) {
		t.Fatalf("err = %v、ErrInvalidTotal を期待", err)
	}
	if len(gw.charged) != 0 {
		t.Errorf("Charge を呼び出すべきではありませんが、charged = %v", gw.charged)
	}
}

func TestCheckout_WrapsGatewayError(t *testing.T) {
	gwErr := errors.New("カードが拒否されました")
	svc := NewService(&fakeGateway{err: gwErr})

	_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})

	if !errors.Is(err, gwErr) {
		t.Fatalf("err = %v、%v をラップすることを期待", err, gwErr)
	}
}

テストを実行:

go test ./checkout -v
=== RUN   TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN   TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN   TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS

PaymentGateway に 10 個のメソッドがある場合、フェイクも 10 個のメソッドをすべて実装する必要があります。これはインターフェースが大きすぎることを最も明確に示しています。

6. Type assertion、type switch、およびコンパイル時の検証

6.1. type assertion を使用した任意のインターフェース

時々、特定の型がそれをサポートしている場合に追加の機能を使いたいことがあります。Go は 2 つの値の形式の type assertion を使用します:

type Refunder interface {
	Refund(txnID string) error
}

func (s *Service) Cancel(txnID string) error {
	r, ok := s.gateway.(Refunder)
	if !ok {
		return errors.New("支払いゲートウェイは払い戻しをサポートしていません")
	}
	return r.Refund(txnID)
}

*payment.Client では、Refund メソッドを持つため ok は true です。fakeGateway では、ok は false で、関数は panic ではなくエラーを返します。標準ライブラリはこの手法を多く使用しています。例えば、io.Copy はパラメータが io.WriterTo であるかどうかをチェックして、より高速なパスを選択します。

注: 1 つの値の形式 r := s.gateway.(Refunder) は、型が一致しない場合 panic します。型が一致することが確実でない限り、常に r, ok := … の形式を使用してください。

6.2. Type switch

複数の異なる型を処理する必要がある場合は、type switch を使用します:

func describe(n Notifier) string {
	switch v := n.(type) {
	case EmailNotifier:
		return "メール from " + v.From
	case SMSNotifier:
		return "sms"
	default:
		return fmt.Sprintf("わかりません: %T", v)
	}
}

業務的な型に対して type switch を複数の場所に書いているのが見えたら、インターフェースにメソッドが足りないかもしれません。

6.3. コンパイル時にインターフェースの満足度を確認する

満足度が暗黙的であるため、誰かがメソッドのシグネチャを変更した場合、エラーは代入の場所でのみ表示されます。実装ファイルでエラーを早期に捕捉するには、次の行を追加します:

var _ checkout.PaymentGateway = (*payment.Client)(nil)

この行は実行時に値を作成しません。これはコンパイラに *payment.Client が PaymentGateway を満たしているかどうかをチェックさせるだけです。実装パッケージがそのインターフェースにコミットしたい場合にのみ使用してください。そうしないと、不要な逆方向のインポートが生じます。

7. よくあるエラー

7.1. nil ポインタを含むインターフェースは nil ではない

これは Go で最も古典的なエラーです:

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string {
	return "フィールドが無効です: " + e.Field
}

func validate(o Order) error {
	var verr *ValidationError
	if o.Total <= 0 {
		verr = &ValidationError{Field: "Total"}
	}
	return verr // 違う
}

func main() {
	err := validate(Order{Total: 100})
	fmt.Println(err == nil)
}
false

インターフェース値は 2 つの部分で構成されます: 型と値。インターフェースが nil に等しいのは、両方が空の場合のみです。ここで、型は *ValidationError で、値は nil ポインタなので、err != nil です。修正方法は明示的に nil を返すことです:

func validate(o Order) error {
	if o.Total <= 0 {
		return &ValidationError{Field: "Total"}
	}
	return nil
}

7.2. ポインタ receiver とメソッドセット

type AuditNotifier struct{ count int }

func (a *AuditNotifier) Notify(customer, message string) error {
	a.count++
	return nil
}

var n Notifier = AuditNotifier{} // コンパイルエラー
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)

ポインタ receiver を持つメソッドは、*AuditNotifier のメソッドセットにのみ属します。&AuditNotifier{} を割り当てる必要があります。逆に、value receiver を持つメソッドは、value と pointer の両方で使用できます。

7.3. 必要な前にインターフェースを書く

Java の習慣を持つコードはしばしば次のようになります:

type OrderService interface {
	Create(o Order) error
	Cancel(id int) error
	Find(id int) (Order, error)
	// … さらに 10 個のメソッドを追加
}

type orderServiceImpl struct{ /* … */ }

func NewOrderService() OrderService { return &orderServiceImpl{} }

この方法には 3 つの問題があります。インターフェースは実装が 1 つだけです。実装側にあり、使用側ではありません。コンストラクタはインターフェースを返すため、呼び出し元は具体的な型へのアクセスを失います。具体的な struct を返すようにしてください。各使用パッケージがテスト時など実際に代替が必要な場合に、小さなインターフェースを自分で宣言するようにしてください。

7.4. any の乱用

any (interface{} の別名) はすべての型を受け入れるため、コンパイラはこれ以上は役に立ちません。Go 1.18 以降、複数の型で動作する関数が必要な場合は、先に generics を検討してください:

func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
	var sum int64
	for _, it := range items {
		sum += it.GetTotal()
	}
	return sum
}

型が事前に不明な場合にのみ any を使用してください。例えば、任意の JSON をデコードするときなど。

8. 結論

Go のインターフェースが強力な理由は、シンプルさにあります。覚えておくべきポイント:

  • 型はインターフェースを暗黙的に満たします。implements は不要です。
  • インターフェースは小さく、理想的には 1 つまたは 2 つのメソッド。それ以上が必要な場合は、小さなインターフェースを組み合わせてください。
  • 使用側でインターフェースを宣言します。関数はインターフェースを受け入れ、コンストラクタは struct を返します。
  • 小さなインターフェースは、手書きの fake でテストするのに役立ち、mock ライブラリは不要です。
  • nil ポインタを含むインターフェースとポインタ receiver のメソッドセットに注意してください。

次回インターフェースを書こうとするときは、自分に問いかけてください: 「誰がそれを使うのか、そして彼らは本当にどのメソッドが必要なのか?」答えは通常、あなたが思うよりも少ないです。

golanginterface設計testing