Goの型アサーションと型変換

(更新) この記事に書かれていることは大体、以下のStackoverflowのベストアンサーに書かれていることから学ばせて頂いた。

go - X does not implement Y (... method has a pointer receiver) - Stack Overflow

毎日、少しづつGoでプログラムを書いているのだが、Goの融通がきかない点と記述の冗長性には少々疲れてきている。もちろん、これは誰が書いても同じになるという保守性、可読性からの観点による利点と、同時に暗黙の型変換によるプログラマが意図しないバグの作り込みを取り除くためという言語設計者の意図も良く理解できる。*1しかし、わかってはいても他言語に比べて生産性の下落は否めない気がする。コンパイラによる未然の問題解決は最近のトレンドであり、より安全なプログラムを求める流れの中で当然の帰結でもある。でも、できれば生産性を犠牲にせずともよしなにしてくれるほうへと発展してくれることを祈る。

Goの融通のきかなさが最も現われるのはやはり多様な数値型が存在する割に、その上で数値演算における暗黙の型変換が一切無い点だろう。

例えば一番極端な例としてはint、int32、int64の間でも、例え実際には同じ型であっても暗黙の型変換が行われない点だ。

https://play.golang.org/p/lnACZ_W_84H

var n int64 = 1
var m int32 = 2
Println(n + m) // invalid operation: n + m (mismatched types int64 and int32)

これはまだわかりやすく、修正し易い。しかしint64とint32の型整合ならint64に寄せるのが普通なのでこれが自動で解決されないのは悲しい。逆にint32に寄せるなら明示的なオーバーフロー対策が必要になるだろう。明示的なキャストの必要性だけでは問題解決に至らない気がする。*2

GoはCと同様にポインタが存在する。構造体のポインタでもメンバのアクセスに'->'を使う必要はない等、一定の暗黙での変換は行ってくれている。しかし代入時には面倒なことに暗黙の型変換は行ってくれないばかりか、明示的なキャストも制限が大きく、型アサーションとif判定と代入を長々と行わなければならない。これが結構苦痛だ。

ここで自分が引っ掛かったのがReaderだ。io.Readerはinterfaceで様々な型がこのio.Readerを実装している。例えば最も身近なものはos.Fileだろう。os.Openでファイルを開くとos.Fileが返ってくるが、os.Fileはio.Readerを実装している。

https://golang.org/pkg/os/#File.Read

ここでメソッド定義のレシーバーに注目。

func (f *File) Read(b []byte) (n int, err error)

Fileのio.Readの実装のレシーバはFileのポインタであるポインタ型だ。

レシーバの型は単一のdefined typeかそのpointerのどちらかであると仕様で決められている。

https://golang.org/ref/spec#Method_declarations

The receiver is specified via an extra parameter section preceding the method name. That parameter section must declare a single non-variadic parameter, the receiver. Its type must be a defined type T or a pointer to a defined type T.

公式訳が存在するのか知らないがdefined typeが定義型、または単に型、ポインタはポインタ型と呼ばれるのが一般的な模様だ。

この時、言語仕様ではdefined typeとpointer typeは明確に区別されており、ポインタ型のほうがより多くのmethodを持つことが可能である。ポインタ型は型のメソッドだけでなく、専用のメソッドを拡張し、両方を持つことが可能だ。

https://golang.org/ref/spec#Method_sets

The method set of any other type T consists of all methods declared with receiver type T. The method set of the corresponding pointer type *T is the set of all methods declared with receiver *T or T (that is, it also contains the method set of T).

method set(メソッド集合)とは非常にユニークな概念に感じる。継承の無いGo言語では型に含まれるメソッドは継承で引き継ぐのではなく、他のインターフェイスや型を埋め込むことで得ることも可能だ。

https://golang.org/ref/spec#Struct_types

A field declared with a type but no explicit field name is called an embedded field. (snip) A field or method f of an embedded field in a struct x is called promoted if x.f is a legal selector that denotes that field or method f.

Promoted fields act like ordinary fields of a struct

恐らくこのためであろうが、型とポインタ型は厳しく区別される。そんなの当たり前だと思うかもしれない。C言語だってそうだったではないかと。しかし、メソッドのレシーバの型違いを理解することが実は重要だったという事実は恐らく初心者の内は気付かないものではないだろうか

さて以前にGoでもJavaと同様にioにはReaderとBufferedReaderが存在するということを先日書いた。Goではbufio.Readerになる。

https://golang.org/pkg/bufio/#NewReader

自分がはまったのはこのbufioの利用においてである。

あるfileを開いてio.Readerを得た後にbufferingを利用するためにbufio.Readerで包むと考えよう。Javaでも良くある話だ。

この時、bufio.NewReaderを用いる。

https://golang.org/pkg/bufio/#NewReader

func NewReader(rd io.Reader) *Reader

