ブログに戻る

Go の interface キーワード:暗黙的な interface と nil の落とし穴

Go の interface キーワードの仕組みを解説します。暗黙的な実装、ポインタレシーバー、小さな interface、any、型アサーション、nil の error の落とし穴、ジェネリクスの制約を扱います。

Go の interface キーワード:暗黙的な interface と nil の落とし穴

interface キーワードはメソッドの集合を宣言します。それらのメソッドを持つ型は、自動的にその interface を満たします。implements キーワードはなく、型の側で interface を列挙することもありません。そのため、interface が生まれる何年も前に書かれた struct でも、その interface を満たせます。Go 1.18 からは、interface にメソッドだけでなく型も列挙できるようになり、ジェネリックなコードの制約として使えるようになりました(Go spec)。

要約

  • type Notifier interface { Notify(...) error } は interface を宣言します。シグネチャが一致する Notify メソッドを持つ型は、すべて Notifier です。
  • interface の実装は暗黙的です。var _ Notifier = (*SlackNotifier)(nil) と書けば、ビルド時にコンパイラが確認してくれます。
  • ポインタレシーバーのメソッドは T ではなく *T に属します。T を代入すると method Notify has pointer receiver で失敗します。
  • interface は小さく保ち、それを使うパッケージで宣言します。引数には interface を受け取り、戻り値には具体的な型を返します。
  • any は interface{} のエイリアスで、どんな型の値でも保持できます。値を取り出すには v, ok := x.(T) か型 switch を使います。
  • interface が nil になるのは、型と値の両方が未設定のときだけです。nil の *ValidationError を error として返すと、err != nil が true になります。
  • ~int64 | ~float64 のような型の列挙を含む interface は制約です。型パラメータリストの中でしか使えません。

Go で interface を宣言するには?

type、名前、interface の順に書き、その後にメソッドのシグネチャを並べます。次の例は、同じメッセージを Slack とメールに送るアラートの仕組みです。

example.gogo
package main

import (
	"context"
	"fmt"
)

type Notifier interface {
	Notify(ctx context.Context, msg string) error
}

type SlackNotifier struct {
	Channel string
}

func (s SlackNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("slack #%s: %s\n", s.Channel, msg)
	return nil
}

type EmailNotifier struct {
	To string
}

func (e EmailNotifier) Notify(ctx context.Context, msg string) error {
	fmt.Printf("email %s: %s\n", e.To, msg)
	return nil
}

func alertAll(ctx context.Context, notifiers []Notifier, msg string) {
	for _, n := range notifiers {
		if err := n.Notify(ctx, msg); err != nil {
			fmt.Println("notify failed:", err)
		}
	}
}

func main() {
	notifiers := []Notifier{
		SlackNotifier{Channel: "oncall"},
		EmailNotifier{To: "[email protected]"},
	}
	alertAll(context.Background(), notifiers, "payment API p99 above 2s")
}
example.texttext
slack #oncall: payment API p99 above 2s
email [email protected]: payment API p99 above 2s

alertAll が知っているのは Notify メソッドだけです。相手が Slack なのか、メールなのか、来月誰かが追加する PagerDuty のクライアントなのかは気にしません。slice の各値は自分の具体的な型を保持しており、呼び出しは実行時にその型のメソッドへ振り分けられます。

interface はほかの interface を埋め込めます。埋め込んだメソッドセットはマージされます。標準ライブラリは、この方法で io.Reader と io.Writer から io.ReadWriter を作っています。

example.gogo
type ReadWriter interface {
	Reader
	Writer
}

Read と Write の両方を持つ型は io.ReadWriter を満たします。*os.File と net.Conn は満たしますが、*strings.Reader は Write メソッドを持たないので満たしません。

Go で型が interface を実装するには?

interface のすべてのメソッドを、まったく同じシグネチャでメソッドセットに持てば実装したことになります。Go ではこれを暗黙的な実装と呼びます。クラスが実装する interface を 1 つずつ明記しなければならない Java や C# との大きな違いです。

これによってパッケージ同士が疎結合に保たれます。SlackNotifier は Notifier を宣言しているパッケージをインポートしませんし、Notifier の存在を知る必要もありません。標準ライブラリやサードパーティのパッケージの型がすでに満たしている interface を、今日から書くこともできます。

メソッドが足りないときは、値が interface として使われた場所でコンパイラが知らせてくれます。

example.gogo
type PagerDutyNotifier struct {
	RoutingKey string
}

