CodeGym /コース /Kotlin SELF /バッファリングとchunkコピー

バッファリングとchunkコピー

Kotlin SELF
レベル 43, レッスン 3
使用可能

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のようなラッパーで、内部で追加のバッファを持ち、基底ストリームへのアクセス回数を減らします。より「下の層」の最適化です。すでにブロックで読んでいても、バッファ付きストリームはさらに効率よくできる場合があります。

違いは次の表で整理すると分かりやすいです:

概念 それは何か どこにあるか 何のためか
ByteArray buffer
作業用のバイト配列 自分のコード内 同じサイズのchunkで読み書きする
BufferedInputStream/BufferedOutputStream
ストリームのラッパー 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コピーの定番ループ

ここからは「黄金テンプレート」です。理解するだけではなく、ほぼ自動的に書けるようになるのが理想です。手順はシンプルです:

  1. 固定サイズのByteArrayを作る(例: 8 * 1024)。
  2. ループでn = input.read(buffer)を読む。
  3. n == -1なら終了。
  4. そうでなければ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、大きいファイルなら少なくともサイズ比較、さらに良いのは専用の検証戦略ですが(これは別の工学的トピックなので、今日は踏み込みません)。

1
タスク
Kotlin SELF, レベル 43, レッスン 3
ロック未解除
バッファの2回読み取り
バッファの2回読み取り
1
タスク
Kotlin SELF, レベル 43, レッスン 3
ロック未解除
チャンク単位のコピー
チャンク単位のコピー
1
タスク
Kotlin SELF, レベル 43, レッスン 3
ロック未解除
末尾の破損を防ぐ
末尾の破損を防ぐ
1
タスク
Kotlin SELF, レベル 43, レッスン 3
ロック未解除
読み取り回数カウンタ
読み取り回数カウンタ
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION