8
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Cのポインタをやさしく本質的に理解してマスターする

Last updated at Posted at 2023-08-30

はじめに

今回はC言語のポインタの基本的な内容をやさしく整理しようと思います.
ポインタはC言語において,難しくてややこしいところとよく言われますが,本質的に理解できると面白いところが多く,今まで「おまじない」で謎だったところもその全容が明らかになります.
ポインタを使うメリットとして,

  • アドレス先の値を取得したり,変更することができる.
  • メモリ効率とパフォーマンスを向上することができる.
  • 動的なメモリ割り当てが可能になる.
  • 配列が扱いやすくなる.
  • 関数が呼び出し元の変数を直接扱えるようになる.
  • 動的なデータ構造(リストやグラフなど)の操作が便利になる.
  • コールバック関数などが実装可能となり,特定の条件やイベントに応じて異なる関数を指定することができる.

などたくさんあります!
ここでは,ポインタの要点をまとめ,その使い方と実装例を取り上げて一緒にポインタをマスターしていきましょう!!

ポインタとは

ポインタを一言で表すと,変数のアドレスを記憶する変数です.ここで,アドレスとは,メモリ上に割り当てられた番号のことで,コンパイラやリンカ(プログラムを実行可能ファイルとして生成するためのソフトウェア)が自動的に決定します.
ポインタを扱う前に,「変数のアドレス」について確認していきます.

変数の仕組み

一般的に,C言語などの命令型言語において,変数は直接的にアドレスを表し,間接的にそのアドレスの中身を表しています(レジスタ変数や定数などアドレスを持たないものもあります).そのため,変数を宣言すると,その変数にアドレスが与えられ,アドレスにアクセスすることで変数の値を取得することができます.

例えば,上図のようにint x = 10と初期化すると,変数xには101番地のアドレスが入り,101番地に値10が代入されます.
x += 1では,xのアドレスである101番地にアクセスし,そこに格納されている値に1を足しています.
このように,人間は変数を,値を入れる「箱」として取り扱っていますが,機械側ではメモリ領域を指すアドレスとして取り扱っています.

変数のアドレスを取得する

変数や配列要素のアドレスを取得するには &演算子(アドレス演算子) を使用します.変数名の前に「&」(アンパサンド)をつけることでアドレスを取得できます.
実際に下記のコードを打ち込んで確認してみます.

アドレスの取得
#include <stdio.h>

int main (void) {
  int x;
  int a[5];
  double y;
  
  // printf関数でアドレスを表示するために「%p」変換子を使用
  printf ("xのアドレス:%p\n", &x);
  printf ("yのアドレス:%p\n", &y);
  printf ("a[0]のアドレス:%p\n", &a[0]);
  printf ("a[1]のアドレス:%p\n", &a[1]);
  printf ("a[2]のアドレス:%p\n", &a[2]);
  printf ("a[3]のアドレス:%p\n", &a[3]);
  printf ("a[4]のアドレス:%p\n", &a[4]);

  return 0;
}
実行結果
xのアドレス:0061FF1C
yのアドレス:0061FF00
a[0]のアドレス:0061FF08
a[1]のアドレス:0061FF0C
a[2]のアドレス:0061FF10
a[3]のアドレス:0061FF14
a[4]のアドレス:0061FF18

アドレスはデフォルトで16進数表示の場合が多いです.実行結果を見てみると,int型の配列aが4byte(int型の大きさ)ごとの連続したメモリ領域に格納されていることがわかります.

注意

  • 関数呼び出しの系列や実行環境,プログラムの実行毎に,アドレスの値は変わります.
  • ここでのアドレスは,実際の物理的なメモリ上のアドレスを表しているとは限りません.
  • アドレスを持たない式への演算はコンパイルエラーとなります.

ポインタの使い方

それでは,実際にポインタを取り扱っていきます.先述の通り,ポインタは変数のアドレスを記憶する変数なので,先ほどのように取得したアドレスを格納した変数のことをポインタといいます.
ポインタ変数は,変数名の前に「*」(アスタリスク)を付けて宣言します.ポインタ変数には他の変数などのアドレスを代入します.
例えば,int*型のポイントの宣言は,int *pと行います.

ポインタの型によって,ポインタが指している先の型情報が分かるのでコンパイル時に型に関する整合性の検査ができます.

ポインタ変数の初期化
#include <stdio.h>
 
int main(void) {
  int x = 10; // int型変数
  int *p; // 変数xへのポインタを用意
  p = &x; // ポインタ変数pに変数xのアドレスを代入  
  printf("変数xへのポインタpのアドレス:%p\n", p);
 
  return 0;
}
実行結果
変数xへのポインタpのアドレス:0061FF18

