Chapter 36

いろんなファイルを読み込もう / Goのファイル読み込み

eihigh
eihigh
2025.01.31に更新

画像、音楽、フォント、シナリオなど、プログラムの外部にあるファイルを読み込む機会は大変多いです。画像を読み込んで表示しようでは便利関数 ebitenutil.NewImageFromFile を使って画像を読み込みましたが、いつもこの手の便利関数があるとは限りません。ebitenutil を使わず自力でファイルを読み込む方法も抑えておいた方がよいでしょう。

ebitenutil を使わず画像を読み込む

bg.jpg を読み込んで bg *ebiten.Image に代入するまでのプログラムを、ebitenutil を使わない形に書き換えるとこのようになります。

-	// 画像を読み込む
-	// img, _, err := ebitenutil.NewImageFromFile("bg.jpg")
-	// if err != nil {
-	// 	return nil, err
-	// }
+	f, err := os.Open("bg.jpg")
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	i, _, err := image.Decode(f)
+	if err != nil {
+		return nil, err
+	}
+	img := ebiten.NewImageFromImage(i)
+	g.bg = img

実行したら以前と同じように背景が描画されているはずです。順を追って見ていきましょう。

ファイルを読み込むには、まず最初に os.Open 関数でファイルを「開く」必要があります。ファイルが存在しないなど、開けなかった場合はエラーを返します。

	f, err := os.Open("bg.jpg")
	if err != nil {
		return nil, err
	}
	defer f.Close()

開いたファイルは使い終わったら必ず Close メソッドで閉じましょう。ファイルは一度に開いておける数に上限があったり、開いているファイルは他のプログラムから開けなくなったりするので、後片付けは大事です。

defer は関数の終わりに処理を実行するよう予約する機能です。つまり f.Close() はここではなくnewGame関数の終了時に実行されるようになります。これで後片付けはOKです。

ファイルを開いたら、image パッケージの Decode 関数を使い、圧縮されたファイルのデータを元の画像にデコード(復号)します。返り値にはデコードした結果の image.Image と、"png" や "jpeg" などの画像形式を表す文字列(今回使わない)と、エラーが返ります。

	i, _, err := image.Decode(f)
	if err != nil {
		return nil, err
	}

image.Decode 関数は画像形式を自動判別してくれますが、そのためには事前に import _ "image/png" のようにデコードに必要なパッケージをブランクインポートする必要があります。

最後に、ebiten.NewImageFromImage 関数を使って画像を GPU に転送します。これで画像を表示する準備が整いました。

	img := ebiten.NewImageFromImage(i)

以前も述べた通り、ファイルを開き、画像をデコードし、GPUに転送する一連の処理はかなり重い処理なので、必ず一度読み込んだ *ebiten.Image は使いまわしましょう。

以上が便利関数なしで画像を読み込む方法でした。

io.Reader

実は image.Decode 関数の引数は io.Reader というインターフェース型です。

func Decode(r io.Reader) (Image, string, error)

具体的にはこんなインターフェースです。

type Reader interface {
	Read(p []byte) (n int, err error)
}

Read メソッドの引数と返り値の意味はかなり深いのでちゃんと説明するのは難しいのですが、とにかく、io.Reader はファイルのように読み込めるもの全てに共通するインターフェースであることが重要で、実に様々なファイル的なものをこのインターフェースを通じて扱うことができます。実際にやってみて体感しましょう。

embed に対応する

ゲームを配布して遊んでもらおう / Goのビルドと配布で紹介した、embed で実行ファイルに埋め込む方法を利用してみます。

//go:embed bg.jpg
var fsys embed.FS
-	// f, err := os.Open("bg.jpg")
+	f, err := fsys.Open("bg.jpg")
 	if err != nil {
 		return nil, err
 	}
 	defer f.Close()
 	i, _, err := image.Decode(f)
 	if err != nil {
 		return nil, err
 	}
 	img := ebiten.NewImageFromImage(i)
 	g.bg = img

なんと、os.Open の代わりに fsys.Open を使うところしか変更がありません。これは os.Open の返り値も fsys.Open の返り値も io.Reader インターフェースを満たしており、どちらも image.Decode に渡せるからです。

インターネットからのダウンロードに対応する

この調子でさらにいろんな読み込み方法に対応してみましょう。Goならインターネット上から画像を読み込むのも簡単です。net/http パッケージの http.Get 関数を使います。

+	resp, err := http.Get("https://eihigh.pages.dev/gopher.png")
 	if err != nil {
 		return nil, err
 	}
+	defer resp.Body.Close()
+	i, _, err := image.Decode(resp.Body)
 	if err != nil {
 		return nil, err
 	}
 	img := ebiten.NewImageFromImage(i)
 	g.bg = img

インターネット上からデータを読み込むには、応答/responseのうち Body (本体)フィールドを使って読み込みますが、それ以外はやはり同じ流れですね。インターネットにアクセスするのが簡単すぎて夢が広がります。

外部とのデータの入出力をI/O(Input/Outputの略)と言いますが、GoはこのI/O関連がかなり気合を入れて整備されておりかなり便利です。深いところに興味のある方はGoならわかるシステムプログラミングなど読んでみると面白いでしょう。

まとめ

Goで頻繁に登場するファイルを読み込む処理について学びました。おさらいしましょう。

  • os.Open 関数でファイルを開く。
  • ファイルは使い終わったら f.Close() で必ず閉じる。defer で関数の最後に実行するように予約する。
  • io.Reader インターフェースを経由して実に様々なファイル的なものを同じように扱える。

これも何度となく出てくる系のプログラムなので、書いているうちに馴染んでくると思います。

詳しい解説

for文中のdeferにご用心

defer は「関数の最後に」処理を実行するよう予約する機能なので、for文とうっかり組み合わせると好ましくない状況になります。

	for _, name := range tooManyNames {
		f, err := os.Open(name)
		if err != nil {
			return nil, err
		}
		defer f.Close()
		// ...
	}

このプログラムでは、ファイル f はループ内でしか使わないにも関わらず、tooManyNames の数分のファイルが開かれたままになり、関数が終わる時にまとめて f.Close() されてしまいます。この数が少ないうちは特に問題にならないかもしれませんが、大量になった時初めて問題が起きうるというのもまた厄介です。

この問題を回避するには、defer を使わず、必要なタイミングで普通に f.Close() するよう書き換えるか、あるいは ebitenutil.NewImageFromFile のようにファイル一個を使う処理を関数に切り出して、その中で defer を使って書くか、になります。どっちを選ぶかは状況にも好みにもよるのでなんとも言えませんが、とにかく for の中での defer は避けましょう。