ヌルポインタ定数がヌルポインタと解釈されない時──CとC++のヌルポインタ定数をめぐるメモ

これを見て、
リリカル☆Lisp開発日記 » Blog Archive » 恐怖! 64bitと0と可変長引数の組み合わせ
最近は『CプログラミングFAQ』を読む人も少ないのだろうかと思ったのだけど、よく考えればあの本はまた絶版になってるんだよなあ。それに例えばC言語を経ずにC++を触り始めたような人は読んでないよなあ、だってC++じゃなくてC言語の本なんだもの、当たり前だ。

※ここに挙げたブログの主が「C言語を経ずにC++を触り始めたような人」だと主張している訳ではないので注意。

ヌルポインタに関しては概念とハードウェアやコンパイラの都合とC/C++の規格と色々あって、ヌルポインタとかNULLと一言書いたときにそれがどういう意味を示しているのか分かりにくい。なのでこのエントリではC-FAQを参考にして次の用語を使おうと思う。

ヌルポインタの概念 「いかなるオブジェクトも関数も指し示していない」という状態のこと。
ヌルポインタ ハードウェアやプロセス上で実際に「ヌルポインタの概念」を表現する為の値。またはプログラム実行時にヌルポインタとして実際に使用されている値。
ヌルポインタ定数 C言語C++ソースコード上にて「ヌルポインタ」を表現する為の定数。
NULLマクロ C言語だとstddef.hやstdio.hあたりで定義されているアレ。プリプロセッサによってヌルポインタ定数に置換される。

まずヌルポインタはハードウェアによって異なるし、ポインタの型によってヌルポインタが異なる可能性もある。具体的な例についてはデータが古いけどC-FAQ 5.17を参照。

一方でヌルポインタ定数についてはC言語C++も言語の規格で決まっている。不勉強なので最新の規格は分からないのだけど、C89やC++ 2003ではヌルポインタ定数は「0と評価される整数定数式」で、その中でも0や0Lあたりが有名(あくまでもこれはC言語C++ソースコード上での話で、ヌルポインタそのもののビット列が0であるとは限らない*1)。ヌルポインタ定数の定義がこうなった経緯は知らない(誰か知ってないかなあ)。あとC++ 11のnullptrとかstd::nullptr_tのことも知らない。

元々C言語においても、ポインタを書くべきところ(とコンパイラが判断したところ)に定数0や0Lなどのヌルポインタ定数に該当する定数式が書かれていた場合、コンパイル時にヌルポインタに変換されるようになっている。これはANSI C規格でそのように定義されている。

ただ実際にC言語でコードを書くときはNULLマクロを使うし、既存のコードもNULLマクロを使っていることが多い。これは基本的には単なるスタイルの問題で、整数値の定数としての0/0Lとヌルポインタ定数としての0/0Lが混在するのをよしとせず、NULLマクロを使って「これはヌルポインタ定数」と明示している人が多いだけだったりする。

この辺りは『プログラミング言語C』の影響が大きいような気がする。あと『プログラミング作法』では定数0を書き分けることが推奨されていて、

──こんな風に使い分けようという主張なのだけど、よく考えれば作者2人ともベル研でのUNIX開発に関わりがあった人で、ブライアン・カーニハンはK&RのKだしロブ・パイクUNIXどころかPlan9などの後継のプロジェクトにも関わっていた訳で、NULLマクロを使う習慣は実はC言語が生まれた本家本元由来のものではないかと妄想している(UNIX v6のソースでもNULLマクロを使っているみたい)。

もう1つ、C言語ではNULLマクロの中身が「((void *) 0)」なことも多い(で、ANSI Cの規格でも許されている)のだけど、これについては後述。

C++では(C++ 2003あたりの規格を元にすれば)NULLマクロの中身はヌルポインタ定数で、ヌルポインタ定数の中には0や0Lなどの整数定数式が含まれている。「((void *) 0)」が無くなったのは、C++の言語仕様的にそれがヌルポインタ定数に含まれていないこともあるけど、多分C++では暗黙の型変換による危険性を減らすためにC言語よりも型の取り扱いが厳しくなったことも影響している。例えばvoid *を任意のポインタに代入する時、C言語ではキャスト不要だけどC++では明示的なキャストが必要だ。

で、本題。

先ほどさらっと「ポインタを書くべきところ(とコンパイラが判断したところ)に定数0や0Lなどのヌルポインタ定数に該当する定数式が書かれていた場合」と書いたのだけど、裏を返せばコンパイラが「ポインタを書くべきところ」と判断できなければヌルポインタに変換してくれない。

そんなことがあるのかというと大有りで、例えばC-FAQ 5.2では次の2つのケースが挙げられている。

  1. (関数の)プロトタイプがスコープにないときの関数呼び出し
  2. 可変個引数の関数引数

まず (1) の時、Cコンパイラは引数の型をとにかく何でもint型と解釈してコンパイルするので、引数に書いたヌルポインタ定数としての0はヌルポインタではなく整数値の0と解釈されてしまう(C++の場合は……忘れてしまった。Visual C++では呼び出し規約をみる限り整数値の0と解釈される気がする)。(2) の場合、そもそも可変個引数の関数では引数の型をチェックできないので、コンパイラが自動的に「ここはポインタを書くべきところだ」と判断してくれるはずがない*2

(1) や (2) に該当する状況で0や0Lあたりのヌルポインタ定数をそのまま使っていて何の問題もないとしたら、多分それは単なる偶然だ。たまたまヌルポインタと整数定数値0/0L等の内部表現(大きさも含む)が全く同じだったからに過ぎない。コンパイラが吐いたコードは整数定数値0/0L等のコードで、決してヌルポインタのコードではない。

