🤮

【情報系新入生向け】C言語のポインタを理解する

2022/04/19に公開約5,400字2件のコメント

対象読者

  • C言語始めたての入門者
  • C言語使っていたけど忘れた人
  • 今までなんとなくで使っていた人

前提

ここではメモリ上の場所のことを「アドレス」というふうに紹介していきます.「番地」という使われ方などもありますがここではアドレスで統一して使用します.

またアドレスは0x200Dのように16進数で表現されており,例えばプログラム中で

sample.c
int a, b[2];

のように定義すれば,int型はメモリを4バイト使用するので,aのアドレスが0x0004,b[0]が0x0008,b[1]が0x000Cのような形でメモリが割り当てられていきます.

ポインタとは

ポインタとはざっくり言うとデータの先頭アドレスを保存する変数のようなものです.
今までの変数と違うところは「値を保存しているのではなくアドレスを保存しているということ」です.
またポインタも変数なのでメモリ上のどこかに保存されており,アドレスを持っています.
定義するときはアスタリスク(*)を付けて以下のように定義します.

sample.c
int a;
int *p; /* ポインタ変数 */

アドレス演算子

では変数のアドレスを取得したいときはどうすれば良いでしょうか.
ここで使用するのが アドレス演算子(&) です.
プログラム中でxのアドレスを取得したいときには&xのように表します.
先ほどのプログラムではint型のaのアドレスを保持するためにポインタの型もint型にしましたが,保存したい変数がdouble型の場合はポインタの定義もdouble型にします.

sample.c
double a;
double *p;

p = &a; /* aのアドレスを保持する */

しかしポインタはあくまでアドレスを格納しているのでポインタ変数自体の大きさは変わりません.

間接演算子

ポインタは変数のアドレスを保持していますが,間接演算子(*)を使うと保持しているアドレスの内容(値)を参照することができます.

sample.c
int a = 10;
int *p;

p = &a;
*p = 20; /* ポインタの指すアドレスに代入する */
printf("a: %d\n", *p); // 20

初期化

ポインタ変数は他の変数と同じように定義しただけでは意図していないアドレスを指している可能性があります.そのため使用するときは初期化が必要になります.
例えば以下のような形で初期化します.

  • 変数のアドレスを代入する
    • p = &a;のような形です.
  • ポインタを代入する
    • p = p2;のような形です.(p2もポインタ変数)

演算

ポインタはアドレスを保存していますが,アドレスもなんらかの数値(16進数)で表されるので演算をすることができます.
例えば以下のようなものです.

sample.c
int *p;
int i = 10;

p += 1;
p += i;

しかしポインタの演算はふつうの変数の演算とは異なります.
char型の例とint型の例を見てみましょう.
以下のようなコードを実行しました.

char.c
#include <stdio.h>

int main(void)
{
  char c = 'a';
  char *p = &c;

  for (int i = 0; i < 3; i++) {
    printf("ADDRESS: %p\n", p + i);
  }
  return 0;
}
char_result
ADDRESS: 0x7fff10f7379b
ADDRESS: 0x7fff10f7379c /* 1ずつ増えている */
ADDRESS: 0x7fff10f7379d /* 1ずつ増えている */
int.c
#include <stdio.h>

int main(void)
{
  int a = 10;
  int *p = &a;

  for (int i = 0; i < 3; i++) {
    printf("ADDRESS: %p\n", p + i);
  }
}
int_result
ADDRESS: 0x7ffce971ede8
ADDRESS: 0x7ffce971edec /* 4ずつ増えている */
ADDRESS: 0x7ffce971edf0 /* 4ずつ増えている */

このようにただ単にポインタ変数をインクリメント(+1)しただけでも結果が異なっています.
char型は+1ずつ増えているのに対してint型は+4ずつ増えています.
実はこの違いはそれぞれの型のサイズが基準となっています.
char型はサイズが1バイトのためポインタ変数をインクリメント(+1)した時にポインタは隣の1バイトを指します.(隣のアドレスを保持する)
しかしint型はサイズが4バイトのためポインタ変数をインクリメントすると4バイト先のアドレスを指します.
このようにポインタ変数をインクリメントした場合はその型のサイズ分だけアドレスが増加します.

配列との関係

ポインタと配列は似ているものとして捉えられることが多くあります.
ではまず配列の復習から入りましょう.
一般に配列名は0番目の要素の先頭アドレスを指します.

array.c
int b[6];
printf("B: %p\n", b);

%pはアドレスを16進数で表示する型識別子です.
このプログラムを実行すると以下のように配列bの先頭アドレスを表示することができます.

array_result
B: 0x7ffd0f84a080

つまり配列の先頭アドレスをポインタ変数に格納したいときは

address.c
int b[6];
int *p;

p = b;
/* または */
p = &b[0];

のようにすることができます.

合計を求めるプログラム

ここで配列の合計値を求めるプログラムを配列とポインタのそれぞれを使って書いてみようと思います.
プログラムは以下のようになります.

array.c
#include <stdio.h>

#define ARRAY_SIZE 5

int main(void)
{
  int a[ARRAY_SIZE] = {1, 2, 3, 4, 5};
  int sum = 0;

  for (int i = 0; i < ARRAY_SIZE; i++) {
    sum += a[i];
  }
  printf("SUM: %d\n", sum);  // SUM: 15

  return 0;
}
pointer.c
#include <stdio.h>

#define ARRAY_SIZE 5

