C# Tips | ファイル・ディレクトリ操作:ファイル内容比較

C sharp C#
スポンサーリンク

はじめに 「同じファイルか?」は業務でめちゃくちゃ重要

業務システムを書いていると、こんなことを知りたくなる場面がよくあります。

  • バックアップ前:「前回のバックアップと中身が同じなら、コピーをスキップしたい」
  • 同期処理:「サーバー上のファイルとローカルのファイルが同じかどうか確認したい」
  • インポート処理:「前回と同じファイルをまた取り込もうとしていないかチェックしたい」

ここで必要になるのが「ファイル内容比較」です。
単に「ファイル名が同じ」「更新日時が同じ」では不十分で、「中身が本当に同じか」を確認したいわけです。

C# では、ファイルの内容を読みながら比較することで、「完全一致かどうか」を判定できます。
ここでは、初心者向けに「一番シンプルな比較」「効率を意識した比較」「ハッシュを使う比較」などを、例題付きで丁寧に解説していきます。


基本の考え方 「サイズ→中身」の順で見る

いきなり全部読むのはもったいない

ファイル内容を比較するとき、いきなり全部読み込んで比較してもいいのですが、
大きなファイルだと時間もメモリももったいないです。

そこで、まずは「サイズ(バイト数)」を見て、違っていたら即座に「別物」と判断するのが定番です。

  • サイズが違う → 中身も絶対違う → 比較終了
  • サイズが同じ → 中身を詳しく比較する

この「サイズチェック → 内容比較」の二段構えを基本パターンとして覚えておくと、効率のよいコードが書けます。


一番シンプルな実装 バイト列を順番に比較する

ファイルをストリームで開いて、少しずつ読みながら比較

まずは「バイナリファイルでもテキストファイルでも使える、汎用的な比較」の実装を見てみましょう。

using System;
using System.IO;

