👁

Goインタフェース

この章では、Goのインタフェースとジェネリクスについて学び、ハンズオン形式で学習します。これにより、同じ操作を実装が異なる複数の型に共通で持たせて切り替える基本と、型パラメータによる複数の型への対応が身につきます。

1. 本章の概要

1.1 本章の目的

プログラムを書いていくと、「同じ操作を、実装が異なる複数の型に共通で持たせたい」場面がよく出てきます。Goでは、この切り替えを interface で実現します。Go構造体で扱った struct とメソッドの上に成り立つ仕組みで、実装の差し替えや拡張のしやすさに直結する重要な機能です。

本章では、interface を使わないと何が困るのかを見たうえで、interface の定義、そのメリット、応用構文(ポインタレシーバとの関係・合成・空インタフェース・型アサーション・型スイッチ)を扱い、最後に interface を型制約として活用するジェネリクスまでを順に扱います。

1.2 ハンズオンの流れ

本章では、まず interface を使わない書き方で困りごとを確認します。そのうえで interface を定義してメソッドを持つ複数の型を切り替える例を実装し、メリットを確認します。次に応用構文として合成・空インタフェース・型アサーション・型スイッチ・ポインタレシーバとの関係を扱い、最後にジェネリクスによる型パラメータの書き方と、interface を型制約として使う書き方を確認します。

1.3 事前準備

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール Goのコードを記述するエディタとして使用する
Go Goのインストール 本章のサンプルコードを実行する言語処理系として使用する

2. インタフェースとは

インタフェースは、同じような処理を1つの共通の書き方でまとめて効率的に記述できるようにする仕組みです。

まずはインタフェースを使わないとどんな困りごとが起きるかを見て、そのうえで interface の定義とメリットまで扱います。応用的な書き方は次の ## interface の応用構文 でまとめて見ていきます。

2.1 インタフェースを使わない場合

たとえば、挨拶を表示する sayHi という処理を考えます。日本語のときは「こんにちは」、英語のときは「Hello」と、言語ごとに切り替えたい、というケースです。呼び出す側は「挨拶する」という操作だけを意識して扱えて、後からフランス語やスペイン語を足すのも簡単、という状態にしたいところです。

まずは interface を使わずに書いてみます。指定した言語に応じて挨拶を切り替えて表示する処理を、sayHi 関数の中で if 分岐する形で書きます。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。

package main

import "fmt"

func sayHi(language string) {
    var message string
    if language == "ja" {
        message = "こんにちは"
    } else if language == "en" {
        message = "Hello"
    }
    fmt.Println(message)
}

func main() {
    sayHi("ja")
    sayHi("en")
}

以下のような実行結果が表示されます。

こんにちは
Hello

動きはしますが、この書き方には次のような問題があります。

  • 挨拶を追加したいとき(フランス語やスペイン語など)は、sayHi の中の if を毎回増やす必要がある
  • 挨拶のメッセージと、それを表示するロジックが1つの関数に混ざってしまう
  • 分岐が増えるほど、sayHi が肥大化していく

interface を使うと、この「新しい種類を追加するたびに sayHi に手を入れる」構造を避けられます。以降で書き方を見ていきます。

2.2 interface の定義

interface は「メソッドの集合」を宣言する型です。定義の書き方は以下のとおりです。

type 名前 interface {
    メソッド名(引数名 型) 戻り値の型
    // 必要に応じて複数のメソッドを並べる
}

このインタフェースを「満たす」ためには、そこに書かれたメソッドをすべて持つ型を定義するだけで済みます(明示的な宣言は不要)。満たした型を受け取る関数は、引数の型としてインタフェース名を指定できます。書き方は以下のとおりです。

type インタフェース名 interface {
    メソッド名(引数) 戻り値の型
}

type 型名 struct { /* フィールド */ }

func (レシーバ 型名) メソッド名(引数) 戻り値の型 {
    // 実装
}

func 関数名(引数 インタフェース名) {
    引数.メソッド名()    // 実装の違いを気にせず呼び出せる
}

次のコードは、Greeter インタフェースを定義し、日本語と英語の挨拶を返す2つの型を作って、どちらも同じ関数で扱えることを示す例です。

package main

import "fmt"

