【汎整数拡張】第2回 単項変換

前回は、以下のことを確認しました。

  1. 基本形が同じならば、signed/unsigendでキャストしようが、その内部表現(ビット列)は変化しない。
  2. 内部表現が同じであっても、その数学的値が同じとは限らない。

今回は、char/unsigned charをint/unsigned intにそれぞれ型変換した場合、その内部表現はどうなるかという実験です。

 19 int main(int argc, char **argv){
 20
 21     char c = -1;
 22     unsigned char uc = 255;
 23
 24     /*
 25         変換なし char/uchar
 26     */
 27     printf("> -1 : char\n");
 28     bit_dump(c, sizeof(c));
 29     printf("\n");
 30
 31     printf("> 255 : unsigned char\n");
 32     bit_dump(uc, sizeof(uc));
 33     printf("\n");
 34
 35     /*
 36         単項変換 char/uchar -> int
 37     */
 38     printf("> -1 : +(char)\n");
 39     bit_dump(+c, sizeof(+c));
 40     printf("\n");
 41
 42     printf("> 255 : +(unsigned char)\n");
 43     bit_dump(+uc, sizeof(+uc));
 44     printf("\n");
 45
 46     /*
 47         明示的キャスト
 48     */
 49     printf("> (int)char\n");
 50     bit_dump((int)c, sizeof((int)c));
 51     printf("\n");
 52
 53     printf("> (unsigned int)char\n");
 54     bit_dump((unsigned int)c, sizeof((unsigned int)c));
 55     printf("\n");
 56
 57     printf("> (int)unsigned char\n");
 58     bit_dump((int)uc, sizeof((int)uc));
 59     printf("\n");
 60
 61     printf("> (unsigned int)unsigned char\n");
 62     bit_dump((unsigned int)uc, sizeof((unsigned int)uc));
 63     printf("\n");
 64
 65     return 0;
 66 }

実行結果

> -1 : char
1111 1111

> 255 : unsigned char
1111 1111

> -1 : +(char)
1111 1111 1111 1111 1111 1111 1111 1111

> 255 : +(unsigned char)
0000 0000 0000 0000 0000 0000 1111 1111

> (int)char
1111 1111 1111 1111 1111 1111 1111 1111

> (unsigned int)char
1111 1111 1111 1111 1111 1111 1111 1111

> (int)unsigned char
0000 0000 0000 0000 0000 0000 1111 1111

> (unsigned int)unsigned char
0000 0000 0000 0000 0000 0000 1111 1111

int型より精度の低い型(char型など)が式中で評価された場合、その型はint型に型昇格します。int型で表現できない場合、unsigned int型に型昇格します。これを単項変換と呼称します。

例えば、char型オブジェクトの先頭に単項+演算子をつけると、char型オブジェクトは式の一部として評価されるため、int型に昇格します。その結果、内部表現のビット長は、int型のバイトサイズ(以下の場合は4バイト=32ビット)に伸長されます。

> -1 : +(char)
1111 1111 1111 1111 1111 1111 1111 1111

> 255 : +(unsigned char)
0000 0000 0000 0000 0000 0000 1111 1111

結果型のビット長が大きい場合、上位ビットに空きができるわけですが、その空きビットを0/1のいずれで埋めるか、という問題があります。

  1. char→intの型昇格においては、符号ビットの値と同じビットで埋める。
  2. unsigned char→intの型昇格においては、0で埋める。

これは、「型昇格前と型昇格後では、その数学的値は変化しない」と考えれば道理ですが…

そうすると、負数のchar→unsigned intへの型昇格ではどうなるか。そもそもunsigned intには符号の概念が無いので、負数をどうやって表現するのか、という問題が出てきます。これについては、以下の通りとなります。

> (int)char
1111 1111 1111 1111 1111 1111 1111 1111

> (unsigned int)char
1111 1111 1111 1111 1111 1111 1111 1111

char→intへの型昇格は先ほどの単項変換の通りだとして、char→unsigned intへの型昇格においては、

char → int → unsigned int

という二段階の型昇格が行われます。int→unsigned intへの型昇格においては、同じビット長なので内部表現は変化しない。結果として、char→unsigned intへの型変換は、char→intへの変換と同様となります。

