LoginSignup
97
87

More than 5 years have passed since last update.

【初心者向け】ポインタはなぜ難しいか(ポインタのちょっとしたコツ)

Last updated at Posted at 2016-10-23

はじめに

C言語初心者には、なぜポインタが難しく感じられるのか。

ポインタの解説というと、しばしば「住所の書かれたメモを渡す」というアナロジーで説明されますが、ポインタという概念自体は、理解するのにそれほど難しいものではないと思います。

難しい理由は、以下の3点だと思っています。

  1. そもそも記法が変態的で、人間の目に優しくない。
  2. この変数には○○を指すポインタが格納されてて、○○もポインタで××を指していて…とか考えているうちに、頭がこんがらがる。
  3. 配列とポインタの奇妙な互換性に混乱する。

1は(まったく同感ですが)慣れが解決する問題であり、3については、配列には配列特有の読み方があるため、別の機会に書きたいと思います。

本記事では、上記の3点のうち、2の問題を軽減するためのコツについて、解説してみます。

対象読者

本記事が対象とする読者像は、以下です。

  • int n; int *p = &n; *p = 10;とかで、ポインタ経由でポイント先の変数の値を書き換えるぐらいは分かる。
  • 関数呼出し時に、引数に&nを渡して、関数内でnに値を入れてもらうのも分かる。
  • ただし、文字列を指すポインタとか、ポインタ配列とか、ポインタのポインタとか、参照関係が複雑になると頭の中がグチャグチャになる。
  • 全体像を紙に描けば理解できなくもないが、いちいち図を描いてると時間がかかって仕方ない。

対象としない読者像は、以下です。

  • ポインタ配列、関数ポインタ、関数ポインタ配列、ばりばり書ける/読めるぜ。
  • 「配列へのポインタ」と「配列の先頭要素へのポインタ」の違いについて説明できる。
  • ポインタ?ああ、アセンブリ言語から入ったから楽勝だった。
  • 関数の仮引数(配列型)をオペランドとするsizeofの結果が何を意味するか知っている。
  • なぜ標準Cでは(void *)p + 1という式が規定違反なのか論理的に説明できる。

ここからは、ポインタ理解のためのコツのようなものを示していきます。

なお、説明文中で「オブジェクト」という言葉を使いますが、これは「オブジェクト指向」のオブジェクトとは関係ありません。漠然と「メモリ上に置かれたモノ」ぐらいのイメージで捉えて下さい。

ポイント(1) 同時に複数のことを考えない

以下のコードを見てください。

例1
#include <stdio.h>

int main(void){
    int n = 42;    /* (1) */

    printf("n = %d\n", n); /* (2) */

    return 0;
}

説明するまでもないですが、ここでやっているのは…

  • 変数nに、42という値を代入する。
  • 変数nの値を表示する。

です。非常に簡単ですね。

もう一つ、やってみましょう。

例1'
#include <stdio.h>

int main(void){
    int n = 0x7fffc00;    /* (1) */

    printf("n = %x\n", n); /* (2) */

    return 0;
}

これも簡単ですよね。

  • 変数nに、0x7fffc00という値を代入する。
  • 変数nの値を表示する。

では、上のコードの変数nの型を、<int *>に変えてみましょう。

例1''
#include <stdio.h>

int main(void){
    int *n = 0x7fffc00;    /* (1) */

    printf("n = %p\n", n); /* (2) */

    return 0;
}

(上の(1)は標準Cの規定に違反するのですが、イメージの具体化のためにあえて書きました。一瞬だけ目をつぶって下さい。)

ポインタが出てきましたが、考えることは先の例1と同じです。

単純に、変数に値を代入して、その値を表示しているだけ。変数nの型が<int><int *>かという違いはありますが、それ以外は先ほどの例と同じです。

何が言いたいかというと、「ただのオブジェクトである」という点においては、ポインタもint型の値も同じである、ということです。ただ、型が違うだけです。

  • ポインタは、ただのオブジェクトである。
  • その型が、たまたま「ポインタ型」という型なだけ。

では、上のコードから、ポインタ変数に代入する値を「nへのポインタ値」に変えてみましょう。

例2
#include <stdio.h>

int main(void){
    int n = 42;
    int *p = &n;    /* (1) */

    printf("n = %d\n", *p); /* (2) */

    return 0;
}

上のコードを読む時の思考を言語化すると、おそらく、こうなったのではないでしょうか。

  • (1)で、変数pには、変数nを指すメモリアドレスが代入された。
  • (2)では、ポインタ変数pに格納された値(つまり変数nのメモリアドレス)で間接参照してるから…ポインタ変数pが指す先の変数nの値42が表示される。

僕は、こういう風に読んでいます。

  • (1)で、変数pになんか値が代入された。
  • (2)では、*演算子がp(被参照型は<int>)にかかるから、結果は<int>の値。
  • で、その値は何かというと、pnを指してるから、変数nの値42