加えてC++では例えば関数のオーバーロードで引数がint型の関数と何らかのポインタ型の関数がある場合、引数にヌルポインタ定数を渡すと引数がint型の方の関数が呼ばれてしまったりする。

(1) の場合はそもそも「関数プロトタイプがスコープにない状態での関数呼び出しなんて爆発しちまえ!」なのだけど、(2) やC++オーバーロードでの問題にはどう対処すれば良いか? 答えは単純で「呼ばれる側の関数が想定しているポインタ型にキャストしろ*3」だ。

なので件のブログのコードでいうなら、仮にC言語のコードだったならばおそらく、

GetParam("foo", 4, &bar, (int*)0, &baz, (int*)0);

もしくは、

GetParam("foo", 4, &bar, (int*)NULL, &baz, (int*)NULL);

のような風に直すのが正しい。実際にはC++のコードみたいだけど、この辺りの機能はC言語由来な事もあるので、多分C++ 2003的にもint *にキャストする方がベターだと思う(ちょっと自信が無い。C++ 11的にはnullptrも有りかもしれないけど、引数がint *の関数とdouble *の関数でのオーバーロードとかだとどうなんだろう? オーバーロードで定義できるのなら、呼び出すときにキャストで型を明示する必要がある気がする)。

何で「関数の都合に合わせて適切なポインタ型に」キャストするのかというと、コンパイラに「これはポインタの型だよ」と明示する為というのもあるけど、環境によってはデータ型によってポインタの内部表現が異なるから。例えばchar *とint *とで大きさが異なる場合、可変個引数の関数にてchar *のヌルポインタを想定している所にint *のヌルポインタが来た時、sizeof(char *) > sizeof(int *)ならスタックからその値を取り出す時に本来触るべきでない部分を触ってしまうだろうし、sizeof(char *) < sizeof(int *)ならsizeof(char *)分のデータしか読み出さなくて(アライメントの都合にもよるけど)以降の引数の値を正しく取得できなくなる可能性がある。もっともC言語ならともかくC++のコードをそういうハードウェア向けに書いたり移植したりすることがあるか否かとなると……。

ちなみにC言語でNULLマクロの中身が「((void *) 0)」の場合、もし全てのポインタの型においてヌルポインタが(大きさを含めて)全く同じ値であるのなら、先ほどの (1) や (2) のシチュエーションにて引数にキャスト無しでNULLマクロを書いていても正しく動作する。void *にキャストしている為にコンパイラが正しくヌルポインタのコードを吐き出して、且つvoid *とint *とでヌルポインタが同じなので食い違いが発生しないからだ。但し移植性は落ちる。

GetParam("foo", 4, &bar, NULL, &baz, NULL);

これが仮にC言語のコードで且つ手元のハードウェアでは問題なく動作しているとすると、複数のアーキテクチャをサポートする必要が生じた時に問題が起こる可能性がある。

C言語では「((void *) 0)」と似たような理由でヌルポインタ定数として0ではなく0Lあたりを使うようにNULLマクロが定義されている環境もある。ヌルポインタと整数値0のビット列が同じで且つ例えばポインタ型が32bit、int型が16bit、long型が32bitの環境では、可変個引数の関数でポインタを想定している箇所にキャスト無しでヌルポインタ定数として0が書かれた場合、コンパイラはその0をint型の0と解釈して16bitのコードを吐き出すので、関数内でその値をスタックから取り出す時に本来触るべきでない16bit分を触ってしまうし、その影響で読み取った値のビット列がヌルポインタのビット列と一致しない可能性がある。しかしlong型の0Lなら32bitなので、意味的には正しくないけどプログラムの動作的には問題が起きなくなる。このような本来は誤っているソースコードをサポートする為にNULLマクロの中身を0でなく0Lにしていることがある。C-FAQ 5.7を参照。

あとNULLマクロの中身が「((void *) 0)」だとヌルポインタ定数を使うべきでない所にNULLマクロを書いていると警告が表示される可能性もなくはない(ヌルポインタとASCIIのヌル文字を混同しているケースとか)。

どちらにしろヌルポインタとヌルポインタ定数に該当する整数式が単なる整数値として解釈された場合とでコンパイル後のビット列が同じであるとは限らないし、環境によってはポインタ型の間でも大きさや内部表現に違いがあるので、

  • 関数のプロトタイプがスコープにないときの関数呼び出し
  • 可変個引数の関数呼び出し
  • C++で関数をオーバーロードしている時の関数呼び出し

これらのコンパイラが引数の型を判断できない(ないし判断できない可能性がある)シチュエーションで引数にヌルポインタ定数ないしNULLマクロを書く場合は、適切なポインタの型にキャストするべきではないかと考えている。

追記

C++にてNULLマクロの中身のヌルポインタ定数が単純な0や0Lあたりではなく何か特殊なシンボルなコンパイラの場合、明示的にキャストしてなくても、少なくともコンパイラがヌルポインタではなく整数定数値0のコードを吐き出してしまうことは起きないと思う。

但しその場合もデータ型によってポインタの大きさや内部表現が異なるような環境では問題が起こりうるのではないだろうか? 誰か教えて。

*1:ヌルポインタのビット列が0でないとしたら、なぜ「if (p)」のようなコードが正しく動作するのか? この辺はC-FAQ 5.3を参照。

*2:よくprintf(3)などでフォーマット指定と実際の引数の型が異なるとコンパイル時に警告が出るけど、あれはコンパイラの独自機能でフォーマット文字列の中身を静的解析している。試しにフォーマット文字列を動的に生成させたりprintf(3)ライクなオレオレ可変個引数関数を定義して使ってみると、警告は出ない。

*3:C-FAQ 5.15より。