【MFC】CEditを数値入力に制限する:ES_NUMBERで防げない符号・小数・貼り付け

【MFC】CEditを数値入力に制限する:ES_NUMBERで防げない符号・小数・貼り付け

数値だけを入れてほしい入力欄にES_NUMBERを付ける、というのはMFCのダイアログでよく見かける書き方です。キーボードから数字以外を打っても入らないのは確かですが、このスタイルが面倒を見るのは打ち込みだけで、入力欄に文字列が入る経路はほかにもあります。キー入力・貼り付け・SetWindowTextの3経路で何が残るかを、ES_NUMBERだけの欄と入力規則を持たせた欄で比べます。

目次

ES_NUMBERがしているのは「打てる文字を減らす」ことだけ

エディットコントロールのスタイルの説明では、ES_NUMBERは数字だけを入力できるようにするスタイルとされ、そのすぐ後ろに「このスタイルを設定していても、数字以外の文字を貼り付けることは依然として可能である」という注記が付いています(原文は Note that, even with this set, it is still possible to paste non-digits into the edit control.)。効く範囲が打ち込みに限られることは、公式の説明の時点ですでに書かれています。なお、ここで言う「数字」は0から9のことで、マイナス記号も小数点も含まれません。

この2点は、実務では逆向きの問題になります。負数や小数を扱う欄に付けると必要な文字が打てなくなり、それでいて貼り付けやSetWindowTextのように打ち込み以外の経路は素通りします。「数字だけの欄にした」という認識と、実際にコントロールへ入る文字列との間にずれが生まれます。

実測:5経路を同じ順序で2つの欄へ流す

実測は、同じ大きさの入力欄を2つ並べたダイアログで行いました。AはES_AUTOHSCROLL | ES_NUMBERだけを付けた素のCEdit、Bは同じ位置に置いたCEdit派生クラス(ES_NUMBERは付けていません)で、あとで示す入力規則を持っています。1経路ごとに両方の欄を空にしてから、AとBへまったく同じ入力を同じ順序で流しています。

  • キー入力は1文字ずつWM_CHARを送ります。キーボードで打ったときと同じ経路をたどらせるためです。
  • 貼り付けはクリップボードへ文字列を置き、全選択してからWM_PASTEを送ります。
  • プログラムからの設定はSetWindowTextを呼びます。

各経路のあとで、欄に残った文字列と、その文字列を確定値として検証した結果を記録します。確定時の検証はAとBで同じ関数を通しています。Aの欄には検証も入力規則も付いていませんが、「もしこの値をそのまま受け取ったらどうなるか」を同じものさしで見るためです。Bについてはさらに、規則で入力を差し戻した回数と、貼り付けから取り除いた文字数も数えています。

Bに設定した規則は「符号あり/小数1桁まで/範囲 -1000〜1000」です。

ES_NUMBERだけを付けた欄Aと入力規則を持たせた欄Bへ、キー入力・貼り付け・SetWindowTextの5経路を同じ順序で流した比較結果の画面
実行画面。上段の欄Aと欄Bに同じ入力を同じ順序で流し、残った文字列と確定検証の結果を下の比較結果欄に書き出しています。-12.5がAでは125になっていること、貼り付けた1,234.5abcがAにそのまま残っていることが読み取れます。
経路A(ES_NUMBERのみ)に残った文字列 / 確定検証B(規則あり)に残った文字列 / 確定検証
[1] キー入力で -12.5 と打つ「125」/受理 125「-12.5」/受理 -12.5(差戻0回・除外0文字)
[2] キー入力で 12a3 と打つ「123」/受理 123「123」/受理 123(差戻1回・除外0文字)
[3] 1,234.5abc を貼り付ける「1,234.5abc」/却下(許可していない文字が含まれています)「1234.5」/却下(範囲外です)(差戻0回・除外4文字)
[4] プログラムから abc を設定する「abc」/却下(許可していない文字が含まれています)(空)/却下(空欄です)(差戻1回・除外0文字)
[5] キー入力で 99999999999999999999 と打つ「99999999999999999999」/却下(範囲外です)「99999999999999999999」/却下(範囲外です)(差戻0回・除外0文字)
Visual Studio 2026 / Debug / x64 / Unicode。B の規則は「符号あり/小数1桁まで/範囲 -1000〜1000」。

