【第3回】ゼロから始めるC言語 ポインタ(1)

C言語

はじめに

ポインタはC言語において非常に重要な特徴の1つである。これを理解すると、C言語においてメモリ管理やデータ操作がより柔軟で効率的に行えるようになる。
まず手始めに、今回はポインタの基本と配列について学んでいく。

1. 変数とメモリ

変数はメモリ上に配置されている。
各変数には、その変数がメモリのどこに配置されているかを示す「アドレス」が割り当てられている。

例えば、上の例では、変数Xはアドレス0に、変数Yはアドレス2に格納されている。
ただし、実際のアドレスは0,1,2… のような整数ではなく、0x7ffeefbff5ecのように16進数で表される。

2. ポインタ

ポインタは、変数のメモリアドレスを保持するための特別な変数である。ポインタ変数には、他の変数やメモリブロックのアドレスを格納することができる。
ポインタ自体もメモリに格納され、そのアドレスも存在する。

ポインタを使うことで、メモリ内の特定の位置にアクセスしたり、その位置に格納されているデータを操作することができるようになる。

試しに次のプログラムを動かしてみよう

ptr.c
#include <stdio.h>

int main(void)
{
    int i;
    printf("%p\n", &i);
    return 0;
}
実行結果
0x16f9df118

実行結果は環境によって変わる可能性があるが、16進数のアドレスを取得することができたはずだ。

ポインタの取得

アドレスを取得するには、アドレス演算子&を使う。

  • &i : 変数iが配置されているアドレスを示す

データの取得

ポインタが示すメモリアドレスに格納されているデータの値を取得するにはデリファレンス演算子*を使う。

  • *i : 変数iのデータの値を取得する

ポインタ値のフォーマット指定子

ポインタ値をprintfで表示する場合は%pを用いる。

3. ポインタを使った関数の実装

では、ポインタを使って関数を実装してみる。
例として次のプログラムを見てみよう。

swap.c
#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a; // aが指すアドレスの値をtempに保存
    *a = *b;       // bが指すアドレスの値をaが指すアドレスに代入
    *b = temp;     
}

int main() {
    int x = 5;
    int y = 10;

    printf("Before swap: x = %d, y = %d\n", x, y);
    swap(&x, &y);
    printf("After swap: x = %d, y = %d\n", x, y);

    return 0;
}

swap関数は受け取ったaとbの値を入れ替える関数である。この関数の戻り値の型はvoidであり、戻り値は存在しない。なぜなら、この関数はaとbの値を戻り値として返すのではなく、main関数から渡された変数x, yが格納されているアドレスを受け取り、そのアドレスに格納されているデータを書き換えるからだ。

ポインタを使うことで、変数x, yのメモリ位置に直接アクセスし、変数x, yの値を書き換えることができる。これは、C言語の関数が一度に1つの値しか返せないためである。Pythonではタプルを使ってreturn (a, b)のように複数の値を返すことができるが、C言語ではそのようなことができない。代わりに、ポインタを使って関数に引数として渡された変数の値を直接変更する方法が用いられている。

ポインタ型

プログラムの3行目を見てみよう。int *a, int *bとしているが、これは先ほど紹介したデリファレンス演算子ではないので注意!全く別物である。
ポインタを格納する変数を宣言するときは、*記号を変数の前につける。

int *p;
int* p;

*はスペースの前と後どちらにつけても問題なく、これらは同じ意味である。

なぜポインタ渡し(参照渡し)を使ったのか?
・関数間でデータを共有するため
値を直接関数に渡してしまうと、swap関数内での変更が呼び出し元の変数に反映されない。ポインタを使うことで関数が呼び出し元の変数に直接アクセスして値を変更できる。

・複数の値を返したいため
C言語では関数は一度に1つの値しか返せないため、複数の値を返す必要がある場合にはポインタ渡しを使うことで実現することができる。

scanf()とポインタ

scanf()に登場する&を覚えているだろうか?
scanfはポインタ渡しにより、複数の値の入力をサポートしている。

scanf("フォーマット指定子", &変数1, &変数2, ..., &変数N);

もし&を使わずに scanf("%d", number) と書くと、コンパイラエラーになる。なぜなら、number は変数の値そのものであり、アドレスではないからだ。scanf() は引数にアドレスを期待しているため、&number を渡す必要がある。

Nullポインタ

NULLポインタは有効なデータを指していないことを意味する特別なポインタ値である。