type Greeter interface {
    Greet() string
}

type JapaneseGreeter struct{}

func (j JapaneseGreeter) Greet() string {
    return "こんにちは"
}

type EnglishGreeter struct{}

func (e EnglishGreeter) Greet() string {
    return "Hello"
}

func sayHi(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    sayHi(JapaneseGreeter{})
    sayHi(EnglishGreeter{})
}

以下のような実行結果が表示されます。

こんにちは
Hello

同じ sayHi 関数を、実装が異なる2つの型で呼び出したところ、それぞれの Greet の結果が出力されました。

コードを解説します。

type Greeter interface {
    Greet() string
}

Greeter インタフェースを定義しています。「Greet() string メソッドを持つ型は、すべて Greeter として扱える」という宣言です。

type JapaneseGreeter struct{}

func (j JapaneseGreeter) Greet() string {
    return "こんにちは"
}

JapaneseGreeter 型を定義し、そのメソッドとして Greet を実装しています。挨拶の型自体には状態(フィールド)が不要なので、struct{} とフィールドを持たない空の構造体にしています。インタフェースはメソッドの有無だけを見るため、空の構造体でも Greet メソッドさえ実装すれば Greeter を満たせます。EnglishGreeter も同様に Greet を持っており、両方が Greeter インタフェースを自動的に満たします。

func sayHi(g Greeter) {
    fmt.Println(g.Greet())
}

sayHi 関数は Greeter インタフェース型の引数を受け取り、渡された値の Greet() を呼び出して結果を表示します。呼び出す時点では実装が JapaneseEnglish かを気にする必要がありません。

明示的な宣言なしに、メソッドを持つだけで満たされるのがGoのインタフェースの特徴です。この性質を構造的部分型または「ダックタイピング」と呼びます。

2.3 インタフェースのメリット

ここまでの例だと、interface を使わずに if で分岐した書き方に比べて、Greeter インタフェースと2つの型を宣言する分だけ interface版のコード量は増えています。それでも interface を使う価値があるのは、変更や拡張が入るとき に効いてきます。特に大きいのは以下の2つです。

  • 新しい言語(フランス語など)を追加する場合、interface版なら Greet() を持つ新しい型を1つ作るだけで済み、既存の sayHi 関数の中身を書き換える必要はない
  • 各言語の挨拶は各型の Greet() メソッドに、表示ロジックは sayHi 関数に、と役割ごとに分かれ、責務が明確になる

書き始めの短いコードでは非 interface 版の方が短くて済みますが、扱う種類が増えたり、変更対応が必要になったりする場面では interface の方が保守しやすくなります。

3. interface の応用構文

基本の定義と満たし方を押さえたところで、実務でよく登場する応用パターンを順に見ていきます。ポインタレシーバとの関係、既存 interface を合成する書き方、あらゆる型を受け取る空インタフェース、そこから元の型に戻す型アサーションと型スイッチまで、それぞれ「どんな場面で不便で、どう解決できるか」の流れで扱います。

3.1 ポインタレシーバと interface の関係

フィールドを変更するメソッドは、Goポインタ で扱ったように ポインタレシーバ で定義するのが基本です。その型を interface に代入したい場合、値のまま代入しようとするとコンパイルエラーになり、悩みどころになります。この場合は &value の形でポインタを渡すことで解決できます。次のコードで実際の書き方と、なぜ値では満たされないのかを見ていきます。

package main

import "fmt"

type Greeter interface {
    Greet() string
}

type EnglishGreeter struct{}

// ポインタレシーバでメソッドを定義
func (e *EnglishGreeter) Greet() string {
    return "Hello"
}

func main() {
    // OK: ポインタは Greeter を満たす
    var g Greeter = &EnglishGreeter{}
    fmt.Println(g.Greet())

    // NG: 値は Greeter を満たさない(コメントアウトを外すとコンパイルエラー)
    // var g2 Greeter = EnglishGreeter{}
}

以下のような実行結果が表示されます。

Hello

&EnglishGreeter{}Greeter を満たすため代入できました。値の EnglishGreeter{} はコメントアウトを外すとコンパイルエラーになります。

コードを解説します。

func (e *EnglishGreeter) Greet() string {
    return "Hello"
}