public static class FileCompareUtil
{
    public static bool AreFilesEqual(string path1, string path2)
    {
        if (string.Equals(path1, path2, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        var fileInfo1 = new FileInfo(path1);
        var fileInfo2 = new FileInfo(path2);

        if (!fileInfo1.Exists || !fileInfo2.Exists)
        {
            return false;
        }

        if (fileInfo1.Length != fileInfo2.Length)
        {
            return false;
        }

        const int bufferSize = 8192;

        using (var fs1 = new FileStream(path1, FileMode.Open, FileAccess.Read, FileShare.Read))
        using (var fs2 = new FileStream(path2, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            var buffer1 = new byte[bufferSize];
            var buffer2 = new byte[bufferSize];

            while (true)
            {
                int read1 = fs1.Read(buffer1, 0, bufferSize);
                int read2 = fs2.Read(buffer2, 0, bufferSize);

                if (read1 != read2)
                {
                    return false;
                }

                if (read1 == 0)
                {
                    return true;
                }

                for (int i = 0; i < read1; i++)
                {
                    if (buffer1[i] != buffer2[i])
                    {
                        return false;
                    }
                }
            }
        }
    }
}
C#

重要ポイントをかみ砕いて解説

サイズチェックで無駄な比較を避ける

FileInfo.Length でファイルサイズ(バイト数)を取得しています。
ここが違っていたら、その時点で false を返しています。

これは「大きなファイルほど効いてくる」最適化です。
サイズが違うのに中身を読み始めるのは、時間の無駄です。

バッファサイズを決めて「少しずつ」読む

bufferSize = 8192 として、8KB ずつ読み込んでいます。

  • 一度に全部読み込む → メモリをたくさん使う
  • 少しずつ読み込む → メモリは少なくて済むが、ループ回数は増える

ここでは「メモリを節約しつつ、そこそこ効率よく」というバランスで 8KB を選んでいます。
実務では、ファイルサイズや環境に応じて 4KB〜64KB くらいの範囲で調整することが多いです。

読み込んだバイト数が 0 になったら終わり

Read の戻り値が 0 のとき、「ファイルの終端に達した」ことを意味します。
ここまで一度も違いが見つからなければ、「完全に同じ内容」と判断して true を返します。

途中で違いを見つけたら即座に false

バイト配列をループしながら、一つでも違うバイトを見つけたら false を返しています。
「違いを見つけた時点で終了する」ことで、無駄な読み込みを避けています。


テキストファイルを「文字列として」比較したい場合

エンコーディングを意識する必要がある

テキストファイル(CSV、ログ、設定ファイルなど)を比較するとき、
「バイト列として同じか」ではなく「文字列として同じか」を見たいこともあります。

この場合、エンコーディング(UTF-8、Shift_JIS など) を意識する必要があります。

using System;
using System.IO;
using System.Text;

public static class TextFileCompareUtil
{
    public static bool AreTextFilesEqual(string path1, string path2, Encoding encoding)
    {
        if (!File.Exists(path1) || !File.Exists(path2))
        {
            return false;
        }

        using (var reader1 = new StreamReader(path1, encoding, detectEncodingFromByteOrderMarks: true))
        using (var reader2 = new StreamReader(path2, encoding, detectEncodingFromByteOrderMarks: true))
        {
            while (true)
            {
                string? line1 = reader1.ReadLine();
                string? line2 = reader2.ReadLine();

                if (line1 == null && line2 == null)
                {
                    return true;
                }

                if (line1 == null || line2 == null)
                {
                    return false;
                }

                if (!string.Equals(line1, line2, StringComparison.Ordinal))
                {
                    return false;
                }
            }
        }
    }
}
C#

どこがバイナリ比較と違うのか

  • 行単位で読むReadLine を使って、1 行ずつ読み込んでいます。
  • 文字列として比較string.Equals で文字列として比較しています。
  • エンコーディング指定Encoding を引数で受け取り、そのエンコーディングでデコードしています。

「改行コードの違い(CRLF と LF)」や「末尾の空行の有無」などをどう扱うかは、要件次第です。
厳密に比較したいなら今のようにそのまま比較、
「改行コードの違いは無視したい」なら、正規化してから比較する、といった工夫が必要になります。


ハッシュを使った比較(MD5/SHA-256 など)

「何度も比較する」ならハッシュが効いてくる

同じファイルを何度も比較する場合、毎回全バイトを読み込むのは非効率です。
そこで、「一度ハッシュ値(要約)を計算しておいて、それを比較する」という方法があります。

ハッシュとは、「ファイルの内容から計算される固定長の値」で、
内容が少しでも違えば、ハッシュ値も変わるように設計されています。

using System;
using System.IO;
using System.Security.Cryptography;

public static class FileHashUtil
{
    public static byte[] ComputeSha256(string path)
    {
        using (var sha = SHA256.Create())
        using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            return sha.ComputeHash(stream);
        }
    }

    public static bool AreFilesEqualByHash(string path1, string path2)
    {
        if (!File.Exists(path1) || !File.Exists(path2))
        {
            return false;
        }

        var hash1 = ComputeSha256(path1);
        var hash2 = ComputeSha256(path2);

        if (hash1.Length != hash2.Length)
        {
            return false;
        }

        for (int i = 0; i < hash1.Length; i++)
        {
            if (hash1[i] != hash2[i])
            {
                return false;
            }
        }

        return true;
    }
}
C#

ハッシュ比較のメリット・デメリット

メリット

  • 一度ハッシュを計算して保存しておけば、次回は「ハッシュ値同士の比較」だけで済む。
  • ネットワーク越しに「ハッシュだけ送る」ことで、内容一致を確認できる。

デメリット

  • ハッシュを計算するときには、結局全バイトを読む必要がある。
  • 理論上、ごくまれに「違う内容なのに同じハッシュになる(衝突)」可能性がある(SHA-256 なら実務上ほぼ無視できるレベル)。

「毎回 1 回だけ比較する」なら、ハッシュを使うメリットはあまりありません。
「同じファイルを何度も比較する」「別システムとハッシュで照合する」といった場面で威力を発揮します。


実務で使える「ファイル内容比較」ユーティリティ設計

汎用バイナリ比較ユーティリティ

まずは「バイナリとして完全一致かどうか」を判定する汎用ユーティリティを 1 個持っておくと便利です。

public static class FileContentComparer
{
    public static bool AreEqual(string path1, string path2)
    {
        return FileCompareUtil.AreFilesEqual(path1, path2);
    }
}
C#

呼び出し側は、こう書くだけで済みます。

string a = @"C:\data\a.bin";
string b = @"C:\data\b.bin";

if (FileContentComparer.AreEqual(a, b))
{
    Console.WriteLine("内容は完全に一致しています。");
}
else
{
    Console.WriteLine("内容が異なります。");
}
C#

テキスト専用の比較ユーティリティ

テキストファイルが多いプロジェクトなら、「エンコーディング込み」の比較ユーティリティも用意しておくとよいです。

public static class TextFileContentComparer
{
    public static bool AreEqualUtf8(string path1, string path2)
    {
        return TextFileCompareUtil.AreTextFilesEqual(path1, path2, Encoding.UTF8);
    }

    public static bool AreEqualShiftJis(string path1, string path2)
    {
        return TextFileCompareUtil.AreTextFilesEqual(path1, path2, Encoding.GetEncoding("shift_jis"));
    }
}
C#

「このプロジェクトでは基本 UTF-8」というルールがあるなら、AreEqualUtf8 だけを使うように統一すると、バグが減ります。


例外とエラー処理をどう考えるか

比較中に起こりうる問題

ファイル内容比較の途中で、次のような問題が起こる可能性があります。

  • ファイルが途中で削除された
  • 他プロセスにロックされて開けなかった
  • 権限がなくて読めなかった
  • パスが不正、または長すぎる

ユーティリティの設計としては、次のような方針を決める必要があります。

  • 例外をそのまま上に投げて、呼び出し側でログ+リトライする
  • 例外をキャッチして false を返し、「比較できなかった=一致しない」とみなす
  • 戻り値を「結果+ステータス」にして、「比較失敗」と「不一致」を区別する

初心者向けには、まずは「例外はそのまま投げる」設計のほうが分かりやすいです。
業務で使うときに、「比較できなかった場合をどう扱うか」をチームで決めてから、ユーティリティを拡張していくとよいです。


まとめ 実務で使える「ファイル内容比較」ユーティリティの考え方

ファイル内容比較は、「バックアップ」「同期」「重複チェック」など、業務のあちこちで顔を出す地味だけど重要な処理です。

押さえておきたいポイントは次の通りです。

  • まずサイズを比較し、違っていれば即「別物」と判断する。
  • サイズが同じなら、ストリームで少しずつ読みながらバイト単位で比較する。
  • テキストファイルを「文字列として」比較したい場合は、エンコーディングを明示して行単位比較などを行う。
  • 何度も比較するなら、ハッシュ(SHA-256 など)を使う設計も検討する。
  • 例外やエラー(ロック・権限・削除など)をどう扱うかを決めてから、ユーティリティの戻り値やログ設計を固める。

ここまで押さえておけば、「なんとなく同じっぽい」ではなく、「内容が完全に同じかどうか」を自信を持って判定できるようになります。

タイトルとURLをコピーしました