C
array
Pointer
C言語Day 21

Re:Cのポインタと配列の関係について考察してみる

C言語 Advent Calendar 2017

この記事はC言語 Advent Calendar 2017 21日目の記事です

<< 20日目|Ancient C探訪記:ラベル編 || 22日目|Ancient C探訪記:プリプロセッサ編 >>

yohhoy氏の独壇場ですね・・・。Ancient C探訪記面白い。

はじめに

先日
Cのポインタと配列の関係について考察してみる
という記事がQiitaに上がりましたが、元記事ではだいぶ混乱している感があるので、考察し直してみましょう。

メモリー確保

みなさんメモリ確保というと、ついつい動的メモリ確保が頭に浮かんで

C99
#include <stdlib.h>
int main(void)
{
   int* p = malloc(sizeof(int));
   //do something
   free(p);
   return 0;
}

みたいなのを想像すると思いますが、メモリ確保って本当にそれだけか?という問題が有ります。

変数=メモリー確保手段

そもそも我々が作成したプログラムは実行するにメモリーを使用します。

当然我々が呼吸するように定義して使用しているメモリーも例外ではありません。

int main(void)
{
    int a;//これ
    return 0;
}

そうです、なにも動的確保しなくたって、変数もメモリーを使っているんだからメモリー確保手段といえます。

配列=メモリー確保手段

すると、配列は同じ型で複数個分の連続したメモリー領域を確保する手段といえます。

int main(void)
{
    int arr[3];
}

・・・Cの文法わかりにくいな。あえてRustで書くと

fn main() {
    let mut xs: [i32; 3];
}

ですかね。int[3]型の変数を作るんだから前述のとおりメモリー確保手段なのは確定的に明らかですね!

ポインタ

参照としてのポインタ

C/C++のポインタはいわゆる参照の概念を内包しています。ここで参照とはコメント欄で言われているようなセマンティック上分類した結果としての参照(C++の参照のようなもの)ではなく、一般的な概念としての参照を指します。

C++
#include <iostream>
int main()
{
    int val = 3;
    std::cout << val << std::endl; // => 3
    int& ref = val;//参照(lvalue reference)
    ref = 4;
    std::cout << val << std::endl; // => 4
}

同じようなことをポインタでやってみましょう。

C99
#include <stdio.h>
int main(void)
{
    int val = 3;
    printf("%d\n", val); // => 3
    int* ptr = &val;
    *ptr = 4;
    printf("%d\n", val); // => 4
    return 0;
}

だいたい同じですね(C++の参照は再束縛できない)

イテレータとしてのポインタ

C/C++のポインタは、多くの言語で存在するイテレータの概念を内包しています。

イテレータといってもピンからキリまであるのでここではC++17で追加された隣接イテレータ (contiguous iterator)を例に考えます。

C++11
#include <array>
#include <iostream>
int main()
{
    std::array<int, 3> arr = {};
    for(auto it = std::begin(arr); it != std::end(arr); ++it) {
        std::cout << *it << std::endl;
    }
}

C++のstd::arrayはCの配列同様、連続したメモリー領域を持ち、要素同士は隣接しています(だから隣接イテレータ)

同じようなことをポインタでやってみましょう。

C99
#include <stdio.h>
#define BEGIN( arr ) (arr)
#define END( arr ) (arr + sizeof(arr) / sizeof(*arr))
int main(void)
{
    int arr[3] = { 0 };
    for(int* it = BEGIN(arr); it != END(arr); ++it) {
        printf("%d\n", *it);
    }
}

だいたい同じですね。しかしCでは

C99
#include <stdio.h>
#define COUNTOF( arr ) (sizeof(arr) / sizeof(*arr))
int main(void)
{
    int arr[3] = { 0 };
    for(size_t i = 0; i < COUNTOF(arr); ++i) {
        printf("%d\n", arr[i]);
    }
}

と書くことのほうが多い気がします。

ポインタ演算

ところでarr + sizeof(arr) / sizeof(*arr)って何か変です。いや、sizeof(arr) / sizeof(*arr)はいいんです。ただの符号なし整数(size_t)同士の除算ですから。しかしarr + sizeof(arr) / sizeof(*arr)とはなんでしょうか。