func (p PagerDutyNotifier) Send(ctx context.Context, msg string) error {
	return nil
}

var _ Notifier = PagerDutyNotifier{}
example.texttext
./main.go:20:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (missing method Notify)

メソッドはあるもののシグネチャが違う場合は、エラーに両方のバージョンが並べて表示されます。

example.texttext
./main.go:16:18: cannot use PagerDutyNotifier{} (value of struct type PagerDutyNotifier) as Notifier value in variable declaration: PagerDutyNotifier does not implement Notifier (wrong type for method Notify)
		have Notify(string) error
		want Notify(context.Context, string) error

型が interface を実装していることをコンパイル時に確認するには?

ブランク識別子を使ってパッケージレベルの変数を宣言します。

example.gogo
var _ Notifier = (*SlackNotifier)(nil)

この行はメモリを確保せず、コードも一切実行しません。*SlackNotifier を Notifier に代入できるかをコンパイラに尋ねるだけです。そのため、誰かが Notify の名前やシグネチャを変えた瞬間にビルドが失敗します。この行がないと、その型が初めて Notifier として使われる場所(別のパッケージかもしれません)まで問題に気づけません。同じテクニックを http.Handler で使う例は Go の var キーワードで解説しています。

ポインタレシーバーで interface を満たせなくなるのはなぜか?

そのメソッドがポインタ型に属しているからです。すべての型はメソッドセットを持ち、そのルールは次のとおりです(Go spec)。

  • T のメソッドセットには、値レシーバー (t T) で宣言したメソッドが含まれます。
  • *T のメソッドセットには、それに加えてポインタレシーバー (t *T) で宣言したメソッドが含まれます。

メッセージをまとめて送る notifier は自分自身を書き換える必要があるので、ポインタレシーバーを使います。

example.gogo
type BatchNotifier struct {
	mu      sync.Mutex
	pending []string
}

func (b *BatchNotifier) Notify(ctx context.Context, msg string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.pending = append(b.pending, msg)
	return nil
}

func main() {
	var n Notifier = BatchNotifier{}
	fmt.Println(n)
}
example.texttext
./main.go:26:19: cannot use BatchNotifier{} (value of struct type BatchNotifier) as Notifier value in variable declaration: BatchNotifier does not implement Notifier (method Notify has pointer receiver)

直し方は var n Notifier = &BatchNotifier{} です。Go が値のほうを拒否するのは意図的です。BatchNotifier を保持する interface はそのコピーを保持することになり、Notify はコピーの pending slice に追加するだけで、元の値は変わりません。Notify を値レシーバーに変えても解決しません。呼び出しのたびに sync.Mutex がコピーされ、go vet がそれを Notify passes lock by value として報告します。

ポインタレシーバーのメソッドを持つ型は、var _ Notifier = (*BatchNotifier)(nil) のように、ポインタを通して確認する必要があります。*T は T のメソッドをすべて持つので、ポインタの形は SlackNotifier のような値レシーバーの型にも使えます。そのため、確認は (*T)(nil) で書くのが一般的です。

Go の interface はどう設計すべきか?

Go の格言に「interface が大きいほど、抽象化は弱くなる」というものがあります(Go Proverbs)。標準ライブラリで最もよく使われる interface は、メソッドが 1 つか 2 つです。

Interfaceメソッド実装している型
io.ReaderReadファイル、ネットワーク接続、HTTP ボディ、gzip リーダー、bytes.Buffer
io.WriterWriteファイル、http.ResponseWriter、ハッシュ、bytes.Buffer
errorError*fs.PathError、*json.SyntaxError、自分で定義したエラー型
fmt.StringerStringfmt での出力形式を制御したいあらゆる型
http.HandlerServeHTTPルーター、ミドルウェア、http.HandlerFunc

メソッドが 1 つの interface は満たすのが簡単なので、多くの型がそれを満たします。io.Copy がどんなコピー元とコピー先でも動くのは、要求するものがごくわずかだからです。メソッドが 12 個ある Storage interface には、たいてい本物の実装が 1 つしかなく、あとは interface が変わるたびに書き直すモックがあるだけです。http.Handler も同じです。ミドルウェアは 1 つ受け取って 1 つ返すので、どのパッケージの Go ミドルウェアでも、どのルーターの周りにも重ねられます。

標準ライブラリと連携するのにいかに手間がかからないかは、fmt.Stringer を見るとわかります。enum に String メソッドを付ければ、fmt のすべての書式指定子がそれを使います。