注意
ポインタ型の変数を複数宣言する場合,
int *a, *b
のように,変数すべてに「*」をつける必要があります.
int *a, b
と宣言すると,aはint型へのポインタ変数となりますが,bはint型の変数となります.

次に,アドレスに格納されている値を取り出します.これには間接演算子「*」を用い,ポインタ変数の宣言の時と同様に,変数名の前に「*」をつけます.

値の取り出し
#include <stdio.h>
 
int main(void) {
  int x = 10; // int型変数
  int *p; // 変数xへのポインタを用意
  p = &x; // ポインタ変数pに変数xのアドレスを代入
  printf("ポインタ変数pのアドレス先の値:%d\n", *p);
 
  return 0;
}
実行結果
ポインタ変数pのアドレス先の値:10

以上のように,ポインタを用いることで,機械側が取り扱っているメモリ領域の操作を人間が行えるようになります.

配列とポインタ

配列とポイントには密接な関係があります.上図のようなメモリ領域になっている配列aに対して,int *p = &a[0]と変数pを初期化すると,pに「100」が代入されます.ここで,p+1の値は,次の要素であるa[1]のアドレスの「104」となります.
このように,ポインタ型の式とint型の式は加減算を行うことができ,ポインタ型の式同士の減算も可能です(足し算はできません).

注意

  • ポインタに対する加減算の意味は型によって異なります.
  • ポインタ型の式とint型の式の加減算は,配列の範囲を超えないように注意する必要があります.上の例の場合,p+5のアドレス先の中身は未定義となります.ポインタが配列の最後の要素+1番目を指すことは許されていますが,アドレス先の値を取得しないように気を付けて下さい!!

また,ポインタの値は代入によって変更できます.上のpに対して,p = p + 1と実行すると,pに「104」が代入されます.  

配列とポインタ
#include <stdio.h>
 
int main(void) {
  int a [5] = {10,20,30,40,50};
  int *p = &a[0];
  printf("アドレスp+1に格納されている値:%d\n", *(p + 1));
  p = p + 1;
  printf("ポインタ変更後の値:%d\n", *p);
 
  return 0;
}
実行結果
アドレスp+1に格納されている値:20
ポインタ変更後の値:20

C言語では,配列をそのまま値として関数に渡すことができません.そこで,配列の先頭要素へのポインタを引数として渡すことで関数内で配列が参照できるようになります!!

配列を関数に渡す
#include <stdio.h>

int sum (int *p) { // 配列の先頭要素のアドレスを受け取る
  int sum=0, i;
  for (i=0; i<5; i=i+1)
    sum = sum + *(p + i); // ポインタが指し示す先の値を取得
  return sum;
}

int main (void) {
  int a[5] = {10, 20, 30, 40, 50};
  printf ("sum = %d\n", sum(&a[0])); // 配列の先頭要素のアドレスを渡す
  return 0;
}
実行結果
sum = 150

配列の表記について

配列を扱うとき,[ ]を用いますが,これはポインタで定義されています.つまり,a[n]と*(a + n)は同じものであり,a[n]の形の式がコンパイル時に*(a + n)に変換されて処理されています.
このような別表記を 糖衣構文(Syntax Sugar) といいます.そのため,関数の仮引数表記においても,int function(int p[]) {...}と書くことができます.

注意
関数の仮引数表記におけるp[ ]は別表記であって,配列ではありません.そのため,p[2]などと書いても「2」は無視されます.

また,int a[3]といった宣言下において,aは長さ3の配列ですが,aは基本的に先頭要素へのポインタ,すなわち&a[0]と同義です(sizeof関数や&の引数など例外はあります).
先ほどのコードを別表記で書くと次のようになります.

配列の別表記
#include <stdio.h>

int sum (int p[]) { // *p == p[]
  int sum=0, i;
  for (i=0; i<5; i=i+1)
    sum = sum + p[i]; // *(p + i) == p[i]
  return sum;
}

int main (void) {
  int a[5] = {10, 20, 30, 40, 50};
  printf ("sum = %d\n", sum(a)); // &a[0] == a
  return 0;
}
実行結果
sum = 150

配列は連続するメモリ領域のかたまりなので,配列というものはあくまで人間が使いやすいように用意されたもので,実際はポインタを扱っているというわけです.

a[i]*(a + i)は別表記であり,i[a]*(i + a)も同様に別記法です.「a + i」と「i + a」の評価結果は同じなので,a[i]i[a]の評価結果も同じになります.
そのため,int a[3]において,a[3]と書いても3[a]と書いても可読性が低下するだけで結果自体は同じになります.

ポインタの応用

