今回紹介する記事は、タイトルからしてかなり挑発的です。
「Everything in C is undefined behavior」――直訳すれば「Cのすべては未定義動作だ」。もちろん厳密には言いすぎですが、著者はあえて極端な表現で、C/C++の怖さを読者に叩きつけています。
率直に言うと、これはかなり面白い記事です。
なぜなら、単に「Cは危ないよね」で終わらず、**“どこがどう危ないのか”** を、具体例を積み上げながら見せてくるからです。しかもその例が、いかにもありがちなものばかり。読んでいて「え、それもダメなの?」と何度もなるはずです。
記事の出発点はシンプルです。
著者は30年近くC/C++を書いてきた人らしく、単なる外野の批判ではありません。
そのうえで「正しいCやC++を書くのは、もはや無理ではないか」とかなり強い口調で語っています。
ここは少し意見が分かれるところだと思います。
私は「さすがに 誰も 正しいコードを書けない」は言い過ぎだと思う一方で、**“正しく書ける人がいても、間違えやすさそのものが言語に深く埋まっている”** という指摘はかなり本質的だと思います。
未定義動作、いわゆる UB(undefined behavior) は、簡単に言うと、
そのコードが動いたときに、何が起きてもC/C++の仕様上は保証されない
という状態です。
ここが重要です。
「たぶん落ちる」とか「たまに変な値になる」ではありません。
仕様として“何が起きてもよい”ことになっている のです。
なので、コンパイラは「そんなことは起きない前提」で最適化できます。
著者が強調しているのは、UBは「コンパイラが意地悪をしている」のではなく、そもそもコンパイラに“この場合どうするか”を義務づけていないという点です。
個人的には、ここがCの難しさの核心だと思います。
人間は「意図」を読めますが、コンパイラは「仕様」しか読めない。
このギャップが、Cではかなり致命的なんですよね。
よくある誤解として、
「最適化を切ればUBは大したことない」という考えがあります。
記事はこれをはっきり否定します。
UBは「最適化が暴走する話」ではなく、**“そのコードがそもそも言語仕様の外に出ている”** という話だからです。
つまり、コンパイラは
という立場になります。
これ、地味に怖いです。
今日たまたま動いていても、コンパイラの版が変わっただけで挙動が崩れる可能性がある。
「現時点で動く」は、「安全」ではないんですよね。
たとえば、こんな関数です。
int foo(const int *p) {
return *p;
}
見た目は普通です。
でも p が int に必要な位置に揃っていないと、これがUBになります。
アラインメント とは、簡単に言えば「データを置くべき位置のルール」です。CPUによっては、このルールがかなり厳しいです。
記事では、
という具合に、アーキテクチャ差を挙げています。
ここがCの嫌なところで、**“自分のPCで動く”が何の保証にもならない**。
しかも未来のCPUでは、また違う扱いになるかもしれない。
著者の言う通り、これは本当に「電話ゲーム」みたいなものです。仕様 → コンパイラ → CPU → 実機、のどこかで意図が崩れる。
これもかなり嫌なポイントです。
const int *magic_intp = (const int *)bytes; // UB!
多くの人は「まだ参照してないからセーフでは?」と思うかもしれません。
でも記事の主張では、キャストした時点で問題になることがある のです。
これは本当に直感に反します。
「読み出してから壊れる」のではなく、「そういう型のポインタだとみなした瞬間にアウト」になりうる。
Cはこういう“先回りの地雷”が多いので、慣れていても油断できません。
isxdigit() に char をそのまま渡す問題bool bar(char ch) {
return isxdigit(ch);
}
ぱっと見、何が悪いのか分かりにくいです。
でも char が signed の環境では、値によっては負数になりえます。
isxdigit() のような文字判定関数は、基本的に int を受け取る 前提で、しかも範囲が想定されています。
もし不正な値が来たら、内部で配列アクセスのような処理が起きて、想定外の場所を読んでしまうかもしれない。
最悪、メモリ上の別の領域を触るかもしれない。
ここで面白いのは、文字判定という「すごく日常的な処理」ですら危ないこと。
Cは本当に、平和そうに見える場所にトラップを仕込んでくるな、と思います。
記事では、秒をミリ秒に変換する例が出ます。
int tmp = (int)(seconds * 1000.0);
これ、実際にかなりのコードで見ます。
でも問題は、変換結果が int の範囲に収まらない場合はUB だということです。
さらに、浮動小数点が非有限値(NaNや∞)でも危険です。
「じゃあ範囲チェックすればいいじゃん」と思うのですが、そこにも罠がある。
int を float に変換する時点で丸めが入るかもしれず、比較自体が怪しくなる。
著者はそこを丁寧に追いかけていて、読んでいると「うわ、面倒くさすぎる」となるはずです。
私もここはかなり印象的でした。
“ただの型変換” が、こんなに神経を使う操作になる言語は珍しい と思います。
もちろん厳密な世界では意味があるのですが、一般的な開発で毎回これを気にするのは、かなりしんどいです。
ゼロ番地にオブジェクトを置く話も出てきます。
これはOSカーネルや組み込みで出る話で、一般アプリではあまり見ません。
でも著者が言いたいのは、「NULL は単なるアドレス0ではない」ということです。
Cの世界では、NULL は**“ヌルポインタ定数”** であって、必ずしも物理アドレス0を意味しません。
しかも、null pointer を参照するのはもちろんUBです。
そして、memset(&ptr, 0, sizeof(ptr)) でポインタが必ずNULLになるとも限らない。
ここは、Cの“歴史の重さ”を感じるところです。
昔の機械ではNULLが0じゃなかったこともある。
つまり、私たちが当たり前だと思っている感覚も、言語仕様の上では保証されていないんですね。
printf や execl のような variable arguments は特に危険です。
printf("%ld\n", blah); // WRONG
uint64_t に %ld を使うとダメ。
正しくは PRIu64 のようなマクロを使うべきです。
これ、地味に面倒です。
でも、可変長引数は「コンパイラが型を十分に検査しづらい」ので、ズレるとかなり危険。
“見た目は似ているのに、型が1個ずれただけで崩壊する” のが、Cの怖さです。
個人的には、ここは「Cの美しさ」ではなく「Cの不親切さ」が最も現れる場面のひとつだと思います。
これはさすがに有名です。
でも記事は、ここにもセキュリティ上の意味があると指摘します。
割る側の値が外部入力なら、攻撃につながることもある。
つまりUBは、単なる「バグ」ではなく、脆弱性の入口 になりうるわけです。
この視点は大事です。
未定義動作は「ちょっと危ない」ではなく、安全保障の問題 でもある。
最近のソフトウェア開発で、C/C++が厳しく見られる理由のひとつはここにあります。
この文章は、単に「Cは危険」と騒ぎたい記事ではないと思います。
むしろ本質は、
という問題提起ではないでしょうか。
ここはかなり重要です。
「注意して書けばいい」で片づけるのは簡単ですが、記事を読むとそれがいかに無理筋かが見えてきます。
もちろん熟練者はかなり避けられる。でも、完全にゼロにするのは相当に難しい。
著者の悲観は強めですが、現場感としては分からなくもないです。
そこまでは言いません。
実際、C/C++は今も大量の基盤ソフトウェアを支えています。
OS、ブラウザ、組み込み、ゲームエンジン、高性能処理……どれも簡単には置き換えられません。
ただし、この記事を読むと、
「C/C++を使うなら、危険を理解した上で、相当意識的に設計しないといけない」
という現実はかなり強く感じます。
私の感想としては、Cは今でも強力だけれど、もはや「気軽に使える便利な言語」ではないです。
むしろ、高い性能と引き換えに、仕様の地雷原を歩く言語 だと思ったほうが近い。
タイトル通り、記事はかなり煽り気味です。
なので「Cの全部がUB」というのはもちろん比喩です。
実際には正しく書ける部分もあるし、厳密に運用すれば安全性をかなり上げることもできます。
でも、誇張が効いているぶん、伝わるものもあります。
“自分のコードは大丈夫” という油断を壊す には、これくらい強い言い方のほうが効くのかもしれません。
この元記事は、C/C++の未定義動作を「よくあるミス」ではなく、言語の根っこにある構造的な危うさとして描いています。
そしてその描き方が、かなり鋭いです。
技術的に正しいだけでなく、読み物としても面白い。
とくに、
「動くじゃん」
「最適化切れば平気でしょ」
「このくらい大丈夫でしょ」
という“雑な安心”を次々に崩していく構成が見事です。
C/C++を使う人なら、少なくとも一度は読んでおきたいタイプの記事だと思います。
そして、Cをあまり知らない人にとっても、低レベル言語の世界では「常識」がいかに脆いか を知る入門としてかなり良い素材ではないでしょうか。