C# Tips | ファイル・ディレクトリ操作:ファイル名重複回避

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

はじめに なぜ「ファイル名重複回避」が必要になるのか

業務でファイルを扱っていると、かなりの頻度でこういう状況に出会います。

同じフォルダに同名ファイルがすでに存在している。
ユーザーが同じ名前で何度もエクスポートしてくる。
日次バッチが毎日同じファイル名で出力しようとする。

このとき、何も考えずに File.WriteAllTextFile.Copy を呼ぶと、既存ファイルを上書きしてしまいます。
「上書きしていい」仕様ならまだしも、「過去分は残したい」「絶対に消したくない」場合は致命傷になります。

そこで必要になるのが「ファイル名重複回避」です。
つまり、「すでに同名ファイルがあれば、別名を自動で生成して保存する」という仕組みです。

ここでは、C# 初心者向けに、実務でよく使うパターンを例題付きで解説しながら、
「どういうルールで別名を付けるか」「どう実装すると安全か」を深掘りしていきます。


基本方針を決める 「どうやって別名を付けるか」

一般的な命名ルールの例

ファイル名重複回避で一番よく見るのは、次のようなパターンです。

report.txt
report (1).txt
report (2).txt

あるいは、

report_1.txt
report_2.txt

といった「連番を末尾に付ける」方式です。

重要なのは、「人間が見て意味が分かる」「規則が単純で実装しやすい」ことです。
ここでは、もっとも馴染みのある name (1).ext 形式で実装してみます。


基本ユーティリティ GetUniqueFilePath の実装

まずは完成形のコードを見てみる

「このパスで保存したいけど、もし同名があったら (1), (2) を付けて空き名を探す」というユーティリティを作ります。

using System;
using System.IO;

public static class FileNameUtil
{
    public static string GetUniqueFilePath(string desiredPath)
    {
        if (desiredPath is null)
        {
            throw new ArgumentNullException(nameof(desiredPath));
        }

        string directory = Path.GetDirectoryName(desiredPath)
            ?? throw new ArgumentException("ディレクトリを含まないパスです。", nameof(desiredPath));

        string fileName = Path.GetFileNameWithoutExtension(desiredPath);
        string extension = Path.GetExtension(desiredPath);

        string candidate = desiredPath;
        int index = 1;

        while (File.Exists(candidate))
        {
            string newFileName = $"{fileName} ({index}){extension}";
            candidate = Path.Combine(directory, newFileName);
            index++;
        }

        return candidate;
    }
}
C#

この GetUniqueFilePath に「本来こうしたかったパス」を渡すと、
「まだ存在しないファイルパス」が返ってくる、というイメージです。

例:同じフォルダに何度も書き出す

string basePath = @"C:\export\report.txt";

for (int i = 0; i < 5; i++)
{
    string uniquePath = FileNameUtil.GetUniqueFilePath(basePath);
    File.WriteAllText(uniquePath, $"Export {i}");
    Console.WriteLine(uniquePath);
}
C#

初回は C:\export\report.txt が使われ、
2 回目以降は report (1).txt, report (2).txt …と増えていきます。


重要ポイント① ファイル名と拡張子の分解

Path.GetFileNameWithoutExtension と Path.GetExtension

GetUniqueFilePath の中で、最初にやっているのは「ディレクトリ」「ファイル名」「拡張子」の分解です。

string directory = Path.GetDirectoryName(desiredPath)
    ?? throw new ArgumentException("ディレクトリを含まないパスです。", nameof(desiredPath));

string fileName = Path.GetFileNameWithoutExtension(desiredPath);
string extension = Path.GetExtension(desiredPath);
C#

ここがとても大事です。

Path.GetDirectoryName
C:\export\report.txtC:\export
report.txtnull

Path.GetFileNameWithoutExtension
report.txtreport
archive.tar.gzarchive.tar

Path.GetExtension
report.txt.txt
archive.tar.gz.gz

このように、拡張子は「最後のドット以降」が対象になります。
archive.tar.gz のような多重拡張子の場合、「.gz だけが拡張子」と見なされます。

ここでファイル名と拡張子を分けておくことで、
report (1).txt のように「拡張子の前に番号を挿入する」ことが簡単にできます。


重要ポイント② while ループで「空き名」を探す

File.Exists で存在チェック

重複回避の心臓部はここです。

string candidate = desiredPath;
int index = 1;

while (File.Exists(candidate))
{
    string newFileName = $"{fileName} ({index}){extension}";
    candidate = Path.Combine(directory, newFileName);
    index++;
}
C#

流れを言葉で追うとこうなります。

最初は「希望のパス(desiredPath)」を候補とする。
そのパスにファイルが存在するか File.Exists で確認する。
存在していれば、fileName (1).ext のように番号付きの名前を作り直す。
それでも存在していれば、(2), (3) と番号を増やしていく。
存在しないパスが見つかったところでループを抜け、そのパスを返す。