unsigned char→int/unsigned intへの型変換は、以下の通り、空きビットをただ0で埋めるだけです。

> (int)unsigned char
0000 0000 0000 0000 0000 0000 1111 1111

> (unsigned int)unsigned char
0000 0000 0000 0000 0000 0000 1111 1111

空きビットに何がセットされるかをまとめたのが、以下の表です。
(行見出しは昇格前の型。列見出しは昇格後の型。-は空きビットが発生しないことを示す)

  char型 uchar型 int型 uint型
char型 - - 元の符号ビット 元の符号ビット
uchar型 - - 0 0

こうして見ると、型昇格後の空きビットに何がセットされるかは「昇格前の型(の符号修飾子)」に依存することが分かります。

char型変数に-1を代入すると、内部表現としては「1111 1111」となりますが、それをunsigned intに型昇格する際、その数学的値は「255」とは解釈されないことに注意したいです。charはいったんintに型昇格しますが、int→unsigned intの型昇格においては内部表現は変化しないため、その数学的値は

(char)-1 → (int)-1 → (unsigned int)2^64-1

と変化します。

【汎整数拡張】第1回 内部表現と数学的値

C言語はポインタが難しい」というのが定説ですが、ポインタは四天王の中でも最弱だと思っています。ポインタ以上に難解なのが、この「汎整数拡張」。インテグラル・プロモーション(integral promotion)とも言います。

汎整数拡張の挙動を確認する上で、ビットレベルで値を確認するのは必須ですが、そういう標準ライブラリは存在しないので、これは自作します。引数numに二進数表示した整数値を指定し、lenには、その整数型のビット幅をsizeof演算子で求めた値を指定すると、指定されたビット幅での二進数表現が表示されます。

  4 void bit_dump(unsigned int num, size_t len){
  5
  6     size_t  bitlen = len*8;
  7     unsigned int mask;
  8
  9     for(bitlen=len*8; bitlen>0; bitlen--){
 10         mask = (1<<(bitlen-1));
 11         printf("%d", !!(num&mask));
 12         printf("%c", (bitlen%4==1 && bitlen>0 ?' ':'\0'));
 13     }
 14     printf("\n");
 15
 16     return;
 17 }

今回はchar型にしぼって、char型/unsigned char型の-1/255、異種型の値を互いに代入したもの、異種型にキャストしたもの、のそれぞれのビットパターンを確認してみます。

 19 int main(int argc, char **argv){
 20
 21     char c = -1;
 22     unsigned char uc = 255;
 23
 24     /*
 25         値の表示
 26     */
 27     printf("> -1 : char\n");
 28     bit_dump(c, sizeof(c));
 29     printf("\n");
 30
 31     printf("> 255 : unsigned char\n");
 32     bit_dump(uc, sizeof(uc));
 33     printf("\n");
 34
 35     /*
 36         代入
 37     */
 38     uc = c;
 39
 40     printf("> unsigned char = char\n");
 41     bit_dump(uc, sizeof(uc));
 42     printf("\n");
 43
 44     c = uc;
 45
 46     printf("> char = unsigned char\n");
 47     bit_dump(c, sizeof(c));
 48     printf("\n");
 49
 50     /*
 51         明示的キャスト
 52     */
 53     printf("> (unsigned char)char\n");
 54     bit_dump((unsigned char)c, sizeof((unsigned char)c));
 55     printf("\n");
 56
 57     printf("> (char)unsigned char\n");
 58     bit_dump((char)uc, sizeof((char)uc));
 59     printf("\n");
 60
 61     return 0;
 62 }

実行結果

> -1 : char
1111 1111

> 255 : unsigned char
1111 1111

> unsigned char = char
1111 1111

> char = unsigned char
1111 1111

> (unsigned char)char
1111 1111

> (char)unsigned char
1111 1111

charの-1、unsigned charの255、それぞれの値を入れ替えて代入した変数の値、それぞれの型を入れ替えてキャストした値の内部表現は、いずれも「1111 1111」となります。以前も書きましたが、(char)-1〜-128と(unsigned char)255〜128は、同じビットパターンを共有しています。
-128〜127、0〜255のビットの動き

