Goの型付けの基本

ブログ
Goの型付けの基本

Goの型付けの基本

若手エンジニアに説明する機会があったのでその時作成した簡単な資料です。

Goをこれから学びたいという方はざっと読んでおくと基礎が入ると思います。

この記事では次の順番で説明します。

  1. 型の互換性の判定方法(nominalとstructural)
  2. Goの型付けはnominalかstructuralか
  3. Goの型にメソッドを定義する
  4. 値レシーバとポインタレシーバ
  5. Goのinterface
  6. 他の言語との比較

型の互換性の判定方法:nominalとstructural

ある型の値を別の型の変数に代入できるか(型に互換性があるか)を判定する方法は、大きく分けて2つあります。

  • nominal(名前的):型の名前で判定します。中身がまったく同じでも、名前が違えば別の型です。
  • structural(構造的):型の形(持っているフィールドやメソッド)で判定します。名前が違っても、形が同じなら同じ型として扱います。

代表的な言語では次のようになります。

  • Java、C#、Rustなど → nominal
  • TypeScript → structural

Goの型付けはnominalかstructuralか

Goは基本はnominalで、interfaceはstructuralです。

1. 名前付き型(defined type)同士は「名前(nominal)」

type UserID int
type OrderID int

var u UserID = 1
var o OrderID = u           // エラー❌ 構造が同じでも名前が違うと別の型
var o2 OrderID = OrderID(u) // 明示的な変換ならOK✅

UserID も OrderID も中身は int です。それでも名前が違うので別の型として扱われます。

2. interfaceの充足は「構造(structural)」

type Stringer interface { String() string }

type Foo struct{}
func (Foo) String() string { return "foo" }

var s Stringer = Foo{} // implements宣言なしで、メソッドがあれば満たす

Foo は Stringer を実装するとどこにも書いていません。String() メソッドを持っているだけで Stringer として使えます。

補足:名前のない型(型リテラル)が関わる場合も「構造(structural)」

type MyInts []int

var a []int = []int{1, 2}
var m MyInts = a // OK

基底型が同一で、どちらか一方が名前のない型(ここでは []int)なら代入できます。

つまりGoは、

  • 自分で定義した型 → nominal
  • interface → structural
  • 無名の型リテラル → structural

という使い分けになっています。

Goの型にメソッドを定義する

Goでは、自分で定義した型にメソッドを定義できます。レシーバを付けた関数として書き、func と関数名の間に「どの型のメソッドか」を書きます。

type Hoge struct {
	Message string
}

func (h Hoge) Say() string {
	return "Hoge says: " + h.Message
}

(h Hoge) の部分をレシーバと呼びます。h はメソッドの中でその値を指す変数で、他の言語の this や self にあたります。呼び出しは次のとおりです。

h := Hoge{Message: "hello"}
fmt.Println(h.Say()) // Hoge says: hello

JavaやPHPのクラスと違い、メソッドは構造体の定義(struct { ... })の中には書きません。外側に別の宣言として書きます。また、構造体に限らず type Celsius float64 のような型にもメソッドを定義できます。

値レシーバとポインタレシーバの違い

レシーバは Hoge の代わりに *Hoge と書くこともできます。

// 値レシーバ
func (h Hoge) Say() string {
	return h.Message
}

// ポインタレシーバ
func (h *Hoge) Say() string {
	return h.Message
}

どちらでも h.Message を読めますし、変数に入っている値からならどちらも同じように呼び出せます。

h := Hoge{Message: "hello"}
fmt.Println(h.Say()) // ポインタレシーバでもOK:Goが自動で (&h).Say() として呼ぶ

p := &Hoge{Message: "hello"}
fmt.Println(p.Say()) // OK

値レシーバは呼び出し時に Hoge のコピーを受け取り、ポインタレシーバは元の値を指すポインタを受け取ります。値レシーバの中でフィールドを書き換えても、変わるのはコピーだけです。呼び出し元の値を変更したいときはポインタレシーバにします。

func (h *Hoge) SetMessage(msg string) {
	h.Message = msg
}

フィールドを読むだけの Say ならどちらでも動きますが、いくつか違いが出てきます。

1. アドレスを取れない値からは呼べない

ポインタレシーバの場合、次のような呼び出しはコンパイルエラーになります。

Hoge{Message: "hello"}.Say() // コンパイルエラー

m := map[string]Hoge{"a": {Message: "hello"}}
m["a"].Say() // コンパイルエラー

ポインタレシーバのメソッドを呼ぶには、Goが裏で & を付けてアドレスを取る必要があります。アドレスを取れない値に対しては、その変換ができません。

#### アドレス可能(addressable)とは

ここで出てくるのが、Go言語仕様の アドレス可能(addressable) という考え方です。