ES_NUMBERを付けた欄で起きていたこと

符号と小数点は弾かれずに消える

いちばん危ないのは[1]です。-12.5と打つと、Aには125が残りました。-.だけが入らず、残りの数字はそのまま並ぶからです。しかも125は数値として正しい形なので、確定時の検証も通ります。エラーにならず、値だけが別物になる。実際の画面では、利用者が打った文字が入らないことに気づくかもしれませんが、プログラム側にはそれを知る手掛かりが何も残りません。

Bは同じ入力で-12.5を受け取り、差し戻しは0回でした。符号と小数点は規則が許しているので、そもそも差し戻す理由がありません。ES_NUMBERを付けるかどうかは「数字だけの欄か」ではなく「符号と小数点を捨ててよいか」で判断するもの、と考えたほうが実態に合います。

貼り付けとSetWindowTextは素通りする

[3]と[4]がその結果です。Aには1,234.5abcabcがそのまま入りました。貼り付けが素通りすることは前述のとおり公式の説明に注記があり、実測もそのとおりでした。SetWindowTextのほうは公式の説明が触れていない経路ですが、こちらも同じく素通りしています。GetWindowTextで受け取る文字列は、数字だけとは限りません。

[4]ではもう1つ確認できたことがあります。Bの差し戻し回数が1回になりました。Bの差し戻しはEN_UPDATEの通知で動くので、単一行のCEditに対するSetWindowTextでもEN_UPDATEが届いている、という実測です。経路ごとにWM_CHARWM_PASTEを捕まえて回らなくても、変更後のテキストを見る一か所で拾えます。

[3]のBは、貼り付けた10文字のうち4文字(,abc)を取り除いて1234.5を受け取り、そのうえで確定検証が「範囲外」で却下しました。文字としては正しく、値としては範囲外でした。

桁あふれは入力中には止まらない

[5]では、AもBも20桁の数字をそのまま受け取りました。Bの差し戻しも0回です。規則が見ているのは文字の並びであって桁数ではないので、20桁の数字列も「入力途中として正しい」と判定されます。止まるのは確定時で、両方とも範囲外として却下されました。

入力中に桁数を止めたい場合はEM_SETLIMITTEXT(MFCではCEdit::LimitText)で文字数の上限を設ける方法もあります。ただしこの上限も、公式の説明では「利用者が入力できるテキストだけを制限する。メッセージを送った時点でコントロールに入っているテキストには影響せず、WM_SETTEXTでコントロールへ複写されるテキストの長さにも影響しない」とされています。SetWindowTextWM_SETTEXTを送る呼び出しなので、文字数制限もES_NUMBERと同じく打ち込み側にしか効きません。値の意味は結局、確定時に見る必要があります。なお、入力中の判定を厳しくするほど、入力途中の状態まで弾いて打てなくなります。

対策:規則を1か所に書き、変更後のテキストで判定する

Bの中身は2つに分かれています。1つは画面に依存しない規則のクラスで、「入力途中として許すか」「貼り付け文字列から使える分だけ残す」「確定値として検証する」の3つを持ちます。もう1つが、その規則を使うCEdit派生クラスです。規則が画面から独立しているので、同じ規則をファイル読み込みや通信で受け取った値の検査にも使えます。

入力途中の判定は、次のように「まだ数値ではない状態」を通す形で書きます。

