【C++】ファイルが開けない・アセットが読み込めない原因とは?|相対パスは「作業フォルダ」基準だと実測で確かめて、起動方法に左右されない書き方に直す

【C++】ファイルが開けない・アセットが読み込めない原因とは?|相対パスは「作業フォルダ」基準だと実測で確かめて、起動方法に左右されない書き方に直す C++

Visual Studioから実行すると読み込めるのに、同じexeをダブルクリックすると、「ファイルが見つからない」になります。ファイルは、確かに、そこにあるのに。

原因の定番は、相対パスが、実行ファイルの場所ではなく「作業フォルダ」を基準にしていることです。同じexeを、起動するフォルダだけ変えて3通り実行し、成功と失敗を実測しました。さらに、開けたかを確認しないで使うと、デバッグ版で何が起きるか(Debug Assertion Failed)も、実際に出たダイアログで見ていきます。

相対パスとは?

相対パスとは、

・「どこから見た道順か」を省略して書いたパス。C++のfopen("data/config.txt")のようなパスは、実行ファイルの場所ではなく、プログラムの「作業フォルダ(カレントディレクトリ)」から見た道順として解釈される

です。ここが、いちばん間違えやすい点です。「exeの隣にあるdataフォルダ」のつもりで書いても、実際に探されるのは、プログラムを起動したときの作業フォルダの下のdataフォルダです。

実験:同じexeを、3つの作業フォルダで動かす

次のようなフォルダ構成を作りました。exeとdata/config.txtは、同じbinフォルダの中にあります。

path
└─ bin
    ├─ open_rel.exe
    └─ data
        └─ config.txt

プログラムは、data/config.txtを相対パスで開いて、成功か失敗かを表示するだけです。作業フォルダも一緒に表示します。

▼open_rel.cpp
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <filesystem>

int main() {
    std::printf("作業フォルダ : %s\n", std::filesystem::current_path().string().c_str());
    FILE* fp = std::fopen("data/config.txt", "rb");      // 相対パスで開く
    if (!fp) {
        std::printf("読み込み失敗 : %s (errno=%d)\n", std::strerror(errno), errno);
        return 1;
    }
    char buf[64] = {};
    size_t n = std::fread(buf, 1, sizeof(buf) - 1, fp);
    std::fclose(fp);
    std::printf("読み込み成功 : %zu バイト\n", n);
    return 0;
}

このexeを、作業フォルダを変えて起動しました。

[open_rel.exe]
  作業フォルダ = exeのフォルダ
      作業フォルダ : C:\blogtest\path\bin
      読み込み成功 : 16 バイト
  作業フォルダ = 1つ上のフォルダ
      作業フォルダ : C:\blogtest\path
      読み込み失敗 : No such file or directory (errno=2)
  作業フォルダ = C:\ (ドライブ直下)
      作業フォルダ : C:\
      読み込み失敗 : No such file or directory (errno=2)
起動したときの作業フォルダ結果
exeと同じフォルダ(bin)成功(16バイト読めた)
1つ上のフォルダ(path)失敗(No such file or directory、errno=2)
C:\(ドライブ直下)失敗(同じ)
同じexe、同じファイル。起動する場所を変えただけ

ファイルもexeも一切動かしていないのに、作業フォルダが違うだけで、読み込みが失敗しました。これが、「自分の環境では動く」の正体です。

作業フォルダは、起動のしかたで変わる

作業フォルダは、プログラムの中で決めるのではなく、起動した側が決めるものです。たとえば、次のように変わります。

  • コマンドプロンプトから起動:cdで移動した場所が作業フォルダになる(今回の実験は、これを使った)
  • ショートカットから起動:ショートカットのプロパティにある「作業フォルダー」が使われる
  • Visual Studioのデバッグ実行:プロジェクトのプロパティの「デバッグ → 作業ディレクトリ」で決まる

Visual Studioの作業ディレクトリは、既定ではプロジェクトのフォルダになるのが一般的で、ビルドしたexeが入っているx64\Debugなどとは別の場所です。ソースの隣にassetフォルダを置いて開発していると、Visual Studioからは動き、x64\Debugのexeを直接起動すると動かない、ということが起きます。

【重要】罠:開けたかを確認しないと、デバッグ版でダイアログが出る

fopenは、開けないとNULLを返します。この結果を確認せずに、そのままfreadなどに渡すと、どうなるでしょうか。わざと試しました。

▼open_nocheck.cpp(開けたかを確認していない)
#include <cstdio>

int main() {
    FILE* fp = std::fopen("data/config.txt", "rb");      // 開けなかったのに、確認しない
    char buf[16] = {};
    std::fread(buf, 1, sizeof(buf), fp);                 // NULL のまま読んでしまう
    std::printf("終わり\n");
    return 0;
}
fopenの失敗を確認せずにfreadしたときに出た、Debug Assertion Failedのダイアログ
作業フォルダを間違えて起動すると、デバッグビルドでは、こういうダイアログが出て止まる
Debug Assertion Failed!

Program: C:\blogtest\path\bin\open_nocheck.exe
File: minkernel\crts\ucrt\src\appcrt\stdio\fread.cpp
Line: 48

Expression: stream != nullptr

For information on how your program can cause an assertion
failure, see the Visual C++ documentation on asserts.

(Press Retry to debug the application)