Greet メソッドを ポインタレシーバ で定義しています。この場合、Greet を持つのは *EnglishGreeter であって、EnglishGreeter(値そのもの)は持ちません。

var g Greeter = &EnglishGreeter{}

Greeter インタフェースに &EnglishGreeter{} を代入しています。*EnglishGreeterGreet() を持つので、Greeter を満たします。

interface を受け取る関数を呼ぶときは、この点を意識して &value の形でポインタを渡すのが基本です。逆に「値でも呼び出せるようにしたい」場合は、全メソッドを値レシーバで統一する設計にします。

3.2 interface の合成(埋め込み)

「読み書きの両方ができる型」だけを引数で受け取りたい、といった場面があります。個別に新しい interface を宣言してもよいですが、既存の ReaderWriter があるなら、それらをまとめた新しい interface を作った方が定義の重複が減ります。Goでは、別のインタフェース名を interface の定義の中に書き入れる 埋め込み で合成できます。書き方は以下のとおりです。

type 合成インタフェース名 interface {
    別のインタフェース1
    別のインタフェース2
    // 追加のメソッドも並べられる
}

次のコードは、Reader インタフェースと Writer インタフェースを合成した ReadWriter を定義する例です。

package main

import "fmt"

type Reader interface {
    Read() string
}

type Writer interface {
    Write(s string)
}

type ReadWriter interface {
    Reader
    Writer
}

type Buffer struct {
    data string
}

func (b *Buffer) Read() string   { return b.data }
func (b *Buffer) Write(s string) { b.data = s }

func use(rw ReadWriter) {
    rw.Write("Hello")
    fmt.Println(rw.Read())
}

func main() {
    b := &Buffer{}
    use(b)
}

以下のような実行結果が表示されます。

Hello

BufferReadWrite の両方を持つため ReadWriter を満たし、use 関数に渡して両方のメソッドを呼び出せました。

コードを解説します。

type ReadWriter interface {
    Reader
    Writer
}

ReadWriter インタフェースの定義の中に、ReaderWriter の名前をそのまま書き入れています。これで「ReaderWriter の両方を満たす型」を要求できます。合成後のインタフェースは、Read()Write(string) の両方を持つ型(今回の *Buffer など)が満たします。

標準ライブラリの io.ReadWriter もこの形で io.Readerio.Writer を合成しており、実務でも定石として使われています。

3.3 空インタフェース

デバッグ用のログ関数や汎用ユーティリティで、「型を問わずどんな値でも受け取りたい」場面があります。個別に intstringbool などを書き分けると、そのたびに関数を増やす必要が出てきて大変です。この場合、メソッドを1つも持たない interface{} を使うと、あらゆる型を1つの引数で受け取れます。書き方は以下のとおりです。

func 関数名(引数名 interface{}) {
    // 引数の中身を処理する
}

次のコードは、interface{} 型の引数を受け取り、渡された値と型を表示する describe 関数の例です。

package main

import "fmt"

func describe(v interface{}) {
    fmt.Printf("値: %v, 型: %T\n", v, v)
}

func main() {
    describe(42)
    describe("hello")
    describe(true)
}

以下のような実行結果が表示されます。

値: 42, 型: int
値: hello, 型: string
値: true, 型: bool

intstringbool のいずれを渡しても同じ関数で受け取れ、%T により実際の型が出力されました。

コードを解説します。