最初は「メモリ上に実体がないから呼べない」のかと思いましたが、そうではありませんでした。構造体リテラルの値もmapの要素も、メモリ上には存在しています。問題は、そのアドレスを取ってよいと言語仕様で保証されているかどうか です。

mapの要素:場所が動く

mapは要素が増えると内部で再配置(リハッシュ)が起き、要素の置き場所が移動します。もし &m["a"] でアドレスを取れてしまうと、移動後に古い場所を指すポインタが残ってしまいます。そのためGoでは、mapの要素のアドレスを取ることが禁止されています。

p := &m["a"] // コンパイルエラー

構造体リテラル:変数に結び付いていない一時的な値

Hoge{Message: "hello"} は式の中で作られる一時的な値で、変数ではありません。関数の戻り値 f().Say() も同じ理由で呼べません。

ただし構造体リテラルには特例があり、&Hoge{...} と書くことは許されています。この場合は新しく領域が確保され、そのポインタが得られます。

(&Hoge{Message: "hello"}).Say() // OK

アドレス可能なもの

言語仕様でアドレス可能とされている主なものは次のとおりです。

  • 変数
  • ポインタの参照先(*p)
  • スライスの要素(s[0])
  • アドレス可能な構造体のフィールド、アドレス可能な配列の要素

スライスの要素はアドレス可能なので、s[0].Say() はポインタレシーバでも呼べます。mapとは逆なので、混同しやすいところです。

感覚としては、「置き場所が固定された変数として扱えるものなら呼べる」と捉えると覚えやすいです。

2. interfaceを満たすのは *Hoge だけになる

type Sayer interface { Say() string }

var s1 Sayer = &Hoge{Message: "hello"} // OK
var s2 Sayer = Hoge{Message: "hello"}  // コンパイルエラー

ポインタレシーバで定義したメソッドは *Hoge のメソッドとして扱われるので、Sayer を満たすのは *Hoge だけです。値レシーバで定義した場合は、Hoge と *Hoge のどちらも Sayer を満たします。

3. nilのポインタでも呼べてしまう

var p *Hoge
p.Say() // コンパイルは通るが、h.Message の参照で実行時にpanic

値レシーバの場合は、nilのポインタから呼び出した時点でpanicします。ポインタレシーバの場合はメソッドの中まで入るので、if h == nil { return "" } のようにnilを考慮したメソッドも書けます。

4. 同じ名前で両方は定義できない

func (h Hoge) Say() と func (h *Hoge) Say() を両方書くとコンパイルエラーになります。どちらか一方を選びます。

どちらを使うか

  • フィールドを変更するメソッドがある → ポインタレシーバ
  • 構造体が大きく、コピーのコストを避けたい → ポインタレシーバ
  • 小さくて、どのメソッドも変更しない → 値レシーバ

1つの型の中ではどちらかに揃えるのが一般的です。1つでもポインタレシーバが必要なメソッドがあれば、すべてポインタレシーバにしておくと、interfaceを満たすかどうかで迷わずに済みます。

Goのinterfaceとは

Goのinterfaceは「どんなメソッドを持っているか」だけを決めた型です。中身のデータや実装は持ちません。

type Notifier interface {
    Notify(message string) error
}

Notifier は「Notify(string) error というメソッドを持つもの」を表します。このメソッドを持っている型なら、どの型でも Notifier として扱えます。JavaやPHPのように implements Notifier と書く必要はありません。

使い方の例

メールとSlackの2種類の通知先を用意します。

type EmailNotifier struct {
    To string
}

func (e EmailNotifier) Notify(message string) error {
    fmt.Printf("メール送信 to=%s: %s\n", e.To, message)
    return nil
}

type SlackNotifier struct {
    Channel string
}

func (s SlackNotifier) Notify(message string) error {
    fmt.Printf("Slack投稿 channel=%s: %s\n", s.Channel, message)
    return nil
}

どちらの型にも Notifier という名前は出てきません。それでも Notify メソッドを持っているので、両方とも Notifier を満たしています。

通知を送る側の関数は、引数を Notifier にしておきます。

func SendAll(notifiers []Notifier, message string) {
    for _, n := range notifiers {
        if err := n.Notify(message); err != nil {
            fmt.Println("通知に失敗:", err)
        }
    }
}

func main() {
    notifiers := []Notifier{
        EmailNotifier{To: "[email protected]"},
        SlackNotifier{Channel: "#alerts"},
    }
    SendAll(notifiers, "デプロイが完了しました")
}

実行結果は次のとおりです。

メール送信 [email protected]: デプロイが完了しました
Slack投稿 channel=#alerts: デプロイが完了しました

SendAll は、渡されたものがメールなのかSlackなのかを知りません。Notify を呼べることだけを前提にしています。通知先にLINEを追加したくなっても、Notify メソッドを持つ型を1つ作れば SendAll を変更せずに済みます。