さて、ここで気をつけなければいけないのはos.Openで返ってきたFileはポインタ型、*Fileだ。引数io.Readerはインターフェイスであるためにio.Readerの定義するメソッド、Readを実装していれば何でも放り込める。ここで先程の定義を思い出して欲しいのだが、FileのReadメソッドのレシーバは*Fileを指定。つまりポインタ型だ。つまり、Fileのメソッド、Readはポインタ型のFileにしか存在しない。ここでは*Fileなのでそのまま渡すことが可能だ。*3

https://play.golang.org/p/CEtqZhSz7-A

func main() {
    f, _ := os.Open("/tmp")
    r := bufio.NewReader(f)
    println(r)
}

さて、無理矢理ポインタでないFileを食わせるとどうなるか。

https://play.golang.org/p/QtcOttzHJ4n

func main() {
    r := bufio.NewReader(os.File{})
    println(r)
}

実行時エラーでなくコンパイルエラーになる。

./prog.go:9:30: cannot use os.File literal (type os.File) as type io.Reader in argument to bufio.NewReader: os.File does not implement io.Reader (Read method has pointer receiver)

これをどれだけのGo初心者が理解しているだろうか。io.Readerはインターフェイスだ。だからポインタ表記は存在し無い。しかし、引数はこの場合、ポインタ型の*os.Fileでないとエラーになる。なぜならFileのメソッド、Readの実装はレシーバがポインタ型だから定義型のos.Fileにはメソッド、Readが存在しない。

さて、今は無理矢理エラーを出したからこれが問題になるのか実感できないだろう。実際にはまった例を出す。

パッケージmainにio.Readerを実装する型Readerを実装する。Readerはio.Readerを実装する型を引数に取る。ユーザは何かio.Readerの実装(例えばos.File)を準備し、Readerに与え、Readerが処理した結果を新たにReadする。これは例えばGoの標準ライブラリであるcompress.gzip等が全く同じことをしている。

https://golang.org/pkg/compress/gzip/

さて、ReaderもまたNewReaderメソッドを実装するとしよう。引数rはio.Readerを実装する。

func (m *Reader) NewReader(r io.Reader) *Reader

非常にややこしいが、goでは単純にReaderと書いた場合はそのソースコードが存在するpackageのReaderになるのでここではReaderはmain.Readerであることに注意

さて、Readerは大量のデータを扱うので引数rが必ずバッファリングされていること、bufio.Readerで包まれていることを確認したいとする。

以下のように実装した。

https://play.golang.org/p/E0m2bptNwL8

type Reader struct {
  r bufio.Reader
}

func NewReader(r io.Reader) Reader {
  newr := new(Reader)
  newr.r = (bufio.Reader)(r)
  return newr
}

func main() {
    f, _ := os.Open("/dev/stdin")
    r := NewReader(f)
    println(r)
}

これが間違いだらけであることに気付かれるだろうか?

実行すると当然コンパイルすら通らない

./prog.go:15:26: cannot convert r (type io.Reader) to type bufio.Reader

./prog.go:16:3: cannot use newr (type *Reader) as type Reader in return argument

15行は落ちて当然でio.Readerを実装している型が全てbufio.Readerに変換できるなんてことはない。それにio.Readerを実装しているのは*bufio.Readerであってbufio.Readerではない。

https://golang.org/src/bufio/bufio.go?s=4864:4914#L187

16行はちょっと変化球でnewrは14行でnewで作っているので実の値でなくポインタである。':='で何でも代入していると型が何なのか考えなくなるが、varで型を指定して代入すると落ちるので良くわかる。

16行をこうすると、

   return *newr

とするととりあえず16行はコンパイルが通るようになる。しかし、これで良いだろうか?今は説明のために型Readerはとても小さい。しかし、実際には型Readerは処理の実装を含め、structのfieldの数もそれなりに増えていく。大きな構造体を値渡しにするのはお勧めできない。実際、標準ライブラリのReader実装も皆、当たり前のようにポインタ渡しだ。だから直すなら、13行の宣言のほうをこう直すべきだ。

func NewReader(r io.Reader) *Reader {

さて、では15行のほうを直そう。 実はbufio.NewReaderには*bufio.Readerを渡すこともできる。引数がio.Readerの実装であることしか見ていないからだ。従って以下のように簡単に直しても良いように思われる。

https://play.golang.org/p/JdvMRbtbh-q

type Reader struct {
  r bufio.Reader
}

func NewReader(r io.Reader) *Reader {
  newr := new(Reader)
  newr.r = bufio.NewReader(r)
  return newr
}

実行するとわかるがこれも落ちる。

./prog.go:15:10: cannot use bufio.NewReader(r) (type *bufio.Reader) as type bufio.Reader in assignment

Reader.rは実型なのにbufio.NewReaderの返り値はポインタなので代入できないという訳だ。

では15行を以下のようにすればコンパイルは通る

https://play.golang.org/p/Vg2_hCtSdvR

  new.r = *bufio.NewReader(r)

でもちょっと待って。さっきと同じだがbufioのメソッドはレシーバがポインタ型である。なぜ実型で保存する必要があるのか。直すべきは10行だった。

https://play.golang.org/p/Vxucsz5F0OV

type Reader struct {
  r *bufio.Reader
}

さて、これで終了だろうか?

もし引数がbufio.Readerだったらbufioにbufioをかけるの無駄じゃね?と思うはずである。

実際、コンパイラを通すだけならこんなコードでも通るのだ。

https://play.golang.org/p/rsI7cuZhBIq

  newr.r = bufio.NewReader(bufio.NewReader(bufio.NewReader(bufio.NewReader(bufio.NewReader(r)))))

このような無駄を憎むなら型アサーションを行わねばならない。やっと本題に入った :-)