func describe(v interface{}) {

引数の型を interface{}(空のインタフェース)にしているので、どんな型の値でも受け取れます。「型を問わない引数」を作りたいときに使います。

Go 1.18 以降では、interface{} の別名として any が使えます。

func describe(v any) {
    fmt.Printf("値: %v, 型: %T\n", v, v)
}

意味は同じですが、可読性が上がるため any の使用が推奨されます。

💡 ポイント
fmt.Printf の書式指定子は、%v が「渡された値そのもの」、%T が「値の型」(Goポインタ で扱ったもの)を表示します。以降の例で登場する %q は「ダブルクォートで囲んだ文字列」、%t は「真偽値」を表示するときに使います。

3.4 型アサーション

空インタフェースで受け取れば「あらゆる型」を扱えますが、そのままでは元の型として使えず、int なら計算、string なら結合、といった型固有の操作ができません。ここで 型アサーション を使うと、interface{} から元の具体型に取り出して、その型固有の操作ができるようになります。書き方は以下のとおりです。第2戻り値の ok を受けると、失敗時に panic せずに判定できます。

v, ok := x.(取り出したい型)   // 成功時 ok=true, v が指定した型の値

次のコードは、interface{} で受けた値を int として取り出そうと試みる例です。

package main

import "fmt"

func main() {
    var x interface{} = 42

    n, ok := x.(int)
    fmt.Println(n, ok)

    s, ok := x.(string)
    fmt.Println(s, ok)
}

以下のような実行結果が表示されます。

42 true
 false

int としての取り出しは成功して 42true が、string としての取り出しは失敗して空文字列と false が返りました。

コードを解説します。

n, ok := x.(int)

x に入っている値を int として取り出そうと試みます。成功すれば n に値、oktrue が入ります。失敗した場合は n にゼロ値、okfalse が入ります。

s, ok := x.(string)

同じ x を今度は string として取り出そうとしています。中身が int なので失敗し、s は空文字列(string のゼロ値)、okfalse になります。

ok を受けない書き方(n := x.(int))もありますが、その場合は失敗時に panic が発生するため、通常は v, ok := ... の形で判定しながら使います。

3.5 型スイッチ

型アサーションは1つの型を取り出すのに便利ですが、判定したい型が増えるたびに if v, ok := x.(int); ok { ... } else if v, ok := x.(string); ok { ... } と書き連ねる形になり、冗長になります。この場合は 型スイッチ で書くと、1つの switch で複数の型を分岐でき、ずっと読みやすくなります。書き方は以下のとおりです。switchx.(type) を書き、各 case に判定したい型を並べます。

switch v := x.(type) {
case1:
    // v は型1として使える
case2:
    // v は型2として使える
default:
    // それ以外の型
}

次のコードは、interface{} で受けた値を intstringbool のいずれかで分岐して表示する例です。

package main

import "fmt"

func describe(x interface{}) {
    switch v := x.(type) {
    case int:
        fmt.Printf("int: %d\n", v)
    case string:
        fmt.Printf("string: %q\n", v)
    case bool:
        fmt.Printf("bool: %t\n", v)
    default:
        fmt.Printf("その他: %v\n", v)
    }
}

func main() {
    describe(42)
    describe("hello")
    describe(true)
    describe(3.14)
}

以下のような実行結果が表示されます。

int: 42
string: "hello"
bool: true
その他: 3.14

intstringbool はそれぞれ対応する case にマッチし、float643.14 はどれにもマッチせず default に流れました。

コードを解説します。

switch v := x.(type) {
case int:
    fmt.Printf("int: %d\n", v)

x.(type) は型スイッチ専用の書き方で、通常のコードでは使えません。マッチした case の中では、v はその型の値として扱われます。ここでは intcase に入るため、vint として %d で表示できます。

default:
    fmt.Printf("その他: %v\n", v)

どの case にもマッチしなかった場合は default が実行されます。v はその時点では interface{} のままなので、任意の型を受け付ける %v で表示するのが安全です。

4. ジェネリクス

「型が違うだけで同じロジック」を書きたい場面はよくあります。例えば int の合計・float64 の合計・string の連結を書きたいとき、そのまま書くと以下のように型ごとに別々の関数を用意する冗長な書き方が必要になります。

func SumInt(nums []int) int {
    var total int
    for _, n := range nums {
        total += n
    }
    return total
}

func SumFloat(nums []float64) float64 {
    var total float64
    for _, n := range nums {
        total += n
    }
    return total
}

func Concat(strs []string) string {
    var total string
    for _, s := range strs {
        total += s
    }
    return total
}

3つの関数はほぼ同じ形をしており、対応したい型が増えるほど関数もさらに増え、ロジックを直したいときの修正箇所も増えます。

Go はこの課題に対して、ジェネリクス(generics)と呼ばれる、型パラメータを使って複数の型に対応する関数を1つだけ定義する仕組みを提供しています。型の集合を表す用途として interface を活用するのが特徴で、この章で学んだ interface の知識をそのまま応用できます。ここでは、ジェネリック関数の定義と、interface による型制約の使い方を扱います。

4.1 ジェネリック関数の定義

関数名のあとに [型パラメータ 型制約] を書くことで、複数の型に対応する関数を1つだけ定義できます。書き方は以下のとおりです。

func 関数名[T 型制約](引数 T) T {
    // T を使った処理
}

次のコードは、intfloat64string の3つに対応する集約関数 Sum を、ジェネリクスを使って1つだけ定義する例です。

package main

import "fmt"

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

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))
    fmt.Println(Sum([]float64{1.5, 2.5, 3.0}))
    fmt.Println(Sum([]string{"a", "b", "c"}))
}