example.gogo
type OrderStatus int

const (
	Pending OrderStatus = iota
	Paid
	Refunded
)

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "pending"
	case Paid:
		return "paid"
	case Refunded:
		return "refunded"
	}
	return fmt.Sprintf("OrderStatus(%d)", int(s))
}

func main() {
	fmt.Println(Paid)
	fmt.Printf("order 1001 is %v\n", Refunded)
}
example.texttext
paid
order 1001 is refunded

Go の interface はどこで宣言すべきか?

実装する側のパッケージではなく、使う側のパッケージで宣言します。実装が暗黙的なので、使う側は自分が呼び出すメソッドだけを並べられます。

返金サービスに必要なのは注文を読み込むことだけです。そのためにメソッドが 1 つの interface を宣言し、コンストラクターで受け取ります。

example.gogo
type orderGetter interface {
	GetOrder(ctx context.Context, id int64) (Order, error)
}

type RefundService struct {
	orders orderGetter
}

func NewRefundService(orders orderGetter) *RefundService {
	return &RefundService{orders: orders}
}

func (s *RefundService) CanRefund(ctx context.Context, id int64) (bool, error) {
	o, err := s.orders.GetOrder(ctx, id)
	if err != nil {
		return false, fmt.Errorf("load order %d: %w", id, err)
	}
	return o.Status == "paid", nil
}

本番環境では Postgres のストアを渡します。このストアにはほかに 20 個のメソッドがありますが、返金サービスからは見えません。テストでは map で十分です。

example.gogo
type fakeOrders map[int64]Order

func (f fakeOrders) GetOrder(ctx context.Context, id int64) (Order, error) {
	o, ok := f[id]
	if !ok {
		return Order{}, ErrNotFound
	}
	return o, nil
}

func main() {
	svc := NewRefundService(fakeOrders{1001: {ID: 1001, Status: "paid"}})
	fmt.Println(svc.CanRefund(context.Background(), 1001))
	fmt.Println(svc.CanRefund(context.Background(), 1002))
}
example.texttext
true <nil>
false load order 1002: order not found

fakeOrders はメソッドを持つ map の定義型(Go の type キーワードを参照)なので、テストにモックライブラリは要りません。

「accept interfaces, return structs」とはどういう意味か?

同じ考え方のもう半分です。関数は引数として interface を受け取るので、呼び出し側は条件に合うものなら何でも渡せます。コンストラクターは *RefundService や *PostgresStore のような具体的な型を返すので、呼び出し側はすべてのメソッドとエクスポートされたフィールドを使え、自分に必要な小さな interface を自分で決められます。

コンストラクターから interface を返すと、その interface に含まれないメソッドが隠れてしまい、呼び出し側は全員、実装者が選んだ 1 つの抽象化に縛られます。主な例外は error です。関数は *ValidationError ではなく error を返します。その理由は後述の nil のセクションで説明します。

Go の any とは?

any は、メソッドを 1 つも持たない interface である interface{} のエイリアスです。どの型も少なくとも 0 個のメソッドを持つので、any 型の変数にはどんな値でも入れられます。any は Go 1.18 で追加され、2 つの書き方はどちらを使っても同じです(Go spec)。

any が登場するのは、実行時まで型がわからない場面です。fmt.Println(a ...any) はどんな型の引数でも受け取りますし、json.Unmarshal で map[string]any にデコードすると、値の型は JSON の内容によって決まります。

その代わり、コンパイラによるチェックが効かなくなります。any から値を読むたびに型アサーションが必要になり、推測が外れるとコンパイルエラーではなく実行時エラーになります。関数が複数の型を扱い、そのすべてに同じ演算を適用するのであれば、代わりに型パラメータを使います。後述の制約のセクションで扱う func Sum[T Amount](values []T) T は型チェックを完全に保ちますが、func Sum(values []any) any ではすべてのチェックが実行時に回されます。

型アサーションと型 switch はどう動くのか?

型アサーションは、interface から具体的な値を取り出します。2 値を返す形なら panic しません。

example.gogo
var payload map[string]any
body := `{"user_id": 42, "email": "[email protected]", "roles": ["admin", "billing"], "manager": null}`
if err := json.Unmarshal([]byte(body), &payload); err != nil {
	log.Fatal(err)
}

email, ok := payload["email"].(string)
fmt.Println(email, ok)

