プロジェクト

全般

プロフィール

C言語で2次元配列を渡せなくて困った話 » 履歴 » バージョン 6

« 前 - バージョン 6/10 (差分) - 次 » - 最新版
健二 酒井, 2019/03/15 21:14


C言語の2次元配列を渡せなくて困った話

タイトルの通りです。正確に言うと、C言語で文字列の配列を引数に取る関数でドハマりしたっていう話です。
ポインタをちゃんと勉強したら「よくできてるな」って納得した感じ。

前提

  • intは4バイトとします。

詰まったところ

二次元配列を受け取れるような関数を定義したかった。
とりあえず話をするために名前を付ける。

int caller_matrix[3][5]

caller_matrixを実引数にして渡そうとしています。
で受け取る側ですが以下の、1番のシグネチャじゃダメってコンパイラから怒られた(警告だったかもしれない)っていうのでわけわかんなくなって詰まったんですね。
1番:

void some_function(int *matrix[5])

※ちなみに正しい渡し方。
2番:

void some_function(int arr[][5])

or
3番:

void some_function(int (*arr)[5]) 

2番は理解するけど、3番と1番の違いって何よ?何でダメ?っていうお話。
「2番、3番はバッファオーバーランの原因になるだろ。その書き方は認めない」っていう突っ込みはとりあえずやめてください。

配列の名前

以下のような宣言があったとする。

int arr[5];
int* p_arr;

p_arr = arr;

これは正しいですね。なぜなら「arr」は評価(?)されたときに、intへのポインタ型であるアドレスを返します。これがまず第一の面白ポイント。
で、配列を引数に取る関数は

void some_function(int* argument) 

って書いても

void some_function(int argument[])

って書いても大丈夫。これを踏まえて

void some_function(int *matrix[5])

って書いた。結果は上で言った通りむっちゃ怒られた。C言語マジ意味わかんねぇとか呟いた

整理

さて、疑問を整理してみると一番は

int *matrix[5]

の型ってなんだ?っていう話になります。

型の計算

ちょっとJavaやC#の総称型っぽく書きます。
型Tに対してArr[T]で「Tの配列」型、Ptr[T]で「Tへのポインタ」型を意味するとします。
さて、問題の型を見てみましょう。これは以下のように二通りの解釈ができます。C言語の初心者にとっては。
これがよく分からない原因です。

  1. Arr[Ptr[int]]
  2. Ptr[Arr[int]]

前者は分かります。実際のデータを考えるとアドレス(intへのポインタ型)がメモリにびっしり詰まっているイメージです。
さて後者は何でしょう?配列へのポインタ?なんだそれ?
そこで「配列へのポインタとはそういえば聞いたことがない気がする」と気づきます。でも、「配列が確保したメモリの先頭アドレスだろ。常識的に考えて…」とか思ったりしました。

後者は何だと頭を悩ませていたときに「そもそもPtrってなんだ?」とか思い始めて来ます。これは理解できます。
メモリをイメージすればいいのです。以下の図はメモリのイメージです。
図形一個で1バイトです。四角がint型変数が確保した領域を指します。(ちゃんとintは4バイトと仮定しましたよ)
○○○ ■□□□ ○○○○

Ptrは■を指します。確保したメモリ領域の先頭を指します。

数学っぽく考えましょう。以下のように一般化します。
「PtrはT型変数が確保した連続したメモリ領域の先頭を指す」
と考えます。こう考えると全ての表記と辻褄があってきそうです。

さて、次に以下のコードに戻りましょう。さっき面白ポイントといったところです。

int arr[5];
int *p_arr;

p_arr = arr;

p_arrの型はPtr[int]です。arrの型はArr[int]です。ですが、ある意味で区別しなくてよい表記ができています。
※厳密にはポインタと配列は区別しなければならないものです。arrにint型へのポインタを代入することはできないですしね。

ポインタ記法と配列記法

この解釈をつきつめていくとポインタ記法と配列記法の整合性もちゃんと取れているのが面白ポイントその2です。

#include <stdio.h>

int main() {
    int arr[5] = {1,2,3,4,5};
    int *p_arr;

    p_arr = arr;

    printf("arr[2]:     %d\n", arr[2]);
    printf("*(arr + 2): %d\n", *(arr+2));
    printf("p_arr[2]:   %d\n", p_arr[2]);
    printf("*(p_arr+2): %d\n", *(p_arr+2));
}

実行結果:

arr[2]:     3
*(arr + 2): 3
p_arr[2]:   3
*(p_arr+2): 3