int main(void)
{
  int a[ARRAY_SIZE] = {1, 2, 3, 4, 5};
  int *p = a;
  int sum = 0;

  for (int i = 0; i < ARRAY_SIZE; i++) {
    sum += *(p + i);
  }
  printf("SUM: %d\n", sum); // SUM: 15

  return 0;
}

配列プログラムについては添字iを1つずつ増やしその値を足していくというふうになっています.
ポインタの方はpaの先頭アドレスを保持し,ポインタを使って値にアクセスしています.

ここで*(p + i)の部分についてですが,これは先ほどの演算のところでも少し触れましたが,ポインタ変数pに例えば1を足すとアドレスが1増えるのではなく,実際には今回の場合int型のポインタ変数なので4(int型のサイズ分)増えることになります.

つまり,pは配列の先頭アドレスを持っているのでp + 1は配列の次の要素(a[1]に当たる)の先頭アドレスを保持していることになります.

よって*(p + 1)a[1]の値と同じです.
このようにするとポインタ変数を使って配列の合計を計算することができます.

配列とポインタの違い

ここまで配列とポインタでの動きを確認してきましたが,これらの違いは何でしょうか.

それは配列は実際に領域を確保するが,ポインタはポインタ変数の領域しか確保しないことです.
ポインタはアドレスを保持する変数なのでそれ自体に値はなく,初期化しないと意味のないものとなってしまいます.

また配列は定義時に領域を確保しているのでそのアドレスを後から変更できないという特徴があります.
つまりint a[];で定義した配列に対して,a++;a += 4;などの操作はできないということです.

文字列との関係

まずC言語には「文字列型」の変数は存在しません.

すべての文字列は「文字型の配列」として扱います.
しかしポインタを使うと文字列をあたかも「文字列型」のように扱えるのです.

文字列定数

プログラム中に"Hello World!"と記述された場合,それはコンパイラによってメモリ中に配置されます

sample.c
char *h = "Hello World!";
printf("H: %p\n", h);
result
H: 0x55e573174011

このような結果となり,hはアドレスを表す定数のように扱えます.

初期化

文字列を扱うには大きく分けて配列かポインタで扱う方法があります.

それぞれについて注意点や使うタイミングなどを紹介していきます.
まず配列で文字列を表現しようとした場合,

sample.c
char str1[] = "hello";

このようになると思います.
初期化の際に文字列+1バイト分の領域を確保し,"hello" + \0(文字列の終端記号)を格納します.

ポインタを使った場合はどうでしょうか.

sample.c
char *str2 = "world";

ポインタを使った場合は"world" + \0があらかじめメモリのどこかに確保され,そのメモリの先頭アドレスをstr2が保持します.

つまり配列に関しては自分でデータ領域を確保するが,ポインタに関してはデータの確保する場所についてはあまり関心がないことがわかります.

それでは実際に両者を扱うプログラムを比較していきましょう.

array.c
#include <stdio.h>

int main(void)
{
  char weather[] = "sunny";
  
  /* 終端まで出力する */
  for (int i = 0; weather[i] != '\0'; i++) {
    putchar(weather[i]);
  }
  printf("\n");
  return 0;
}
pinter.c
#include <stdio.h>

int main(void)
{
  char *weather = "cloudy";
  
  /* 終端まで出力する */
  for (char *p = weather; *p != '\0'; p++) {
    putchar(*p);
  }
  printf("\n");
  return 0;
}

既に前に説明した通り,ポインタの場合,p++とするとpが1増える(char型のサイズ分)ので次の文字を指すアドレスの値を取得できるようになっています.

まとめ

なんとなくポインタの概要がつかめたでしょうか.
ポインタには「ポインタのポインタ」,通称ダブルポインタと呼ばれるものもあります.
しかしポインタの考え方をきっちり抑えていればすぐに理解できるはずです.
この記事の内容が頭に入っていれば授業の課題レベルで困ることはないでしょう.
C言語の最初の難関なのでこれが誰かの役に立てれば幸いです.

お願い

もし記事の中に間違いや,解釈が分かれる,わかりにくなどありましたら,ぜひコメントをお願いします!

参考

https://www.ohmsha.co.jp/book/9784274064401/

Discussion

言語仕様の規則としては以下のような内容が含まれています。 (C99 だと項目 6.5.6 に記述があります)

  • ポインタと整数を加算した結果のポインタは配列内の要素、または配列の最後の要素のひとつ後ろを指さなければならない。 そうでない場合は未定義。 (たとえそのポインタが指す場所にアクセスしなくても)
  • 配列ではないオブジェクトへのポインタは長さ 1 の配列の最初の要素へのポインタと同じ動作をする

つまりこの記事内の char.c などの結果は未定義です。 言語仕様に厳密に言えば期待通りに動くことは保証されません。

現代的な処理系では provenance (由来) という概念でポインタが指す先がどのオブジェクトと対応付くのかある程度は静的に解析し、最適化などに活用することがあります。 どのオブジェクトも指していないようなポインタがあると破綻してしまいます。

C は低レイヤ寄りでどのような機械語に対応するのか透けて見えるような言語ですが、その一方では処理系が超強力な最適化機構を持っている場合もあり、未定義を踏むとわけのわからない結果を引き起こすこともあります。

コメントいただきありがとうございます.

つまりchar.cのアドレスについては本来未定義なため今回のような結果になる保証はないということなのですね.

非常に勉強になりました.
ありがとうございました!

ログインするとコメントできます