id, ok := payload["user_id"].(int)
fmt.Println(id, ok)
fmt.Printf("%T\n", payload["user_id"])
example.texttext
[email protected] true
0 false
float64

2 つ目のアサーションが失敗するのは、デコード先が any の場合、encoding/json が JSON の数値をすべて float64 にデコードするからです。ok を使っていれば、失敗しても false とゼロ値が返るだけです。1 値の形である payload["user_id"].(int) では、代わりに panic が起きます。

example.texttext
panic: interface conversion: interface {} is float64, not int

アサーションは interface にも使えます。r.(io.Writer) は r の中の値が Write メソッドを持っているかを確認するもので、標準ライブラリはこれを使ってオプションの振る舞いを探します。io.Copy はコピー元が io.WriterTo かどうかを確認し、そうであればより高速な経路を使います。

型 switch は、複数のアサーションを 1 つの文で実行します。値がいくつかの型のどれかになりうる、先ほどの JSON ペイロードにぴったりです。

example.gogo
func describe(v any) string {
	switch v := v.(type) {
	case nil:
		return "null"
	case float64:
		return fmt.Sprintf("number %g", v)
	case string:
		return fmt.Sprintf("string of %d bytes", len(v))
	case []any:
		return fmt.Sprintf("array of %d items", len(v))
	case map[string]any:
		return fmt.Sprintf("object with %d keys", len(v))
	default:
		return fmt.Sprintf("other %T", v)
	}
}

func main() {
	// payload decoded as above
	for _, key := range []string{"user_id", "email", "roles", "manager"} {
		fmt.Println(key+":", describe(payload[key]))
	}
}
example.texttext
user_id: number 42
email: string of 15 bytes
roles: array of 2 items
manager: null

各 case の中では v がその case の型になるので、文字列、slice、map のいずれでも len(v) がコンパイルできます。エラーに対する型 switch も同じように動きますが、見えるのはいちばん外側のエラーだけです。その例と、ラップされたエラーにはたいてい errors.As のほうが適している理由は、Go の type キーワードで解説しています。

Go で nil の error が nil にならないのはなぜか?

interface の値は、動的な型と動的な値の 2 つの部分から成ります。nil と等しくなるのは、その両方が未設定のときだけです。nil ポインタを interface に格納すると型の部分が設定されるので、その interface は nil になりません(Go FAQ)。

この落とし穴にはまりやすいのは、独自のエラー型を使うときです。次の関数は *ValidationError の変数を持ち、代入されたかどうかにかかわらず、それを返します。

example.gogo
package main

import (
	"fmt"
	"strings"
)

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return "invalid " + e.Field
}

func validate(email string) error {
	var verr *ValidationError
	if !strings.Contains(email, "@") {
		verr = &ValidationError{Field: "email"}
	}
	return verr
}

func main() {
	err := validate("[email protected]")
	if err != nil {
		fmt.Printf("validation failed: %v (%T)\n", err, err)
		return
	}
	fmt.Println("ok")
}
example.texttext
validation failed: <nil> (*main.ValidationError)

メールアドレスは正しく、verr は nil です。しかし返された error は、型として *main.ValidationError を持ち、中身は nil ポインタです。err != nil は interface 全体を型を持たない interface と比較するので、結果は true になります。呼び出し側は正しいサインアップを拒否し、ログには <nil> と出るので、バグに気づきにくくなります。

直し方は、成功時にはリテラルの nil を返し、型付きのポインタを error の戻り値に通さないことです。

example.gogo
func validate(email string) error {
	if !strings.Contains(email, "@") {
		return &ValidationError{Field: "email"}
	}
	return nil
}

戻り値の型は *ValidationError ではなく error として宣言し、nil を直接返します。フィールドが必要な呼び出し側は errors.As で取り出せます。errors.As は、エラーがラップされた後でも見つけてくれます。

example.gogo
err := fmt.Errorf("signup: %w", validate("ana.example.com"))
var verr *ValidationError
if errors.As(err, &verr) {
	fmt.Println("bad field:", verr.Field)
}

このコードは bad field: email と出力します。同じ落とし穴は、どの interface にもあります。一度も設定されていない var store *PostgresStore を orderGetter が必要な場所に渡しても、nil ではない interface になります。

反対に、誰も設定していない Notifier 型の struct フィールドのように、本当に nil の interface もあります。そのメソッドを呼ぶと、メソッドを探すための型がないので、invalid memory address or nil pointer dereference で panic します。NewRefundService(orders) のように依存関係を必須にするコンストラクターを使えば、これを防げます。