何が違うのかというと、僕の場合

  • ポインタが何を指しているのかを、必要になるまで考えない。

ことです。遅延評価といいますか。

どういうことかというと、僕はシングルタスク人間なので、同時に複数の物事を考えるのが苦手なのです。だから「ポインタが何を指すか」と「ポインタ自身のこと」を同時に考えるのが苦手。なので、これらを切り分けて考えることで、思考の負担を減らしています。

これぐらい短いコードであれば差異が分かりにくいですが、より複雑なデータ構造・・・ポインタ配列とか、ポインタ配列の先頭要素へのポインタとかになると、「このポインタ変数の値は何で、その値はポインタを指してて、そのポインタは…」と同時思考していたら、頭がパンクするのではないでしょうか。

ポインタが難しいとされる一つの理由として、僕の仮説ですが…

  • ポインタ自身のことと、ポインタが何を指すかを同時に考えるから、頭が混乱するのではないか。

その対策として

  • ポインタが何を指すかは、ポインタが使用される時まで考えない。

ポイント(2) 型分類と被参照型を知る

シングルポインタは難なく理解できても、ポインタのポインタ、ポインタ配列を走査するポインタ…になってくると、図を描いてじっくり考え込まないと、理解できませんよね。

そういう泥臭い作業に時間をかける時期は避けて通れないと思いますが、複雑なポインタ型を読み解くコツについて書いてみたいと思います。

そのコツとは、型分類被参照型です。


まず、型分類というのは、「結局それの型は何なのさ?」と聞かれたときに、答える答えです。

これは言葉で説明するよりも、例を挙げたほうが早いですね。

<int>を指すポインタって、何の型なの?」
 - ポインタ型です。

<int>の配列って、何の型なの?」
 - 配列型です。

<int>を指すポインタの配列って、何の型なの?」
 - 配列型です。

<char>を指すポインタの配列の先頭要素を指すポインタって、何の型なの?」
 - ポインタ型です。

もう気づいたかもしれませんが、型分類とは「最外側の型」、言い換えると「型を日本語で書き下した時に、いちばん最後にくる型」です。

「ポインタのポインタ」を使ったコードで説明してみます。


例4
#include <stdlib.h>

void f(int **ptr_to_intptr)
{
    *ptr_to_intptr = malloc(sizeof(int));
}

上のコードでは、関数f()の仮引数ptr_to_intptrが、型「int型ポインタへのポインタ」です。

では、これの型分類は何でしょうか?

「ポインタ型」です。

では、そのポインタ型が指す型は何でしょうか?

「int型へのポインタ(型<int *>)」です。

この「ポインタによって指される型<int *>」のことを被参照型と呼びます。

つまり、こうなります。

型分類 被参照型
(指される型)
<int **> ポインタ型 <int *>

コード例4に出てくる変数ptr_to_intptrは、こう読みます。

  • 変数ptr_to_intptrはポインタ型で、被参照型は<int *>である。

関数f()の定義を、もう一度、見てみます。

#include <stdlib.h>

void f(int **ptr_to_intptr)
{
    *ptr_to_intptr = malloc(sizeof(int));
}

くどいですが、私の読み方では、int **ptr_to_intptrは「<int>を指すポインタのポインタptr_to_intptr」とは読まないです。

「変数ptr_to_intptrの型分類はポインタ型である。かつ、その被参照型は<int *>である」と分けて読みます。

擬似コードで表現すると、このような表現になるでしょうか。

/* 型分類 : 変数名 -> <被参照型> */
Pointer : ptr_to_intptr -> <int *>;