このビットパターン「1111 1111」をcharとして解釈すれば、数学的値は「-1」、unsigned char型としては「255」となります。「1111 1111」というビットパターンのことを「内部表現」と呼称します。この内部表現を特定の型で解釈した-1や255の値のことを「数学的値」(便宜的に10進数で表現)と呼称します。

同じビットパターンでも、「型」という解釈によって、その数学的値が異なるということは、内部表現(ビットパターン)と型の組み合わせで、その数学的値は決定されることになります。このビットパターンを16進数で表現すれば「FF」と表記されるわけですが、2進数/16進数はあくまで表記の違いなだけで、その表現される数学的値とは無関係です。当たり前のことですが、ここを区別しておきたいものです。

malloc(3)に負の値を渡すと

「詳説Cポインタ」p43より。

2.2.1 malloc関数の使い方
(略)
 void* malloc(size_t);

 malloc関数はsize_t型の引数を1つ取ります。この型は1章で扱いました。引数に値を指定する際には、
注意が必要です。負の値を指定した場合には、問題となります。システムによっては、引数が負の値で
あった場合には、NULLが返ります。

malloc(3)の引数に例えば-1を指定した場合ですが、malloc(3)の引数であるsize_tにキャストされた値で処理されます。size_tの型は、うちの環境だとこうです。

/* /usr/lib/gcc/x86_64-redhat-linux/4.4.4/include/stddef.h */
 25 /*
 26  * ISO C Standard:  7.17  Common definitions  <stddef.h>
 27  */ 
(略)
207 #ifndef __SIZE_TYPE__
208 #define __SIZE_TYPE__ long unsigned int
209 #endif
210 #if !(defined (__GNUG__) && defined (size_t))
211 typedef __SIZE_TYPE__ size_t;
212 #ifdef __BEOS__
213 typedef long ssize_t;
214 #endif /* __BEOS__ */
215 #endif /* !(defined (__GNUG__) && defined (size_t)) */

size_t型は、「__SIZE_TYPE__」(でマクロ定義された型名)の別名に定義されており、__SIZE_TYPE__はlong unsigned intにマクロ展開されます。

つまり、malloc(3)の引数に-1を指定した場合、-1はlong unsigned intに型変換されます。64ビット環境で0xFFFFFFFFFFFFFFFF、10進数で2^64-1。32ビット環境だと、4294967295、つまり4GB。このサイズが要求サイズに指定されるため、結果的にメモり不足エラーが返ってくるのでは、と考えられます。

  1 #include <stdio.h>
  2 #include <stdlib.h>
  3 #include <errno.h>
  4
  5 extern int errno;
  6
  7 int main(int argc, char **argv){
  8
  9     char    *p;
 10     int     errno_sv;
 11
 12     errno = 0;
 13     p = malloc(-1);
 14     errno_sv = errno;
 15
 16     printf("errno=%d p=%p\n", errno_sv, p);
 17     printf("sizeof(size_t)=%zd\n", sizeof(size_t));
 18
 19     return 0;
 20 }

実行結果

errno=12 p=(nil)
sizeof(size_t)=8

malloc(3)の戻り値にはNULLが返され、errnoに12(ENOMEM=Not enough space (POSIX.1) )がセットされました。

以下は、該当コード部分の逆アセンブル結果です。-1も(+2^64-1)も、内部表現は同じなので、malloc(3)側がこの値を符号ありとして扱うか、符号無しとして扱うかは、malloc(3)内部を見ないと判断できないですが。

	p = malloc(-1);
  40057e:	48 c7 c7 ff ff ff ff 	mov    rdi,0xffffffffffffffff
  400585:	e8 be fe ff ff       	call   400448 <malloc@plt>

引数に、(+2^64-1)を指定した場合。

	p = malloc(0xFFFFFFFFFFFFFFFF);
  40057e:	48 c7 c7 ff ff ff ff 	mov    rdi,0xffffffffffffffff
  400585:	e8 be fe ff ff       	call   400448 <malloc@plt>

配列とは結局こういうこと

