【MFC】CString::Tokenize で文字列を分割すると空欄が消える:CSV の列がずれない分け方

【MFC】CString::Tokenize で文字列を分割すると空欄が消える:CSV の列がずれない分け方

ログやCSVの1行をCString::Tokenizeで分割すると、たいていはそのまま使えます。困るのは、空欄の列があるときです。7列あるはずの文字列が4要素になり、3列目を読んでいるコードが4列目の値を受け取ります。

列の位置に意味があるデータなら、AfxExtractSubStringかFind + Midを使って分けましょう。


目次

空欄を含む1行をTokenizeに通すと、要素数が合わない

分けるのは、この1行です。

2026-08-14,ERROR,,timeout,,retry,

カンマが6個で、末尾を含めて7列。空欄は3列目・5列目・7列目です。この行を3通りで分けて、取れた要素数を数えました。実測はVisual Studio 2026 / Debug / x64 / Unicode(MFC共有DLL)です。

分け方取れた要素数空欄の列
CString::Tokenize4結果から消える
AfxExtractSubString7空文字列として残る
Find + Midの自前ループ7空文字列として残る
同じ1行を3通りで分けた結果

足りない3つが空欄の列です。Tokenizeのループはこう書きます。

// Tokenize で 1 行を分ける。連続した区切りはまとめて読み飛ばされる。空欄の列は結果に残らない。
static void SplitByTokenize(const CString& sLine, LPCTSTR lpszSeparators, CStringArray& arrFields)
{
	arrFields.RemoveAll();

	int iStart = 0;
	CString sToken = sLine.Tokenize(lpszSeparators, iStart);
	while (!sToken.IsEmpty())
	{
		arrFields.Add(sToken);
		sToken = sLine.Tokenize(lpszSeparators, iStart);
	}
}

取り出せたのは2026-08-14、ERROR、timeout、retryの4つ。3番目に取れるのはtimeoutで、本来の4列目の値です。

7列のログ行をCString::Tokenize・AfxExtractSubString・Find+Midの3通りで分割し、Tokenizeだけ4要素になった実行結果
本来7列に対し、Tokenizeだけ4要素

空欄が消える理由は、先頭の区切り文字の読み飛ばし

どこで消えたかは、iStartの動きで分かります。呼び出しごとに、次の検索開始位置がここへ書き戻されます。

iStart  0 -> [2026-08-14]  iStart 11
iStart 11 -> [ERROR]       iStart 17   ← 16 と 17 の連続したカンマのうち 1 つ分しか進んでいない
iStart 17 -> [timeout]     iStart 26
iStart 26 -> [retry]       iStart 33   ← 文字列長と同じ。次の呼び出しは空文字列を返し iStart は -1

2回目の呼び出しはERRORを返して、iStartを17にします。17の位置は、16と並んだカンマの2つ目です。3回目はそれを読み飛ばしてtimeoutを切り出します。空欄の列はここで消えます。

公式リファレンスにも、呼び出しのたびに先頭の区切り文字を読み飛ばすこと、トークンが尽きると空文字列を返してiStartが -1になることが書かれています。空欄の列に対応する空のトークンは返りません。今回の実行では、エラーダイアログも例外も出ませんでした。分割した直後に件数を数えるまで、列がずれたことは表に出てきません。


区切りに渡しているのは文字列ではなく文字の集合

ずれる原因は、もうひとつあります。第1引数は「この文字列で区切る」という指定ではありません。「区切りとして扱う文字の集合」です。key, value;noteという1行に2通りの指定を通すと、こう分かれました。

入力: key, value;note

_T(",;")  -> [key][ value][note]    カンマとセミコロンのどちらでも切れる(空白は残る)
_T(", ")  -> [key][value;note]      カンマと空白のどちらでも切れる(セミコロンは残る)

_T(“, “) は「カンマと空白が並んだ区切り」という意味になりません。カンマでも空白でも切れます。値の途中に空白があると、そこでも分かれます。区切りが1種類なら、_T(“,”) のように1文字だけ渡すのが安全です。


