結論
- Go の interface は型安全にダックタイピングを使いたいときに使おう
- Go の interface は(
implementsのような)キーワードを使うことなく、同じメソッドを実装するだけで暗黙的に決定される
はじめに
皆さんは Go の interface を知っていますか?
自分はこの interface を理解するまでに少し時間が掛かりました。今では「完全に理解した」*1状態になっています。
そのため、この記事ではできる限り分かりやすく interface を解説して、世界一の分かりやすさを目指します!
interface を使いたくなるとき
interface を使いたくなるとき、それはダックタイピングをしたくなったときです。
まず、ダックタイピングとは何なのかを説明しましょう。
ダックタイピングとは、Dave Thomasの言葉を借りるならこのようにまとめられます。
これだけではよく分からないかもしれません。もうちょっと具体的に示しましょう。
ダックタイピングの例
まずは例として Ruby のコードを紹介します。
このコードは、異なる出力方法(コンソール、ファイル、バッファ)にデータを書き込むためのクラスを作り、それらを共通の関数で扱えるようにしている例です。
class ConsoleWriter def write(data) puts data end end class FileWriter def initialize(filename) @filename = filename end def write(data) File.open(@filename, 'w') { |file| file.write(data) } end end class BufferWriter def initialize @buffer = "" end def write(data) @buffer << data end def contents @buffer end end # 共通の関数 def write_to_somewhere(writer, data) writer.write(data) end # 使用例 message = "Hello, Interface!" console_writer = ConsoleWriter.new write_to_somewhere(console_writer, message) file_writer = FileWriter.new("output.txt") write_to_somewhere(file_writer, message) buffer_writer = BufferWriter.new write_to_somewhere(buffer_writer, message) puts "Buffer contents: #{buffer_writer.contents}"
このコードには write_to_somewhere 関数があります。この関数はwriter オブジェクトの具体的な型を指定していませんが、write メソッドを持っていることを前提としています。
そして、ConsoleWriter、FileWriter、BufferWriter の各クラスは、すべて write メソッドを実装しているため、write_to_somewhere 関数で使用できます。
つまり、期待されるメソッドを持っていれば、そのオブジェクトを使用できます。
この方法は柔軟性が高く、新しいWriterクラスを追加する際に、既存のコードを変更することなく、write_to_somewhere 関数と互換性を持たせることができます。
ダックタイピングのデメリット
しかし、ダックタイピングにはデメリットがあります。
ダックタイピングでは、オブジェクトの型ではなく振る舞い(メソッド)に基づいて操作を行います。
そのため、実行されるまでエラーに気づかない可能性もあります。
class InvalidWriter def write_data(data) # メソッド名が異なる puts "Writing: #{data}" end end
InvalidWriter クラスは write メソッドではなく write_data メソッドを持っています。
そのため、このクラスから生成されたオブジェクトをwrite_to_somewhere 関数に渡すと、実行時に NoMethodError が発生します。
これはとても簡単な例です。型チェックがない場合、こうしたミスをしてしまう可能性があります。
シンプルに書けるというメリットと引き換えに、上記のデメリットを引き受ける必要があります。
Go で同じコードを実装すると……
さて、次に全く同じ振る舞いのコードを Go で実装してみましょう。
このコードは、異なる出力方法(コンソール、ファイル、バッファ)を Go のインターフェースを使って抽象化し、共通の関数で扱えるようにしている例です。*2
type Writer interface { Write([]byte) (int, error) } type ConsoleWriter struct{} func (cw ConsoleWriter) Write(data []byte) (int, error) { n, err := fmt.Println(string(data)) return n, err } type FileWriter struct { filename string } func (fw FileWriter) Write(data []byte) (int, error) { err := os.WriteFile(fw.filename, data, 0644) if err != nil { return 0, err } return len(data), nil } type BufferWriter struct { buffer bytes.Buffer } func (bw *BufferWriter) Write(data []byte) (int, error) { return bw.buffer.Write(data) } // 共通の関数 func WriteToSomewhere(w Writer, data string) { w.Write([]byte(data)) } // 使用例 func main() { message := "Hello, Interface!" cw := ConsoleWriter{} WriteToSomewhere(cw, message) fw := FileWriter{filename: "output.txt"} WriteToSomewhere(fw, message) bw := &BufferWriter{} WriteToSomewhere(bw, message) fmt.Println("Buffer contents:", bw.buffer.String()) }
先ほどの Ruby のコードと似ていますが、一番大きく違う部分があります。そう、interface の存在です。
type Writer interface { Write([]byte) (int, error) }
Writer インターフェースが明示的に定義されています。これにより、Write メソッドの期待される振る舞いが明確になります。
例えば、コンパイル時に型チェックが行われるため、WriteToSomewhere 関数に渡されるオブジェクトが確実に Writer インターフェースを実装していることを保証します。*3
また、Write メソッドの引数と戻り値の型が Writer インターフェースで明確に定義されています([]byte を受け取り、(int, error) を返す)。
これにより、全ての実装が同じシグネチャに従うことが保証されます。
interface を使うと、ダックタイピングをしつつ、型を明示することができます。
他の言語の interface との比較
ここまでの説明でまとめに入って良いかもしれませんが、理解を深めるためもう少しだけ解説します。
interface という概念は他の静的型付け言語にもあります。それらと Go の interface はどう異なるのでしょうか?
今度は別の言語と比較してみましょう。
次のコードは、Java を使って同じ振る舞いを実装したものです。
import java.io.IOException; interface Writer { void write(String data) throws IOException; } class ConsoleWriter implements Writer { @Override public void write(String data) { System.out.println(data); } } // 省略 public class Main { // 共通のメソッド public static void writeToSomewhere(Writer writer, String data) throws IOException { writer.write(data); }
このコードにも interface が出てきました。ここだけ見ると、Go の実装と似ています。
interface Writer { void write(String data) throws IOException; }
ただし、ここからが重要なポイントですが、先ほどの Go のコードと大きな違いがあります。
それは、implements キーワードを使っているということです。
class ConsoleWriter implements Writer {
つまり、Writerという interface を使うためには、必ず implements キーワードをセットで使う必要があります。*4
Go の場合はこのimplementsキーワードを書かなくても、暗黙的に型チェック時に interface に適合しているかをチェックしてくれます。
Go のインターフェースの実装は暗黙的です。つまり、実装を明示的に宣言する必要はありません。インターフェースの実装は、インターフェースのメソッドを実装する型によって決定されます。その型がインターフェースで定義された全てのメソッドを実装している限り、追加の宣言は必要なく、そのインターフェースを暗黙のうちに実装することになります。
> Dog 型が Animal インターフェースを実装しているかどうかは、Dog 型が Animal インターフェースで定義されたすべてのメソッドを実装しているかどうかで判断できます。
改めて先ほどの Go のコードを見てみましょう。
type Writer interface { Write([]byte) (int, error) } type ConsoleWriter struct{} func (cw ConsoleWriter) Write(data []byte) (int, error) { n, err := fmt.Println(string(data)) return n, err }
ここで、ConsoleWriterにはWriteというメソッドが実装されていて、引数がdata []byteで戻り値が(int, error)になっています。
これはWriterの interface に合致すると Go が暗黙的に決定してくれるため、implementsが必要ないというわけです。
Go では、「コードが interface に従うかどうかは、メソッドが合致するかどうかで決まる」という哲学を持っています。*5
そのため、後からコードを書き換えるのが楽なつくりになっています。
まとめ
改めて結論をまとめると、次のようになります。
- Go の interface の使いどころは、ダックタイピングを使いたくなったとき
- Go の interface によるダックタイピングは、型安全に実装できる
- Go の interface は、他の言語の interface とは違って、特別なキーワードを用いなくても暗黙的に決定される。
Go 言語の interface について理解の一助になれば幸いです。
参考
この記事を書くにあたって、オライリー・ジャパンさんから出版されている『初めての Go 言語』がとても参考になりました。
特に日本語版オリジナルの内容である、付録 A と B が素晴らしいので、ぜひご一読をオススメします!
*1:https://zenn.dev/activecore/articles/9862409de182c7
*2:実際のGoのコードではio.Writerが使えるため、あくまでサンプルとしてお読みください🙏
*3:この場合だと Write メソッドがあることをチェックします
*4:ちなみにTypeScriptにもimplementsというキーワードがありますが、これは使っても使わなくてもどちらでも大丈夫なようになっています(これはJavaと異なるポイントです)。Goと同様に構造的型付けを採用しているからです。 → https://typescriptbook.jp/reference/values-types-variables/structural-subtyping