配列の添字指定は「人間にとっての分かりやすい表現」であって、配列概念など知ったこっちゃない処理系は「ベースアドレス(先頭要素へのポインタ値)+要素サイズ*要素番号」という計算式で、配列の各要素にアクセスしているのです。配列が「先頭要素へのポインタ値」で管理されるのは、その配列が仮想アドレス空間上の連続した領域に確保されることが保証されているからであって、その前提があるのなら、以下のような邪悪なコードでもプログラムは動作します。

以下は、変数c1〜c6の領域が、「スタック上の連続した領域に、メモリアドレスの昇順で確保されている」前提で、c1領域のアドレスをベースとしたオフセット指定により、c1〜c6の各領域にアクセスしているコードです。変数c2〜c6は、「配列」領域を静的に確保するためだけに宣言したものであり、使用はしていません(スタックフレームのサイズはスタックポインタで調整されるので、動的確保とも言えますが)。

  1 #include <stdio.h>
  2
  3 int main(int argc, char **argv){
  4
  5     char c1 = 'A';
  6     char c2 = 'B';
  7     char c3 = 'C';
  8     char c4 = 'D';
  9     char c5 = 'E';
 10     char c6 = '\0';
 11     int i;
 12
 13     for(i=0; *((char *)&c1+i*sizeof(c1))!='\0'; i++)
 14         printf("%p : %c\n", (char *)&c1+i*sizeof(c1), *((char *)&c1+i*sizeof(c1)));
 15     printf("\n");
 16
 17     return 0;
 18 }

実行結果

0x7fff5d863b86 : A
0x7fff5d863b87 : B
0x7fff5d863b88 : C
0x7fff5d863b89 : D
0x7fff5d863b8a : E

ポインタについて語ってみた

ポインタ・配列についての「分かりやすい説明」を目指すことは、ネットの解説でも多くみかけますが、ポインタと配列は、根本的に日常とはかけ離れたアクロバティックな頭の使い方を求められるので、日常的直観との距離を縮める比喩的説明(住所を書いたメモを渡す、など)は、ポインタ理解にはあまり役に立たないのでは…と思います。

ポインタを一言で言うと、「住所を書いたメモを渡す」…確かにそうなんです。「ポインタとは何か」なら、その一言で終わってしまうんですが、ポインタに対する需要は「ポインタとは何か」ではなく、「感覚に頼らず論理的にポインタを使えるようになりたい」ことだと思います。

僕が思う解決策としては、そこはもう、とにかくポインタ・配列を使い倒して、身体で覚えるしかないかと。あとは「こんな風に書いたらどう作用するか」という実験を繰り返すことで、自分なりのポインタ理解を育てることが大切かと思います。

int matrix[][5] = {{100, 101, 102, 103, 104},
                   {200, 201, 202, 203, 204},
                   {300, 301, 302, 303, 304},};

int (*tbl_p)[ARRAY_NUM(*matrix)] = matrix;

for(i=0; i<ARRAY_NUM(matrix); i++){
    for(j=0; j<ARRAY_NUM(*matrix); j++)
        printf("%d ", *(*(tbl_p+i)+j));
    printf("\n");
}

例えば、上記のようなコードを書いてる(読んでる)時の、僕の頭の中を言語化すると、以下のような感じになります。

  1. 「tbl_pは、二次元配列matrixの先頭レコードへのポインタだ。ループカウンタiは、matrixのレコード番号を示している。jは、1レコードのフィールド番号を示している。
  1. tbl_p+iは、+の左辺が「配列へのポインタ」なので、+iはレコードサイズを1単位として、参照カーソルを移動する演算となる。
  1. *(tbl_p+i)は「*」によって「現在のレコードへのポインタ」を剥がすので、現在のレコード(配列オブジェクト)を返す。式中の配列オブジェクトは、「その先頭要素へのポインタ」を返す。したがって*(tbl_p+i)は「現在のレコードの先頭フィールドへのポインタ」を返す。
  1. *(tbl_p+i)+jは、+の左辺が「現在のレコードの先頭フィールドへのポインタ」なので、+jはフィールドサイズを1単位として参照カーソルを移動する演算となる。
  1. *(*(tbl_p+i)+j)は左端の「*」によって、上記のポインタを剥がす。したがって*(*(tbl_p+i)+j)は、現在のフィールドの値を返す。」

