数値だけを入れてほしい入力欄にES_NUMBERを付ける、というのはMFCのダイアログでよく見かける書き方です。キーボードから数字以外を打っても入りません。ただ、このスタイルはキーボードからの打ち込みにしか効かず、貼り付けた文字やSetWindowTextで設定した文字はそのまま欄に残ります。また、キーボードで-12.5と打つと、125が残ります。
ES_NUMBERがしているのは「打てる文字を減らす」ことだけ
エディットコントロールのスタイルの説明では、ES_NUMBERは数字だけを入力できるようにするスタイルとされ、そのすぐ後ろに「このスタイルを設定していても、数字以外の文字を貼り付けることは依然として可能である」という注記が付いています(原文はNote that, even with this set, it is still possible to paste non-digits into the edit control.)。効く範囲が打ち込みに限られることは、公式の説明の時点ですでに書かれています。なお、ここで言う「数字」は0から9のことで、マイナス記号も小数点も含まれません。
負数や小数を扱う欄にES_NUMBERを付けると、マイナス記号や小数点が打てなくなります。一方で、貼り付けや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についてはさらに、規則で入力を差し戻した回数と、貼り付けから取り除いた文字数も数えています。
Bに設定した規則は「符号あり/小数1桁まで/範囲 -1000〜1000」です。

| 経路 | 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文字) |
ES_NUMBERを付けた欄で起きていたこと
符号と小数点は弾かれずに消える
[1]で-12.5と打つと、Aには125が残りました。-と.だけが入らず、残りの数字がそのまま並んだためです。125は数値として正しい形なので、確定時の検証も通ります。エラーは出ないまま、利用者が打った-12.5とは違う125が値として使われます。実際の画面では、利用者が打った文字が入らないことに気づくかもしれませんが、プログラム側にはそれを知る手掛かりが何も残りません。
Bは同じ入力で-12.5を受け取り、差し戻しは0回でした。符号と小数点は規則が許しているので、そもそも差し戻す理由がありません。符号と小数点が入らなくても困らない欄、たとえば0以上の整数だけを受け取る欄なら、ES_NUMBERを付けられます。
貼り付けとSetWindowTextは素通りする
[3]と[4]がその結果です。Aには1,234.5abcとabcがそのまま入りました。貼り付けが素通りすることは前述のとおり公式の説明に注記があり、実測もそのとおりでした。SetWindowTextのほうは公式の説明が触れていない経路ですが、こちらも同じく素通りしています。GetWindowTextで受け取る文字列は、数字だけとは限りません。
[4]では、Bの差し戻し回数が1回になりました。Bの差し戻しはEN_UPDATEの通知で動くので、単一行のCEditに対するSetWindowTextでもEN_UPDATEが届いていることが分かります。そのため、WM_CHARやWM_PASTEを経路ごとに処理しなくても、EN_UPDATEで変更後のテキストを取り出して規則に合うかを調べれば、どの経路で入った文字も判定できます。
[3]のBは、貼り付けた10文字のうち4文字(,とabc)を取り除いて1234.5を受け取り、そのうえで確定検証が「範囲外」で却下しました。1234.5は数値として読める形ですが、規則の範囲(-1000〜1000)を超えています。
桁あふれは入力中には止まらない
[5]では、AもBも20桁の数字をそのまま受け取りました。Bの差し戻しも0回です。規則が見ているのは文字の並びであって桁数ではないので、20桁の数字列も「入力途中として正しい」と判定されます。止まるのは確定時で、両方とも範囲外として却下されました。
入力中に桁数を止めたい場合はEM_SETLIMITTEXT(MFCではCEdit::LimitText)で文字数の上限を設ける方法もあります。ただしこの上限も、公式の説明では「利用者が入力できるテキストだけを制限する。メッセージを送った時点でコントロールに入っているテキストには影響せず、WM_SETTEXTでコントロールへ複写されるテキストの長さにも影響しない」とされています。SetWindowTextはWM_SETTEXTを送る呼び出しなので、文字数制限もES_NUMBERと同じく打ち込み側にしか効きません。数値に変換できるか、範囲に入っているかは、確定するときに調べます。入力中の判定を厳しくしすぎると、「-」だけや「12.」のような入力途中の状態まで弾いてしまい、続きが打てなくなります。
対策:規則を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を使うと、そのSetWindowText自身がEN_UPDATEをもう一度起こします。上の実測[4]で確かめたとおり、単一行のCEditでも通知は届きます。m_bRestoringが無いと、差し戻しの途中で、戻した文字列に対するOnUpdateがもう一度呼ばれます。戻した文字列は規則を満たしているのでそのまま通りますが、差し戻しの処理の中に判定が入れ子になるので、フラグで止めています。
差し戻したことの合図を音だけにすると、なぜ入らなかったのかが利用者に伝わらないことがあります。入力欄の背景色を変えて知らせる方法もあり、その手順はOnCtlColorでコントロールの背景色を変えるで扱っています。
入力欄のすぐ下に理由を文字で出すこともできます。その場合は入力欄の位置をGetWindowRectで取り、ScreenToClientでダイアログのクライアント座標に直してから、説明用のスタティックを動かします。GetWindowRectがスクリーン座標を返すことは「GetWindowRectとGetClientRectの違い」で確かめられます。
貼り付けだけは、差し戻しではなく「使える分だけ受け取る」ほうが実用的です。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が用意しているCMFCMaskedEdit(afxmaskededit.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で数値へ変換し、変換後に残った文字が無いこと、errnoがERANGEでないこと、範囲内であることを見ます。実測の[3]と[5]で「範囲外です」が出たのはこの最後の判定です。文字の並びは規則に合っていても範囲を超えている値は、入力中の判定では止まらず、ここで初めて却下されます。
ダイアログのメンバー変数とDDV_系のマクロで最小値・最大値を指定する方法も同じ役割を持ちます。どちらを使うにせよ、UpdateDataが呼ばれるまで検証は走りません。この仕組みはUpdateDataとDDXの同期の仕組みで扱っています。ここで確定検証を自前のクラスに置いたのは、同じ規則をキー入力中の判定と共有するためです。
まとめ
- ES_NUMBERが効くのはキーボードからの打ち込みだけです。貼り付けた1,234.5abcも、SetWindowTextで設定したabcも、そのまま欄に残りました。
- -12.5と打つと125が残ります。数値として正しい形なので確定検証も通り、エラーが出ないまま値だけが変わります。
- EN_UPDATEで変更後のテキストを判定すれば、キー入力・貼り付け・SetWindowTextのどれで入った文字も1か所で調べられます。単一行のCEditへのSetWindowTextでも通知は届きました。貼り付けだけは差し戻すのではなく、WM_PASTEを捕まえて使える文字だけを受け取るほうが実用的です。
- 入力中の判定は空欄や-だけを通すぶんゆるくなります。桁あふれや範囲外は確定時の検証で止めます。
