【MFC】CFileDialogの複数選択が1件だけ:バッファ不足を直す

【MFC】CFileDialogの複数選択が1件だけ:バッファ不足を直す

CFileDialogOFN_ALLOWMULTISELECTを付けて複数選択を有効にすると、DoModalIDOKを返すのに、選んだはずの件数が返ってきません。必要なのは、ダイアログが返す一覧が全部入る大きさのバッファを自分で用意して、その先頭をm_ofn.lpstrFileに、文字数をm_ofn.nMaxFileに設定することです。

12件のファイルを選ぶ操作を4回行い、従来形式と既定形式のそれぞれで、バッファを既定の260文字のままにした場合と自前で用意した場合に何件返るかをWindowsで実測しました。返ってくる件数と中身は形式によって違い、CommDlgExtendedErrorでは気づけない経路もありますが、対処のほうは形式によらず同じで、lpstrFilenMaxFileを対で設定すれば選んだ全件を受け取れます。

目次

OFN_ALLOWMULTISELECTを付けても受け取り容量が増えない理由

複数選択を有効にするフラグは、ダイアログを構築するときに渡します。実測に使った設定は次のとおりです。

DWORD MultiSelectDialogFlags()
{
	return OFN_EXPLORER | OFN_ALLOWMULTISELECT | OFN_HIDEREADONLY | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST;
}

複数選択が有効なとき、OPENFILENAMElpstrFileには「ディレクトリ、NUL、ファイル名、NUL、……、最後にもう1つNUL」という並びが返ります。フルパスの一覧ではなく、共通の親ディレクトリ1つとファイル名の列です。1件だけ選んだ場合は区切りが入らず、フルパスがそのまま入ります。

この並びを収める容量がnMaxFileです。CFileDialogを構築しただけでは、この容量は260文字のままで、フラグを足しても増えません。今回実測に使った12件は、この書式で367文字になりました。260文字には収まりません。必要な長さは、共通の親ディレクトリの長さと、選ばれたファイル名の長さの合計で決まります。

Microsoft LearnのOPENFILENAMEWには、バッファが小さいと関数はFALSEを返し、CommDlgExtendedErrorFNERR_BUFFERTOOSMALLを返すこと、そのときlpstrFileの先頭2バイトに必要なサイズが入ることが記載されています。ところが、その戻り値がDoModalの戻り値にそのまま現れるとは限りません。次に実測を示します。

実測:ダイアログ形式によって返ってくる件数が変わる

Visual Studio 2026、Debug/x64、Unicode、MFC共有DLLで確認しました。同じフォルダにある24文字のファイル名12件(フルパスは各90文字)を、実際にファイル選択ダイアログを開いて選ぶ操作を4回行っています。CFileDialogの最後の引数bVistaStyleFALSEを渡した従来形式と、既定値のTRUEのままにした既定形式のそれぞれで、既定のバッファと自前バッファを比べました。