以下のような実行結果が表示されます。

6
7
abc

同じ Sum 関数を intfloat64 のスライスに加えて string のスライスにも呼び出せ、それぞれ数値の合計 67、文字列の連結結果 abc が得られました。

コードを解説します。

func Sum[T int | float64 | string](values []T) T {

[T int | float64 | string] の部分が型パラメータで、Tintfloat64string のいずれかを受け付けます。| は「または」を意味します。引数の型 []T、戻り値の型 T も、呼び出し時に決まる型に置き換わります。

var total T
for _, v := range values {
    total += v
}
return total

関数の中では T を型として扱い、totalT のゼロ値で初期化してから += で集約しています。Goの += は数値では加算、文字列では連結として動作するため、呼び出し時に Tint / float64 なら合計、string なら連結が行われます。

4.2 interfaceによる型制約の定義

複数のジェネリック関数で同じ型制約を使い回したいときは、その制約を interface として名前付きで定義できます。書き方は以下のとおりです。| は「または」を意味します。

type 型制約名 interface {
    型1 | 型2 | 型3
}

func 関数名[T 型制約名](引数 T) T {
    // 処理
}

次のコードは、Number という型制約を定義して、順序比較する Max 関数で使う例です。

package main

import "fmt"

type Number interface {
    int | int64 | float64
}

func Max[T Number](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Max(3, 7))
    fmt.Println(Max(2.5, 1.5))
}

以下のような実行結果が表示されます。

7
2.5

同じ Max 関数を intfloat64 の両方で呼び出せ、それぞれ大きい方の値が得られました。

コードを解説します。

type Number interface {
    int | int64 | float64
}

Number は「intint64float64 のいずれか」を意味する型制約です。ここまで扱ってきた「メソッドを持つ型」を表す interface とは違い、ジェネリクスの文脈では「型の集合」を表す用途として interface を使えるようになっています。

func Max[T Number](a, b T) T {

[T Number] で「TNumber を満たす型」と制約を付けています。標準ライブラリの constraints.Ordered を使うと、順序比較可能な型すべてを簡潔に表現できます。

5. まとめ

この章では、Goのインタフェースとジェネリクスを学びつつ、実際に interface による切り替えと、ジェネリクスによる型パラメータの書き方を体験しました。

  • interface は「メソッドの集合」を宣言する型で、メソッドを持つだけで自動的に満たされる(構造的部分型)
  • interface を受け取る関数は、実装の違いを気にせず操作の名前だけで動かせるため、実装の差し替えや追加がしやすくなる
  • ポインタレシーバで定義したメソッドを持つ型は、値ではなく ポインタ で interface に渡す必要がある
  • interface は別の interface 名を書き入れることで 合成(埋め込み) できる(io.ReadWriter = io.Reader + io.Writer のパターン)
  • メソッドを1つも持たない interface{}any)は、あらゆる型を受け取れる
  • 型アサーション v, ok := x.(T)interface{} から具体型を取り出せる
  • 複数の型で分岐したい場合は 型スイッチ switch v := x.(type) を使う
  • ジェネリクスは [T 型制約] で型パラメータを宣言し、複数の型に対応する関数を1つだけ定義できる
  • ジェネリクスの型制約としても interface を活用でき、「型の集合」を表す用途で type Number interface { int | int64 | float64 } のように使える
  • 標準ライブラリでも interface による抽象化が広く使われており、代表例は fmt.StringerGo標準ライブラリ)や errorGoエラーハンドリング)などで扱う

次の章では、Goのモジュールとパッケージを学びつつ、実際にモジュール管理・自作パッケージの分割・外部パッケージの取り込みを体験します。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000