LoginSignup
238
136

More than 1 year has passed since last update.

io.Readerをすこれ

Last updated at Posted at 2019-11-30

0. なんで io.Reader?

去年のアドベントカレンダーでは Go4 までだったのが今年はなんと Go7 までできており、Go への関心が高まっているのはいち Go 好きとしてはうれしい限りです。Go の良さは色々なところにあります、それは例えばシンプルな言語仕様だったり、標準ライブラリの充実度だったり、様々なサポートツール(go getgo vetgoreturnsgorename など)だったり。こういった側面はしばしばGoの良さとして語られますし私自身もそれには同意します。一方でそれらのわかりやすい利点の裏に隠れてしまっている影の立役者がいると思っています。そう、それこそが io.Reader です。io.Reader はその使い方を正しく理解するだけでGoのインターフェースという仕組みの強力さがわかる素晴らしいインターフェースのお手本であると思っています。

1. io.Readerって?

一言で言ってしまえばバイト列を読み出すためのインターフェースです。Go を書いたことがある方はどこかで一度はコードの中で io.Reader に触れたことがあると思います。たとえその覚えがなくても io.Reader はGoを書いていれば様々な場所で出てきます。例えばファイル入力のとき、f, err := os.Open(filename) の返り値 f*os.File という型ですが、この*os.Fileio.Reader インターフェースを持っています。他に頻出する例としては http.ResponseBody メンバが io.ReadCloser というインターフェースを持っていますがこれは io.Readerio.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 メソッドが何をするか(することを期待されるか)を手続き的にご説明します。

  1. 与えられたバイトスライス p []byte を先頭から埋めていく。
  2. 埋まったバイト数 n と、埋める過程で発生したエラー err を返す。
  3. n > 0 であっても、err != nil である可能性がある。
  4. (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)
}
  1. Read メソッドは可能な限り渡されたスライスの全長を埋めることを期待されるため、あらかじめ読みたいバイト数分のサイズを持ったスライスを作成しておきます。
  2. スライスの実態はポインタなのでこれをそのまま Read メソッドに渡すと中でこのスライスの中身を埋めてくれます。
  3. Readでエラーが発生していても n > 0 バイト埋められている可能性があるため先にその処理を行います。Goは多くの場合真っ先にエラー処理をすることが多いですが、Read メソッドの返り値処理はその数少ない例外と言えるでしょう。ここでは要求したバイト数が読めなかったため log.Fatalf を呼んでいますが、場合によっては p[:n] でなにかの処理をすることも想定できるでしょう(バッファリングなど)。
  4. 最後にエラーを処理します。このとき**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.Readerio.Reader を満たした構造体です。パッケージ名の bufio が示唆する通り bufio.Readerio.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)
}

リンク先の発表のコードとは異なる部分がいくつかあります。

  1. io.Reader から1バイト読み込む際にエラーがあってもそのバイトをチェックする仕組みを追加
  2. 単語の定義を 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.FileRead メソッドが呼び出されるたびにシステムコールが発生しているのが原因なので、バッファリングによってパフォーマンスの改善が期待されます。先程のコードを次のように変更します。

--- 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.FileRead メソッドの呼び出しが 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.Readertransform.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.Readertransform.Reader などは io.Reader を加工しますがそれ自体がまた io.Reader になっています。そのため、その他世の中に存在するありとあらゆる io.Reader を要求する仕組みと連携することができるのです。このように io.Reader は非常にシンプルで役割が明快でありつつ強力なよくデザインされたインターフェースなのです。

手前味噌にはなりますが、私が個人的に開発しているパーサコンビネータライブラリ pars では内部状態を表す pars.Stateio.Reader を受け取って必要なときに必要なだけそこから読み出してパーサを適用する設計になっています。Goで書かれたパーサコンビネータライブラリの先駆者として直接のデザインキューとなっている vektah/goparsify やそのインスピレーションとなっている prataprc/goparsec などがありますが、いずれも文字列やバイトスライスを受け取るAPI設計になっています。今私が開発しているバイオインフォマティクスの分野では非常に巨大なファイルを扱うことがよくあるため、こういったAPIは微妙に使い勝手が悪いのです。加えて、vektah/goparsify では空白を無視する仕組みがパーサの状態の中に実装されていますが、io.Reader を受け取る設計にすることでその必要はなくなります。文字列リテラル内の空白以外をすべて落とすような io.Reader のマニピュレータを作って pars.State に渡してあげれば、特別な仕組みをパーサ状態内に作り込むことなく簡単に空白を除いてパーサを適用するといったこともできるのです。

3. まとめ

io.ReaderRead(p []byte) (n int, err error) というメソッドを持った標準ライブラリに実装された特段何のひねりもない実にシンプルなインターフェースです。その反面、io.ReaderGoという言語の哲学インターフェースという機能の強力さをその身に宿した最強のインターフェースだと思っています。ここではカバーしませんでしたが対となるインターフェースである io.Writer と合わせて「Goを語る上で欠かせない、言語仕様の外にある最も重要な概念」だと言ってしまっても過言ではないというのが私の持論です。

Goのインターフェースを使いこなすのは非常に難しいですが、コミュニティとしても広く受け入れられて使われている io.Reader を積極的に使ってみることでその利便性に気がつけるという側面もあるのではないかと思います。Go初心者から脱却する第一歩としてぜひ io.Reader を使い倒してみてください。

  1. io.Reader を知り
  2. io.Reader を使い
  3. io.Reader で作りましょう
238
136
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
238
136