帰ってきた「しっぽのさきっちょ」

Go 言語における「オブジェクト」 — プログラミング言語 Go

Go 言語がいわゆる「オブジェクト指向言語」と言えるかどうかについては色々とあるようだが,オブジェクト指向プログラミングを助けるための仕掛けはいくつか存在する。 今回はその中の type キーワードを中心に解説していく。

なお,今回のソースコードは “A Tour of Go” のものをかなり流用しているため取り扱いに注意。 Go 言語の公式ドキュメントは CC License の by 3.0,ソースコードは BSD license で提供されている。

Go 言語の基本型

今さらだけど, Go 言語の基本型(basic type)は以下の通り。

  • bool
  • string
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64
  • uintptr
  • byte
  • rune
  • float32, float64
  • complex64, complex128

このうち byte は uint8 の別名で rune1 は int32 の別名である。 また int, uint, uintptr のサイズはプラットフォーム依存になっている。 string は不変(immutable)な値で,その実体は byte 配列である。 基本型は組み込み型であり,振る舞いを追加・変更することはできない。

さらにこれらの基本型を集約した構造体 struct を定義できる。

package main

import "fmt"

func main() {
    vertex := struct {
        X int
        Y int
    }{X: 1, Y: 2}
    fmt.Println(vertex)
}

ちなみに構造体のフィールド(field)には構造体を含めることができ,入れ子構造にすることもできる。

この他に配列(array/slice)や連想配列(map)あるいは関数値(function value)といったものもあるが,今回は踏み込まない2

型に名前を付ける

全ての型には type キーワードを使って名前を付けることができる。 例えば先ほどのコードは

package main

import "fmt"

type Vertex struct {
    X int
    Y int
}

func main() {
    vertex := Vertex{X: 1, Y: 2}
    fmt.Println(vertex)
}

と書き直すことができる。

type キーワードが使えるのは構造体だけではない。 上述の基本型も type キーワードを使って型を再定義できる。

たとえば,2つの時点間の時間を表す time.Duration は以下のように定義されている。

type Duration int64

また,配列なども型として再定義できる。

type Msgs []string

type キーワードを使って型に名前を付ける利点は3つある。

  1. 名前を付けることでコードの可読性を上げる(オブジェクト指向設計では名前がとても重要)
  2. 再利用性の向上(特に構造体の場合)
  3. 型に関数を関連付けることができる。

type キーワードによる名付けは単なる別名定義ではないということだ。

型に関数を関連付ける

型に関数を関連付けるには以下のように記述する。

func (v Vertex) String() string {
    return fmt.Sprint("X =", v.X, ", Y =", v.Y)
}

(v Vertex) の部分はメソッド・レシーバ(method receiver)と呼ばれ,これが型と関数を関連付ける役割を果たす。 内部処理としては

func Vertex.String(v Vertex) string {
    return fmt.Sprint("X =", v.X, ", Y =", v.Y)
}

と等価である3。 関数の呼び出し側は

package main

import "fmt"

type Vertex struct {
    X int
    Y int
}

func (v Vertex) String() string {
    return fmt.Sprint("X = ", v.X, ", Y = ", v.Y)
}

func main() {
    vertex := Vertex{X: 1, Y: 2}
    fmt.Println(vertex.String())
}

のようにピリオドで関数を連結して記述する4

構造体そのものには関数を付与できない5。 たとえば

package main

import "fmt"

func (v struct{ X, Y int }) String() string {
    return fmt.Sprint("X =", v.X, ", Y =", v.Y)
}

func main() {
    var vertex = struct {
        X int
        Y int
    }{X: 1, Y: 2}
    fmt.Println(vertex.String())
}

などと書いても,コンパイル時に

invalid receiver type struct { X int; Y int } (struct { X int; Y int } is an unnamed type)

と怒られる。 type キーワードによって型に名前が付けられていることが重要なのだ。

Go 言語には class キーワードはないが, type キーワードを使うことで,名前と属性と操作を持つクラスを記述することができる6

汎化・特化と処理の委譲

オブジェクト指向設計においてクラス間の関係は大きく2つある。

  1. 汎化・特化7(継承または is-a 関係)
  2. 関連8(包含または has-a 関係)

このうち関連についてはこれまで説明した方法で実現できるが,汎化・特化は表現できない。 そこで以下の機能を使って汎化・特化を実現する。