テストで差し替える

interfaceを引数にしておくと、テストで偽物に差し替えるのも簡単です。

type FakeNotifier struct {
    Messages []string
}

func (f *FakeNotifier) Notify(message string) error {
    f.Messages = append(f.Messages, message)
    return nil
}

func TestSendAll(t *testing.T) {
    fake := &FakeNotifier{}
    SendAll([]Notifier{fake}, "テスト")

    if len(fake.Messages) != 1 {
        t.Fatalf("1件届くはずが %d 件", len(fake.Messages))
    }
}

FakeNotifier はメソッドをポインタレシーバで定義しているので、前の章で説明したとおり Notifier を満たすのは *FakeNotifier のほうです。そのため &FakeNotifier{} で渡しています。

実際の型を取り出す

interfaceの値から元の型を取り出したいときは、型アサーションか型スイッチを使います。

func describe(n Notifier) {
    switch v := n.(type) {
    case EmailNotifier:
        fmt.Println("メール:", v.To)
    case SlackNotifier:
        fmt.Println("Slack:", v.Channel)
    default:
        fmt.Println("その他の通知先")
    }
}

標準ライブラリのinterface

Goの標準ライブラリには、メソッドが1つだけの小さなinterfaceがたくさんあります。代表的なのが io.Reader です。

type Reader interface {
    Read(p []byte) (n int, err error)
}

ファイル(*os.File)、HTTPレスポンスのボディ、strings.Reader、bytes.Buffer は、どれも Read メソッドを持っているので io.Reader として扱えます。io.Reader を受け取る関数を1つ書けば、ファイルからでもネットワークからでも文字列からでも同じように読めます。

メソッドを1つも持たない interface{}(Go 1.18以降は別名の any)は、すべての型が満たします。どんな値でも入るぶん、使う側で型アサーションが必要になるので、多用は避けたほうが扱いやすいです。

他の言語との比較

ここからは、Goの型付けを他の言語と比べてみます。

TypeScriptの型付け

TypeScriptは基本structuralです。

type UserID = number
type OrderID = number

const u: UserID = 1
const o: OrderID = u // OK:typeは単なる別名なので区別されない

Goなら弾かれるコードが、TypeScriptでは通ります。

ただしnominal的に振る舞う例外もあります。

  • private / protected メンバーを持つclass
  • enum
  • unique symbol

TypeScriptでnominalにしたいときは、number & { __brand: "UserID" } のようなbranded typeで擬似的に表現するのが定番です。

型の分類はnominalとstructuralの2つだけなのか

ここで気になったのが、そもそも型の分類はこの2つしかないのか、という点です。

「型の互換性をどう判定するか」という軸に限れば、主なものはnominalとstructuralの2つです。そこにduck typingを3つ目として数えることもあります。

方式判定基準主な言語
Nominal型の名前・宣言(継承やimplements)Java, C#, Kotlin, Swift, Rust, Haskell
Structural型の形(メンバー構成)TypeScript, OCaml, Elm, Goのinterface
Duck typing実行時に、そのメソッドを呼べるかどうかPython, Ruby, JavaScript

duck typingはstructuralの動的版と考えるとわかりやすいです。structuralはコンパイル時に形を検査し、duck typingは実行時に呼べるかどうかで判断します。duck typingを「静的な型の分類ではない」として数に入れない文献もあります。

中間的なものもあります。

  • Row polymorphism(行多相):「少なくとも name フィールドを持つレコード」のように一部のフィールドだけを指定するstructuralの一種。PureScript、OCaml、Elmのextensible recordで使われています。
  • 静的duck typing:C++のテンプレートは、インスタンス化したときに「その操作ができるか」だけを検査します。コンパイル時のduck typingです。C++20のconceptsはそこに制約を付けられるようにしたものです。
  • 型クラス / トレイト:HaskellのtypeclassやRustのtraitは impl / instance を明示的に宣言するのでnominal。宣言なしで満たせるGoのinterfaceとは対照的です。

型システムには他の軸もある

nominalかstructuralかは、型システムを分類する軸の1つにすぎません。

軸分類例
検査のタイミング静的 / 動的 / 漸進的(gradual)静的:Go, Rust / 動的:Python, Ruby / 漸進的:TypeScript, Pythonの型ヒント
暗黙の型変換の多さ強い / 弱い(定義は曖昧)弱い:JavaScript, C / 強い:Python, Haskell
型の書き方明示的 / 型推論Hindley–Milner推論:Haskell, OCaml, F#
多相性パラメトリック(ジェネリクス)、サブタイプ、アドホック(オーバーロード、型クラス)ほとんどの言語が複数を組み合わせる
値の使い方の制約線形型 / アフィン型Rustの所有権(アフィン型的)
型の表現力依存型、篩型(refinement type)依存型:Idris, Agda, Lean / 篩型:Liquid Haskell