空欄を保ったまま分ける:AfxExtractSubStringとFind + Midを使う

列の位置を保ちたいならAfxExtractSubStringです。インデックスで1列ずつ取り出し、区切りはTCHAR 1文字を渡します。

// AfxExtractSubString で 1 列ずつ取り出す。空欄の列でも TRUE が返る。列の位置はずれない。
static void SplitByAfxExtractSubString(const CString& sLine, TCHAR chSeparator, CStringArray& arrFields)
{
	arrFields.RemoveAll();

	CString sField;
	for (int nIndex = 0; AfxExtractSubString(sField, sLine, nIndex, chSeparator); ++nIndex)
	{
		arrFields.Add(sField);
	}
}

同じ行で0から順に呼ぶと、空欄の列でも末尾の空欄でもTRUEが返りました。このforは7回まわって止まり、8回目はFALSEでした。呼び出しのたびに、文字列の先頭から走査し直します。7列を取り出すのに8回、毎回先頭からです。

全列をまとめて取るなら、FindとMidで自分で回します。

// 区切り文字で分け、空欄はそのまま空文字列として残す
static void SplitKeepEmpty(const CString& sLine, TCHAR chSeparator, CStringArray& arrFields)
{
	arrFields.RemoveAll();

	int nPos = 0;
	for (;;)
	{
		const int nNext = sLine.Find(chSeparator, nPos);
		if (nNext < 0)
		{
			arrFields.Add(sLine.Mid(nPos));   // 最後の列。末尾が区切りなら空文字列が入る
			break;
		}

		arrFields.Add(sLine.Mid(nPos, nNext - nPos));
		nPos = nNext + 1;
	}
}

Midは毎回コピーを返します。SplitKeepEmptyは列の数だけMidを呼びます。バッファを直接触って詰め替える作法はCStringのメモリ管理: GetBuffer / ReleaseBufferの作法にまとめています。

どちらの分け方でも、列を読む前に件数を確かめます。

// 列を読む前に件数を確かめる。合わない行は読まずに戻し、出荷ビルドにも残る形で記録する。
static BOOL LoadLogLine(const CString& sLine, CString& sDate, CString& sLevel, CString& sDetail)
{
	const int nExpected = 7;

	CStringArray arrFields;
	SplitKeepEmpty(sLine, _T(','), arrFields);

	if (arrFields.GetCount() != nExpected)
	{
		CString sWarning;
		sWarning.Format(_T("列数が想定と違います: %d 列(想定 %d 列)\r\n"), (int)arrFields.GetCount(), nExpected);
		::OutputDebugString(sWarning);

		return FALSE;
	}

	sDate = arrFields.GetAt(0);
	sLevel = arrFields.GetAt(1);
	sDetail = arrFields.GetAt(3);

	return TRUE;
}

記録にTRACEを使うと、Debugビルドにしか残りません。公式リファレンスは「In a release build, this macro compiles to nothing (no code is generated at all)」と書いています。出荷したビルドにも残したいなら、OutputDebugStringを直接呼びます。これはUNICODEの定義でOutputDebugStringWとOutputDebugStringAのどちらを呼ぶかを選ぶ別名で、実体はどちらもKernel32.dllの関数です。TRACEのように構成で丸ごと消えることはありません。受け取るのはデバッガか、OutputDebugString() で自作ログ出力のようなツールです。

実行して確かめました。7列そろった行はTRUEで、日付2026-08-14・種別ERROR・詳細timeoutを取り出せました。空欄が落ちた4列の行はFALSEです。

空白区切りの単語を拾うだけなら、Tokenizeの読み飛ばしがそのまま役に立ちます。避けるのは、列の位置に意味があるデータのときです。


まとめ

  • Tokenizeは連続した区切り文字をまとめて読み飛ばします。空欄の列が消えて、以降の値が手前へずれます。エラーダイアログは出ません。
  • 第1引数は区切り文字の集合です。_T(“, “) はカンマでも空白でも切れます。
  • 列の位置を保つならAfxExtractSubString、全列をまとめて取るならFind + Mid。どちらも分割直後に件数を確かめます。
目次