0. なんで io.Reader?
去年のアドベントカレンダーでは Go4 までだったのが今年はなんと Go7 までできており、Go への関心が高まっているのはいち Go 好きとしてはうれしい限りです。Go の良さは色々なところにあります、それは例えばシンプルな言語仕様だったり、標準ライブラリの充実度だったり、様々なサポートツール(go get
、go vet
、goreturns
、gorename
など)だったり。こういった側面はしばしばGoの良さとして語られますし私自身もそれには同意します。一方でそれらのわかりやすい利点の裏に隠れてしまっている影の立役者がいると思っています。そう、それこそが io.Reader
です。io.Reader
はその使い方を正しく理解するだけでGoのインターフェースという仕組みの強力さがわかる素晴らしいインターフェースのお手本であると思っています。
1. io.Readerって?
一言で言ってしまえばバイト列を読み出すためのインターフェースです。Go を書いたことがある方はどこかで一度はコードの中で io.Reader
に触れたことがあると思います。たとえその覚えがなくても io.Reader
はGoを書いていれば様々な場所で出てきます。例えばファイル入力のとき、f, err := os.Open(filename)
の返り値 f
は *os.File
という型ですが、この*os.File
は io.Reader
インターフェースを持っています。他に頻出する例としては http.Response
の Body
メンバが io.ReadCloser
というインターフェースを持っていますがこれは io.Reader
と io.Closer
の複合的なインターフェースです。
io.Readerインターフェース
Go の標準ライブラリの中で io.Reader
は以下のように定義されています(コメント略)。
type Reader interface {
Read(p []byte) (n int, err error)
}
初めてこの定義を見る方は「え、たったこれだけ?」と思われるかもしれませんが、これだけです。 Read([]byte) (int, error)
というシグネチャを持ったメソッドを実装しているありとあらゆる型が io.Reader
に該当します。これで io.Reader
がGoの様々な場面で出てくるという主張に納得していただけるのではないかと思います。
同時にそういった方々にとってこのインターフェースを見ただけでは直感的に Read
メソッドがどういった振る舞いをするのかは自明ではないでしょう。多くのライブラリは Read
メソッドを叩かなくて済むように様々な仕組みを提供しているため裏で何が行われているのかを知る機会があまりないのではないかと思います。例えばファイルを読むときは *bufio.Scanner
を使って一行ずつ読み込むでしょうし、HTTPのレスポンスボディは ioutil.ReadAll
に渡して全部まるっと読んでしまえばよかったりするわけです。シンプルなユースケースであればごそっとバイトスライスに読み込んでしまえばよさそうですが、巨大なファイルを開く場合やストリームから継続的にデータを読み出すような使い方をする場合は io.Reader
を使わざるを得ないので io.Reader
を介するAPI設計は重宝されます。
Readメソッドの振る舞い
では具体的に Read
メソッドが何をするか(することを期待されるか)を手続き的にご説明します。
- 与えられたバイトスライス
p []byte
を先頭から埋めていく。 - 埋まったバイト数
n
と、埋める過程で発生したエラーerr
を返す。 n > 0
であっても、err != nil
である可能性がある。- 組
(0, nil)
を返すことは非推奨。
たとえばファイルから先頭 m
バイトを読みたい場合は以下のように記述します。
f, err := os.Open(filename)
p := make([]byte, m) // 1. スライス確保
n, err := f.Read(p) // 2. Read実行
if m < n { // 3. 返り値処理
log.Fatalf("%dバイト読もうとしましたが、%dバイトしか読めませんでした\n", n, m)
}
if err != nil { // 4. エラー処理
log.Fatalf("読込中にエラーが発生しました:%v\n", err)
}
-
Read
メソッドは可能な限り渡されたスライスの全長を埋めることを期待されるため、あらかじめ読みたいバイト数分のサイズを持ったスライスを作成しておきます。 - スライスの実態はポインタなのでこれをそのまま
Read
メソッドに渡すと中でこのスライスの中身を埋めてくれます。 - Readでエラーが発生していても
n > 0
バイト埋められている可能性があるため先にその処理を行います。Goは多くの場合真っ先にエラー処理をすることが多いですが、Read
メソッドの返り値処理はその数少ない例外と言えるでしょう。ここでは要求したバイト数が読めなかったためlog.Fatalf
を呼んでいますが、場合によってはp[:n]
でなにかの処理をすることも想定できるでしょう(バッファリングなど)。 - 最後にエラーを処理します。このとき**
n < m
でもerr == nil
の可能性がある**ことと、n == 0 && err == nil
であってもEOF
ではないことに注意しましょう(この組の返り値が非推奨になっている所以です)。
2. io.Readerの良さ
io.Reader
というインターフェースと Read
メソッドの振る舞いをご理解いただいた上でなぜ io.Reader
が素晴らしいのかを語っていきます。前述の通り io.Reader
はGoのあらゆる場面で出てくるインターフェースですが、その理由は圧倒的な汎用性にあります。このことを説明するのにもってこいなのが標準ライブラリに実装されている bufio.Reader
です。
bufio.Readerとは
io.Reader
がインターフェースであるのに対して bufio.Reader
は io.Reader
を満たした構造体です。パッケージ名の bufio
が示唆する通り bufio.Reader
は io.Reader
オブジェクトにバッファリングの機能を実装します。公式ドキュメントも以下の通り:
Reader implements buffering for an io.Reader object.
バッファリングの恩恵についてはこちらの GopherCon の発表をご覧ください。ここでは発表の中で使われている単語数カウントのプログラムを借りつつ io.Reader
の便利さの面にアプローチしていきます。まず単語数カウントのプログラムを作成していきます。
package main
import (
"fmt"
"io"
"log"
"os"
"unicode"
)
var p [1]byte
func readbyte(r io.Reader) (rune, error) {
n, err := r.Read(p[:])
if n > 0 {
return rune(p[0]), nil
}
return 0, err
}
func main() {
filename := os.Args[1]
f, err := os.Open(filename)
if err != nil {
log.Fatalf("cannot open file %q: %v", filename, err)
}
defer f.Close()
words := 0
inword := false
for {
r, err := readbyte(f)
if unicode.IsSpace(r) {
if inword {
words++
}
inword = false
} else {
inword = true
}
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("read failed: %v", err)
}
}
fmt.Printf("%q: %d words\n", filename, words)
}
リンク先の発表のコードとは異なる部分がいくつかあります。
-
io.Reader
から1バイト読み込む際にエラーがあってもそのバイトをチェックする仕組みを追加 - 単語の定義を
wc
コマンドと統一
ベンチマークのために Moby Dick のテキストを使用します。
$ go build slow_wc.go
$ time ./slow_wc moby.txt
"moby.txt": 215822 words
./slow_wc moby.txt 0.61s user 0.76s system 97% cpu 1.404 total
$ time wc -w moby.txt
215822 moby.txt
wc -w moby.txt 0.01s user 0.00s system 77% cpu 0.019 total
wc
の100倍くらい時間がかかっています。これは *os.File
の Read
メソッドが呼び出されるたびにシステムコールが発生しているのが原因なので、バッファリングによってパフォーマンスの改善が期待されます。先程のコードを次のように変更します。
--- slow_wc.go 2019-11-24 03:40:14.000000000 +0900
+++ fast_wc.go 2019-11-24 03:40:17.000000000 +0900
@@ -1,6 +1,7 @@
package main
import (
+ "bufio"
"fmt"
"io"
"log"
@@ -26,11 +27,13 @@
}
defer f.Close()
+ b := bufio.NewReader(f)
+
words := 0
inword := false
for {
- r, err := readbyte(f)
+ r, err := readbyte(b)
if unicode.IsSpace(r) {
if inword {
words++
$ go build ./fast_wc.go
$ time ./fast_wc moby.txt
"moby.txt": 215822 words
./fast_wc moby.txt 0.03s user 0.01s system 87% cpu 0.045 total
wc
の2倍程度の実行時間まで落ちました。コードの変化としてはファイルオブジェクトを bufio.Reader
で包んで readbyte
関数に渡すオブジェクトを変更しただけですがここまでの差が出てきます。bufio.Reader
の中ではデフォルトで4096バイトごとに読み込みを行うので *os.File
の Read
メソッドの呼び出しが readbyte
の呼び出し4096回ごとに1回になったと捉えられます。
ここでポイントなのは readbyte
関数に一切手を加えなくて良かったことです。よく知っている方は bufio.Reader
には ReadByte
というメソッドがあるのでわざわざ readbyte
を使う必要がないじゃないかと仰るかもしれませんがそれはその通りです(※というかむしろこの例では unicode.IsSpace
を呼んでいるので本来は ReadRune
を使うべきです)。しかしその議論は本質的ではなく、ここで主張したいことは、io.Reader
を受け取ることを前提に設計されたAPIは非常に頑強であるということです。
上記の安全性も含めて記述するのであれば私であれば以下のようなコードを書くでしょう。
package main
import (
"bufio"
"fmt"
"io"
"log"
"os"
"unicode"
)
func wordcount(r io.Reader) (int, error) {
words, inword := 0, false
b := bufio.NewReader(r)
for {
c, _, err := b.ReadRune()
if unicode.IsSpace(c) {
if inword {
words++
}
inword = false
} else {
inword = true
}
if err == io.EOF {
return words, nil
}
if err != nil {
return 0, err
}
}
}
func main() {
filename := os.Args[1]
f, err := os.Open(filename)
if err != nil {
log.Fatalf("cannot open file %q: %v", filename, err)
}
defer f.Close()
words, err := wordcount(f)
if err != nil {
log.Fatalf("read failed: %v", err)
}
fmt.Printf("%q: %d words\n", filename, words)
}
このように単語数カウントを wordcount
という io.Reader
を受け取る関数にしてしまうことで、ファイルだけでなくHTTPのレスポンスボディを含むありとあらゆる io.Reader
に適用できるようになります。このように io.Reader
をうまく組み込んでいくことで io.Reader
を扱う様々なライブラリとシームレスに連携することができます。その1つの例が golang.org/x/text/transform です。
io.Readerへの操作
シンプルな例として io.Reader
から読み出す際に改行を落とすようなケースを考えます。golang.org/x/text/transform 内の transform.Reader
を使うと io.Reader
に transform.Transformer
を適用して様々な変換を行うことができます。文字単位で扱う場合には golang.org/x/text/runes 内に便利な transform.Transformer
がいくつか定義されているためこちらを利用します。
func IsNewline(r rune) bool { return r == '\n' }
func RemoveNewline(r io.Reader) io.Reader {
s := runes.Predicate(IsNewline)
t := runes.Remove(s)
return transform.NewReader(r, t)
}
たったこれだけであらゆる io.Reader
型のオブジェクトに RemoveNewline
を適用するだけで改行をすべて落とす io.Reader
を生成できます。
io.Readerは強力
ここまでお読みいただいたらもうお気づきかと思いますが、bufio.Reader
や transform.Reader
などは io.Reader
を加工しますがそれ自体がまた io.Reader
になっています。そのため、その他世の中に存在するありとあらゆる io.Reader
を要求する仕組みと連携することができるのです。このように io.Reader
は非常にシンプルで役割が明快でありつつ強力なよくデザインされたインターフェースなのです。
手前味噌にはなりますが、私が個人的に開発しているパーサコンビネータライブラリ pars では内部状態を表す pars.State
は io.Reader
を受け取って必要なときに必要なだけそこから読み出してパーサを適用する設計になっています。Goで書かれたパーサコンビネータライブラリの先駆者として直接のデザインキューとなっている vektah/goparsify やそのインスピレーションとなっている prataprc/goparsec などがありますが、いずれも文字列やバイトスライスを受け取るAPI設計になっています。今私が開発しているバイオインフォマティクスの分野では非常に巨大なファイルを扱うことがよくあるため、こういったAPIは微妙に使い勝手が悪いのです。加えて、vektah/goparsify では空白を無視する仕組みがパーサの状態の中に実装されていますが、io.Reader
を受け取る設計にすることでその必要はなくなります。文字列リテラル内の空白以外をすべて落とすような io.Reader
のマニピュレータを作って pars.State
に渡してあげれば、特別な仕組みをパーサ状態内に作り込むことなく簡単に空白を除いてパーサを適用するといったこともできるのです。
3. まとめ
io.Reader
は Read(p []byte) (n int, err error)
というメソッドを持った標準ライブラリに実装された特段何のひねりもない実にシンプルなインターフェースです。その反面、io.Reader
はGoという言語の哲学やインターフェースという機能の強力さをその身に宿した最強のインターフェースだと思っています。ここではカバーしませんでしたが対となるインターフェースである io.Writer
と合わせて「Goを語る上で欠かせない、言語仕様の外にある最も重要な概念」だと言ってしまっても過言ではないというのが私の持論です。
Goのインターフェースを使いこなすのは非常に難しいですが、コミュニティとしても広く受け入れられて使われている io.Reader
を積極的に使ってみることでその利便性に気がつけるという側面もあるのではないかと思います。Go初心者から脱却する第一歩としてぜひ io.Reader
を使い倒してみてください。