と、こんな風に、富田先生の英文解釈のように(笑)展開規則に従って書いて(読んで)います。

学生時代に通っていた英語学校のGRE Verbalの授業で、「英文中の空欄に当てはまる動詞を選択肢から選べ」という問題があったのですが、GREのVerbalなんて、それはもう日本人にはお目にかかることのない難単語ばかりなんですが、もちろん単語の意味をすべて知っている先生は、単語の意味から適切な答えを割り出していました。

単語の意味を知らない僕は「主語が単数なので、正解は三人称単数のsがついている動詞だ」という解析的な解き方をして、先生になるほど…と感心されたことがあるのですが、昔からこういう「言語を形式的に解析する」作業が好きで、いかに単語の意味をとらずに(ニュアンスなどというあやふやなものに頼らずに)答えを割り出すかにハマっていました。

話が脱線しましたが、よく数学の受験参考書に、三角関数とか微分積分など各分野に特化した参考書ってありますよね。C言語におけるポインタ・配列も、人生のある一時期に「ポインタ・配列について重点的に考える」時期が必要なのではないか、と思います。あ、普通の人じゃなくて、C言語を極めたい奇特な人限定ですが(笑)

★ポインタ・配列に関するおすすめ本とサイト
・『C言語ポインタ完全制覇』…ポインタと言えばこの本。ポインタと配列について考え抜いた、著者の追求思考の痕跡が伺えます。C言語をミソクソに批判する記述が目立ちますが、著者の方は、本当はC言語を愛してやまないんだと思います。じゃないと、ポインタと配列だけでこんなに語れないですよ…。

・『ポインタ虎の巻』…ポインタ完全制覇より難易度高め。

未読ですが、O'Reillyの『詳説Cポインタ』も、ポインタの地頭を鍛えるのにちょうどいい難易度かと思います。

最後に、僕が練習で書いてるコードを載せておきます。ポインタ・配列の素振り練習として、こんなんを毎日繰り返し書いています。

  1 #include <stdio.h>
  2
  3 #define ARRAY_NUM(array)        (sizeof(array)/sizeof(*array))
  4
  5 int matrix[][5] = {{100,101,102,103,104},
  6                    {200,201,202,203,204},
  7                    {300,301,302,303,304},};
  8
  9 int main(int argc, char **argv){
 10
 11     int i,j;
 12
 13     /* 添字使用 */
 14     for(i=0; i<ARRAY_NUM(matrix); i++){
 15         for(j=0; j<ARRAY_NUM(*matrix); j++)
 16             printf("%d ", matrix[i][j]);
 17         printf("\n");
 18     }
 19     printf("\n");
 20
 21     /* 添字不使用 */
 22     for(i=0; i<ARRAY_NUM(matrix); i++){
 23         for(j=0; j<ARRAY_NUM(*matrix); j++)
 24             printf("%d ", *(*(matrix+i)+j));
 25         printf("\n");
 26     }
 27     printf("\n");
 28
 29     /* 先頭部分配列へのポインタ */
 30     int (*ent_p)[ARRAY_NUM(*matrix)] = matrix;
 31
 32     for(i=0; i<ARRAY_NUM(matrix); i++){
 33         for(j=0; j<ARRAY_NUM(*matrix); j++)
 34             printf("%d ", *(*(ent_p+i)+j));
 35         printf("\n");
 36     }
 37     printf("\n");
 38
 39     /* 二次元配列へのポインタ */
 40     int (*tbl_p)[ARRAY_NUM(matrix)][ARRAY_NUM(*matrix)] = &matrix;
 41
 42     for(i=0; i<ARRAY_NUM(*tbl_p); i++){
 43         for(j=0; j<ARRAY_NUM(**tbl_p); j++)
 44             printf("%d ", *(*(*tbl_p+i)+j));
 45         printf("\n");
 46     }
 47     printf("\n");
 48
 49     return 0;
 50 }

二次元配列をダンプする