C言語には参照渡しがなく,関数に値を渡す際,単なる数値として情報を渡す値渡しを行います.このとき,引数として与える値はコピーされて渡されるので,直接的に変数などを更新することができません.そこで,関数にアドレスを数値として渡すことで,関数内からその変数等にアクセスできるようになります.
scanf関数などでscanf("%d", &x)のように「&」をつけるのも,変数xのアドレスをscanf関数に渡すことで,入力された値をxそのものに代入することを可能にしています.もし「&」がなかったら,xのコピーが値としてscanf関数に渡されるので,xそのものに値をいれることができないのです.
このように,C言語ではポインタを暗に利用しているところが多くあります.ここからはポインタを利用した応用例についていくつか見ていきたいと思います.

文字列とポインタ

C言語には文字列を扱う型が用意されていないので,文字列を扱うには文字列配列を用意する必要がありますが,ポインタを利用することもできます.
以下のコードを見てください.

文字列とポインタ
#include <stdio.h>

int main() {
  char str1[] = "BANANA";
  char *str2 = "MILK";

  printf("%s\n", str1); // BANANA
  printf("%s\n", str2); // MILK
}
実行結果
BANANA
MILK

str1では文字列配列を用いて文字列を代入しています.対して,str2はchar*型のポインタを用いて文字列を代入しています.

文字列リテラルには,プログラムの実行開始から終了まで,常にメモリ上に存在するといった性質があります.文字列配列の初期化時に文字列リテラルを代入すると,その文字列の長さとNULL文字のサイズを持つ文字列リテラルのメモリ領域が自動的に生成され(上図①),一文字ずつそれらの値が配列のメモリ領域にコピーされます(上図②).そのため,
char str1[] = { 'B', 'A', 'N', 'A', 'N', 'A', '\0' };
と文字列を一文字ずつの文字に分解した初期化リストを代入するのと処理は変わりません.
一方,str2では文字列リテラルの先頭要素のアドレス番号が代入されています.そのため,上図でいうと「504」がstr2に初期化されたこととなります.

ポインタを用いるうえで,配列にはない便利な点として,代入だけで文字列を別の文字列に変更可能な点があります.

ポインタを用いるメリット
#include <stdio.h>

int main() {
  char str1[] = "BANANA";
  char *str2 = "MILK";
  char *str3;

  //これはダメ
  str1 = "ORANGE";

  //これはOK
  str2 = "JUICE";

  //これもOK
  str3 = str2;
}

配列のメモリの仕組みを説明した上図からもわかる通り,str1 = "ORANGE"のような書き方はできません.そのため,一文字ずつ書き換えていくか,strcpy関数などの関数を使用する必要があります.
ポインタを用いるとこれが可能で,メモリ領域のどこかに確保された,文字列リテラルの先頭文字のポインタを扱っているだけなので,これを別の文字列のアドレスに変えることで文字列が代入できます.
また,str2 = "JUICE"のように元の文字列よりも大きい文字列も代入可能で,文字列の大きさを考慮する必要がない点や,str3 = str2のように別のポインタからの代入ができる点もポインタを用いるメリットです.

しかし,文字列配列ではできても文字列のポインタではできないデメリットもあります.それは,文字列を書き換えることができないといった点です.これは,C言語に文字列リテラルは書き換えてはならないといった規則があることに起因しています.
先ほど説明した通り,配列は文字列リテラルとは別の場所にメモリ領域を確保し,そこに値を保存しているので書き換えても問題はありません.しかし,ポインタが指し示しているのは文字列リテラルそのもののため,一文字だけ書き換えるといったことができないのです.書き換える可能性がある文字列は文字列配列,書き換える可能性がない文字列はポインタを用いるといった使い分けが良いと思います.

注意
ポインタを用いた文字列リテラルの書き換えにおいて,問題なく動くこともありますが,文字列リテラルを書き換えた時の動作は未定義なため,避けた方がいいです.

ポインタのポインタ(ダブルポインタ)

ポインタは「変数のアドレスを記憶する変数」と説明しましたが,ポインタも変数なので,メモリ領域が割り当てられ,アドレス番号があります.そのため,別のポインタにそのポインタのアドレスを格納することができます.これを ポインタのポインタ(ダブルポインタ) といいます.
ダブルポインタを宣言するには,変数名の前に「*」を2つ付けます.int **xの場合,int*型へのポインタ型として「*」を足している解釈です.
使い方はポインタと同様です.

ダブルポインタ
#include <stdio.h>
 
int main(void) {
  int x = 10; // int型の変数
  int *p; // int型のポインタ変数
  p = &x; // ポインタpに変数xのアドレスを代入
    
  int **pp; // int型のポインタ変数のポインタ
  pp = &p; // ダブルポインタppにポインタpのアドレスを代入

  printf("ポインタpのアドレス:%p\n", &p);
  printf("ダブルポインタpp:%p\n", pp);
    
  return 0;
}
実行結果
ポインタpのアドレス:0061FF14
ダブルポインタpp:0061FF14