BOOL CNumericInputRule::IsEditableText(LPCTSTR lpszText) const
{
	// 入力途中の状態も通す。"" や "-" や "12." はまだ数値ではないが、
	// ここで弾くと符号や小数点を打った瞬間に差し戻されて入力できなくなる。
	CString sText(lpszText);
	int nLength = sText.GetLength();
	int nIndex = 0;

	if (nIndex < nLength && sText[nIndex] == _T('-'))
	{
		if (!m_bAllowSign)
			return FALSE;

		nIndex++;
	}

	while (nIndex < nLength && IsAsciiDigit(sText[nIndex]))
		nIndex++;

	if (nIndex < nLength && sText[nIndex] == _T('.'))
	{
		if (m_nDecimalDigits <= 0)
			return FALSE;

		nIndex++;

		int nFractionDigits = 0;
		while (nIndex < nLength && IsAsciiDigit(sText[nIndex]))
		{
			nIndex++;
			nFractionDigits++;
		}

		if (nFractionDigits > m_nDecimalDigits)
			return FALSE;
	}

	return (nIndex == nLength) ? TRUE : FALSE;
}

数字の判定に_istdigitを使っていないのは、ロケール次第で全角数字の扱いが変わるためです。この規則では'0'から'9'に限定しています。

入力欄側は、EN_UPDATEの反射でこの規則を呼びます。EN_UPDATEは表示を更新する直前に届く通知で、変更後のテキストがすでにコントロールに入っています。経路を問わずここを通るので、判定は1か所で済みます。

BEGIN_MESSAGE_MAP(CNumericEdit, CEdit)
	ON_CONTROL_REFLECT(EN_UPDATE, &CNumericEdit::OnUpdate)
	ON_MESSAGE(WM_PASTE, &CNumericEdit::OnPaste)
END_MESSAGE_MAP()

void CNumericEdit::OnUpdate()
{
	if (m_bRestoring)
		return;

	CString sText;
	GetWindowText(sText);

	// キー入力・貼り付け・ドラッグ&ドロップ・SetWindowText のどれで変わっても
	// ここを通る。経路ごとに塞ぐのではなく、変更後のテキストだけを見て判定する。
	if (m_rule.IsEditableText(sText))
	{
		m_sLastGoodText = sText;
		return;
	}

	m_nRejectCount++;
	RestoreLastGoodText();
}

void CNumericEdit::RestoreLastGoodText()
{
	// SetWindowText は EN_UPDATE をもう一度起こす。再入を止めてから戻さないと
	// 差し戻しの中で差し戻しが走り、回数の集計も壊れる。
	m_bRestoring = TRUE;
	SetWindowText(m_sLastGoodText);
	SetSel(m_sLastGoodText.GetLength(), m_sLastGoodText.GetLength());
	m_bRestoring = FALSE;

	::MessageBeep(MB_ICONASTERISK);
}

差し戻しの実装で気をつける点は、SetWindowText自身がEN_UPDATEをもう一度起こすことです。上の実測[4]で確かめたとおり、単一行のCEditでも通知は届きます。再入を止めるm_bRestoringが無いと、差し戻しの中で差し戻しが走り、回数の集計も壊れます。

差し戻したことの合図を音だけにすると、なぜ入らなかったのかが利用者に伝わらないことがあります。入力欄の背景色を変えて知らせる方法もあり、その手順はOnCtlColorでコントロールの背景色を変えるで扱っています。

貼り付けだけは、差し戻しではなく「使える分だけ受け取る」ほうが実用的です。WM_PASTEを捕まえ、クリップボードの文字列を規則で絞り込んでから挿入します。

LRESULT CNumericEdit::OnPaste(WPARAM /*wParam*/, LPARAM /*lParam*/)
{
	CString sClipboard;
	if (!ReadClipboardText(this, sClipboard))
	{
		m_nRejectCount++;
		::MessageBeep(MB_ICONASTERISK);
		return 0L;
	}

	int nStart = 0;
	int nEnd = 0;
	GetSel(nStart, nEnd);

	CString sCurrent;
	GetWindowText(sCurrent);

	// 貼り付け位置の前後を渡し、貼った結果の全文が規則を満たす分だけ受け取る。
	CString sHead = sCurrent.Left(nStart);
	CString sTail = sCurrent.Mid(nEnd);
	CString sAccepted = m_rule.FilterText(sHead, sClipboard, sTail);

	m_nFilteredCharCount += (sClipboard.GetLength() - sAccepted.GetLength());

	if (sAccepted.IsEmpty())
	{
		m_nRejectCount++;
		::MessageBeep(MB_ICONASTERISK);
		return 0L;
	}

	ReplaceSel(sAccepted, TRUE);

	return 0L;
}