振る舞いのみを定義した型

interface を使うと振る舞いのみを定義した型を表現することができる。

interface で定義された型で最もよく目にするのは error だろう。 error は以下のように定義できる9

type error interface {
    Error() string
}

つまり error は「string 型を返す Error() 関数」のみが定義されている。 逆に言うと「string 型を返す Error() 関数」を持つ全ての型は error の一種(つまり is-a 関係)であると見なすことができる10

たとえば

package os

// PathError records an error and the operation and file path that caused it.
type PathError struct {
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

と定義される os.PathErrorerror の一種である。

interfacetype キーワードで名前を付けることができ,他の型と同じように扱うことができる。 さらに interface で定義した型は振る舞いのみで具体的な実装を含まないため,多態性を持たせた記述が可能になる11

型の埋め込み

もうひとつの汎化・特化の機能が型の埋め込み(embedding)である。 構造体や interface には別の型を埋め込むことができる。

たとえば io.ReadWriter は以下のように Reader および Writer を埋め込んでいる。 (このときの Reader および Writer を「埋め込みインタフェース(embedding interface)」と呼ぶ)

package io

// Implementations must not retain p.
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Implementations must not retain p.
type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter is the interface that groups the basic Read and Write methods.
type ReadWriter interface {
    Reader
    Writer
}

これによって ReadWriterRead() および Write() を自身の振る舞いのように扱うことができる。 この場合も ReadWriterReader および Writer の一種であると見なすことができる。

同様に bufio.ReadWriter についても

package bufio

// ReadWriter stores pointers to a Reader and a Writer.
// It implements io.ReadWriter.
type ReadWriter struct {
    *Reader
    *Writer
}

// NewReadWriter allocates a new ReadWriter that dispatches to r and w.
func NewReadWriter(r *Reader, w *Writer) *ReadWriter {
    return &ReadWriter{r, w}
}

と実装されていて, bufioReader および Writer を埋め込み,これらの型の一種として実装されている(このときの Reader および Writer を「埋め込みフィールド(embedded field)」または「匿名フィールド(anonymous field)」と呼ぶ)。 なお, bufio.ReadWriterio.ReadWriter の一種として機能している点にも注目してほしい。

関数のオーバーライドと処理の委譲

では,今まで述べたことを使って以下のコードを書いてみる。

package main

import "fmt"

type ErrorInfo interface {
    error
    Errno() int
}

type ErrorInfo1 struct{}

func (err *ErrorInfo1) Error() string {
    return fmt.Sprint("Error Information: ", err.Errno())
}

func (err *ErrorInfo1) Errno() int {
    return 1
}

func Action() error {
    err := &ErrorInfo1{}
    return err
}

func main() {
    if err := Action(); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Normal End")
}

error の拡張として ErrorInfo を定義する。 ErrorInfo では error を埋め込み,さらに Errno() を追加している。 これを実装したのが ErrorInfo1 である。 したがって実行結果は “Error Information: 1” が出力される。

次に ErrorInfo1 のバリエーションとして ErrorInfo2 を追加してみよう。

package main

import "fmt"

type ErrorInfo interface {
    error
    Errno() int
}

type ErrorInfo1 struct{}

func (err *ErrorInfo1) Error() string {
    return fmt.Sprint("Error Information: ", err.Errno())
}

func (err *ErrorInfo1) Errno() int {
    return 1
}

type ErrorInfo2 struct {
    ErrorInfo1
}

func (err *ErrorInfo2) Errno() int {
    return 2
}

func Action() error {
    err := &ErrorInfo2{}
    return err
}

func main() {
    if err := Action(); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Normal End")
}

ErrorInfo2 では Error()ErrorInfo1 のものをそのまま使い回したいが Errno() では異なる値を出力したい,と考えた。 実行結果として “Error Information: 2” が出力されることを期待したが,実際には前回と同じ “Error Information: 1” が出力される。

埋め込みフィールド(ErrorInfo1)の関数の名前が埋め込みを行った型(ErrorInfo2)の名前と衝突する場合は埋め込みを行った型のほうが優先的される12 が,これは C++ や Java などにある仮想関数のオーバーライドとは少し異なる。

上のコードでは ErrorInfo2 と直接関連付けられた Error() がないため ErrorInfo1Error() が呼ばれるが,その関数の中で呼ばれる Errno()ErrorInfo2 と関連付けられた関数ではなく ErrorInfo1 と関連付けられた関数になる。

delegation
delegation

これは Go 言語では埋め込みフィールドの関数呼び出しが「委譲」として機能しているためである13。 たとえば C++ 言語では virtual 修飾子を付与して仮想関数化することで意図的にオーバーライドできるが14Go 言語ではこのような仕掛けがないため,呼ばれた関数は常に委譲として機能する。

上の例はクラス構成からして明らかにダメダメなのだが,今回のポイントはサブクラスである ErrorInfo2 から Errno() 関数を上書きすることでスーパークラス ErrorInfo1Error() 関数の処理を書き換えようとした点にある。 継承15 の実装で一番よくあるミスがこの「カプセル化の破れ」で, Go 言語の場合は敢えて移譲を強制することでこの手の不具合が発生するのを回避しようとしているように見える。

また,他の言語では明示的に委譲を実装しようとすると冗長な記述になることが多いが, Go 言語の場合は埋め込みを使うことでシンプルな記述で委譲を実装できる点がメリットと言える。

ブックマーク

Go 言語に関するブックマーク集はこちら


  1. rune は Unicode 文字の符号点(code point)を示す型で文字そのものを表現する。 string と rune の関係については「String と Rune」を参照のこと。 [return]
  2. slice については「素数探索アルゴリズムで遊ぶ」で少し紹介している。 [return]
  3. Go 言語の関数呼び出しにおいて引数の渡し方は基本的に「値渡し」である。「参照渡し」にするにはポインタを値として渡せばよい。メソッド・レシーバについては,関数の呼び出し側インスタンスがポインタか否かに関係なく,値渡しの場合は値が,参照渡しの場合はポインタが渡される。詳しくは「関数とポインタ」を参照のこと。 [return]
  4. ちなみに fmt.Print などでは引数の型が String() を持っていることを期待し,この関数の出力結果をデフォルト書式にしている。したがって fmt.Println(vertex.String())fmt.Println(vertex) は同じ結果になる。 [return]
  5. 他にも基本型や他パッケージで定義されている型に関数を追加することはできない。 [return]
  6. クラスは名前と属性と操作の3つの要素で構成されている。名前は他クラスと識別できるものを1個。属性と操作は0個以上存在する。 Go 言語では空のフィールドの struct を定義することにより0個の属性を持つクラスを構成できる。 [return]
  7. 言わずもがなだが,サブクラスから見たスーパークラスが「汎化」でその逆が「特化」である。 [return]
  8. 関連は更に集約と複合に分類できるが今回は踏み込まない。 [return]
  9. error は組み込み型なので,実際にこのような定義が標準パッケージにあるわけではない。 error について詳しくは「エラー・ハンドリングについて」を参照のこと。 [return]
  10. Go 言語では Java の implement のような継承を明示するキーワードはない。記述された振る舞いからクラス関係を決定する方法を「ダック・タイピング(duck typing)」と呼ぶ。ダック・タイピングの由来は「ダック・テスト(duck test)」だそうで,ダック・テストとは “If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.” と帰納法的に対象を推測する手法を指すらしい。ダック・タイピングのメリットのひとつは多重継承で発生する様々な問題(名前の衝突や菱形継承など)を気にする必要がない点である。 [return]
  11. たとえば interface{} と記述すればあらゆる型を含むことになる。これを利用して fmt.Printfunc Print(a ...interface{}) (n int, err error) { ... } と定義されている。ちなみに Go 言語にはいわゆる「総称型」はサポートされていない[return]
  12. 複数の型を埋め込んでいる場合,埋め込みフィールド間で名前が衝突しているフィールドや関数を使おうとするとコンパイルエラーになる。この場合は err.ErrorInfo1.Error() のように型を明示して回避できる。 [return]
  13. Go 言語的には埋め込みフィールドはフィールドのバリエーションのひとつにすぎないため,動作も通常のフィールドが持つ関数を呼び出した場合と変わらない。そういう意味では構造体への埋め込みは,見かけ上は「is-a 関係」でも,実質的には「has-a 関係」に近いと言えるかもしれない。 [return]
  14. 逆に Java では関数は常に仮想関数として機能しオーバーライドされる可能性がある。これを抑止するためには final 修飾子を付加する。 [return]
  15. ここで言う継承は設計時の「汎化・特化」のことではなく,言語機能などを使った実装上の継承のこと。 [return]