なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

なぜC言語のポインタは難しいと言われるのか

  • 2021/09/04 最後に補足を追加
  • 2021/09/05 「補足」項にて、ローカル文字列の注意点を追記

Cを学ぶにあたってよくぶち当たる壁と言われているのがポインタである。

なぜポインタは難しいと言われるのか?今回はそもそもポインタとは何なのかを交えてわかりやすくなるように努めて解説していこうと思う。

そもそもポインタ自体はさほど難しい概念ではない

早速タイトル詐欺のようで申し訳ないが、そもそもポインタ自体はそこまで難しい概念ではない(と思う)。ただ、普通の変数や配列とは異なった概念であり、かつ仕様自体は単純ではあるが、使いこなすのが難しいため、ポインタそのものが難しく感じるだけであると自分は感じている。

「変数を箱に例える」理論で解説してみる。まず変数aと言う箱がある。

f:id:opaupafz2:20210904103722p:plain

変数aという箱に5が入っている。そしてポインタbという箱がある。ポインタbは変数aの4歩右にあるとする。

f:id:opaupafz2:20210904104622p:plain

そしてポインタbには「aは左4歩先にあるよ」ということを教えてくれる紙を入れることができる。

f:id:opaupafz2:20210904105149p:plain

その情報をもとにコンピュータは左4歩先にある変数aを探し出し変数aから5を取り出すことができるわけである。

f:id:opaupafz2:20210904110907p:plain

以上をもとにCのプログラムを見てみよう。

#include <stdio.h>

int main(void)
{
    int a  = 5;     /* aに5を代入 */
    int *b = &a;    /* bにaの場所を代入 */
    
    /* 詳細は後ほど解説 */
    printf("*b = %d\n", *b);
    
    return 0;
}

この結果は以下のようになる。

*b = 5

ポインタにおいて、*bは「bに入っている場所の変数から値を取り出す」という意味である。
つまり、bにはaの場所を入れているので、ポインタに*を付けることでその情報をもとにaから5を取り出すという意味となっている。
したがって、現時点では

*bから値を取り出すこと

aから値を取り出すこと

は意味としては同じなのである。

ポインタのメリット

ポインタのメリットはいくつかあるが、その恩恵を感じやすい場面を解説していこう。

変数そのものの値を変更できる

たとえば、値を交換したい場合、Cでは以下のように書く。

temp = a;
a    = b;
b    = temp;

なぜCではこのようにしなければならないのかについては今回は省くが、いちいち値の交換をするたびにこのように書かなければならないのはしんどいはずである。
そこで、値を交換する関数を作る。関数化すると以下のようになる。

/**
 * 値を交換する関数
 */
void swap(int a, int b)
{
    int temp;
    
    /* 値を交換する */
    temp = a;
    a    = b;
    b    = temp;
}

しかし、実際使ってみると関数化してもうまくいかないことがわかる。実際に実行してみよう。

#include <stdio.h>

/* swap関数は省略 */