デバッグビルドでは、CRT(C言語の標準ライブラリ)が、stream != nullptrという式が成り立たないことを検出して、Debug Assertion Failed!のダイアログを出しました。原因は、「ファイルが無い」ことなのに、ダイアログには、fread.cppという内部の名前しか出ないので、原因が分かりにくくなります。

これは、私のDirectXのプロジェクトでも実際に起きました。シェーダーのファイル(.cso)を相対パスで読んでいたので、ビルドしたexeをx64\Debugから直接起動したところ、今回と同じ種類のDebug Assertion Failed!(fileno.cpp、Expression: stream.valid())が出て、起動できませんでした。「ファイルが無い」のに、「ストリームが不正」と言われるので、原因にたどり着くまでに時間がかかりました。

なお、リリースビルドでは、同じコードがアクセス違反などで落ちることがありますが、今回はリリースビルドでは試していません。

直し方

①開けたかを、必ず確認する

最初の実験のように、fopenがNULLでないかを確認して、失敗したら、どのパスを開こうとしたかを表示します。それだけで、原因の切り分けが、ずっと速くなります。

②実行ファイルの場所を基準にしてパスを作る

作業フォルダに頼らないよう、実行ファイルがあるフォルダを取得して、そこからパスを組み立てます。WindowsではGetModuleFileNameWで、実行ファイルのパスが取れます。

▼open_exe.cpp(実行ファイルの場所を基準にする)
#include <windows.h>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <filesystem>

// 実行ファイルがあるフォルダを返す(作業フォルダとは関係ない)
static std::filesystem::path ExeDir() {
    wchar_t buf[MAX_PATH];
    GetModuleFileNameW(nullptr, buf, MAX_PATH);
    return std::filesystem::path(buf).parent_path();
}

int main() {
    std::printf("作業フォルダ : %s\n", std::filesystem::current_path().string().c_str());
    std::filesystem::path path = ExeDir() / "data" / "config.txt";   // 実行ファイルからの相対で組み立てる
    FILE* fp = _wfopen(path.c_str(), L"rb");
    if (!fp) {
        std::printf("読み込み失敗 : %s (errno=%d)\n", std::strerror(errno), errno);
        return 1;
    }
    char buf[64] = {};
    size_t n = std::fread(buf, 1, sizeof(buf) - 1, fp);
    std::fclose(fp);
    std::printf("読み込み成功 : %zu バイト\n", n);
    return 0;
}

同じ3つの作業フォルダで、この版を動かしました。

[open_exe.exe]
  作業フォルダ = exeのフォルダ
      作業フォルダ : C:\blogtest\path\bin
      読み込み成功 : 16 バイト
  作業フォルダ = 1つ上のフォルダ
      作業フォルダ : C:\blogtest\path
      読み込み成功 : 16 バイト
  作業フォルダ = C:\ (ドライブ直下)
      作業フォルダ : C:\
      読み込み成功 : 16 バイト

作業フォルダが、どこであっても、成功しました。fopenではなく_wfopenを使っているのは、パスをワイド文字列のまま渡して、日本語などを含むフォルダでも扱えるようにするためです(今回の実験は、英数字のパスだけで確認しています)。

③開発中は、作業フォルダを決めておく

exeの場所を基準にするのが難しいときは、Visual Studioの「作業ディレクトリ」と、配布時のショートカットの「作業フォルダー」を、同じ場所にそろえる方法もあります。ただし、ユーザーが別の方法で起動する可能性があるので、配布するアプリでは、②が確実です。

ファイルが開けないときのよくある失敗例と対処法

①Visual Studioで動いたので、そのまま配布する

作業フォルダが、開発中の環境でたまたま合っていただけです。配布前に、exeを別のフォルダにコピーして、ダブルクリックで起動してみると、相対パスの問題が見つかります。

②失敗しても何も表示しない

NULLを確認して、どのパスを開こうとしたかを表示します。作業フォルダ(current_path)も一緒に表示すると、原因の特定が、ほぼ一瞬で済みます。

③アセットの置き場所を、作業フォルダに依存したまま増やす

テクスチャ、シェーダー、サウンドと、読み込むファイルが増えるほど、相対パスの前提が散らばります。読み込みの入口になる関数で、exeの場所を基準にしたパスに、一括で直すようにしておくと、あとが楽です。

注意点

  • 相対パスは、実行ファイルの場所ではなく、作業フォルダが基準
  • 作業フォルダは、起動のしかたで変わる(コマンドプロンプト、ショートカット、Visual Studio)
  • fopenは開けないとNULLを返す。確認せずに使うと、デバッグ版でDebug Assertion Failed!
  • 実行ファイルの場所は、GetModuleFileNameWで取れる(Windowsの場合)
  • 失敗したときは、開こうとしたパスと作業フォルダを表示する
  • 今回の検証は、Windows 11(日本語環境)、MSVC 19.44のデバッグビルド。英数字のパスだけで確認した

要点を整理

  • 同じexeでも、作業フォルダを変えるだけで、読み込みが成功(exeの隣)から失敗(errno=2)に変わった
  • 確認せずに使うと、デバッグ版でDebug Assertion Failed!が出て、原因が分かりにくい
  • GetModuleFileNameWで実行ファイルの場所を基準にしたら、3つの作業フォルダのすべてで成功した
  • 配布前に、exeを別のフォルダにコピーして起動してみる

「ファイルがあるのに開けない」は、ほとんどの場合、ファイルではなく、作業フォルダの問題でした。開けなかったときに、パスと作業フォルダを表示するだけで、原因はすぐに見えてきます。

関連記事