頭の体操として、二次元配列の各要素を参照するコードを、いろんな書き方で書いてみようと思います。

いまさら二次元配列?と思うなかれ。こういう当たり前のコードを、1ステップずつ意味を理解した上で、高速でコーディングできるのも、ひとつの技術力だと思うのです。相対的な話ですけど、息を吸うようにシェルスクリプトを操れる技術とか、自分により距離の近い技術の方が、熟達すべき技術としての優先度は高いと思います。

さておき、前回使ったint型二次元配列の全要素についてをダンプするコードを、添字指定で書いてみました。

  1 #include <stdio.h>
  2
  3 int main(int argc, char **argv){
  4
  5     int matrix[][5] = {{100,101,102,103,104},
  6                        {200,201,202,203,204},
  7                        {300,301,302,303,304},};
  8     int i,j;
  9
 10     /* 添字指定あり */
 11     for(i=0; i<sizeof(matrix)/sizeof(matrix[0]); i++){
 12         for(j=0; j<sizeof(matrix[0])/sizeof(matrix[0][0]); j++){
 13             printf("%d ", matrix[i][j]);
 14         }
 15         printf("\n");
 16     }
 17
 18     return 0;
 19 }

Cプログラマの間では、配列の各要素へアクセスするのに、あえて添字指定を使わずに間接演算子で間接参照する方が、なんかハッカーっぽくてかっこいいという暗黙の風潮があるような気がしますが、僕は添字指定の方が直観的に分かりやすいと感じるので、極力、添字指定を使っています。確かに間接演算子の方がよりマシン語実装に近い表現ではあるのですが、コンパイルすりゃどうせ同じですしね。

ここから頭の体操です。上のダンプ処理の部分を、間接演算子を使って書いてみます。

    /* 添字指定なし */
    for(i=0; i<sizeof(matrix)/sizeof(*matrix); i++){
        for(j=0; j<sizeof(*matrix)/sizeof(**matrix); j++){
            printf("%d ", *(*(matrix+i)+j));
        }
        printf("\n");
    }