NULLポインタは有効なメモリ位置を指していないポインタで、データが存在しない or ポインタが初期化されていないことを示すために使用される。

NULLポインタは以下のようにエラーハンドリングを行う場面で非常に便利である。

errorcheck.c
char *get_memory() {
    char *p = malloc(100); // 100バイトのメモリを割り当て
    if (p == NULL) {
        // メモリ割り当て失敗
        return NULL;
    }
    return p;
}

NULLポインタの危険性

NULLポインタのデリファレンスは未定義動作となるため、注意が必要である。

int *p = NULL;
*p = 10; // 未定義動作

これはプログラムのクラッシュや予期しない動作の原因となる。

多くの場合はOS側でNULLポインタのデリファレンスを検出してプログラムを終了させてくれることが多い。

4. 配列

C言語の配列は、同じ型の値を連続するメモリアドレス上に並べたものである。さまざまな異なる型の値を混ぜて入れることはできない。

配列も変数と同様に宣言が必要である。

int x[3] = {10, 20, 30};

C言語の配列は、宣言の際に配列のサイズを指定する。

int arr[128];
int nums[5] = {1,2,3,4,5};

初期化データを指定する場合、サイズは省略可である。

short xs[] = {1,2,3};

なお、初期化の指定は宣言の時のみにしか行えない。

要素へのアクセス

配列の要素へのアクセスは添字を用いて行う。a[i] で配列 a の i 番目の要素にアクセスできる。最初の要素は0番目であることに注意する必要がある(ゼロオリジン)。

次のプログラムを動かして配列の操作を確認しよう。

array.c
#include <stdio.h>

int main() {
    // 配列の宣言と初期化
    int nums[5] = {1, 2, 3, 4, 5};

    // 添字を用いて要素にアクセスし、値を表示する
    for (int i = 0; i < 5; i++) {
        printf("nums[%d] = %d\n", i, nums[i]);
    }

    // 添字を用いて要素に新しい値を代入する
    nums[2] = 10;

    printf("\nAfter modifying nums[2]:\n");
    for (int i = 0; i < 5; i++) {
        printf("nums[%d] = %d\n", i, nums[i]);
    }

    return 0;
}

二次元配列・多次元配列

C言語では、二次元配列や多次元配列を作成することもできる。これは、配列の配列を連続するメモリアドレス上に配置したものである。同様に、三次元以上の多次元配列も作成可能である。

次のコードは、X個の要素からなる一次元配列をY個並べた配列aを作る例。

int a[Y][X]; 
2次元配列の例
#include <stdio.h>

int main() {
    // 2次元配列の宣言と初期化
    int matrix[3][4] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };

    // 2次元配列の要素を表示
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
            printf("matrix[%d][%d] = %d\n", i, j, matrix[i][j]);
        }
    }

    return 0;
}
3次元配列の例
#include <stdio.h>

int main() {
    // 3次元配列の宣言と初期化
    int tensor[2][3][4] = {
        {
            {1, 2, 3, 4},
            {5, 6, 7, 8},
            {9, 10, 11, 12}
        },
        {
            {13, 14, 15, 16},
            {17, 18, 19, 20},
            {21, 22, 23, 24}
        }
    };

    // 3次元配列の要素を表示
    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 3; j++) {
            for (int k = 0; k < 4; k++) {
                printf("tensor[%d][%d][%d] = %d\n", i, j, k, tensor[i][j][k]);
            }
        }
    }

    return 0;
}

5. 配列とポインタの関係

まず、配列とポインタがどのようなものだったかを簡単に復習しよう。
それぞれの意味は次のようにまとめられる。

配列

同じ型の複数の値を連続するメモリアドレス上に並べたもの

ポインタ

メモリのどこにデータが格納されているかを示すアドレス

これらは全く異なる性質を持つものだが、C言語ではこれらを同一のものとして扱うことになっている。

配列の名前は、配列の最初の要素へのポインタとして使われる。

char arr[] = {1,2,3};

以下のメモリレイアウトを参考に、対応関係を理解しよう。

arrは配列の最初の要素のアドレスを示すポインタであり、この場合はアドレス0番地を示す。つまり、arrは&arr[0]と同じである。
また、*arrはアドレス0番地に格納されている値を意味する (= arr[0]に格納された値を示す)ので、この場合*arrは1となる。

タイトルとURLをコピーしました