int main(void)
{
    int a = 1;
    int b = 2;
    
    /* 交換前の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    /* 値の交換 */
    swap(a, b);
    
    /* 交換後の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    return 0;
}

結果としては以下のようになる。

a = 1, b = 2
a = 1, b = 2

swap関数の引数にabを渡したにも関わらず、値が交換されていないことがわかる。これはアルゴリズム上のミスではなく、値の交換自体はされている

ではなぜabの値は交換されなかったのか?
それについては、関数がどのように展開されているか考えてみるとわかる。

#include <stdio.h>

int main(void)
{
    int a = 1;
    int b = 2;
    
    /* 交換前の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    /* 値の交換 */
    /* 関数を展開してみる */
    {
        int _a = a;
        int _b = b;
        int temp;
        
        temp = _a;
        _a   = _b;
        _b   = temp;
    }
    
    /* 交換後の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    return 0;
}

以上のようになっている(今回は便宜上交換する変数は_a_bとしているが)。
まず_aa_bbまったく異なるものである。つまり以下の図のようになっている。

f:id:opaupafz2:20210904141027p:plain

そして今回交換したのは_a_bの値であるためabの値は交換できていないことがわかる。

f:id:opaupafz2:20210904142241p:plain

これでabの値が交換されなかった理由はわかった。しかし、同時にこのままでは関数を実行して変数の値を交換する方法がないこともわかった。

ではどうすればいいのか?ここでポインタの出番である。
「そもそもポインタ自体はさほど難しい概念ではない」項の最後で言ったことを思い出してほしい。

*bから値を取り出すこと

aから値を取り出すこと


は意味としては同じなのである。

これはつまり、

*bに値を再代入する
のと
aに値を再代入する

のも意味としては同じであるとも言えないだろうか?

答えはYesである。
ポインタに*を付けることで、ポインタに入れている場所の変数そのものを使うことができるのである。

この考えを値を交換する関数に適用して考えると、_a_bをポインタに置き換えることでabの値を交換することができそうである。

f:id:opaupafz2:20210904150652p:plain

では以上をもとに値を交換する関数を改造してみる。

/**
 * 値を交換する関数
 */
void swap(int *pa, int *pb)
{
    int temp;
    
    /* 値を交換する */
    temp = *pa;
    *pa  = *pb;
    *pb  = temp;
}

これで値を交換することができるはずである。早速関数を実行して値を交換してみよう。

#include <stdio.h>

/* swap関数は省略 */

int main(void)
{
    int a = 1;
    int b = 2;
    
    /* 交換前の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    /* 値の交換 */
    /* `&`は変数の場所を意味するので必ず付けること! */
    swap(&a, &b);
    
    /* 交換後の値を表示 */
    printf("a = %d, b = %d\n", a, b);
    
    return 0;
}

結果は以下のようになる。

a = 1, b = 2
a = 2, b = 1

ちゃんと値が交換されていることが確認できた。
このように、ポインタは主に代入した場所の変数そのものを変更したい場合に用いられるものである。

これは非常に強力であり、これによってCではハードを直接動かすようなプログラミングも可能となっている。

巨大なデータを効率良く移し替えることができる

これ以外にも、巨大なデータを別のデータに移し替えるときにもポインタは使われる。巨大なデータを移し替えるとき、通常は以下のようになる。

/**
 * 巨大なデータ
 */
typedef struct  {
    int data[10000];
} bigdata_t;

int main(void)
{
    bigdata_t bd_1 = { { 0 } };
    bigdata_t bd_2;
    
    /* 巨大なデータを移す */
    bd_2 = bd_1;
    
    return 0;
}

しかし、これだと値を移すだけで倍の余分なデータを必要とする。そこでポインタを使えば、この余分なデータを用意することなく簡単に移し替えることができる

/**
 * 巨大なデータ
 */
typedef struct  {
    int data[10000];
} bigdata_t;

int main(void)
{
    bigdata_t bd = { { 0 } };
    bigdata_t *pbd_1 = &bd; /* ポインタにbdの場所を代入 */
    bigdata_t *pbd_2;       /* 巨大データを移し替えるためのポインタ */
    
    /* 巨大なデータを移す */
    /* 場所を再代入する場合、`*`は付けない */
    pbd_2 = pbd_1;
    
    return 0;
}

なぜこれで余分なデータを必要とせずデータを移し替えることができるのか?それは、ポインタ自体はそのデータの場所を入れるものでしかないためである。つまり、情報量としては小さいものである。

f:id:opaupafz2:20210904161546p:plain

情報量が小さいもの同士で移し替えているだけなので、2つのポインタの情報量だけで済む

f:id:opaupafz2:20210904163123p:plain

これを応用した考え方がC++11、Rustのムーブセマンティクスである。ムーブセマンティクスについては一応ここで書いている(多分もっと良いサイトがあると思うのでこれを見ずにググったものを見て)。

ポインタのデメリット(というか難しい点)

さて、このポインタだが、メリットもあれば、当然デメリットも存在する。そしてそのデメリットが「ポインタが難しい」につながっているのではないかと思う。

変数と間違って使ってしまう

実はポインタは変数と同じように四則演算を使うことが可能である。ポインタを使った四則演算を特にポインタ演算と呼ぶ。

int main(void)
{
    int a  = 5;
    int *b = &a;
    
    /* このように四則演算も可能 */
    b += 1;
    
    return 0;
}

ポインタに対して四則演算をすると、場所を変更することができる。つまり上の例の場合、b += 1とするだけでaとは違う変数の場所になるのである。
「そもそもポインタ自体はさほど難しい概念ではない」項にて、「左4歩先に~」といった例え方をしたが、あの例えで考えるとb += 1は「変数aから右一歩先の変数の場所を代入する」という意味である。
つまり、左4歩先にaがある例の場合変数を取り出すときにこのようになってしまう。

f:id:opaupafz2:20210904171943p:plain

この「何か」とは、「何か」である。何が入っているかはわからない。最悪の場合、以下のようにポインタ自身の場所を取り出してしまうかもしれない(実際には変数の値を取り出すときに「変数の場所」を「数値」として取り出す)。

f:id:opaupafz2:20210904173217p:plain

このように「意図しない変数の場所を入れてしまう」ことでバッファオーバーフロー*1の原因にもなる。

配列とほぼ同等に扱える

実はCではポインタと配列はほぼ同等に扱えるように設計されている。それゆえ、「Cの配列とポインタは同じである」と誤解している人が非常に多い。しかし実際はまったく違うものである。
まずポインタだが、実は以下のようにしても代入した場所の変数を使うことができる。

int main(void)
{
    int a  = 5;
    int *b = &a;
    
    /* これでもaを使うことができる */
    b[0];
    
    return 0;
}

なぜ使うことができるのか?これはそもそもb[0]という書き方自体が*(b + 0)の糖衣構文、つまり単純に書き表すための書き方なのである

したがって、配列においても以下のように書いていることになるわけである。

int main(void)
{
    int a[3] = { 0, 1, 2 };
    
    /* 配列は以下のようにしても扱える */
    *(a + 0);
    
    return 0;
}

「え、これって配列もポインタも同じじゃん」・・・と思ってしまっただろう。しかしこれは罠である。たとえば配列に対して四則演算をしてみよう。

int main(void)
{
    int a[3] = { 0, 1, 2 };
    
    /* 配列に対して四則演算 */
    a += 1;
    
    return 0;
}

これはできない。なぜなら配列は「変数をまとめるためのもの」であり、「変数の場所を代入するためのものではない」からである。
違いをわかりやすくするために図示すると、配列とポインタの違いは以下のようになる。

f:id:opaupafz2:20210904182337p:plain

しかしいずれにしても「配列とポインタの違いがわかりづらい」というのは事実であるため、配列とポインタは混同しないように気を付けなければならない。

ちなみに配列において、実は以下も糖衣構文である。

int main(void)
{
    int a[3] = { 0, 1, 2 };
    
    a;              /* これは・・・ */
    &a[0];          /* ・実はこれの糖衣構文である */
    &(*(a + 0));    /* ・すなわちこうであるとも言える */
    
    return 0;
}

ポインタに「配列の要素1番目の場所」を代入するときint *b = &aなどのように代入できないのはこのためである。

もうすでになくなってしまった変数を使うことができてしまう

たとえば以下のプログラムがあったとする。

/* NULLの定義は省略 */

int main(void)
{
    /* Cでは最初ポインタに何も代入しない場合NULLを代入する慣例がある */
    /* ちなみにほとんどのCコンパイラにおいてNULLとは(void *)0である */
    int *p = NULL;
    
    {
        int a = 5;
        
        /* pにaの場所を代入する */
        p = &a;
    }   /* ここで変数aはなくなる */
    
    *p; /* これはどうなる? */
    
    return 0;
}

このプログラムは通るし、何ならprintfで出力しても普通に出力されてしまう場合もあるだろう。しかしこれは非常に危険な状態である。なぜならpに代入された場所にあるa変数を取り出す時点でもうすでに存在しないからである。

これによって、pを使って変数の値を変更するときに偶然その場所に別のデータが入っていた場合にそのデータを変更してしまう、といった不具合が生じてしまう可能性もある。

f:id:opaupafz2:20210904195852p:plain

このように、なくなってしまった変数の場所を代入しているポインタをダングリングポインタと呼ぶ。
このダングリングポインタを回避するためには、以下のことを念頭に置こう。

  • ポインタより狭いスコープの変数の場所は極力代入しない
  • ポインタと同じスコープか、もしくはそれより広いスコープの変数の場所を代入、もしくはmallocを使う
    • mallocにより確保されたメモリをfreeで解放した場合もダングリングポインタとなるので、再度freeしないように気を付ける
  • 変数がなくなる直前、もしくはなくなった直後ポインタにNULLを代入しておく

終わりに

「ポインタは難しい」と聞いて、極力理解しやすいように書いたつもりだが、わかりづらかったら申し訳ない。

このようにCのポインタは仕様自体単純そのものであるものの、使いこなすのが難しい。そのために、「ポインタは難しい」や「Cは難しい」といった認識が広がってしまったのでは、と考えている。
しかし同時に、Cのポインタを理解することはかなり重要なことであり、理解することでほかのプログラミング言語の理解も深まるのではないかと自分は考えている。

補足

※ここからは(おそらく)上級者向けです。ポインタ初心者には理解できない可能性があります。

なぜchar *型に文字列を直接代入できるのか

Cにおいて、実はchar *型には文字列を直接代入することができる。

#include <stdio.h>

int main(void)
{
    const char *str = "abc";
    
    printf("str = %s\n", str);
    
    return 0;
}

上記プログラムは問題なく動く。

str = abc

これはなぜなのか?

実はCでは文字列リテラルグローバル文字配列である。すなわち、先ほどのプログラムは以下のプログラムと同義なのである。

#include <stdio.h>

static const char literal[4] = { 'a', 'b', 'c' };

int main(void)
{
    const char *str = literal;
    
    printf("str = %s\n", str);
    
    return 0;
}

以下のように書くとちゃんとしたローカル文字配列となる。

#include <stdio.h>

int main(void)
{
    const char str[4] = "abc";
    
    printf("str = %s\n", str);
    
    return 0;
}

そのため、ローカルスコープで宣言したchar型の配列を利用してchar *型を返す関数を作ってはならない(これはダングリングポインタとなる)。

また、代入した文字列リテラルは「書き換えてはいけない」ことになっている。そのため、文字列リテラルを直接代入する場合は必ずconst char *型で宣言するべきである

*1:「バッファ」と呼ばれるデータの「外側」の値を変更してしまう不具合のこと