
2026/05/25 14:05
C 配列の型は奇妙です
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
C 言語の中核となる区別は、配列が関数呼び出し中にポインタに退化するメカニズムにあります。通常、これは重要なサイズ情報を失うことを意味しますが、
sizeof 演算子や & オペレーターと組み合わせて使用される場合を除きます。関数の引数において、あらゆる配列型は自動的にポインタとして解釈され、サイズ指定子は破棄され、その結果 sizeof(arr) は sizeof(T) を返します。標準的な慣行では、配列名はその最初の要素へのポインタ(&arr[0])と扱われますが、sizeof および & のようなオペレーターは元の配列の身元を保持します。特定の実行長が強制されているより短い配列を渡す場合(例:char buf[static 8])、サイズ指定子は呼び出し時にコンパイラの最適化にのみ有用であるため、未定義の動作を引き起こします。これらのリスクを軽減するために、開発者は単なるポインタではなく「配列へのポインタ型(T (*)[n])」を使用して、関数呼び出し中に長さ情報を保持するようにする事ができます。関数は配列と同様にポインタに退化しますが、関数名を解除参照する(*fn)ことで、関数シンボルを直接使用せずに直接呼出しを行うことができます。配列は &arr[0] によって退化するのに対し、関数は自動的に宣言位置へのアドレスに変換されます(&fn は fn と同等です)。両者とも、& オペレーターへの引数として渡された場合、退化しません。高度な解決策としては、「ワイドポインタ」を使用する事があり、これは構造体のように動作し、全サイズなどの追加データを格納することで、コンパイラが関数の境界を越えて長さを追跡できるようにします。これは C++ の std::vector に準じるものであり、サイズ指定のない型整合性を維持します。GDB などのツールは、アドレス表現に @ オペレーターを使用し、メモリアドレスに長さを付与することで、場所表現を配列に変換します(例:*ptr@2)。配列を単純なポインタの退化ではなく、値ベースの構造体として捉えるというコンセプトを採用する事により、複雑なシステムにおけるコードの安全性とメンタルモデルを著しく向上させる事が可能です。本文
C 言語の配列とポインタ:考察と改善案
本記事では、C 言語における配列型の振る舞いに関する奇妙な点について解説し、どのように改善すべきかを提示します。また、関連する話題(関数、@ 演算子など)についても考察を加えます。
1. 技術的な定義と「型崩れ」
配列型とポインタ型の違い
- 型
の意味:メモリ上に連続して配置された長さT[n]
の要素のシーケンスを表します。n - 型
との違い:明確に異なる別物の型です。T*
プログラムでの参照不可避性
- コード上で
を直接参照することはできません。T[n] - 配列を持つ式は、瞬時に型
(最初の要素へのポインタ)に変換されます(配列の自動退化)。T* - 配列インデックス演算子
も、実質的にはarr[ix]
のような挙動として動作します。*(arr + ix)
例外:sizeof
オペレータ
sizeof配列がポインタに変換されず、サイズ情報を保持する唯一のケースは
sizeof です。
int arr[3] = {10, 20, 30}; int *arr_ptr = arr; // 退化したポインタ型 size_t arr_size = sizeof(arr); // サイズ:sizeof(T) × 3 size_t ptr_size = sizeof(arr_ptr);// 固定サイズ:sizeof(T*)
は配列全体のサイズを返します。sizeof(arr)
はポインタのサイズ(通常は 4 または 8 バイト)を返します。sizeof(arr_ptr)
「例外中の例外」:関数への渡り渡し
関数の引数リストにおいて、配列型も実際にはポインタとして解釈されます。この際、サイズ
n は完全に無視されます。
// 関数内部では sizeof(buf) は配列のサイズではなく、 // ポインタのサイズ (sizeof(char*)) を返します。 size_t foo(char buf[6]) { return sizeof(buf); } char msg[6] = "!! ??"; size_t msg_size = sizeof(msg); // 正解:6 bytes * sizeof(char) size_t msg_size_in_fn = foo(msg); // 誤り:sizeof(char*) (通常 4 or 8 bytes)
サイズ情報を「強制」することはできるか?
などでサイズを記述しても、単に未定義の動作を引き起こすだけです。char buf[static 8]- これもまた、コンパイラの最適化を援助するためのヒント(restrict などと同様)でしかありません。
2. 関数と配列:第二の「退化」型
C 言語には配列に似た振る舞いを持つが混乱を招かない別の型があります。**「関数」**です。
関数の自動変換
- 関数値も、即時に変換されて関数ポインタとなります。
- デリファレンスの特殊性:配列とは異なり、関数を参照する変数を
で記述しても、記号そのものを用いて呼び出すことが可能です。*fn
void foo() {} (*foo)(); // 同義:foo()
アドレス取得の挙動の違い
| 対象 | 式 | 結果の型 | 説明 |
|---|---|---|---|
配列 | | | 配列へのポインタ(退化せず) |
関数 | | | と完全に同等 |
- 配列
はarr
へ変換されるため、&arr[0]
は「配列全体へのポインタ」になります。&arr - 一方、関数
は自動的に正確にfn
に変換されるため、&fn
と&fn
は同一です。fn - 注意点:両者とも引数として
を使うと退化をしません(&
のような動作)。&arr[0]
関数の引数定義における同義性
T fn() と T (*fn)() は同じ意味を持ちます。後者の形式は前者へ自動的に修正されます(配列がポインタに変換されるのと同様)。
3. 「値として渡す配列」への理想と課題
構造体との類似性と現実
- 配列型は、すべてのメンバーが同一の型を持つ構造体 (struct) に似ています。
- しかし、構造体の 2 番目のメンバーへのアドレス取得(シフト配列)はめったになされます。
- 原因:頭部(先頭要素)をシフトさせた配列もサイズを変えつつ配列であり続けるため、サイズ情報を失いがちです。
コピーによる動作
理想的には
char[5] を関数に渡す際、実際の 5 つの値が渡されるべきです(構造体のような挙動)。
しかし現在の実装ではコピーが行われることがあります。
int compute(int arr[3]) { arr[2] += arr[1]; // ... 計算処理 return arr[0] - arr[2]; } int arr[3] = {10, 20, 30}; int result = compute(arr); // arr は変更されない(コピーがなされているため)
ポインタ操作の複雑さ
- 配列へのポインタは間接参照レベルが一つしかかからないため、
を書く必要があります。&arr[0]
void toggle(bool *flag) { *flag = !*flag; } bool arr[2] = {true, true}; toggle(&arr[1]); // 明示的に & が必要
メリットとデメリット
- メリット:言語学習を混乱させない。初心者にとって「関数内で書き込むと外部も変更される」という事実への戸惑いが起きにくい。
- デメリット:配列が毎回コピーされる場合がある(価値を損なわないが使い分けが必要)。
- 実装面:コンパイラは目的に応じてポインタを使用する選択肢があり、直感的な語法を維持できる可能性があります。
4. @
演算子とメモリアドレスによる配列構築
@GDB の @
演算子から学ぶ
@GDB(デバッグガール)では、アドレスに長さを持たせて配列として扱うための
@ 演算子が存在します。
(gdb) print *at_ix_1 // $1 = 20 (gdb) print *at_ix_1@1 // $2 = {20} (gdb) print *(at_ix_1 + 1)@2 // $4 = {30, 40}
場所表現 (Place Expression) と左辺値
はメモリアドレスを持つ式(左辺値)に対して動作します。*ptr = 2
は値そのものなので代入できません。2 = 2
は「最初の要素が*ptr@10
で残り 9 つの要素を持つ配列」を得る意味を持ちます。*ptr
C 言語への適用案
この演算子を C に導入すれば、以下のような自然なスライシングが可能になります。
// 配列のスライシング(部分的な切り出し) int iota[4] = {0, 1, 2, 3}; int one_two[2] = iota[1]@2; // 先頭から 2 要素 // ワイドポインタ的なアプローチ char arr[4] = {'x', 'y', 'z', 'w'}; char *arr_ptr = &arr[0]; char arr_again[4] = *arr_ptr@4;
デメリットと残課題
- 先頭をシフトする際、新しい長さを明示的に指定する必要があります(例:
)。&arr[1]@1 - サイズ推論のための特殊な構文を導入するには、3 つのアプローチがあります:
のような記述を許可する。arr + 1- 特殊な構文(例:
)で長さを自動推論させる。arr[1]@... - 新しいカスタム演算子(例:
)を導入する。arr +@ 1
現時点では C を再設計する必要はないため、具体的な推奨は留保します。
5. ->
演算子の考察と好み
->構文の任意性
-> 演算子も @ 演算子と同様に、ポインタと場所表現 (lvalue) の扱いは若干任意です。
現状
はptr->foo
と同じ意味(参照が含まれている)。(*ptr).foo- アドレス取得:
(定義すべきではなかったかもしれない)。&ptr->foo - ネスト:
ptr->foo.bar
代替案 (ptr->foo->bar
)
ptr->foo->bar- もし
を明確に「ポインタ」と見なせる構造にすれば、ネストはptr->foo
と書けます。ptr->foo->bar - 個人的な好み:
を好みます。ptr->foo->bar- 理由:これは「ポインタの領域内で動作しており、コンパイラが単一のオフセットを適用するだけ」であることを反映していると感じるから。
- 一方で
は場所表現、デリファレンス、アドレス取得の相互作用を反映しています(少し背徳的ですが)。ptr->foo.bar