ジェネリクスの制約として interface をどう使うのか?

Go 1.18 からは、interface にメソッドだけでなく型も列挙できます。この列挙を型セットと呼び、~T は「基底型が T であるすべての型」を意味します。

example.gogo
type Cents int64

type Amount interface {
	~int64 | ~float64
}

func Sum[T Amount](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

func Dedupe[T comparable](ids []T) []T {
	seen := make(map[T]struct{}, len(ids))
	out := make([]T, 0, len(ids))
	for _, id := range ids {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func main() {
	fmt.Println(Sum([]Cents{4999, 1250, 300}))
	fmt.Println(Dedupe([]string{"u_42", "u_7", "u_42"}))
}
example.texttext
6549
[u_42 u_7]

Amount の型セットに含まれる型はすべて + をサポートしているので、Amount では + が使えます。comparable は == が使える型のための事前宣言された制約で、map のキーに必要なのはまさにこれです。型セットに含まれない型はコンパイラが拒否し、問題点を示してくれます。

example.texttext
./main.go:27:17: string does not satisfy Amount (string missing in ~int64 | ~float64)

型の列挙を含む interface は、制約としてしか使えません。その型の変数を宣言すると失敗します。

example.texttext
./main.go:10:8: cannot use type Amount outside a type constraint: interface contains type constraints

つまり Go には、1 つのキーワードを共有する 2 種類の interface があります。メソッドだけの interface は、変数の型、引数、制約のどこでも使えます。型の列挙を含む interface は、角括弧の中にしか登場しません。型セットがこの 2 つをどう統一しているかは、Go blog のジェネリクス入門で解説されています。

LevelUpGo で学ぶ

LevelUpGo では、ブラウザ上で本物の Go コードを実行する演習を通じて Go を学べます。Interfaces & Polymorphism では、暗黙的な実装、メソッドセット、any、型アサーション、interface の合成を扱います。Interface Design では、小さな interface、使う場所での宣言、モックのためだけに存在する interface を避ける方法を扱います。Go Generics Masterclass では、制約と型セットをさらに深く学びます。Training Ground には、コースの外で練習できる interface の短い単独の演習があります。残り 24 個の予約語については、Go のキーワード一覧:全 25 個の予約語を解説をご覧ください。

よくある質問

Go で interface はキーワードですか?

はい。interface は Go の 25 個の予約語の 1 つなので、変数名や関数名には使えません。type Notifier interface { ... } のような interface 型の宣言、interface{} というリテラル、[T interface{ ~int | ~string }] のようなインラインの制約に登場します。

Go に implements キーワードはありますか?

ありません。型は、interface のすべてのメソッドを一致するシグネチャで持つだけで、その interface を満たします。型が interface を満たしているかをコンパイラに確認させるには、型の隣に var _ Notifier = (*SlackNotifier)(nil) を追加します。

Go の any と interface{} の違いは何ですか?

違いはありません。any は Go 1.18 で追加された interface{} の事前宣言されたエイリアスなので、2 つは同じ型です。短く書けるので新しいコードでは any が使われており、古いコードは gofmt -r 'interface{} -> any' で書き換えられます。

Go で「method has pointer receiver」と表示されるのはなぜですか?

メソッドが *T に宣言されているので、そのメソッドを持つのは *T だけです。それなのに T の値を interface として使おうとしたためです。&BatchNotifier{} のようにポインタを渡すか、メソッドがレシーバーを書き換えないのであれば値レシーバーに変更してください。

nil ポインタを返したのに error が nil にならないのはなぜですか?

interface が nil になるのは、型と値の両方が未設定のときだけです。戻り値の型が error の関数から nil の *ValidationError を返すと、型が *ValidationError に設定されるので、err != nil は true になります。成功時には、型付きのポインタ変数ではなくリテラルの nil を返してください。

Go の interface はフィールドを持てますか?

持てません。interface が記述するのはメソッドだけで、制約として使う場合は型セットも記述します。呼び出し側が値を必要とするなら、ID() string のようなゲッターメソッドを追加するか、interface ではなく具体的な struct を受け取ってください。

出典

シニアエンジニアのように Go を書く

ブラウザで学べるインタラクティブなレッスン。最初のレッスンは無料です。

無料レッスンを試すまたは無料アカウントを作成