さて、本題に入ったのに少し戻るけど、先程Readerのfield、rの型を*bufio.Readerに修正した。この場合に一番、最初の明示的な型変換をかけるとこうなる。

https://play.golang.org/p/HlxTNaZ2mjz

  newr.r = (*bufio.Reader)(r)

エラーが少し変わる。

./prog.go:15:27: cannot convert r (type io.Reader) to type *bufio.Reader: need type assertion

*bufio.Readerはio.Readerを実装しているけれども、事前に型アサーションが必要だよと教えてくれる訳だ。

アサーションの仕様は以下。

https://golang.org/ref/spec#Type_assertions

x.(T)の形で利用するがこの時、xはインターフェイス型だと指定されている。

アサーションは単体で用いると他言語のassertと同じで型チェックのみを行い、異なるとpanicを発生する。

アサーションを代入文や初期化に利用すると第二の返り値でassertの成否が判定できる。

さて、まず15行を以下のように変えてみよう。

https://play.golang.org/p/MIb8AjBX9SD

  newr.r = r.(bufio.Reader)

コンパイラが不可能なアサーションだと教えてくれる。

./prog.go:15:13: impossible type assertion: bufio.Reader does not implement io.Reader (Read method has pointer receiver)

io.Readerを実装しているのはポインタレシーバだとまで教えてくれる。

で、以下のように変更する。

https://play.golang.org/p/auKCHLcklRk

  newr.r = r.(*bufio.Reader)

するとコンパイルは通るが実行するとpanicで落ちる。

panic: interface conversion: io.Reader is *os.File, not *bufio.Reader

引数rは*os.Fileであって*bufio.Readerではないという訳だ。 今の実装ではまさにその通りだ。

では第二返り値を利用して判定するのだが、その前にまた悪戯してみよう。今度は第二返り値を捨ててみよう。

https://play.golang.org/p/vjGLBcEtH9V

  newr.r, _ = r.(*bufio.Reader)

なんとコンパイルも通り、実行もできる。ただし、*bufio.ReaderであるReader.rの値は0x0とゼロ値だw これはアサーションが落ちているのだから当然である。Goでは_は返り値を捨てるためだけに用いり、_を変数として判定には使えないので新たに名前を付けて代入する必要がある。

では第二返り値をきちんと代入してみよう。以下ではどうだろう。

https://play.golang.org/p/MCZqJ5ZbGsR

  newr.r, ok := r.(*bufio.Reader)

./prog.go:15:7: non-name newr.r on left side of :=

これもエラーとなる。':='の代入はshort variable declarationとのことで、ここにはidentifierしか認められない。つまりnew.rがIDとして相応しくないと蹴られている。

https://golang.org/ref/spec#Short_variable_declarations

んなアホなという感じだ。このような記載は認めるべきかもしれないとの議論がissueで行われている。

github.com

さて、ここでGoの冗長さが発揮される。':='では駄目なので'='を使うという方針で修正するならokを事前に宣言しなければならない。

https://play.golang.org/p/VvzAULeyq3x

  var ok bool
  newr.r, ok = r.(*bufio.Reader)
  if !ok {
    newr.r = bufio.NewReader(r)
  }

これで全てがうまく行った。 別解

https://play.golang.org/p/ynpw7bk0d2l

  var ok bool
  if newr.r, ok = r.(*bufio.Reader); !ok {
    newr.r = bufio.NewReader(r)
  }

さて、さらに別解としてfieldへの代入が':='では駄目なのだから別の変数に一旦入れれば良いとの解法もある。

https://play.golang.org/p/PEwUSxxtTML

  if r2, ok := r.(*bufio.Reader); ok {
    newr.r = r2
  } else {
    newr.r = bufio.NewReader(r)
  }

あまりの冗長さに身悶えしてしまう。ちなみにこの場合にr2でなくrを代入しようとするとまたエラーで落ちる。変換結果はr2に入っているだけでrはアサーションが成功しても何も変わらないことに注意が必要。

以上、Goの型アサーションと型変換の冗長さについて、冗長に書いてみた。書いている間に色々な情報が出てきて自分でも納得のいく出来になったのが幸いだ。こんな長い記事を果たして投稿できるのかが気掛かり。

*1:実際、C/C++ではintとuintの区別をしないがためにセキュリティホールに発展する場合もある。Javaはuintを廃止した。しかし、Javaはそのためにバイト演算が非常に面倒になっている。

*2:Goではexplicit conversionと言いcastという言葉は一切使用されていない

*3:わかり易くするためerrを捨てているけど良い子は真似しないこと。