従来形式と既定形式のCFileDialogで、既定バッファ260文字と自前バッファ3133文字のときの取得件数を比較した実測結果
同じ12件を選んでも、既定のままでは従来形式で1件、既定形式で8件しか返りません。対で設定するとどちらの形式でも12件すべてが返ります。
ダイアログ形式バッファnMaxFileDoModalDoModalの直後に読んだCommDlgExtendedError取り出せたフルパス
従来形式(bVistaStyle=FALSE既定のまま260文字IDOK0x3003(FNERR_BUFFERTOOSMALL1件(ファイル名ではない)
従来形式自前バッファ3133文字IDOK012件すべて
既定形式(bVistaStyle=TRUE既定のまま260文字IDOK08件(末尾は途中で切れた名前)
既定形式自前バッファ3133文字IDOK012件すべて

この記事で一番伝えたいのはここです。4通りのすべてでDoModalIDOKを返しました。if (dlg.DoModal() == IDOK)だけを見ている呼び出し側は成功したと判断し、そのまま次の処理へ進みます。

従来形式で既定のままだったとき、GetNextPathNameは1件だけ返してきました。中身はファイル名ではなく、記号1文字でした。同じ画面でlpstrFileの先頭2バイトを読むと367が入っています。これはCommDlgExtendedErrorFNERR_BUFFERTOOSMALLを返したときに必要な文字数が書き込まれる場所で、既定の260文字を超えた値です。必要サイズを示す数値が、そのまま1件分のパス文字列として読み出されていたことになります。

既定形式では結果が違いました。同じ260文字のまま12件を選ぶと、返ってきたのは8件でした。末尾の1件はファイル名が途中で切れた文字列です。容量に収まるところまで詰めて返し、入り切らなかった分は返りません。

この8件という数は一般化できません。今回のフォルダ名とファイル名の長さで決まった値で、260文字に収まるところで切れた結果です。名前が長ければもっと少なく、短ければもっと多く返ります。問題は8件という値ではありません。選んだ件数より少ない件数が、IDOKのまま返ってくる経路があることのほうです。

bVistaStyleの既定値はTRUEです。引数を省いてCFileDialogを書いたコードは、この8件のほうと同じ結果になります。業務コードから見ると「例外も出ず、戻り値もIDOKで、ファイルが8件返ってきた」ようにしか見えません。DoModalの戻り値だけでは、結果が欠けたことを判定できません。失敗した場所と症状が出る場所が離れている点は、GetLastError 3「指定されたパスが見つかりません」と同じです。

lpstrFileとnMaxFileを同じバッファへ対で設定する手順

Microsoft LearnのCFileDialogクラスには、複数選択で使う場合は自分で確保したバッファへのポインタでm_ofn.lpstrFileを置き換えること、そのタイミングはCFileDialogの構築後・DoModalの呼び出し前であること、あわせてm_ofn.nMaxFileを設定することが記載されています。必要量の式も示されていて、最大n件を選ばせるならn * (_MAX_PATH + 1) + 1です。

int CMultiSelectPicker::RequiredCharsForFileCount(int nFileCount)
{
	if (nFileCount <= 0)
	{
		return 0;
	}

	return nFileCount * (_MAX_PATH + 1) + 1;
}

バッファは、ダイアログを使い終わるまで生きている入れ物へ確保します。実測に使った実装では、確保と設定と回収をひとつのクラスにまとめ、バッファはそのメンバが所有しています。newdeleteを書かずに済ませるため、std::vector<TCHAR>を使いました。

CMultiSelectPicker::CMultiSelectPicker(int nMaxFiles)
{
	const int nRequired = RequiredCharsForFileCount(nMaxFiles);
	if (nRequired > 0)
	{
		m_arrBuffer.assign(static_cast<size_t>(nRequired), _T('\0'));
	}
}

設定はDoModalの直前に行います。ここが対処の本体です。

	if (!m_arrBuffer.empty())
	{
		// lpstrFile と nMaxFile は必ず対で設定する。
		// 片方だけ差し替えても、もう片方が既定の _MAX_PATH のままだと意味がない。
		std::fill(m_arrBuffer.begin(), m_arrBuffer.end(), _T('\0'));
		dlg.m_ofn.lpstrFile = &m_arrBuffer[0];
		dlg.m_ofn.nMaxFile = static_cast<DWORD>(m_arrBuffer.size());
	}

ポインタと容量のどちらか片方だけを変えても意味がありません。lpstrFileだけ差し替えると容量は既定のままなので、確保したバッファの先頭260文字しか使われません。nMaxFileだけ増やすと、実際には存在しない領域を指したまま容量だけが大きいと宣言することになります。

nMaxFileはバイト数ではなく文字数です。Unicodeビルドでsizeof(buffer)のようなバイト数を渡すと、実際の2倍の容量があると宣言することになります。std::vector<TCHAR>のようにTCHAR単位で数えられる入れ物を使うと、この取り違えを避けられます。バッファを外部のAPIへ預ける間は生存させておく必要がある点は、CString::GetBufferとReleaseBufferの正しい使い方と同じ性質の話です。

今回の12件で必要だったのは367文字で、公式の式で確保した3133文字の1割強でした。それでも節約せず、式のまま確保するほうが安全です。選ばれるファイル名の長さは呼び出し側では決められないからです。

この対処は、ダイアログ形式によらず同じように効きました。実測では、従来形式でも既定形式でも、3133文字を対で設定した経路は12件すべてを返しています。bVistaStyleをどちらにするかを決める前に、まずバッファを対で設定してください。

IDOKと拡張エラーだけでは足りない

対で設定したあとも、受け取った結果は判定します。まず、DoModalの直後にCommDlgExtendedErrorを読みます。

	const INT_PTR nAnswer = dlg.DoModal();

	// 計測用: 何が返るのかを観察するため、IDOK / IDCANCEL のどちらでも生値を記録する。
	// 公式仕様では、直前の呼び出しが成功した場合の拡張エラーは未定義。IDOK のときは判定に使わない。
	const DWORD dwError = ::CommDlgExtendedError();
	if (dwError == FNERR_BUFFERTOOSMALL && dlg.m_ofn.lpstrFile != NULL)
	{
		// 公式仕様: バッファ不足のときは lpstrFile の先頭 2 バイトに必要量が入る。
		result.m_dwRequiredHintFromBuffer = *reinterpret_cast<const WORD*>(dlg.m_ofn.lpstrFile);
	}

FNERR_BUFFERTOOSMALL<cderr.h>で定義されています。afxwin.hを読み込んだだけでは入らないので、参照した時点でC2065になります。

ただし、この判定だけでは足りません。公式仕様では、直前の共通ダイアログ関数の呼び出しが成功した場合、CommDlgExtendedErrorの値は未定義です。IDOKのときは拡張エラーを判定に使わず、回収した件数を数えて確保した上限と突き合わせます。

	if (nAnswer == IDOK)
	{
		result.m_bSucceeded = TRUE;

		POSITION pos = dlg.GetStartPosition();
		int nGuard = 0;
		while (pos != NULL && nGuard < gnEnumerationLimit)
		{
			result.m_arrPaths.push_back(dlg.GetNextPathName(pos));
			++nGuard;
		}

		return result;
	}

件数が確保した上限と同じところで止まっているときは、上限を超えて選ばれていた可能性があります。取り込み処理を持つアプリなら、一度に扱う上限件数はたいてい決まっているはずです。その件数を式へ渡して確保しておけば、上限内の選択は全件返ります。実測でも、12件用に確保した3133文字で12件が返りました。

もう1つ、受け取ったパスを使う前に実在を確かめる方法もあります。既定形式で欠けたときの末尾は、名前が途中で切れた文字列でした。ファイルとして開こうとするより前に弾いておけば、取り込み処理の途中で失敗せずに済みます。

まとめ

  • OFN_ALLOWMULTISELECTを付けても、構築直後のm_ofn.nMaxFileは260文字のままです。
  • 容量が足りないとき、実測では4通りすべてでDoModalIDOKを返しました。戻り値だけでは失敗に気づけません。
  • 従来形式では1件(ファイル名ではない値)+FNERR_BUFFERTOOSMALL、既定形式では8件で末尾が切れた名前、という結果でした。bVistaStyleの既定はTRUEなので、後者が既定の書き方で起きます。
  • 自前バッファをm_ofn.lpstrFileへ差し替え、同じバッファの文字数をm_ofn.nMaxFileへ対で設定します。容量はn * (_MAX_PATH + 1) + 1で確保します。この対処は両方の形式で効きました。
  • IDOKのときは拡張エラーの値を判定に使わず、GetNextPathNameで回収した件数を上限と突き合わせて判定します。

参考資料

目次