ポインタ型は基本となる型から派生して作られます。例えばint*型はint型から派生してできますし、double*型はdouble型から派生してできますし、int**型はint*型から派生してできるわけです。

なんでそういう仕様かというのがこのポインタ演算と関わります。

C99
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <inttypes.h>
int main(void)
{
    int8_t* arr1 = malloc(sizeof(int8_t) * 4);
    int32_t* arr2 = malloc(sizeof(int32_t) * 4);
    int64_t* arr3 = malloc(sizeof(int64_t) * 4);

    int8_t* arr1_1 = arr1 + 1;
    int32_t* arr2_1 = arr2 + 1;
    int64_t* arr3_1 = arr3 + 1;

    printf(
        "Diff(CHAR_BIT=%d):\n\tarr1:%" PRIu64 "\n\tarr2:%" PRIu64 "\n\tarr3:%" PRIu64 "\n",
        CHAR_BIT,
        (((uint64_t)(arr1_1)) - ((uint64_t)(arr1))),
        (((uint64_t)(arr2_1)) - ((uint64_t)(arr2))),
        (((uint64_t)(arr3_1)) - ((uint64_t)(arr3)))
    );

    free(arr1);
    free(arr2);
    free(arr3);

    return 0;
}

https://wandbox.org/permlink/Z85fg8xZJZSzXJ2b

実行結果
Diff(CHAR_BIT=8):
    arr1:1
    arr2:4
    arr3:8

いずれも+ 1という同じ演算をしてポインタを動かしています。

しかし動いた先と動く前の差はこのとおり違うものになりました。

これはポインタを動かす時に動かす最小単位、つまり1単位1がどれだけの大きさか、という情報を派生元の型情報から得ているからです。

例えばarr1の派生元の型はint8_t型なので動いた先と動く前の差は1byteになりました。これはsizeof(int8_t)と一致しています。
同様にしてarr2, arr3についても、差が4, 8 byteとやはりsizeof(int32_t), sizeof(int64_t)に一致しています。

つまりポインタ演算をする時の1単位の大きさを知るためにこのような仕様になっていると思われます。

なお、ポインタをキャストすれば派生元の型を変えられたりします。

シンタックスシュガーとしてのoperator[]

先程arr[i]という記法を使いましたが、これはなんだったかというと、*(arr + i)のシンタックスシュガーです。つまり先程のコードは

C99
#include <stdio.h>
#define COUNTOF( arr ) (sizeof(arr) / sizeof(*arr))
int main(void)
{
    int arr[3] = { 0 };
    for(size_t i = 0; i < COUNTOF(arr); ++i) {
        printf("%d\n", *(arr + i));
    }
}

と書いても同じです(i[arr]でも可)。でもちょっと待ってください。arr + iってなんか変です。配列に足し算??どういうことでしょう?

暗黙の変換

この謎を解くには暗黙変換について知る必要があります。

Array-to-pointer conversion

Cに存在する暗黙変換なんてたくさんありますが、ここではArray-to-pointer conversionを指します。

C言語ではいくつかの例外を除き、配列は常にポインタに読み替えられます。
言い換えると配列名を添字無しで使うと,それは配列の先頭要素を指すポインタ定数となります。

幾つかの例外とは

  1. sizeof (array)のようにsizeof演算子に引き渡された場合
  2. char array[] = "abcde";のように文字列リテラルで初期化するとき
  3. & arrayのように,アドレス演算子&に引き渡された場合
  4. (C89) 関数の戻り値などでrvalueな構造体型に含まれる配列型変数などrvalueな配列変数の場合
  5. (C11) _Alignof演算子に引き渡された場合

を指します。

ref:

まとめ

  • 配列はメモリー確保手段
  • operator []は配列に固有のものではなく、ポインタ演算のsyntax sugar
  • ポインタ:=参照+イテレータ

  1. 単位というのはC規格にはない言葉だと思いますが、Unicodeの用語を説明のために流用しました。