どれも同じ値として評価されますね。メモリの状態を頭の中で描くとこんな感じになります。
○○○ ■□□□ ■□□□ ■□□□ ■□□□ ■□□□ ○○○○○
四角系はarrが占めている領域です。色塗りが各要素の先頭アドレスの指す領域です。
arrの評価結果は最初の■を表し、
arr[1]は2番目の4ブロックを表します。2ブロック(1ブロック=sizeof(int))の分だけ進んだ値の実態です
ポインタ演算p_arr+2の演算結果は2ブロック分進んだ三つ目の■のアドレスを指します。
素晴らしいですね。
ややこしいですね。

ということでPtrと解釈を広げてあげることでPtr[Atr[T]]が意味づけられ、今までのものとちゃんと一貫してるかのような表記方法が得られるわけですね。
ただ素直に考えた配列のアドレス「&arr」というのは通用しなくなってしまいます。ここが惜しい感じがします。

再び二次元

さて、新しい記法を手に入れたので戻ってみます。
int型の二次元配列(int matrix[3][5]、厳密には『「int型の配列」の配列』、はArr[Arr[T]]ですね。メモリのイメージは

○○○ ■□□□ ■□□□ ■□□□ ■□□□ ■□□□
    ■□□□ ■□□□ ■□□□ ■□□□ ■□□□
    ■□□□ ■□□□ ■□□□ ■□□□ ■□□□ ○○○○○

といった具合です。
この確保の仕方は二次元配列の初期化子{{1,2,3,4,5},{6,7,8,9,10},{11,12,13,14,15}}とも一貫しています。
また、matrix[1]で2列目の先頭の黒のアドレスを指すことも整合性が取れています。matrix[1]はPtr[Arr[int]ではないはずですがね…。
これらはPtr[T]がArr[T]と同じように考えたられることになるかと思います
TをArr[int]で置き換えてみましょう(int型の配列も立派な型です)。
それぞれ、Ptr[Arr[int]]、Arr[Arr[int]]となります。

そして今、戻りに戻って当初の問題となった「2次元配列を受け取る関数」シグネチャを見てみましょう。

void some_function(int arr[][5])

この書き方はArr[Arr[int]]を想起させます。

とすると

void some_function(int (*arr)[5]) 

はPtr[Arr[int]]に相当するのでしょう。*を括弧内に入れることで「仮引数arrはPtr型」を優先的に示していると。

そして、ダメなシグネチャ

void some_function(int *matrix[5])

この引数の宣言はArr[Ptr[int]]と解釈されます。C言語的には。

結論

「ハゲの山田氏のカツラ」という名詞句は「ハゲの山田氏」なのか「ハゲのカツラ」なのかという2通りの解釈ができますね。「ハゲの」を受けるのが山田氏なのかカツラなのかどちらかが優先されるかで意味合いが変わってきます。
本質的にはこれと同じく、int *arr[5]と書いたときの*と[5]のどちらの解釈が優先されるのかということが問題だったんですね。
左から修飾するもの(今回の*)と右から修飾するもの(今回の[5])を括弧なしに並べて書いたときは同様に解釈の優先順位の問題がでてくるんじゃないかと思います。

余談

  • Arr[int]とPtr[int]が同じように解釈できるならダメなのもできなきゃおかしくねっていう気も若干してきましたが、
    Arr[Ptr[int]]は、Ptr[int]が確保するサイズを1ブロックとしてメモリを連続して確保するの意味合いが変わってきてしまいますね。やっぱダメですね。
    一番外側のArrだけPtrと交換可能なんですかね。

    • メモリ確保で考えると交換できないけど、そこだけ気を付ければなんかうまくいく気もするぞ…
  • 例えば、JavaScriptの式typeof 'string' === 'bool'ということを考えるとtypeof ('string' === 'bool')と解釈すれば'bool'ですし、(typeof 'string' ) === 'bool'ならtrue(だよな?)という評価になるかと思います。前者の表記はとても不自然ですが、一つの読み方としてはあり得ない話ではないかと思います。
    ちゃんと括弧つけてあげると安心。

  • 今度は3次元を引数に取ろうとして迷う気がする。

  • C言語の二次元配列のメモリの確保の仕方はCOBOLの配列の宣言と同じです。なんてこったい、つながったぞ。まぁ一次元を以下に二次元にするかだから自然な発想ですよね。

参考

オライリーさんの詳説Cポインタ:https://www.oreilly.co.jp/books/9784873116563/

こんなことを考えて配列とポインタとなんとなく分かったような気がしたのです。長い長い言い訳でした。
というか段々何言ってるか分からなくなってきた。