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)には構造体を含めることができ,入れ子構造にすることもできる。

この他に配列(slice2 を含む)や連想配列(map)あるいは関数値(function value)といったものもあるが,今回は踏み込まない。

型に名前を付ける

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

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 宣言による名付けは単なる別名定義ではないということだ3

型に関数を関連付ける

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

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)
}

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

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())
}

のようにインスタンスと関数をピリオドで連結して記述する5

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

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 宣言を使うことで,名前と属性と操作を持つクラスを記述することができる7

汎化・特化と処理の委譲

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

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

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

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

interface を使うと振る舞いのみを定義した汎化クラスを表現することができる。

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

type error interface {
    Error() string
}

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

たとえば

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 で定義した型は振る舞いのみで具体的な実装を含まないため,多態性を持たせた記述が可能になる12

将来バージョンで総称型(generics)がサポートされるかも。

現行版の Go 言語ではいわゆる「総称型」はサポートされていないが,将来バージョンで導入される可能性がある。

型の埋め込み

もうひとつの汎化・特化の機能が型の埋め込み(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 interface を定義する。 ErrorInfo interface では error を埋め込み,さらに Errno() 関数を追加定義している。 更に ErrorInfo interface に適合するクラスとして ErrorInfo1 型を作った。 このコードの実行すると “Error Information: 1” が出力される。

ErrorInfo interface

次に 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() 関数では異なる値を出力したい,と考えた。 そこで Errno() 関数をオーバライドしようと考えた。 図で書くと以下のような構成を期待したわけだ。

Override ?

しかし実際には,期待した “Error Information: 2” ではなく,前回と同じ “Error Information: 1” が出力される。

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

delegation

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

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

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

ブックマーク

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

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)


  1. rune は Unicode 文字の符号点(code point)を示す型で文字そのものを表現する。 string と rune の関係については「String と Rune」を参照のこと。 ↩︎

  2. 配列と slice の関係については「配列と Slice」で紹介している。 ↩︎

  3. 型の別名定義はバージョン 1.9 から導入された。詳しくは「Go 1.9 と Type Alias」を参照のこと ↩︎

  4. Go 言語の関数呼び出しにおいて引数の渡し方は基本的に「値渡し」である。詳しくは「関数とポインタ」を参照のこと。 ↩︎

  5. ちなみに fmt.Print などでは引数の型が String() を持っていることを期待し,この関数の出力結果をデフォルト書式にしている。したがって fmt.Println(vertex) と書いても同じ結果になる。 ↩︎

  6. 他にも基本型や他パッケージで定義されている型に関数を追加することはできない。当たり前だけど。 ↩︎

  7. クラスは名前と属性と操作の3つの要素で構成されている。名前は他クラスと識別できるものを1個。属性と操作は0個以上存在する。 Go 言語では空のフィールドの struct を定義することにより0個の属性を持つクラスを構成できる。 ↩︎

  8. 言わずもがなだが,サブクラスから見たスーパークラスが「汎化」でその逆が「特化」である。 ↩︎

  9. 関連は更に集約と複合に分類できるが今回は踏み込まない。 ↩︎

  10. error は組み込み型なので,実際にこのような定義が標準パッケージにあるわけではない。 error について詳しくは「エラー・ハンドリングについて」を参照のこと。 ↩︎

  11. Go 言語では Java の implement のような interface クラスからの継承を明示するキーワードはない。記述された振る舞いからクラス関係を決定する方法を「構造的部分型(structural subtyping)」と呼ぶ。構造的部分型のメリットのひとつは多重継承で発生する様々な問題(名前の衝突や菱形継承など)を気にする必要がない点である。 ↩︎

  12. たとえば interface{} と記述すればあらゆる型を含むことになる。これを利用して fmt.Printfunc Print(a ...interface{}) (n int, err error) { ... } と定義されている。 ↩︎

  13. 逆に Java では関数は常に仮想関数として機能しオーバーライドされる可能性がある。これを抑止するためには final 修飾子を付加する。 ↩︎

  14. Go 言語的には埋め込みフィールドはフィールドのバリエーションのひとつにすぎないため,動作も通常のフィールドが持つ関数を呼び出した場合と変わらない。そういう意味では構造体への埋め込みは,見かけ上は「is-a 関係」でも,実質的には「has-a 関係」に近いと言えるかもしれない。 ↩︎

  15. 複数の型を埋め込んでいる場合,埋め込みフィールド間で名前が衝突しているフィールドや関数を使おうとするとコンパイルエラーになる。この場合は err.ErrorInfo1.Error() のように型を明示して回避できる。 ↩︎

  16. ここで言う継承は設計時の「汎化・特化」のことではなく,言語機能などを使った実装上の継承のこと。 ↩︎