Goのインターフェースとは?暗黙実装とダックタイピング・型制約を実装例で解説
Go(golang)のインターフェース(interface)は、メソッドの一覧だけを書いた型です。Javaのimplementsのような宣言は要らず、必要なメソッドが揃っていればその型は自動的にインターフェースを満たします。これが「暗黙の実装」と呼ばれる仕組みです。
暗黙の実装はダックタイピングとも呼ばれますが、代入や引数渡しの判定はコンパイル時に片が付きます。動的型付け言語のように、実行してみるまで失敗が分からないわけではありません。本記事では定義の書き方から、値レシーバとポインタレシーバでメソッドセットが変わる落とし穴、ジェネリクスの型制約におけるcomparableとcmp.Orderedの使い分けまでを、Go 1.26で実際にビルドしたコードとコンパイラの出力で確認していきます。
まとめ
Goのインターフェースを扱ううえで押さえる要点は次のとおりです。
- インターフェースは型集合を定義するもので、メソッドを列挙した基本形では「そのメソッドをすべて持つ型の集合」を表します。
- 実装宣言は不要です。メソッド名・引数の型・戻り値の型が一致すれば実装とみなされ、食い違えばコンパイルエラーになります。
- ポインタレシーバで定義したメソッドは値型のメソッドセットに入りません。
Counter{}を渡してコンパイルが通らない事故はここが原因です。 - ジェネリクスの型制約では
comparableは==と!=だけを許します。大小比較にはcmp.Ordered(Go 1.21で追加)を使います。 nilのポインタをerrorとして返すと、インターフェース値はnilになりません。戻り値の型は具体型ではなくerrorにします。
実務で詰まるのは、3番目のメソッドセットと、5番目のnilポインタを入れたerrorの2つです。残りは定義さえ押さえれば迷いません。以降で、それぞれをコードとコンパイラの出力で確かめます。
Goのインターフェース(interface)の基本|メソッドの集合が決める型集合
インターフェースの定義と暗黙の実装
インターフェースはtype 名前 interface { ... }の形で、必要なメソッドのシグネチャだけを並べて定義します。実装側には特別な記述が一切要りません。
package main
import "fmt"
// 通知の送信口だけを定義したインターフェース
type Notifier interface {
Notify(msg string) error
}
type SlackNotifier struct{ Channel string }
func (s SlackNotifier) Notify(msg string) error {
fmt.Printf("[slack:%s] %s\n", s.Channel, msg)
return nil
}
type MailNotifier struct{ To string }
func (m MailNotifier) Notify(msg string) error {
fmt.Printf("[mail:%s] %s\n", m.To, msg)
return nil
}
// 具体型ではなくインターフェースを受け取る
func Alert(n Notifier, msg string) error {
return n.Notify(msg)
}
func main() {
Alert(SlackNotifier{Channel: "#ops"}, "deploy finished")
Alert(MailNotifier{To: "[email protected]"}, "deploy finished")
}
SlackNotifierもMailNotifierもNotifierを実装するとはどこにも書いていませんが、Notify(msg string) errorを持つのでそのままAlertに渡せます。言語仕様は実装条件を2つ定めています。型Tが非インターフェースならその型集合の要素であること、Tがインターフェースならその型集合が相手の型集合の部分集合であることです。基本形のインターフェースの型集合は「列挙されたメソッドをすべて実装する非インターフェース型の集合」なので、インターフェースを後から追加しても既存の型に手を入れる必要がありません。同じ関数が複数の型を受け取れるこの性質は、ポリモフィズム(多態性)とは?読み方・種類・Javaの実装をわかりやすく解説で扱う多態性を継承なしで実現しています。
メソッドシグネチャの構成要素
インターフェースが一致を判定する単位がメソッドシグネチャです。シグネチャはメソッド名・引数の型の並び・戻り値の型の並びで構成され、引数名や戻り値名は判定に関与しません。Goはメソッドのオーバーロードを持たないため、1つの型が同名メソッドを複数持つことはできません。型の並びが1つでも違えば、別のシグネチャとして扱われます。
package main
import "fmt"
type Notifier interface {
Notify(msg string) error
}
type Broken struct{}
func (b Broken) Notify(msg string) {} // 戻り値 error が無い
func main() {
var n Notifier = Broken{}
fmt.Println(n)
}
戻り値のerrorを落としただけで、ビルドは次のように失敗します。
./main.go:14:22: cannot use Broken{} (value of struct type Broken) as Notifier value in variable declaration: Broken does not implement Notifier (wrong type for method Notify)
have Notify(string)
want Notify(string) error
haveとwantで期待と実体を並べてくれるので、実装漏れの原因はコンパイラの出力だけで特定できます。
インターフェースの埋め込みによる合成
インターフェースは他のインターフェースを埋め込んで合成できます。このとき合成後の型集合は、埋め込んだ各インターフェースの型集合の積集合になります。標準ライブラリのio.ReadWriterが代表例で、io.Readerとio.Writerを並べただけの定義です。
package main
import (
"bytes"
"fmt"
"io"
)
// 2つのインターフェースを埋め込んで合成する
type ReadWriter interface {
io.Reader
io.Writer
}
func copyThrough(rw ReadWriter) (string, error) {
if _, err := io.WriteString(rw, "hello"); err != nil {
return "", err
}
b, err := io.ReadAll(rw)
return string(b), err
}
func main() {
var buf bytes.Buffer
s, err := copyThrough(&buf)
fmt.Println(s, err) // hello <nil>
}
*bytes.BufferはReadとWriteの両方を持つため、自作のReadWriterをそのまま満たします。これは非インターフェース型の判定なので、効いているのは実装条件の1つ目です。2つ目の部分集合関係が働くのはcopyThroughの内側、ReadWriterをio.WriteStringやio.ReadAllへ渡している箇所になります。ReadWriterの型集合がio.Writer・io.Readerの型集合の部分集合になっているため、インターフェース同士でもそのまま渡せます。細かいインターフェースを1つずつ定義しておき、必要な組み合わせだけを埋め込みで作る。これがGoの流儀です。
ダックタイピング(duck typing)とGoの構造的部分型|コンパイル時に終わる実装チェック
「カモのように歩き、カモのように鳴くならカモである」という比喩から、golangのインターフェースはダックタイピング(duck typing)と呼ばれます。ただしPythonやRubyのそれとは決定的に違う点が1つ。Goの判定はコンパイル時に完了します。前節のとおり、メソッドが足りない型を代入した時点でビルドが止まるため、「実行してみたらメソッドが無かった」という失敗は起きません。
同じことをPythonで書くと、失敗は呼び出しの瞬間まで遅れます。
class Broken:
pass
def notify(n):
return n.notify("x")
notify(Broken())
Python 3.9での実行結果です。
Traceback (most recent call last):
File "duck.py", line 7, in <module>
notify(Broken())
File "duck.py", line 5, in notify
return n.notify("x")
AttributeError: 'Broken' object has no attribute 'notify'
この差は実務で大きく効きます。Goなら同じ誤りがビルド時に落ちるので、テストが通っていない経路のタイプミスも本番へ抜けません。
Goの方式は型理論では構造的部分型と呼ばれ、名前ではなく構造(メソッドの並び)で適合を判断します。実行時に型情報を見て振る舞いを決める仕組みはリフレクションとは?実行時にクラス情報を操る仕組みを言語横断で解説で扱うreflectの領域であり、インターフェースの実装判定とは別の話です。実行時に型を確かめる必要が出るのは、後述する型アサーションを使う場面に限られます。
メソッドセットの落とし穴|ポインタレシーバと値型の非対称性
インターフェースを実装したはずなのに代入できない、という詰まり方の大半はここです。言語仕様はメソッドセットを次のように定めています。定義型Tのメソッドセットはレシーバ型がTのメソッド、*Tのメソッドセットはレシーバ型が*TとTの両方のメソッドです。ポインタ側だけが値側のメソッドも含む、非対称な関係になっています。
package main
import "fmt"
type Notifier interface {
Notify(msg string) error
}
type Counter struct{ n int }
// ポインタレシーバで実装
func (c *Counter) Notify(msg string) error {
c.n++
fmt.Println(msg, c.n)
return nil
}
func main() {
var n Notifier
n = Counter{} // ここでコンパイルエラー
_ = n
}
実際のビルド結果です。
./main.go:20:9: cannot use Counter{} (value of struct type Counter) as Notifier value in assignment: Counter does not implement Notifier (method Notify has pointer receiver)
method Notify has pointer receiverと原因まで書かれています。解決はn = &Counter{}とポインタを渡すか、状態を更新しないメソッドなら値レシーバに変えるかの二択です。判断基準は単純で、レシーバのフィールドを書き換えるメソッドはポインタレシーバ一択です。値レシーバはコピーに対して更新をかけるため、変更が呼び出し元に反映されません。
メソッド呼び出しだけが通ってしまう理由
混乱の元は、同じ値型でもメソッド呼び出しだけは成功する点にあります。次は前掲のNotifierとCounterの定義に続けて、mainだけを差し替えたものです。
func main() {
c := Counter{}
c.Notify("direct") // 変数はアドレスを取れるので呼べる
var n Notifier = &Counter{} // インターフェースにはポインタを渡す
n.Notify("via interface")
}
direct 1とvia interface 1が出力され、どちらも動きます。cは変数なのでアドレスを取れます。そのためコンパイラが(&c).Notify(...)へ自動で読み替えてくれるのです。一方でインターフェースへの代入はメソッドセットの厳密な判定なので、この読み替えは働きません。利用側でポインタを渡す前提に統一しておくと、この非対称性に悩まされずに済みます。
インターフェースを切る判断基準|差し替え・テスト・定義しない選択
文法よりも実務で迷うのは「どこでインターフェースを切るか」です。実装が2つ以上になる見込みがあるかどうかが最初の分かれ目になります。冒頭のNotifierはSlackとメールの2実装があるので切る価値があります。実装が1つしかない型に「将来増えるかもしれない」と先回りしてインターフェースを被せると、呼び出し経路が1段深くなるだけで得るものがありません。
もう1つの動機は外部I/Oの差し替えです。DBアクセスやHTTPクライアントのように、テストで本物を動かしたくない依存が該当します。後述するio.Writerの例のように、標準の小さなインターフェースで足りることも多くあります。
逆に、切るべきでない場面もはっきりしています。Go Code Review Commentsは「モックのために実装側でインターフェースを定義してはいけない」「使われる前にインターフェースを定義してはいけない」と明言しています。使用例が無い段階では、そのインターフェースが必要かどうかも、どのメソッドを含めるべきかも判断できないからです。したがって実装の順番は、具体型で動くものを先に作り、2つ目の実装かテストの都合が現れた時点で利用側から切り出す形になります。これは同文書が示す順番でもあります。
標準ライブラリのインターフェース実例|fmt.Stringerとio.Writer
fmt.Stringerによる出力表現の定義
fmt.StringerはString() stringの1メソッドだけを持つインターフェースです。これを実装した型はfmt.Printlnや%vで自動的にその文字列表現が使われます。
package main
import "fmt"
type Person struct {
Name string
Age int
}
// fmt.Stringer を満たす
func (p Person) String() string {
return fmt.Sprintf("%s(%d)", p.Name, p.Age)
}
func main() {
p := Person{Name: "Taro", Age: 30}
fmt.Println(p) // Taro(30)
fmt.Printf("%v / %s\n", p, p) // Taro(30) / Taro(30)
}
Effective Goは、文字列変換メソッドはToStringではなくStringと名付けるよう明記しています。1メソッドのインターフェースはメソッド名に-erを付けてReader・Writer・Formatterのように命名する、というのが標準ライブラリから続く慣習です。逆にRead・Write・Close・Stringは意味とシグネチャが固定された名前なので、別の意味で使い回すと読み手を混乱させます。
io.Writer依存による出力先の差し替え
インターフェースの実利がいちばん分かりやすいのがio.Writerです。関数の引数を*os.Fileではなくio.Writerにしておくと、本番はファイルや標準出力、テストはbytes.Bufferと、呼び出し側で自由に差し替えられます。
package main
import (
"bytes"
"fmt"
"io"
"os"
)
// io.Writer だけに依存させると出力先を差し替えられる
func WriteReport(w io.Writer, title string) error {
_, err := fmt.Fprintf(w, "# %s\n", title)
return err
}
func main() {
var buf bytes.Buffer
WriteReport(&buf, "buffer") // テストではバッファへ
fmt.Print(buf.String())
WriteReport(os.Stdout, "stdout") // 本番では標準出力へ
}
出力は# bufferと# stdoutの2行です。テスト時にモック用の型を自前で用意しなくても、標準ライブラリのbytes.Bufferがそのまま検証先になります。
any(interface{})の扱い方|型アサーション・型スイッチと使いすぎの境界
メソッドを1つも持たない空のインターフェースは、あらゆる非インターフェース型を型集合に含みます。Go 1.18でanyが空インターフェースのエイリアスとして追加されたため、新しいコードではinterface{}ではなくanyを使うのが標準です。両者は完全に同じ型で、書き換えても挙動は変わりません。
anyに入れた値を使うには、型アサーションか型スイッチで具体型を取り出します。
package main
import "fmt"
func describe(v any) string {
switch x := v.(type) {
case string:
return "string:" + x
case int:
return fmt.Sprintf("int:%d", x)
case fmt.Stringer:
return "Stringer:" + x.String()
default:
return fmt.Sprintf("other:%T", x)
}
}
func main() {
fmt.Println(describe("abc")) // string:abc
fmt.Println(describe(7)) // int:7
fmt.Println(describe(3.5)) // other:float64
var v any = "abc"
s, ok := v.(string)
fmt.Println(s, ok) // abc true
n, ok := v.(int)
fmt.Println(n, ok) // 0 false
}
2値受け取りのv.(int)は失敗してもパニックせず、0 falseを返します。1値で受けるとパニックするため、型が確実でない場面では必ずvalue, ok := v.(T)の形にしてください。
ただしanyを多用すると、コンパイラが型を検査できる範囲がその都度狭まります。受け取る型が有限なら、必要なメソッドだけを書いたインターフェースか次節の型制約を使いましょう。型の取り違えが実行時パニックではなくコンパイルエラーとして表面化します。
型制約と型集合|comparableとcmp.Orderedの使い分け
Go 1.18でジェネリクスが言語に入り、インターフェースは型パラメータの制約としても使われるようになりました。この用途では、インターフェースはメソッドの一覧ではなく型集合そのものを書くための構文として働きます。型パラメータの基礎そのものはジェネリクスとは|型パラメータの仕組みと主要言語での違いで整理しています。
comparableで順序比較ができない理由
取り違えが起きやすいのがcomparableです。本記事の旧版も、これを「数値だけを処理する制約」と説明していました。Go 1.18のリリースノートはcomparableを「==または!=で比較できるすべての型の集合を表すインターフェース」と定義しています(現行の言語仕様の表現は「strictly comparable な非インターフェース型の集合」で、この差は次の項で扱います)。大小比較の<は含まれません。したがって最小値を返す関数をcomparableで書くとビルドが通りません。
package main
import "fmt"
func Smallest[T comparable](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
fmt.Println(Smallest(3, 5))
}
上のソースをそのままビルドすると次の1行が出ます。
./main.go:6:8: invalid operation: a < b (type parameter T cannot use operator <)
順序比較が必要ならcmp.Orderedを使います。cmpパッケージはGo 1.21で標準ライブラリに入り、Ordered・Compare・Lessが追加されました。それ以前に使われていたgolang.org/x/exp/constraintsはGo 1の互換性保証の対象外である実験的パッケージなので、新規コードでは標準のcmpを選びます。
package main
import (
"cmp"
"fmt"
)
// comparable は == と != のみ。順序比較には cmp.Ordered を使う
func Smallest[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
func Equal[T comparable](a, b T) bool { return a == b }
func main() {
fmt.Println(Smallest(3, 5)) // 3
fmt.Println(Smallest("a", "b")) // a
fmt.Println(Equal([2]int{1, 2}, [2]int{1, 2})) // true
}
2つの制約の違いを整理すると次のとおりです。
| 制約 | 使える演算子 | 導入バージョン | 満たさない型の例 |
|---|---|---|---|
| comparable | == != | Go 1.18 | スライス・マップ・関数 |
| cmp.Ordered | == != < <= > >= | Go 1.21 | 構造体・配列・ポインタ |
comparableが無意味なわけではありません。配列や構造体をマップのキーにしたい、等値判定だけしたいという場面ではcomparableが正解です。一方でスライスは==での比較自体ができない型なので、制約を満たさず弾かれます。
package main
import "fmt"
func Equal[T comparable](a, b T) bool { return a == b }
func main() {
fmt.Println(Equal([]int{1}, []int{1}))
}
./main.go:8:22: []int does not satisfy comparable
comparableを満たしても実行時にパニックする境界
comparableを付ければコンパイル時に安全、とまでは言えません。Go 1.20で制約充足の規則が緩和され、インターフェース型はcomparableを「実装」しないものの「充足」するようになりました。つまりanyを型引数に渡すコードはビルドを通ります。
package main
import "fmt"
func Equal[T comparable](a, b T) bool { return a == b }
func main() {
fmt.Println(Equal[any](1, 1)) // コンパイルは通る
var x any = []int{1}
fmt.Println(Equal[any](x, x)) // ここで実行時パニック
}
実行結果です。
true
panic: runtime error: comparing uncomparable type []int
goroutine 1 [running]:
main.Equal[...](...)
/tmp/e6/main.go:5
main.main()
/tmp/e6/main.go:11 +0xa5
exit status 2
1行目はtrueを返しますが、スライスを詰めたany同士を比べた2行目でランタイムパニックになります(スタックトレースのパスは実行ディレクトリによって変わります)。comparableがコンパイル時に守ってくれるのは具体型を型引数に渡した場合だけ、と覚えておくと事故を避けられます。外部から任意の値が流れ込む経路では、if t := reflect.TypeOf(v); t != nil && t.Comparable()のように型を確かめてから比較します。reflect.TypeOf(nil)はnilを返すので、ガードを外すとこの確認自体がパニックする点に注意してください。そもそもanyを挟まない設計にできるなら、そのほうが確実です。
チルダとunionによる名前付き型の取り込み
型集合には型の名前を直接書くこともできます。このとき~Tが指すのは「基底型がTであるすべての型」、|が表すのは型集合の和です。~の有無で、type UserID intのような名前付き型を受け取れるかどうかが決まります。
package main
import "fmt"
// ~ を付けると「基底型が int の名前付き型」も型集合に入る
type Integer interface {
~int | ~int64
}
type UserID int
func Sum[T Integer](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]UserID{10, 20})) // 30
}
~intをintに変えるとSum([]UserID{10, 20})はコンパイルできません。ドメイン型を定義する設計と組み合わせるなら、制約側に~を付けておきます。この型集合の書き方はcmp.Orderedの定義そのもので、実体は~int | ~int8 | ... | ~float64 | ~stringという和集合です。
インターフェース設計の実務指針|定義位置・nil error・実装漏れ検出
nilポインタを格納したインターフェースの非nil判定
インターフェース値は内部に型と値の2要素を持ちます。Go FAQが明記しているとおり、インターフェースがnilになるのは型と値の両方が未設定のときだけです。nilのポインタを入れると型のほうが埋まるため、値がnilでもインターフェースは非nilになります。
package main
import "fmt"
type MyError struct{ Code int }
func (e *MyError) Error() string { return fmt.Sprintf("code=%d", e.Code) }
// 具体型のポインタを返すと、nil でも非 nil の error になる
func badReturn() error {
var p *MyError = nil
return p
}
func goodReturn() error {
return nil
}
func main() {
fmt.Println(badReturn() == nil) // false
fmt.Printf("%T\n", badReturn()) // *main.MyError
fmt.Println(goodReturn() == nil) // true
}
実行結果はfalse・*main.MyError・trueです。badReturnは何も失敗していないのに、呼び出し側のif err != nilが必ず成立してしまいます。回避策は明快で、エラーを返す関数の戻り値型は具体型ではなくerrorにし、成功時は変数ではなくnilリテラルを返すことです。標準ライブラリのos.Openも、実体が*os.PathErrorでありながら戻り値型はerrorに統一されています。
利用側パッケージでのインターフェース定義
Go Code Review Commentsは、「Goのインターフェースは原則として、その値を実装するパッケージではなく、値を利用するパッケージに属する」と述べています。実装側は具体型を返し、利用側が必要な分だけインターフェースを切る形です。
実務でこれを外すと、実装パッケージにメソッドを1つ追加するだけでインターフェース定義と全モックの修正が波及します。利用側で定義していれば、追加したメソッドを使う場所だけが影響範囲に収まります。前掲のNotifierも、通知の送り手ではなく通知を呼ぶ側に置くのが定石です。
コンパイル時の実装漏れ検出
インターフェース経由で渡す箇所がコード上に無い型は、実装が壊れてもビルドが通ってしまいます。encoding/jsonのMarshalerのように、実行時に型アサーションで確認される型が典型です。Effective Goが示す対処は、空白識別子を使った宣言を1行置くことです。次の1行は、前掲のNotifierとCounterの定義に続けて書きます。
// Counter が Notifier を満たさなくなった時点でビルドが失敗する
var _ Notifier = (*Counter)(nil)
変数は作らず、型検査だけを目的にした宣言です。インターフェース側にメソッドが増えた時点でそのパッケージがコンパイルできなくなり、追従漏れに気づけます。ただしEffective Goは「静的な変換がコード中に存在しない場合に限って使う慣習であり、満たすすべての型に対して書くものではない」と釘を刺しています。
なおcmpやanyのように、使える機能は言語バージョンで決まります。手元と本番のGoバージョンを揃える方法はGOTOOLCHAINとは|Go Toolchainでバージョンを自動切り替えする仕組みと設定方法にまとめています。
よくある質問
Goのインターフェースにimplementsの宣言は必要ですか?
不要です。インターフェースが要求するメソッドをすべて同じシグネチャで持っていれば、その型は自動的にインターフェースを実装しているとみなされます。判定はコンパイル時に行われ、足りなければ代入や引数渡しの箇所でビルドエラーになります。ただし、インターフェース型へ変換される箇所がコード中に1つも無い型は検査が走りません。そうした型にはvar _ Notifier = (*Counter)(nil)を1行置いて、実装漏れをコンパイル時に落とすようにしてください。
interface{}とanyはどう違いますか?
違いはありません。anyはGo 1.18で追加された空インターフェースのエイリアスで、リリースノートにも「interface{}の代わりに使える」とあります。同じ型なので相互に置き換えが利き、新規コードではanyが標準です。既存コードを一括で置換するならgofmt -r 'interface{} -> any'が使えます。ただしanyはGo 1.18で入った識別子なので、go.modでそれ以前の言語バージョンを指定しているモジュールでは使えません。
メソッドシグネチャとは何を指しますか?
メソッド名・引数の型の並び・戻り値の型の並びの組み合わせです。引数名や戻り値名は含まれないため、Notify(msg string) errorとNotify(text string) errorは同じシグネチャです。一方で戻り値の有無や型が違えば別のシグネチャとなり、インターフェースは満たされません。
Goのインターフェース名に-erを付けるのはなぜですか?
Effective Goが、1メソッドのインターフェースはメソッド名に-erを付けた行為者名詞で命名する慣習を示しているためです。Readを持つならReader、Writeを持つならWriterという具合です。標準ライブラリ全体がこの規則で書かれているので、従うと役割が名前から読み取れます。
comparableとcmp.Orderedはどちらを使うべきですか?
等値判定だけならcomparable、大小比較を行うならcmp.Orderedです。comparableは==と!=しか許さないため、<を書くとtype parameter T cannot use operator <でビルドが止まります。cmpパッケージはGo 1.21以降の標準ライブラリで利用できます。