syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

C/C++可変個引数のダークサイド

(これは2010-11-07に書いた故OCNブログの記事を移植したものです)

C/C++ の可変個引数(可変長引数)はとても強力です。こいつにお世話にならない人はおそらくほとんど居ないと言っても過言ではありません。とくに printf/scanf 系の文字列入出力関数は、C++ のストリームが使いづらい*1せいで、たとえ C++ のヘビーユーザーであってもずっと使い続けている人も多いことでしょう。かくいう自分もその一人です。C++ のストリームは型安全なのが良いところなんですが、細かい書式を指定しようとすると、記述があまりにも冗長になりすぎます。

ただ、可変個引数は値をパラメータとして渡した時点で、一旦パラメータの型情報が失われてしまうせいで、型安全なプログラムを記述することができないという致命的な欠点があります。具体的にどんなのがやばいかというと、例えば Windows の ATL/MFC プログラムでこんな記述をするときでしょうか。

const char* pParam = "Hello, World!";
CString str;
str.Format("%s\n", pParam);

このコード、MBCS(ANSIマルチバイト文字セット)設定では普通にコンパイルが通るし、正しい実行結果が得られるんですが、UNICODE 設定ではコンパイル エラーになってしまいます。CString が内部で管理しているバッファは、正確には char 型配列でなく TCHAR 型配列であり、また CString::Format() 関数の第一引数の型 LPCTSTR は const char* 型でなく const TCHAR* 型です。それゆえ、UNICODEシンボルが定義されていない場合にはそれぞれ char 型配列、const char* 型ですが、一方 UNICODE シンボルが定義されている場合にはそれぞれ wchar_t 型配列、const wchar_t* 型になるからです。

UNICODE 定義有無と各種データ型の変化

UNICODE シンボル未定義 UNICODE シンボル定義済み
TCHAR char wchar_t
LPTSTR char* あるいは LPSTR wchar_t* あるいは LPWSTR
LPCTSTR const char* あるいは LPCSTR const wchar_t* あるいは LPCWSTR
CString CStringA CStringW
_T("hoge"), TEXT("hoge") "hoge" L"hoge"


しかたねーな、じゃあこれでどうよ?

str.Format(_T("%s\n"), pParam);

確かにこれだと MBCS だろうが UNICODE だろうがコンパイルは通ります。_T() マクロは UNICODE 設定のときにリテラル文字列をワイド文字列化してくれる仕組みになっているからです。しかし pParam の型は相変わらず const char* なので、UNICODE 設定では正しくデータを解釈できず、実行時に文字化けしてしまいます。CString::Format() 関数は、%s 書式に対応する文字ポインタ型として、const TCHAR* を想定しているからです。つまり正しくは、上記修正に加えてさらに、

const TCHAR* pParam = _T("Hello, World!");

あるいは

str.Format(_T("%s\n"), static_cast<LPCTSTR>(CString(pParam)));

と修正することも必要になります。後者はリテラル以外にも対応できますが、実行時に const char* から CString へ変換するためのオーバーヘッドが加わるので注意。CString 一時変数の代わりに「ATL と MFC の文字列変換マクロ」を使う方法もあります。

ここで何が問題かというと、CString のバッファが char 型であることを想定した既存の古いコード(特に最初 VC++ 7.1 以前の処理系向けに書かれていたがために、VC++ 8.0 以降の新しいコンパイラを使いながらも未だに MBCS バージョンのライブラリを使っているコード)を Unicode 移行するためにコードを修正するときに、コンパイラがエラーを出力してくれている箇所以外にも目を光らせないといけない、ということ。型安全性が担保されない可変個引数を誤って使うと、「コンパイラが検出できない(コンパイル時には分からない)コーディングミスによるバグ」を作り込んでしまうことになります。

ちなみに ATL/MFC の CString::Format() の他にも、Win32 API で言うと wsprintf() 関数が可変個引数を用いています。wsprintf() は UNICODE シンボル定義の有無で wsprintfA() もしくは wsprintfW() のいずれかに展開されます。標準Cライブラリの swprintf() とは名前が似てますが出自も機能も別物の関数なので注意。なお、sprintf_s(), swprintf_s() と比較して、wsprintf() はいろいろと制限や問題を抱えているので、よほどのことがない限り使わないほうがいいでしょう。

コンパイル時にはエラーとならず、実行するまでエラーが発覚しない、というのは最も厄介です。なぜなら全てのフローや条件をテストしないとバグが発覚しないということの裏返しでもあるから。バカ正直にテストしようとすると組み合わせ爆発で破たんするのは目に見えています。テストを自動化しているコードならばともかく、Unicode 関連の修正の影響は自動化の難しい UI まわりに如実に表れるので、余計にタチが悪いです。さらに、書式のミスによりランタイムエラーや例外が送出されるならばともかく、実際は目に見える形で即エラーになることはありません。せいぜい表示がおかしいとか、出力結果にゴミが混ざるとかいった程度で、いったい何が原因で引き起こされた不具合なのかをすぐに特定するのは難しいでしょう。場合によってはホワイトボックステストが必要になります。単体テストをきっちりやらずに、いきなり結合してビッグバンテスト、なんてプロジェクトだとなおさらです。エンドユーザー運用環境で初めてバグが発覚するという最悪のケースもありえます。