ここでのポイントは、「存在チェックと命名をセットで回している」ことです。
単に「番号を付ける」だけではなく、「実際に空いているか」を必ず確認しています。


バリエーション① 接頭辞・接尾辞を変えたい場合

「(1)」ではなく「_1」にしたい

プロジェクトによっては、report_1.txt のような形式にしたいこともあります。
その場合は、番号付きファイル名の組み立て部分だけを差し替えれば OK です。

public static string GetUniqueFilePathWithUnderscore(string desiredPath)
{
    string directory = Path.GetDirectoryName(desiredPath)
        ?? throw new ArgumentException("ディレクトリを含まないパスです。", nameof(desiredPath));

    string fileName = Path.GetFileNameWithoutExtension(desiredPath);
    string extension = Path.GetExtension(desiredPath);

    string candidate = desiredPath;
    int index = 1;

    while (File.Exists(candidate))
    {
        string newFileName = $"{fileName}_{index}{extension}";
        candidate = Path.Combine(directory, newFileName);
        index++;
    }

    return candidate;
}
C#

命名ルールを変えるときは、「人間が見て意味が分かるか」「ソートしたときに直感的な順番になるか」も意識しておくとよいです。


バリエーション② 既に番号付きのファイル名がある場合

report (1).txt を渡されたらどうする?

少しレベルを上げて、「最初から番号付きの名前が来る」ケースも考えてみます。

例えば、ユーザーが最初から report (1).txt という名前を指定してきた場合、
そのまま GetUniqueFilePath に渡すと、次は report (1) (1).txt になってしまいます。

これを避けたい場合は、「既に (n) が付いているかどうか」を解析して、
そこから番号を増やす、というロジックも書けます。

ただし、これは一気に複雑になるので、初心者向けの段階では、

「元の名前が何であれ、こちらのルールで (1), (2) を付ける」

と割り切ってしまって構いません。
実務で本当に必要になったときに、「末尾の (n) を正規表現で解析する」といった発展版に進めば十分です。


実務ユーティリティとしての注意点

ディレクトリが存在しない場合の扱い

GetUniqueFilePath は、「ディレクトリが存在する前提」で書いています。
もしディレクトリが存在しない場合は、File.Exists は常に false になり、
結果として「希望のパスそのまま」が返ってきます。

実務では、ファイルを書き出す前に必ずディレクトリを作っておくのが定番です。

string basePath = @"C:\export\report.txt";

string? dir = Path.GetDirectoryName(basePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
    Directory.CreateDirectory(dir);
}

string uniquePath = FileNameUtil.GetUniqueFilePath(basePath);
File.WriteAllText(uniquePath, "内容");
C#

「ディレクトリ作成」と「重複回避」をセットで考える癖をつけておくと、実務での事故が減ります。

マルチプロセス・マルチスレッドでの競合

重要な話を一つ。

GetUniqueFilePath は、「呼び出した瞬間に空いている名前」を返しているだけです。
その後、実際にファイルを書き込むまでの間に、別プロセスや別スレッドが同じ名前でファイルを作ってしまう可能性があります。

つまり、「存在チェック」と「作成」が分かれている限り、
理論上は競合を完全には防げません。

本当に厳密にやるなら、

FileStreamFileMode.CreateNew で開く(既存ファイルがあれば例外)
例外が出たら、番号を増やして再チャレンジする

といった「作成と重複チェックを一体化した」実装が必要になります。

ただし、そこまでの競合が現実的に起きない環境(単一プロセス・単一スレッド)なら、
今回のようなシンプルな GetUniqueFilePath でも十分実用的です。


まとめ 「ファイル名重複回避」は“上書き事故”からデータを守る最後の砦

ファイル名重複回避は、地味ですが、業務システムでデータを守るうえでとても重要な仕組みです。

押さえておきたいポイントを整理すると、こうなります。

希望のパスから「ディレクトリ」「ファイル名」「拡張子」を分解し、拡張子の前に番号を挿入する形で別名を作る。
File.Exists で存在チェックをしながら、(1), (2) …と番号を増やして「空き名」が見つかるまでループする。
命名ルール((1)_1 かなど)は、人間が見て分かりやすく、実装しやすいものをプロジェクトとして決める。
ディレクトリの存在確認・作成とセットで使うことで、「書き出し時の例外」を減らせる。
マルチプロセス環境では「存在チェックと作成の間に割り込まれる」可能性があることを理解し、必要なら FileMode.CreateNew などの厳密な方法も検討する。

ここまで理解できれば、「とりあえず同じ名前で上書きしてしまう」コードから卒業して、
「過去の成果物をちゃんと残しながら、新しいファイルも安全に作る」ユーティリティを書けるようになります。

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