言語の型システムは、これらの軸の組み合わせで捉えると整理しやすくなります。

よく使われている言語を軸の組み合わせで見る

Goと、よく使われている6言語を並べてみました。

Go

  • 検査のタイミング:静的
  • 互換性:混合型。defined typeはnominal、interfaceと無名の型リテラルはstructural
  • 強弱:強い。int と int64 の間でも暗黙の変換はない
  • 型推論::= によるローカル変数の推論、ジェネリクスの型引数の推論
  • 多相性:サブタイプ多相はinterfaceで実現(クラスの継承はなく、埋め込みで代用)。ジェネリクスはGo 1.18から使え、制約は型集合(~int | ~string など)で書く。オーバーロードはない
  • 所有権:なし(GC)

TypeScript

  • 検査のタイミング:静的。ただしJavaScriptにコンパイルされると型は消える。any で検査を部分的に外せるのでgradualでもある
  • 互換性:structural(例外はclassの private / protected、enum、unique symbol)
  • 強弱:実行時はJavaScriptなので弱い。型チェッカーは数値と文字列の掛け算などをエラーにするが、"1" + 1 の文字列連結は通す
  • 型推論:強力。制御フローによる型の絞り込み(narrowing)やリテラル型の推論がある
  • 多相性:ジェネリクス、サブタイプ、オーバーロード(シグネチャは複数書けるが、実装は1つ)。ユニオン型、交差型、条件型、Mapped Typesなど、型レベルの計算がかなり書ける
  • その他:strictNullChecks でnull安全性を検査

PHP

  • 検査のタイミング:動的。PHP 7以降の型宣言を使えばgradual。Pythonと違って 型宣言は実行時に実際に検査され、違反すると TypeError になる。静的な検査はPHPStanやPsalmが担当
  • 互換性:クラスとinterfaceはnominal(implements が必要)。型宣言のないコードは実行時のduck typing
  • 強弱:弱め。スカラー型の引数はデフォルトで暗黙に変換される("5" を int 型の引数に渡せる)。declare(strict_types=1) で厳格になる。== の緩い比較もある
  • 型推論:言語としてはなし。PHPStanなどが推論する
  • 多相性:サブタイプ。ネイティブのジェネリクスはなく、PHPDocの @template をPHPStanやPsalmが解釈する方式で代用。オーバーロードもない(__call で擬似的に実現は可能)
  • その他:PHP 8.0でユニオン型、8.1で交差型とenumが入った

Python

  • 検査のタイミング:動的。型ヒントを使えばgradual。型ヒントは実行時には検査されず、mypyやPyrightなどの外部ツールが検査する
  • 互換性:実行時はduck typing。型ヒント上は通常のクラスがnominal、typing.Protocol を使うとstructural
  • 強弱:強い("1" + 1 はエラー)
  • 型推論:型チェッカーがローカル変数などを推論
  • 多相性:サブタイプ、ジェネリクス(TypeVar、3.12以降の def f[T]() 構文)、アドホック(__add__ などの特殊メソッド、functools.singledispatch)

Java

  • 検査のタイミング:静的
  • 互換性:nominal。interfaceを満たすには implements が必要
  • 強弱:強い
  • 型推論:ローカル変数の var(Java 10以降)、ダイヤモンド演算子 <>
  • 多相性:サブタイプ、ジェネリクス、オーバーロード。ジェネリクスは型消去されるので、実行時に List<String> と List<Integer> を区別できない
  • その他:sealed、レコード、パターンマッチで代数的データ型に近い書き方ができる

C#

  • 検査のタイミング:静的。dynamic 型の部分だけ動的に検査
  • 互換性:基本はnominal。ただし foreach(GetEnumerator)や await(GetAwaiter)は、メソッドを持っていれば使えるパターンベースの仕組み
  • 強弱:強い
  • 型推論:var、ラムダ式、ジェネリクスの推論
  • 多相性:サブタイプ、ジェネリクス、オーバーロード。ジェネリクスは実行時にも型情報が残る(reified)点がJavaと違う
  • その他:nullable参照型(string?)でnull安全性を静的に検査

C++

  • 検査のタイミング:静的
  • 互換性:クラスはnominal。テンプレートはコンパイル時のduck typingで、C++20のconceptsで構造的な制約を付けられる
  • 強弱:Cより強いものの、Cから受け継いだ暗黙の型変換が残っている
  • 型推論:auto、テンプレート引数の推論
  • 多相性:パラメトリック(テンプレート)、サブタイプ(継承、virtual)、アドホック(オーバーロード)のすべてを持つ
  • 所有権:RAII、ムーブセマンティクス、スマートポインタ。Rustのように使用後のアクセスをコンパイラが禁止する仕組みはない

一覧へ戻る