WinAPI/ATL/MFC 初心者は、まず間違いなく TCHAR 関連の話が分かっていないというかそもそもワイド文字とか MBCS とか Unicode とか何それ食えんの状態であること間違いなしなので、このことを教えないで初心者に大量の Windows アプリケーションコードを組ませるべきではありません。特に素人に printf 系の書式と可変個引数を使わせると、後で修正&デバッグするのに多大な労力が必要となります。まぁそもそも初心者に十分な教育を施す前に、いきなり製品用プログラムのソースを触らせる時点で間違っているわけですが……

もしあなたが新人を指導する立場にあり、さらにあなたが関わっているソフトウェア製品が(日本語を含む)多言語環境向けに開発されているのであれば、文字および文字列に関する(正しい)知識は最初から徹底的にたたき込むことを強く推奨します。将来的に使い物にならないゴミコードが大量生産されていく前に……


なお、C/C++ は多バイト文字列どころか、文字および文字列自体のサポートがあまりに弱いので、昔からこのあたりは苦労していたところですが、C++11/C11 (C++0x/C1x) ではようやく UTF-16UTF-32 向けの文字型 (char16_t, char32_t) が用意されることになります*2。個人的には、int8_t / uint8_t 型を組み込み型に昇格させて、(char 型に依存しない形で)言語標準レベルで1バイト整数型をサポートして欲しいんですが。これは int32_t / uint32_t 型にも言えます。でないとオーバーロードがらみの問題が発生して見苦しい上にややこしいです。sizeof(int) とか sizeof(long) とか sizeof(long long) とかあまつさえ sizeof(wchar_t) までもが処理系依存*3とかいう C/C++ のヘボ仕様は、もういい加減勘弁してほしいです……JavaC# がはるか昔に(というか最初から)サポートしているものを、いつまでたってもサポートしようとしない C/C++ にはホント嫌気が差してきます。膨大なC/C++コード資産を再利用しなければならないなどの正当な理由がないかぎり、今後はJava/C#のようなプログラマーに優しい真の高水準言語を積極的に選択するべきだと思います。

余談:%lc, %ls, %wc, %ws, %C, %S 書式

%c, %s には長さ修飾子 l を指定することができます。本来、wchar_t型やwchar_t*型を可変個引数に指定する際には必須の修飾子なのですが、MSVC付属の CRT およびそれらを内部的に使用している ATL/MFC では、ワイド文字版関数に関しては %lc, %ls はそれぞれ %c, %s のシノニムになっており、長さ修飾子を指定せずともwchar_t型やwchar_t*型とみなすようになっています(標準規格外の動作)。なお、非標準の長さ修飾子として wもサポートしており、%wc, %ws はそれぞれ %lc, %ls と等価です。

MSVCでは、ほかに非標準の書式として %C, %S 書式もサポートしています。これらの書式を使うと、マルチバイト書式文字列でワイド文字列を可変個引数に受け付けたり、逆にワイド書式文字列でマルチバイト文字列を受け付けたりできます。具体的には、

CStringA strA;
strA.Format("%S\n", L"hoge");

とか、

CStringW strW;
strW.Format(L"%S\n", "hoge");

とかいうことができるようになります。ただし、CString の場合、const TCHAR* 書式文字列において %C, %S を使うと、MBCS 設定と UNICODE 設定では意味が逆になり混乱するので、使用は推奨されません。また、内部的に char ⇔ whcar_t の変換が入るので、統一できるときはしておかないと余計なオーバーヘッドが発生する羽目になります。どちらにせよ、やはり可変個引数は MBCS と UNICODE をきちんと理解している人間のみが使うべきでしょう。

なお、size_t と ptrdiff_t に関しても、サイズがターゲット プラットフォームに依存するので、こいつらを文字列変換する場合にも細心の注意が必要となります。

sygh.hatenadiary.jp

*1:Boost.Format を使えばかなり改善されるんですが、C++11 においても標準ライブラリには取り込まれていないマイナーライブラリなのが欠点です。また、string/wstring の違いはやはりプログラマーが意識する必要があります。

*2:ただし、char16_tもchar32_tも最小のサイズが保証されるだけで、型のサイズがそれぞれ2バイトと4バイトにキッチリ規定されるわけではありません。相変わらずいい加減で、極めて残念な規格です。

*3:ちなみにsizeof(char)は常に1であることが規格で保証されますが、1byteが8bitであることは保証されません。これは新しい規格においても改善されておらず、C/C++はいつまで経っても雑な言語仕様を引きずったままです。