絞り込みは1文字ずつ「その文字を足した結果の全文」が規則を満たすかで判定しています。貼り付けた断片だけを見て文字種を判定すると、2個目の小数点や途中に現れる符号を見落とすからです。[3]で1,234.5abcから4文字が落ちて1234.5になったのは、この判定の結果です。

自分で規則クラスを持たせるほかに、MFCが用意しているCMFCMaskedEditafxmaskededit.h)を使う道もあります。公式の説明では、マスクに対して入力を検証し、テンプレートに従って検証された結果を表示するクラスです。EnableMaskでマスク文字列と表示テンプレートを、SetValidCharsで入力できる文字を指定し、GetWindowTextで検証済みのテキストを受け取ります。マスクは桁の位置ごとに文字種を決める形式で(Dは数字、+はプラス・マイナス・スペース、など)、マスク文字列と表示テンプレートは同じ長さである必要があります。

向くのは電話番号や製造番号のように桁数と書式が決まっている入力です。ここで扱っている「範囲」や「小数点以下の桁数」のような値としての条件は公開されているメンバーの一覧に見当たらないので、確定時の検証は結局別に書くことになります。貼り付けやSetWindowTextでこのクラスが何を受け取るかは今回のサンプルで測っていないため、ここでは触れません(公式ドキュメントにも貼り付けに関する記述はありません)。なお同じページの冒頭には、MFCライブラリは引き続きサポートされるものの機能の追加とドキュメントの更新は行われない、という注記が付いています。

入力中の判定と、確定時の検証は別物として書く

入力中の判定は「途中の状態」を通す必要があるので、必ずゆるくなります。空欄も、-だけも、12.も通します。通さなければ符号や小数点を打った瞬間に差し戻されて、そもそも入力できません。そのぶん、値として使ってよいかどうかの判断は別に要ります。

BOOL CNumericEdit::Commit(double& dValue, CString& sError)
{
	ASSERT_VALID(this);

	// 入力途中として許した状態(空欄・"-" だけ・"12." など)は、
	// ここで初めて確定値として弾かれる。入力中の判定と確定の判定は別物。
	return m_rule.Validate(GetText(), dValue, sError);
}

確定側のValidateは、空欄・許可していない文字・数字が1つも無い状態を順に弾いてから_tcstodで数値へ変換し、変換後に残った文字が無いこと、errnoERANGEでないこと、範囲内であることを見ます。実測の[3]と[5]で「範囲外です」が出たのはこの最後の判定です。文字としては正しくても値として使えない、という結果はここでしか出せません。

ダイアログのメンバー変数とDDV_系のマクロで最小値・最大値を指定する方法も同じ役割を持ちます。どちらを使うにせよ、UpdateDataが呼ばれるまで検証は走りません。この仕組みはUpdateDataとDDXの同期の仕組みで扱っています。ここで確定検証を自前のクラスに置いたのは、同じ規則をキー入力中の判定と共有するためです。

まとめ

  • ES_NUMBERが効くのはキーボードからの打ち込みだけです。貼り付けた1,234.5abcも、SetWindowTextで設定したabcも、そのまま欄に残りました。
  • -12.5と打つと125が残ります。数値として正しい形なので確定検証も通り、エラーが出ないまま値だけが変わります。
  • 経路ごとに捕まえるより、EN_UPDATEで変更後のテキストを1か所で判定するほうが漏れません。単一行のCEditへのSetWindowTextでも通知は届きました。貼り付けだけは差し戻すのではなく、WM_PASTEを捕まえて使える文字だけを受け取るほうが実用的です。
  • 入力中の判定は空欄や-だけを通すぶんゆるくなります。桁あふれや範囲外は確定時の検証で止めます。
目次