はじめに 「違うかどうか」から一歩進んで「どこが違うか」
前回の「ファイル内容比較」は、
「同じか」「違うか」を true / false で判定する話でした。
でも、業務ではそれだけでは足りない場面がよくあります。
「どこが違うのか知りたい」
「どの行が変わったのか知りたい」
「どのファイルが追加・削除・変更されたのか知りたい」
こういうときに必要になるのが「ファイル差分検出」です。
ここでは、プログラミング初心者向けに、まずはシンプルな差分検出から始めて、
実務でよく使う「テキスト差分」「ディレクトリ差分」まで、段階的に解説していきます。
基本の考え方 「差分検出」と「一致判定」の違い
「一致判定」は、「同じかどうか」だけが分かればよい処理です。
一方、「差分検出」は、「どこが違うのか」「何が変わったのか」まで知りたい処理です。
例えば、次のようなイメージです。
一致判定
「この 2 つのファイルは同じです」または「違います」
差分検出
「3 行目が違います」
「A.txt は削除され、B.txt は追加されました」
「同じファイル名だが内容が変わっています」
つまり、「一致判定」は yes/no の世界、「差分検出」は「差分の内容」を扱う世界です。
当然、後者のほうが情報量が多く、実装も少し複雑になります。
バイナリファイルの差分検出 「最初に違う位置」を見つける
どこで違い始めるかを知る
バイナリファイル(画像、PDF、バイナリデータなど)の場合、
「どのバイト位置から違うのか」を知るだけでも役に立つことがあります。
例えば、「途中までは同じだが、後半だけ違う」といった状況をログに残したい場合です。
まずは、「最初に違いが出るオフセット(バイト位置)」を返すユーティリティを書いてみます。
using System;
using System.IO;
public static class BinaryDiffUtil
{
public static long FindFirstDifferenceOffset(string path1, string path2)
{
var file1 = new FileInfo(path1);
var file2 = new FileInfo(path2);
if (!file1.Exists || !file2.Exists)
{
throw new FileNotFoundException("どちらかのファイルが存在しません。");
}
long minLength = Math.Min(file1.Length, file2.Length);
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];
long offset = 0;
while (offset < minLength)
{
int toRead = (int)Math.Min(bufferSize, minLength - offset);
int read1 = fs1.Read(buffer1, 0, toRead);
int read2 = fs2.Read(buffer2, 0, toRead);
if (read1 != read2)
{
return offset;
}
for (int i = 0; i < read1; i++)
{
if (buffer1[i] != buffer2[i])
{
return offset + i;
}
}
offset += read1;
}
if (file1.Length != file2.Length)
{
return minLength;
}
return -1;
}
}
}
C#このメソッドは、次のような意味を持ちます。
戻り値が -1 のとき
内容もサイズも完全に一致している。
戻り値が 0 以上のとき
そのバイト位置から違いが始まっている。
例えば、次のように使えます。
string a = @"C:\data\a.bin";
string b = @"C:\data\b.bin";
long diffOffset = BinaryDiffUtil.FindFirstDifferenceOffset(a, b);
if (diffOffset < 0)
{
Console.WriteLine("完全に一致しています。");
}
else
{
Console.WriteLine($"最初の差分は {diffOffset} バイト目にあります。");
}
C#ここでの重要ポイントは、「全部の差分を取るのではなく、まずは“最初の差分”だけに絞る」と割り切っていることです。
これだけでも、「どの程度違うのか」を把握するには十分なことが多いです。
テキストファイルの差分検出 「どの行が違うか」を知る
行単位で比較して、差分行を列挙する
CSV や設定ファイル、ログなど、テキストファイルの差分は「行単位」で見ることが多いです。
ここでは、「どの行番号で内容が違うか」を検出するシンプルなユーティリティを書いてみます。
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
public sealed class TextLineDiff
{
public int LineNumber { get; }
public string? Left { get; }
public string? Right { get; }
public TextLineDiff(int lineNumber, string? left, string? right)
{
LineNumber = lineNumber;
Left = left;
Right = right;
}
}
public static class TextDiffUtil
{
public static IEnumerable<TextLineDiff> GetLineDiffs(
string path1,
string path2,
Encoding encoding)
{
using var reader1 = new StreamReader(path1, encoding, detectEncodingFromByteOrderMarks: true);
using var reader2 = new StreamReader(path2, encoding, detectEncodingFromByteOrderMarks: true);
int lineNumber = 0;
while (true)
{
string? line1 = reader1.ReadLine();
string? line2 = reader2.ReadLine();
lineNumber++;
if (line1 == null && line2 == null)
{
yield break;
}
if (!string.Equals(line1, line2, StringComparison.Ordinal))
{
yield return new TextLineDiff(lineNumber, line1, line2);
}
}
}
}
C#使い方の例は次の通りです。
string oldFile = @"C:\data\old.csv";
string newFile = @"C:\data\new.csv";
foreach (var diff in TextDiffUtil.GetLineDiffs(oldFile, newFile, Encoding.UTF8))
{
Console.WriteLine($"行 {diff.LineNumber} が異なります。");
Console.WriteLine($" 左: {diff.Left}");
Console.WriteLine($" 右: {diff.Right}");
}
C#この実装は、あくまで「同じ行番号同士を比較して、違う行を列挙する」だけです。
行の挿入・削除(いわゆる diff ツールのような「+」「-」の表現)は扱っていません。
それでも、業務では十分役に立つ場面が多いです。
例えば、「設定ファイルのどの行が変わったか」「CSV のどの行が前回と違うか」をログに残す用途などです。
ディレクトリの差分検出 「追加・削除・変更されたファイル」を見つける
2 つのフォルダを比較して差分を取る
バックアップや同期処理では、「フォルダ A とフォルダ B の中身の差分」を取りたいことがよくあります。
ここで知りたいのは、例えば次のような情報です。
A にはあるが B にはないファイル(削除された、または未コピー)
B にはあるが A にはないファイル(新規追加)
両方にあるが内容が違うファイル(更新された)
これをシンプルに実装してみます。
using System;
using System.Collections.Generic;
using System.IO;
public enum FileDiffKind
{
OnlyInLeft,
OnlyInRight,
DifferentContent,
Same
}
public sealed class FileDiffEntry
{
public string RelativePath { get; }
public FileDiffKind Kind { get; }
public FileDiffEntry(string relativePath, FileDiffKind kind)
{
RelativePath = relativePath;
Kind = kind;
}
}
public static class DirectoryDiffUtil
{
public static IEnumerable<FileDiffEntry> GetDirectoryDiff(
string leftRoot,
string rightRoot)
{
var leftFiles = GetRelativeFileMap(leftRoot);
var rightFiles = GetRelativeFileMap(rightRoot);
var allKeys = new HashSet<string>(leftFiles.Keys, StringComparer.OrdinalIgnoreCase);
allKeys.UnionWith(rightFiles.Keys);
foreach (var relativePath in allKeys)
{
bool inLeft = leftFiles.TryGetValue(relativePath, out string? leftFull);
bool inRight = rightFiles.TryGetValue(relativePath, out string? rightFull);
if (inLeft && !inRight)
{
yield return new FileDiffEntry(relativePath, FileDiffKind.OnlyInLeft);
}
else if (!inLeft && inRight)
{
yield return new FileDiffEntry(relativePath, FileDiffKind.OnlyInRight);
}
else if (inLeft && inRight)
{
bool same = FileCompareUtil.AreFilesEqual(leftFull!, rightFull!);
yield return new FileDiffEntry(
relativePath,
same ? FileDiffKind.Same : FileDiffKind.DifferentContent);
}
}
}
private static Dictionary<string, string> GetRelativeFileMap(string root)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.GetFiles(root, "*", SearchOption.AllDirectories))
{
string relative = Path.GetRelativePath(root, fullPath);
map[relative] = fullPath;
}
return map;
}
}
C#使い方の例は次の通りです。
string oldDir = @"C:\backup\old";
string newDir = @"C:\backup\new";
foreach (var entry in DirectoryDiffUtil.GetDirectoryDiff(oldDir, newDir))
{
switch (entry.Kind)
{
case FileDiffKind.OnlyInLeft:
Console.WriteLine($"削除された(または未コピー): {entry.RelativePath}");
break;
case FileDiffKind.OnlyInRight:
Console.WriteLine($"新規追加: {entry.RelativePath}");
break;
case FileDiffKind.DifferentContent:
Console.WriteLine($"内容変更: {entry.RelativePath}");
break;
case FileDiffKind.Same:
break;
}
}
C#ここでの重要ポイントは、「相対パスで比較している」ことです。GetRelativePath を使って、ルートフォルダからの相対パスに変換してから比較することで、
「ルートが違っても同じ構造なら同じファイル」とみなせます。
差分検出の「深掘りポイント」 実務で気を付けたいこと
「差分がある=悪い」ではない
差分検出は、「変化を見つける」ための仕組みです。
業務では、「差分があること自体は正常」というケースも多いです。
例えば、毎日更新されるレポートファイルの差分を取って、
「どの行が変わったか」をログに残す、というのは正常な動きです。
大事なのは、「どの種類の差分を、どう扱うか」を決めることです。
追加されたファイルはコピーするのか、無視するのか。
削除されたファイルは、相手側でも削除するのか、残しておくのか。
内容が変わったファイルは、上書きするのか、別名で保存するのか。
差分検出ユーティリティは、「変化を見える化する」役割で、
その後のアクションは業務ロジック側で決める、という分担を意識すると設計しやすくなります。
パフォーマンスと精度のバランス
ディレクトリ差分や大きなファイルの差分を取るとき、
「どこまで厳密にやるか」と「どこまで速さを優先するか」のバランスを取る必要があります。
例えば、ディレクトリ差分で「内容が違うかどうか」を判定するときに、
毎回全バイト比較をすると時間がかかります。
そこで、次のような段階的な判定をすることもあります。
ファイルサイズが違う → 即「内容変更」
サイズが同じで更新日時も同じ → 「同じとみなす」
サイズが同じだが更新日時が違う → バイト比較して最終判断
どこまでやるかは要件次第ですが、「全部厳密にやる」と決める前に、
「どこまで厳密さが必要か」を一度立ち止まって考えると、無駄な処理を減らせます。
まとめ 実務で使える「ファイル差分検出」ユーティリティの考え方
ファイル差分検出は、「同じかどうか」から一歩進んで、「どこがどう違うのか」を知るための仕組みです。
押さえておきたいポイントを整理すると、こうなります。
バイナリファイルなら、「最初に違いが出るバイト位置」を返すだけでも十分役に立つ。
テキストファイルなら、「行番号とその内容の差分」を列挙するだけでも、ログや調査に大きく貢献する。
ディレクトリ差分では、「追加」「削除」「内容変更」「同じ」を相対パスで判定するのが基本パターン。
差分検出ユーティリティは「変化を見える化する」役割で、その後のアクションは業務ロジック側で決める。
パフォーマンスと精度のバランスを意識して、「どこまで厳密にやるか」を設計段階で決めておく。
ここまで押さえておけば、「なんとなく違う気がする」ではなく、
「どこがどう違うのか」をコードでちゃんと説明できるようになります。
