コンパイラが生成する型とAPI

前回 の続き.

C言語のおさらい

繰り返しすぎてそろそろクドいかもしれないが,C言語は,いろいろとフリーダムすぎて扱いづらい.

にも関わらず,UnixというOSの記述言語を超えて広く使われた理由の一つに,ポインタを用いた自由なメモリアクセスがある.

OSやハードウェアの制約がなければ,プログラム自身を含む,全てのメモリは参照可能で,書き込みもできる. ポインタは,加減算によってアドレスを変更できる.ポインタ同士の加減算も可能だ.

自らが管理する変数を,ポインタを用いて,書き換えることもできる. そのような操作を支援するために,特定の変数が専有しているメモリサイズを取得することが可能だ.…など書くと無駄にややこしいが,要するに sizeof 演算子がある.

sizeof は sizeof(foo) など括弧を添える書き方が一般的なので,ときどき関数やマクロと誤解されるが,演算子 である.本稿では,この事実が大きな意味を持つ.

sizeof によって得られる値は?

sizeof の演算結果は,正整数となることは確実だ.マイナスのサイズなんてありえない. さて,では,unsigned int が適切だろうか? それとも unsigned long?

ここで,C言語の整数型のフリーダムっぷりが仇となる. まず,unsigned long が扱える範囲が処理系定義だ. 加えて,仮想記憶などの技術で,またはムーアの法則に沿って,実装可能なメモリの量は爆発的に増えた. そのため sizeof の結果が unsigned long の上限を超える可能性が出てきた.

たとえば,unsigned long がUnix系の慣習に沿って32ビットだとしたら,4GB以上のメモリを占める配列の sizeof の結果は格納できない. 本稿読者には言うまでもないが,4GB以上のRAMを持つ機器は,いまや珍しくない.

このような問題への対処として,sizeof の結果を保存するための型を,C言語仕様は導入した. それが,size_t である.

size_t は,多くの場合,符号なし整数型の typedef となる. しかしどの型の typedef なのかは,処理系次第である. 動作するCPUが扱えるメモリサイズによって,処理系の作者が決める.

たとえば,メモリアドレスの上限が65535なのに 32ビット分を割り当てても無駄だろう. 16ビットのメモリ空間なら,uint16_t 相当になる可能性はある.同様に 32ビットなら uint32_t 相当だろうし,もし 24ビットのメモリ空間なら uint24_t になるかもしれない.でも24ビットの型を作るのは面倒だからuint32_t相当にしました,という実装もあるかもしれない.

こんな按配なので,C言語を用いるプログラマは,size_t の最大値を決め打ちしたコーディングをしてはいけない. そのコーディングの典型が,(値チェックを伴わない)size_t 以外の整数型変数への代入である.

これは好みの問題ではない. 仕様から容易に導き出される,事実だ.

蛇足になるが,このように演算結果により処理系が生み出す型は,ptrdiff_t など他にもある. 移植性を保ったAPIを考える際には,これらの型についても十分に理解しておく必要がある.

いつになく噛み付いた理由

ここまでくると,mruby の API 変更を意図した Matz 氏のコミットについて,私が噛み付いた理由が見えてくるだろう.

mruby の mrb_int 型は,実際は符号付き整数型の typedef だ. しかも,mruby の実行環境に合わせて,ビット幅は変えてもよい造りになっている.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* 参考: include/mruby/value.h から引用 */

#if defined(MRB_INT64)
# ifdef MRB_NAN_BOXING
#  error Cannot use NaN boxing when mrb_int is 64bit
# else
   typedef int64_t mrb_int;
#  define MRB_INT_BIT 64
#  define MRB_INT_MIN INT64_MIN
#  define MRB_INT_MAX INT64_MAX
#  define PRIdMRB_INT PRId64
#  define PRIiMRB_INT PRIi64
#  define PRIoMRB_INT PRIo64
#  define PRIxMRB_INT PRIx64
#  define PRIXMRB_INT PRIX64
# endif
#elif defined(MRB_INT16)
  typedef int16_t mrb_int;
# define MRB_INT_BIT 16
# define MRB_INT_MIN INT16_MIN
# define MRB_INT_MAX INT16_MAX
#else
  typedef int32_t mrb_int;
# define MRB_INT_BIT 32
# define MRB_INT_MIN INT32_MIN
# define MRB_INT_MAX INT32_MAX
# define PRIdMRB_INT PRId32
# define PRIiMRB_INT PRIi32
# define PRIoMRB_INT PRIo32
# define PRIxMRB_INT PRIx32
# define PRIXMRB_INT PRIX32
#endif

一方,size_t は,符号なし整数型の typedef だ. しかも,ビット幅は判らない.

これらを,API 関数呼び出しという,値チェックを伴わない(実質的な)代入を行った時,何が起こるか.

先日とりあげた,暗黙的なダウンキャストが,起こる. 特定環境のみ,かつ特定のアプリケーションのみで.

「概ね大丈夫だが,たまに致命的に動かない.」 こういう挙動は,ライブラリとしては,忌み嫌われる最悪のパターンである.

strlen は問題ではない.

この問題に対し Matz 氏が一部を revert した際のコミットメッセージは下記のとおりだ.

change mrb_int to size_t that would take strlen()

コミットメッセージなんて,ちゃっちゃっと済ますものなので,筆が滑ることはよくある. Matz 氏は実は理解しておられるだろうが,そうでない人もいるだろうから,書いておく.

strlen() の返値も,size_t ではある. しかし,別に strlen() はどうでもよい. なぜならば,strlen() は関数だから. mrb_int を返す strlen() の代替を作るのは,難しい作業ではない.

しかし,sizeof は演算子である. Cにはそもそも演算子オーバーロードの機能が無いし,C++ でさえオーバーロードできない. だから,sizeof 演算子の結果の型を size_t 以外にすることは,不可能である.

そして,mruby の文字列系・シンボル系の長さには sizeof 演算子が使われる可能性が極めて高い. なにしろ,mruby の API 自身に, sizeof を内包するものもあるのだから.

1
#define mrb_strlen_lit(lit) (sizeof(lit "") - 1)

というわけで,C言語仕様の視点から,なぜ私が一連のAPI変更に異議を立てたのかということを纏めた. 本件は,好みの問題ではない. ISO Cの仕様を踏まえると,自明として適切な結果が浮かび上がる.

とはいえ,mrb_int にしたくなる気持ちも解らなくもない. 明日以降,その辺りの,わりと人情系というかピープルウェア的な話で,一連の話題を閉じたいと思っている.