で、式*ptr_to_intptr = malloc(sizeof(int))の行まで来て、はじめて「ptr_to_intptrのポイント先に、`動的確保した領域へのポインタを代入しているんだな」と、参照先のことを考えます。

この考え方の何が嬉しいかというと、ポインタ変数自体の型分類と、その被参照型を分けて考えることで、ポインタのポインタだろうが、ポインタのポインタのポインタだろうが、すべて「型分類がポインタ型の変数」としてシンプルに考えられる点です。

繰り返しますが、<int *>だろうが、<int **>だろうが、<int ***>だろうが、すべて型分類は「ポインタ型」です。それぞれ、被参照型が違うだけ。

ここまでのまとめ

ここでいったん、まとめます。

  • ポインタとは、ただのオブジェクトである。その点においては、int型オブジェクトと同じ。
  • int **pは「pはポインタ型変数である。かつ、その被参照型は型<int *>である。」と読む。

応用編:ポインタ配列

いきなりですが、ポインタ配列です。

あまり簡単な例ばかりだと効果を測定しにくいので、今まで説明した読み方で実演してみたいと思います。

まず、ポインタ配列の前に、以下のint型配列を使ったコードを見てください。

int型配列(1)
#include <stdio.h>

int main(void)
{
    int nums[] = {10, 20, 30};

    printf("%d %d %d\n", nums[0], nums[1], nums[2]);

    return 0;
}

このコードを実行して何が表示されるかというと、もちろん
10 20 30
ですよね。

では、ちょっと数字を変えて見ましょう。
配列numsの中身を変更してみました。

int型配列(2)
int main(void)
{
    /* 配列の各要素の値を変更*/ 
    int nums[] = {0x7fffc00, 0x7fffc01, 0x7fffc02};

    printf("%x %x %x\n", nums[0], nums[1], nums[2]);

    return 0;
}

このコードを実行すると、表示されるのはもちろん
0x7fffc00 0x7fffc01 0x7fffc02
ですよね。

配列numsの中身はどうなっているかというと
[ 0x7fffc00 | 0x7fffc01 | 0x7fffc02 ]
です。

では、変数numsの型をint *[]に変えてみましょう。

intポインタの配列
int main(void)
{
    int *nums[] = {0x7fffc00, 0x7fffc01, 0x7fffc02};

    printf("%p %p %p\n", nums[0], nums[1], nums[2]);

    return 0;
}

(上のコードは標準Cの規定に違反するコードなのですが、イメージの具体化のためにあえて書きました。一瞬だけ目をつぶって下さい。)

このコードを実行すると、表示されるのはもちろん
0x7fffc00 0x7fffc01 0x7fffc02
ですよね。

配列numsの中身はどうなっているかというと
[ 0x7fffc00 | 0x7fffc01 | 0x7fffc02 ]
です。

そう、int型配列の時と同じです。


何が言いたいかというと

  • int型配列も、ポインタ配列も、型分類は同じ「配列」である。
  • 違うのは、配列要素の型。

ということです。表にすると、こんな感じ。

型分類 配列要素の型
int [] 配列型 <int>
int *[] 配列型 <int *>

といっても「ポインタは整数と同じじゃい」ということではありません。ポインタ型はポインタ型です。

非常にくどくて恐縮ですが、「ポインタはただのオブジェクト」である。ということを、配列を通して言いたかったのです。

まとめ

  • ポインタもただのオブジェクトであり、その型はポインタ型である(くどい)。
  • ポインタ配列とは「配列要素の型がポインタ型である配列」である。

ここまで読まれた方で「ポインタの間接参照による値の参照と代入をほとんどやってない。そこがポインタの難しいところなのに」と思われた方もいるかもしれません。

おっしゃる通りだと思います。「ポインタが難しい」という初心者がつまずいているのはこの先の話で、簡単な部分だけを取り上げて「ポインタはこうすれば理解できる」と言っているだけではないかと。

しかし、ポインタについて説明するとき、ポインタの目玉である「間接参照」が主役になることが多いと思いますが、僕は間接参照の前に、まず「ポインタ自身もただのオブジェクトである」という認識を、意識に強く刷り込む必要があるのではないか、と思っています。

サッカーボールをリフティングしながら縄跳びするのが難しいように、「ポインタの参照先を考えながら、ポインタ自体のことも考える」のは、思考の負担が大きい。それがC初心者が「ポインタが難しい」と考える一因ではないか、と。

分割して統治せよではないですが、ならば間接参照の説明は切り捨てて、「ポインタ自身もオブジェクトである」の一点に絞り込んで説明すべきだと考えました。


僕自身、ポインタを理解する過程で「ポインタは整数値であり、int型と同じと考えてよい」と考えた時期があります。もちろんこれは間違った解釈ですが、ただ、この「誤解」を持ってから、加速度的にポインタの理解が進んだ経験があります。そして理解が進むにつれて、先の誤解も自律的に修正されました(と自分では思っています)。

解釈こそ間違ってはいるものの、「ポインタは(スカラ型の)オブジェクトである」という認識に近いところに来たからこそ、理解が進んだのではないかと。

それで何が言いたいかというと、「ポインタはオブジェクトである」と後から気づくよりも、先にそのニュアンスを固めておけば、その後のポインタ理解の効率化にもなるのではないかと思い、このような記事を書いたしだいです。

まあ、なんだかんだ言って、ポインタは難しいですね。ポインタ理解に王道なし。分からんかったら自分の手で図を描け!ですかね。

最後に僕にとって、これがポインタ理解に役に立った、というものを挙げておきます。

  • 「C言語ポインタ完全制覇」を読む。
  • 双方向リストを実装する(ノードの追加と削除も)。
  • main関数の第2引数であるargvの値、argvが指すポインタ配列、その配列要素のポインタが指す配列、これらのアドレスや中身を表示し、全体像を図に描いてみる。

あ、図に描くとき、メモリアドレスは100とか200とか適当な整数ではなく、ちゃんと0x7fffc00とか「いかにもアドレスらしい値」を使ったほうがよいです。でないと、intの値と混同してしまうんですよ。

97
87
3

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
97
87