1ステップずつ追って行きます。

    for(i=0; i<sizeof(matrix)/sizeof(*matrix); i++){

sizeof(matrix)は、二次元配列matrixの全体サイズ(sizeof(int)*5*3=60バイト)を返します。

sizeof(*matrix)は、

  1. matrixはT型配列(T=配列)オブジェクトである。
  2. 式中の配列オブジェクトは、その先頭要素へのポインタ値に展開される。
  3. 先頭要素へのポインタ値とは、先頭部分配列{100,101,102,103,104}へのポインタ値である。
  4. このポインタ値に間接演算子(*)をつけると、配列オブジェクト{100,101,102,103,104}に展開される。

通常、式中における配列オブジェクトは、その先頭要素へのポインタ値に展開されますが、(*matrix)はsizeof演算子オペランドであるため、その展開は行われません。したがって、sizeof(*matrix)は、配列{100,101,102,103,104}のサイズ(int*5=20バイト)を返します。

以上より、式sizeof(matrix)/sizeof(*matrix)は、60/20=3、つまりmatrixのエントリ数(レコード数)を返します。このループは、テーブルmatrixの各エントリについて、iを参照カーソルとして1エントリずつ舐めるループとなります。

        for(j=0; j<sizeof(*matrix)/sizeof(**matrix); j++){

sizeof(*matrix)は、先ほどの通り、matrixの1エントリ数のサイズ(20)を返します。
sizeof(**matrix)は、

  1. *matrixは、配列{100,101,102,103,104}を返します。
  2. 式中の配列型オブジェクトは、その先頭要素へのポインタを返します。この場合、先頭要素{100}へのポインタ値を返します。
  3. **matrixは、先頭要素{100}へのポインタ値を間接参照するため、値100を返します。
  4. sizeof(**matrix)は、すなわちsizeof(100)という意味になるので、もちろん4(int型サイズ)を返します。

以上より、sizeof(*matrix)/sizeof(**matrix)は、20/4=5、1エントリのフィールド数を返します。

            printf("%d ", *(*(matrix+i)+j));
  1. matrixは、T型配列(T=配列)の先頭要素へのポインタ値を返します。つまり、配列{100,101,102,103,104}へのポインタを返します。
  2. matrix+iは、matrixが先頭部分配列へのポインタ値を返すことから、これにiを加算すると、matrixの先頭アドレスに「i個分のエントリのサイズ」を加算したポインタ値を返します。例えばi=1の場合、そのポインタ値(matrix+1)は、配列{100,101,102,103,104}の領域末尾の次の領域を指すことになります。
  3. 演算子の優先順位は「*>+」ですから、*(matrix+i)+jにおいて、*(matrix+i)が先に計算されます。
  4. (matrix+i)は、現在参照している配列オブジェクト{x00,x01,x02,x03,x04}へのポインタ値を返しますから、*(matrix+i)は配列{x00,x01,x02,x03,x04}に展開されます。
  5. 式中の配列オブジェクトは、その先頭要素へのポインタ値に展開されるので、*(matrix+i)は値x00へのポインタ値に展開されます。
  6. (*(matrix+i)+j)は、値x00へのポインタ値にjを加算したものに展開されます。x00はint型であるため、そのポインタ値にj加算すると、j*4バイト先を参照することになります。つまり、参照カーソルはフィールド単位で移動します。
  7. *(*(matrix+i)+j)は上記を間接参照しますから、現在参照カーソルのある値(x00〜x04のいずれか)に展開されます。

変数iはエントリ単位での参照カーソル、変数jはフィールド単位での参照カーソルとしてふるまうため、以上よりコード例の二重ループは、エントリ単位で参照カーソルiを回して、現在のエントリについて、参照カーソルjで各フィールドの値をダンプする。という動きになります。

sizeof演算子のまとめ(仮引数の多次元配列)

関数の引数に配列型変数を指定した場合、関数内の当該仮引数はポインタ型変数に型調整される(正確には、配列の先頭要素へのポインタ値が格納されたポインタ型変数に型調整される)。これが多次元配列になると、どうなるだろう。

二次元配列とは、T型配列のTが「配列型」の配列である。まず、二次元配列を関数の引数に指定すると、その仮引数は、その配列の先頭部分配列へのポインタ値が格納された、配列型オブジェクトへのポインタ変数に型調整される。ここまでは予想がつく。

では、そのポインタ変数を間接参照すると、どうなるだろうか。

ここでポインタ変数の間接参照のふるまいについておさらいすると、

  1. 式中においてchar型へのポインタ変数を間接参照すると、char型オブジェクトに展開される。
  2. 式中においてint型へのポインタ変数を間接参照すると、int型オブジェクトに展開される。
  3. 式中において配列型へのポインタ変数を間接参照すると、配列型オブジェクトに展開される。

ということは、先頭部分配列(配列型オブジェクト)にsizeof演算子を適用した返却値は、以下の可能性が考えられる。

  1. 仮引数の配列の要素となる配列型オブジェクトなので、仮引数の配列と同じく「その配列の先頭要素へのポインタ型変数に型調整される」というルールが適用される。その結果、sizeof演算子を適用すると、配列の先頭要素へのポインタ型変数のサイズ、つまりポインタ型整数値のサイズを返す。
  2. 仮引数の配列の要素となる部分配列だとはいっても、配列は配列なので、やはり配列として扱われる。sizeof演算子を適用すると、当該部分配列のサイズを返す。

例えば、以下の二次元配列は、

    int buf[][5] = {{100, 101, 102, 103, 104},
                    {200, 201, 202, 203, 204},
                    {300, 301, 302, 303, 304},};
[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

というマトリクスで示される。

上記のマトリクスをふまえた上で、考えてみる。
仮引数のbufは先頭要素へのポインタ値を格納するT型オブジェクト(T=配列)へのポインタ型変数に型調整される。そして関数内の式中で「*buf」と間接参照すると、もちろん、以下の部分配列(色つき部分)に展開される。

[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

ここからが問題である。「仮引数の配列はポインタ型に型調整される」というルールが、その構成要素である部分配列にも適用されるのならば、*bufもやはりポインタ型に型調整される。そしてsizeof(*buf)は、ポインタ型のサイズ(64ビットOSで8バイト)を返す。

あるいは、そのようなルールが適用されないのならば、sizeof(*buf)は以下マトリクスの部分配列(色つき部分)のサイズ(int*5=20バイト)を返す。

[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

そこで、こんなコードを書いてみた。

  1 /* sizeof_func2.c */
  2 #include    <stdio.h>
  3
  4 void func(int buf[][5]){
  5
  6     printf("@func()\n");
  7     printf("sizeof(buf)   = %zu\n", sizeof(buf));   /* 先頭部分配列へのポインタのサイズ */
  8     printf("buf   = %p\n", buf);
  9     printf("buf+1 = %p\n", buf+1);                  /* bufの参照先データ型サイズを確認 */
 10     printf("sizeof(*buf)  = %zu\n", sizeof(*buf));  /* 先頭部分配列(int*5)のサイズ */
 11     printf("sizeof(**buf) = %zu\n", sizeof(**buf)); /* 先頭部分配列の先頭int型のサイズ */
 12 }
 13
 14 int main(int argc, char *argv[]){
 15
 16     int buf[][5] = {{100, 101, 102, 103, 104},
 17                     {200, 201, 202, 203, 204},
 18                     {300, 301, 302, 303, 304},};
 19
 20     printf("@main()\n");
 21     printf("sizeof(buf)   = %zu\n", sizeof(buf));   /* 配列型配列(int*5*3)のサイズ */
 22     printf("sizeof(*buf)  = %zu\n", sizeof(*buf));  /* 先頭部分配列(int*5)のサイズ */
 23     printf("sizeof(**buf) = %zu\n", sizeof(**buf)); /* 先頭部分配列の先頭int型のサイズ */
 24     printf("\n");
 25
 26     func(buf);
 27
 28     return 0;
 29 }
 27 }

実行結果

@main()
sizeof(buf)   = 60     /* 配列型配列(int*5*3)のサイズ */
sizeof(*buf)  = 20     /* 先頭部分配列(int*5)のサイズ */
sizeof(**buf) = 4      /* 先頭部分配列の先頭int型のサイズ */

@func()
sizeof(buf)   = 8      /* 先頭部分配列へのポインタのサイズ */
buf   = 0x7fffac612440
buf+1 = 0x7fffac612454 /* bufの参照先データ型サイズを確認 */
sizeof(*buf)  = 20     /* 先頭部分配列(int*5)のサイズ */
sizeof(**buf) = 4      /* 先頭部分配列の先頭int型のサイズ */

となった。

関数内のsizeof(buf)は8バイト(ポインタ型のサイズ)となっている。つまり、bufは以下の色つきの「部分配列そのもの」へのポインタ値を格納するポインタ型変数として扱われていることが分かる。このポインタ値を+1するとアドレスが20バイト進んでいることからも、int*5の配列へのポインタ型変数であることを示している。

[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

そして、関数内のsizeof(*buf)は20バイト(int*5)となっている。このことから、*bufは以下の色つきの「部分配列そのもの」として扱われていることが分かる。

[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

また、関数内のsizeof(**buf)は4バイト(int)なので、**bufは以下の色つきの「部分配列の先頭要素(int型オブジェクト)」として扱われていることが分かる。

[0] [1] [2] [3] [4]
buf[0] 100 101 102 103 104
buf[1] 200 201 202 203 204
buf[2] 300 301 302 303 304

以上をまとめると、

  1. 関数内の仮引数の配列は、その配列の先頭要素へのポインタ値を格納したT型オブジェクト(T=配列)へのポインタ変数に型調整される。
  2. ただし上記は最上位次元の配列のみの話である。それ以外の次元の配列は、配列そのものに展開される。

無用に難しく考えてしまった気がするけど、実装レベルで考えてみると、関数呼出し時にスタックに積むのは、「配列の先頭要素へのポインタ値」である。また、関数プロトタイプ宣言においては、仮引数の多次元配列は、最上位次元以外の次元の要素数は省略できない。つまり、最上位次元以外の要素数コンパイラに伝わる情報なので、ポインタ型扱いになるのが最上位次元のみに限った話というのも道理ではある。