上のコードで整数xのアドレスを「700」,ポインタpのアドレスを「200」としてこれらの関係を図で表すと,次のようになります.

ダブルポインタを関数の引数として渡すことで,関数内でポインタの値を変更することができます.
下記のコードではダブルポインタを用いて関数内で配列を動的に作成(呼び出し元のポインタの値を変更)した後,値を代入しています.

ダブルポインタの応用
#include <stdio.h>
#include <stdlib.h>

void allocate(int **a, int size, int value){ // ダブルポインタとしてaのアドレスを受け取る
  *a = (int*)malloc(size * sizeof(int)); // aの中身(アドレス)に動的にメモリを確保
  for (int i=0; i<size; i++){
    *(*a + i) = value + i; // aが指し示すアドレス先に値を代入
  }
}

int main(void) {
  int *a = NULL; // ポインタをNULLで初期化しておく
  int size = 5; // 配列のサイズを関数に与える
  int value = 1;

  allocate(&a, size, value); // ポインタのアドレスを渡す

  for (int i=0; i<size; i++){
      printf("a[%d] = %d\n", i, a[i]);
  }

  return 0;
}
実行結果
a[0] = 1
a[1] = 2
a[2] = 3
a[3] = 4
a[4] = 5

このように,ダブルポインタを用いることで様々なデータ型を格納できる動的配列を作れたり,呼び出し先の関数内でのポインタの変更,複雑なデータ構造を扱うときなど様々な恩恵が受けられます.

ポインタのポインタのポインタ(トリプルポインタ)のように,宣言の際に「*」を追加することで多重にポインタを定義することもできます.

関数ポインタ

関数ももちろんメモリ上に登録されており,そのアドレスを示すポインタ(関数ポインタ)を使用すれば関数を呼び出す事ができます.
関数ポインタの宣言も関数名の前に「*」をつければよく,関数アドレスは関数から括弧(関数呼出し演算子)を除いた関数名で取得できます.
例えば,関数sumの関数ポインタを初期化する際は,
int (*psum)(int, int) = sum
のようにすればいいです.typedef句を使って関数ポインタの型を宣言すると,記述が簡潔になります.
実際のコードで見てみましょう.

関数ポインタ
#include <stdio.h>
 
// 関数ポインタの宣言
typedef int (*calc)(int a, int b);
 
// 関数ポインタに代入する関数の定義
// 関数ポインタの引数の型と引数の数をそろえる必要があります
int add(int a, int b) {
    return a + b;
}
 
int sub(int a, int b) {
    return a - b;
}
 
int main(void) {
  int a, b, result;
  calc p; // calc型の関数ポインタのオブジェクトを生成
  a = 5;
  b = 2;
    
  p = &add; // 関数ポインタにadd関数のアドレスを代入
  result = p(a, b); // 関数ポインタのアドレスからadd関数の呼び出し
  printf("a + b = %d\n", result);
    
  p = &sub; // 関数ポインタにsub関数のアドレスを代入
  result = p(a, b); // 関数ポインタのアドレスからsub関数の呼び出し
  printf("a - b = %d\n", result);
 
  return 0;
}
実行結果
a + b = 7
a - b = 3

このように,関数ポインタを使うと処理内容を意図的に変えることができるので,拡張性や柔軟性が上がったり,動的な関数選択やコールバック関数の実装が可能になります.

構造体とポインタ

構造体の実体(オブジェクト)にポインタを用いると,すべてのメンバを含めた実体全体を操作することができます.
ポインタからメンバを呼び出す際は,アロー演算子「->」を使用します.
以下の例でみてみましょう.

実体のポインタ
#include <stdio.h>
 
typedef struct {
    int num;
    char *name;
} info;
 
int main(void) {
  info entity, *p;
  p = &entity; // info型ポインタに構造体のアドレスを代入
    
  // ポインタを使ってメンバの初期化
  p->num = 10;
  p->name = "Taro";
    
  printf("num:%d\n", entity.num);
  printf("name:%s\n", entity.name);
 
  return 0;
}
実行結果
num:10
name:Taro

上の例で,ポインタpは実体entityを指しているので,ポインタから実体のメンバにアクセスして値を更新すると,元の実体の値が変更されます.
リストやツリーなどのデータ構造を構造体を用いて実装する際に,ポインタを使用することで,要素間のリンクや親子関係などの複雑なデータ構造を効率的に表現できます.

まとめ

今回はポインタの本質的な部分を説明した後にいくつかの応用例を紹介しました.
C言語が実際に使用されるような組み込み機器などではメモリ領域を意識したコーディングが必要とされます.また,意識せずにポインタを使用している場面や,メモリ領域の問題によるエラーに遭遇することもあると思います.
ぜひポインタをマスターしてワンランク上のコーディングライフを楽しんでください✨

8
13
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?