1. 1バイトずつコピーするのは遅い
もし、隣にバケツがあるのにティースプーンで水を運ぼうとしたことがあるなら、この講義の発想はすでに理解できています。1バイトずつの読み書きは技術的には可能ですが、ほとんどの場合、入出力システムへの呼び出し回数が膨大になります。I/O操作はメモリ上の「足し算」ではなく、「外側」(OS、ファイルシステム、ディスク)へ出ていく処理です。
「1バイト読んだらすぐ1バイト書く」という素朴なコードは、たいてい速度面で何倍も、場合によっては何十倍も負けます。理由は「コンピュータがバイトを嫌う」からではなく、小さなI/O操作を何百万回も行うのが嫌いだからです。私たちはI/O操作回数を減らし、たとえば8KBずつのようにブロック単位でデータを運びたいのです。
「やってはいけない例」のミニスケッチ(「禁じ手」ではなく、「SSDをいたわってください」という話です):
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
while (true) {
val b = input.read()
if (b == -1) break
output.write(b)
}
}
}
}
意味としては正しいコードですが、データの「一回あたりの量」が小さすぎます。
2. バッファとバッファリング: ByteArray vs Buffered...Stream
ここからが、このテーマでよくあるミスの半分を防いでくれる重要ポイントです。「バッファ」と言うとき、実は2つの別物を指していることがあります。両方は一緒に使われることも多いですが、解決している課題は別です。
1つ目: 自分たちのバッファ。これは読み込んだバイトを入れ、そこから書き出すための普通のByteArrayです。固定サイズのコンテナにすぎません。配列としてのロジック(インデックス、sizeなど)がそのまま当てはまります。ByteArrayはKotlinのprimitive配列(boxingなし)なので、「バイト単位でそのまま扱う」データにとても理にかなっています。
2つ目: ストリームのバッファリング。これはBufferedInputStreamやBufferedOutputStreamのようなラッパーで、内部で追加のバッファを持ち、基底ストリームへのアクセス回数を減らします。より「下の層」の最適化です。すでにブロックで読んでいても、バッファ付きストリームはさらに効率よくできる場合があります。
違いは次の表で整理すると分かりやすいです:
| 概念 | それは何か | どこにあるか | 何のためか |
|---|---|---|---|
|
作業用のバイト配列 | 自分のコード内 | 同じサイズのchunkで読み書きする |
|
ストリームのラッパー | Java I/Oの内部 | 基底ストリームを呼ぶ回数を減らす(I/O操作を減らす) |
つまり、ByteArrayは「ボウル」で、Buffered...Streamは「1個ずつではなくトレーでまとめて運んでくる給仕」のようなものです。
3. read(buffer)の契約: 戻り値n
chunkコピーを正しく動かすためには、シンプルですが非常に重要な事実を受け入れる必要があります: read(buffer)はバッファを必ずしも満杯にする義務がありません。
read(buffer)はIntを返します。この数値の意味は「実際に何バイト読んでbufferに入れたか」です。戻り値は:
- ファイルがまだ終わっていなくても、バッファサイズより小さくなることがあります;
- EOF(ストリーム終端)に到達すると-1になります。
ここでコピーの最重要不変条件が生まれます: 出力ストリームに書くのは、バッファ全体ではなく、きっちりnバイトだけです。
とても小さな例(ファイルなし)で発想を見てみましょう: ByteArrayInputStreamから4バイトずつ読みます。もちろん「おもちゃ」の入力元ですが、契約の理解には最適です。
import java.io.ByteArrayInputStream
fun main() {
val data = byteArrayOf(10, 20, 30, 40, 50)
val input = ByteArrayInputStream(data)
val buffer = ByteArray(4)
val n = input.read(buffer)
println("n=$n") // n=4
println(buffer[0]) // 10
}
2回目のreadをすると、残りは1バイトなので1を返します。これは正常です。だからこそ、nを無視してはいけません。
4. chunkコピーの定番ループ
ここからは「黄金テンプレート」です。理解するだけではなく、ほぼ自動的に書けるようになるのが理想です。手順はシンプルです:
- 固定サイズのByteArrayを作る(例: 8 * 1024)。
- ループでn = input.read(buffer)を読む。
- n == -1なら終了。
- そうでなければoutput.write(buffer, 0, n)で書く。
関数としての最小の正しい版は次のとおりです(mainに直書きするより便利です):
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
fun copyFileChunked(srcPath: String, dstPath: String) {
FileInputStream(srcPath).use { rawIn ->
FileOutputStream(dstPath).use { rawOut ->
BufferedInputStream(rawIn).use { input ->
BufferedOutputStream(rawOut).use { output ->
val buffer = ByteArray(8 * 1024)
while (true) {
val n = input.read(buffer)
if (n == -1) break
output.write(buffer, 0, n)
}
}
}
}
}
}
ここで2点に注目してください。1つ目は、use {}でリソースを閉じていることです。これはAutoCloseableを実装するものに対するKotlinのイディオムで、内部でエラーが起きてもストリームが確実にクローズされます。2つ目は、Buffered...Streamを使っていても、それで自分のバッファを置き換えるわけではないという点です。読み込み先はあくまでByteArrayです。
5. なぜoutput.write(buffer)はよくあるミスなのか
この節は初心者が「でもバッファを埋めたんだから全部書けばいいのでは?」と驚きがちです。なぜダメなのかを整理します。
output.write(buffer)は配列全体、つまりbuffer.sizeバイトを書き込みます。しかし、read(buffer)が読んだのはnバイトだけかもしれません。残りの要素は、前のループの値が残っていたり、あるいは単に「古い値」(配列の以前の内容)だったりします。結果としてファイル末尾に余計なバイトが書き足され、コピーが破損します。
悪い例(意図的にミスを入れています):
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val buffer = ByteArray(1024)
val n = input.read(buffer)
if (n != -1) output.write(buffer) // ERROR: writes all 1024 bytes
}
}
}
正しいのはwrite(buffer, 0, n)です。
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val buffer = ByteArray(1024)
val n = input.read(buffer)
if (n != -1) output.write(buffer, 0, n) // write only what was read
}
}
}
なぜこのミスが「たまに見つからない」のでしょうか。ファイルサイズがバッファサイズのちょうど倍数だと、最後のブロックも満杯になり、バグが隠れてしまうからです。「金曜日にだけ落ちる」系のバグに近い、開発者が特に好きなジャンルです。
6. ミニユーティリティにコピーを組み込む
余計な魔法なしのシンプルなmain
例がバラバラの断片にならないように、簡単なコンソールユーティリティを育てているとしましょう。仮にFileToolと呼びます。目的は1つ、ファイルをAからBへコピーすることです。
今日はCLIアーキテクチャの話ではないので、複雑なコマンドや引数パース、リッチなUXはなしです。2つのパスを取り、コピーを実行するだけです。
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("out.bin")
copyFileChunked(src.path, dst.path)
println("Done. size=${dst.length()} bytes") // Done. size=...
}
この版では、copyFileChunkedがuseでストリームを正しく閉じることに依存しています。なお、File.length()は簡単な一次チェックです: ファイルがコピーされ、何らかのサイズになっているか。とはいえ「何らかのサイズ」はデータの正しさの保証ではなく、「少なくとも何かを書いた」ことを素早く示すサインにすぎません。
簡易チェック: サイズとcontentEquals
コピーが終わったら「完了」と言ってお茶を飲みに行きたくなります。しかし、ときにはコピーが元と似ているかを最低限確認するのも有益です。
最も速くて安いテストは、サイズ比較です:
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("out.bin")
println(src.length() == dst.length()) // true/false
}
ただし、サイズが同じでもバイト列が同じとは限りません。異なる2つのファイルが同じサイズであることは普通にあります。ファイルが小さいなら、メモリ上で内容比較もできます。重要なのは、ギガバイト級ファイルでこれをやろうとしないことです。
Kotlinで配列を比較するなら、==ではなくcontentEqualsを使います。
import java.io.File
fun main() {
val a = File("in.bin").readBytes()
val b = File("out.bin").readBytes()
println(a.contentEquals(b)) // true/false
}
このチェックはバイト単位で一致を確認できますが、両方のファイルをメモリに読み込むコストがかかります。したがって小さな例や学習用シナリオのテストには良い一方で、「何でもかんでもこれで」という万能戦略にはなりません。
7. バッファサイズとデータフロー図
バッファサイズ: 妥当な値
バッファサイズについては、まるで「永遠に正しい唯一の値」があるかのように質問されがちです。結論から言うと、ありません。多くの状況でうまく動く妥当な値はありますし、たいてい意味がない極端な値もあります。
8 * 1024(8KB)がよく選ばれるのはなぜでしょうか。十分大きなchunkで反復回数を減らしつつ、メモリを膨らませすぎず、キャッシュとの相性も悪くしにくい、という良い妥協点だからです。実際には4KB、8KB、16KB、64KBなどもよく見かけ、これらはどれも妥当になりえます。
バッファが小さすぎる(例: 1バイトや16バイト)と、read/writeの回数が増えて速度で負けます。大きすぎる(例: 数十MB)と、メモリを消費する一方で、必ずしも速度が上がりません。結局ディスク/ファイルシステムの速度がボトルネックになったり、巨大配列がキャッシュやGCに優しくなかったりします。
有益な習慣として、バッファサイズを定数にして「1か所に集める」と読みやすくなります。
private const val DEFAULT_BUFFER_SIZE_BYTES = 8 * 1024
fun makeBuffer(): ByteArray = ByteArray(DEFAULT_BUFFER_SIZE_BYTES)
そしてコピー処理ではmakeBuffer()を使います。コードは少し長くなりますが、「なぜ8192なのか」という意図が明確になります。
chunkコピーにおけるデータフロー図
頭の中が「テンプレートを書いて祈っているだけ」という感覚にならないように、プロセスを一度図として見ると理解が安定します。
flowchart TD
A[ソースファイル] --> B[FileInputStream]
B --> C[BufferedInputStream]
C --> D[ByteArray buffer]
D --> E[BufferedOutputStream]
E --> F[FileOutputStream]
F --> G[宛先ファイル]
重要な考え方: ByteArray bufferは「メモリ上のアーカイブ」ではなく、ループ1回分の作業領域にすぎません。chunkを読み、chunkを書き、同じ配列を何度も再利用します。
8. chunkコピーでよくあるミス
ミス1: nを無視してバッファ全体を書いてしまう。
最も多い問題です: read(buffer)が137バイトしか読んでいないのに、8192バイトを書いてしまう。するとファイル末尾にバッファの古い値による「尻尾」が付いてきます。特に厄介なのは、ファイルサイズがバッファの倍数だと出ないことがあり、別のファイルでは確実に壊れる点です。
ミス2: read(buffer)を1回呼べばコピーできると思い込む。
ストリームは、1回のreadでファイル全体を渡す義務はありません。たとえファイルが小さくても、正しいモデルは「EOFが来るまで少しずつ読む」です。1回のreadは「冒頭を読んだ」にすぎないことがあります。
ミス3: 「自分のバッファ」とBufferedInputStreamを混同する。
BufferedInputStreamで包んだからByteArrayは不要だ、と誤解することがあります。しかしBufferedInputStreamは「chunkで読んでchunkで書く」というあなたの仕事をなくしません。基底ストリームへのアクセス回数を内部で減らし、効率化するだけです。
ミス4: ストリームを閉じない(または適当に閉じる)。
ファイルストリームはリソースなので、確実にクローズする必要があります。Kotlinではuse {}があり、ブロック内で使ったあと、自動的に(例外が起きても)クローズされます。閉じないと、未書き込みデータ、ファイルディスクリプタのリーク、「たまに動く/たまに動かない」といった奇妙な挙動につながります。
ミス5: 配列の等価比較に==を使う。
テストで2つのByteArrayを比較したいならcontentEqualsを使ってください。Kotlinの==は配列に対しては内容ではなく、「同じオブジェクトかどうか」を見ます。内容比較のためにcontentEqualsが用意されています。
ミス6: 「メモ帳で開いて見た」でコピーの正しさを確認しようとする。
バイナリファイルをテキストエディタで開くと、ほとんどの場合は文字化けのように見えますが、それが正常です。「開いてみて、たぶん似てる」では検証になりません。小さなファイルならcontentEquals、大きいファイルなら少なくともサイズ比較、さらに良いのは専用の検証戦略ですが(これは別の工学的トピックなので、今日は踏み込みません)。
GO TO FULL VERSION