khurata’s blog

khurata’s blog

なぜポインタを使うのか?

(もともと「Yahoo!知恵袋」の「知恵ノート」だったものを転載しています)
(最終更新日時:2016/7/14)投稿日:2012/4/8

はじめに

 ここ「知恵袋」でも、他の所でも、C のポインタは、「難しい」と言われています。 そう言われる理由はいくつかありますが、大まかに言って次の3つの理由からだろうと私は考えています。

1) C におけるポインタの文法が変な書き方で分かりにくい。
2) 分かりやすく書かれた書籍が非常に少ない。
3) 他の言語ではポインタが多用されないので、何のために使うのかが分かりにくい。

 本ノートでは、特に 3)に重点を置いて説明します。 誤解が無いように書くと長文になってしまうのですが(現在9930字)、ゆっくり読めば、全く難しくありません。 必ず分かるという自信を持って、あせらず、ゆっくり読んでみてください。

 

そもそも「何者」なのか

 ポインタ(pointer)という言葉は point-er ですから、「指し示す者」と訳せます。 猟犬にポインターという犬種がいますが、あれは、仕留めた獲物の場所を教えてくれるので、そういう名前になったのです。

 C のポインタも、それに似ています。 C のポインタとは、何かを指し示すための型、変数です。

 一体、何を指し示すのか……それは、「メモリ memory に格納されている何か」です。

 「何か」は、int型変数の場合もありますし、char型変数だったり、配列だったり、あるいは何らかの構造体だったりします。 場合によっては関数だったりします。

 これら「メモリに格納されている何か」達を、総称して「オブジェクト object」とも呼びます。

 関数は実行出来るオブジェクト、定数やリテラル(即値)は参照しか出来ないオブジェクト、int型変数は整数を格納するオブジェクト、float型変数は浮動小数点数を格納するオブジェクト、ポインタ変数は「オブジェクトを指し示すオブジェクト」、なのです。

 では、C で扱うオブジェクト(変数・定数・即値・関数)が置かれている「メモリ」とはどういうモノか……それは、「同じ大きさの小箱を、たくさん一列に並べた」ようなもので、それぞれの小箱にアドレス address という整数値の通し番号が付けられています。 C プログラム上においては、この小箱1つのサイズは1バイトであり、それは char型サイズと同じである事が、C の規格で決まっています(実際のコンピュータのメモリが、たとえ1ワード14ビットであろうが関係なく、C のメモリの小箱1つは1バイトです)。

 あるオブジェクトが、小箱いくつ分なのか(何バイトなのか)は、そのオブジェクトの型 type によって決まります。 float型なら4つで char型なら1つ、などです(char型以外の型サイズは、処理系依存です)。

 1つの型は、メモリ上で連続した小箱に置かれます。 float型が、2つの小箱と、別のところにある2つの小箱に分断される事はありません。 また、配列は全要素が連続した小箱に置かれます。 従って、オブジェクトの先頭アドレスを使えば―それがどんなに大きなサイズであっても先頭アドレスだけで―、それを指し示す事が出来ます。

 オブジェクトが、複数のオブジェクトの組み合わせから成る場合、それらがメモリのあちこちに分散する事は普通にありますが、「最初の・先頭オブジェクト」の型1つは、やはり連続した小箱に置かれます。

 ポインタは、対象が置かれているメモリ領域の「先頭アドレス値と型を格納する」ことによって、対象オブジェクトを指し示します。 アドレス値と型があれば、対象のオブジェクトが「どこにあり」「どこまであるのか」が分かるからです。

 たとえて言えば、ポインタとは、「どこかの住所を書き留めるための小さな紙きれ」のようなものです。 「東京都千代田区1-1」という住所を、小さな紙切れに書いて机上に置いておいたり、コピーしたり、誰かに渡す事は簡単に出来ますが、「皇居そのもの」を机上に置いたりコピーしたり誰かに渡すなんて事は、まず不可能です。
 「皇居オブジェクト」そのものを扱うのは大変ですが、「皇居の住所オブジェクト」は、簡単に扱える……住所(address)という概念は、素晴らしいと思いませんか?

 なお、C のアドレス値は整数なのですが、ポインタは整数型ではありません。 ポインタは対象オブジェクトの型情報も持つからです。 また、ポインタ変数が持つアドレス値の具体的な値は、特殊な場合を除き、気にする必要はありません。 

 ポインタがどういう仕組みなのかは処理系依存であり、printf( ) 関数の出力変換書式 %p でアドレス値を表示する際の出力形式も、処理系依存です。

 

基本的な文法

 ここでは、C のポインタにまつわる、基本的な3つの書き方、「宣言」、「アドレス取得」、「参照」について触れます。

ポインタ型変数の宣言

 ポインタ型変数を宣言するには、次のように書きます。

 int * a; /* 「int型領域を指すポインタ型変数」 a を宣言 */
 char * b = "ABC"; /* 「char型領域を指すポインタ型変数」 b を宣言 */

 型名の後ろにポインタ宣言子の * が有る変数宣言は、「ポインタ型変数」宣言です。 指定した型の領域のアドレス値に限り格納できる変数が、この宣言後に使えるようになります。 ポインタ型変数は、単にポインタとも呼びます。

 上記例のポインタ a は、int型領域しか指し示すことが出来ません。 他の型の領域のアドレスを設定すると、コンパイル・エラーになります。 ポインタは型情報を持つからです。

 なお、文字列の初期化には特別な決まりがあります。 本ノートを納得した後で「配列と文字列とポインタ」 https://khurata.hatenablog.com/entry/2019/04/04/063814 をご覧下さい。

 「皇居のたとえ」で言えば、住所をメモするための紙切れを用意したり、すでに住所が書かれている紙切れを用意する、というのが、この「ポインタ型変数の宣言」です。

 型に void を指定すると、「特に決まった型を指さないポインタ」が宣言されます。 void * 型ポインタは、キャスト(型を強制的に読み替えること、あまり推奨はしません)によって、様々な型の領域を指し示す事が出来ます。

 void * c; /* 「指す領域の型を特定しないポインタ」 c を宣言 */

アドレス取得

 単項アドレス演算子 & を使うと、その対象が占有しているメモリ領域の先頭アドレス値が得られます。 「単項」というのは、マイナス符号のように、「相手が1つだけ」という意味です(= とか / は、2つの相手が要るので、2項演算子です)。 以下のように使います。

 int d = 365; /* 「int型の領域を占有し整数を格納する変数」 d を宣言 */
 a = &d; /* a には d の先頭アドレス値が入る */

 上記例で、&d は、変数 d のアドレス値、すなわち d の「住所」を示しています。 変数 a は int型専用ポインタですから、int型のアドレスである &d が代入出来ます。

 「皇居のたとえ」で言えば、皇居の住所を調べるのが、アドレス演算子の役割だ、と言えます。

参照

 ポインタに格納されたアドレス値そのものを見ても、そのアドレスに格納されているモノ自体については全く分かりません。 「東京都港区赤坂2-3」という住所だけ見ても、そこに何があるか、何が建っているか、誰がいるかは分からないのと似ています。
 何らかのアドレス値を格納しているポインタに、単項参照演算子 * を付けると、対象としているアドレスの内容値、つまりメモリの内容が得られます。 この参照演算子 * は、ポインタ宣言子の * とは、全く意味が違う「別物」です(これは混乱を招く文法だと思います……)。 以下のように使います。

 printf( "%d\n", *a ); /* 参照した内容値 365 が表示される */
 *a = 123; /* 参照した内容値が書き換えられる */
 printf( "%d\n", d ); /* 書き換えられたので 123 が表示される */

 「参照」とは、「皇居のたとえ」で言えば、「東京都千代田区1-1」と書かれた紙片を見て、その住所へ実際に出かけてみるようなものです。 変な・架空の住所が書かれていたり、白紙だと困ってしまいます。 * で参照されるポインタには、あらかじめ何らかのアドレス値が入っていなければなりません。

 

局面A「そのモノ自体を持ち回りたくない!」

 ここからは本ノートの本題、ポインタの「使いどころ」について、見ていきましょう。

 「メモリ上にある int や char や struct や配列は、その変数名で直に示せるじゃないか、わざわざポインタで示さなくても」……と思いませんか。

 確かにその通りです。 ポインタで書けるプログラムは、配列でも書けたりしますし、配列の方が分かりやすい感じがします。 一体、どんな時にポインタを使いたくなるのでしょうか。

 関数の外側で宣言した変数は、どこからでも変数名でアクセス出来ますので、ポインタの出番は無さそうです。 1つの関数の中だけで使う変数も、同様に、ポインタの出番は無さそうです(アルゴリズムの都合でポインタにしたい場合はあるかも知れませんが)。

 ……ということは、ポインタが役に立ちそうなのは、「関数の中で宣言した変数・領域を、別の関数で使いたい時ではないか」、と予想出来ますね。

 呼び出し元の関数が持つ値や、宣言した変数(の内容値)を、別の関数で使いたい時は、引数として渡す事が出来ます。
 引数のある関数を呼び出す時、C では呼ぶ側の実引数の内容値が、呼ばれ側の関数の仮引数にコピーされます。 int型の引数なら 4 ないし 8バイト、char型の引数なら 1バイト、必ずコピー動作が入るのです。

 実用的なプログラムでは、そこそこ大きな構造体や配列を、関数の間で持ち回りたい、という事が、よくあります。

 しかし、関数の引数に構造体や配列を書くと、関数を呼び出す度に、大量のコピー動作が発生してしまいます。 1回や2回程度なら許容範囲かも知れませんが、ループの中で関数呼び出しが有ったりすると、処理時間が無駄です。

 そこで、呼ぶ側で「持ち回りたい領域」の先頭アドレスを用意して、これを引数として関数を呼ぶ、という事が考え出されました。 アドレス値を渡すので、受け取る側の関数は、仮引数をポインタ型にしなければなりません。

 呼ばれ側で、ポインタに * を付けて参照すれば、目的の領域を扱う事が出来ます。

 ポインタのサイズは、多くの場合、4~8バイトに過ぎませんが、ポインタ1つで、数百、数千、数メガバイト、それ以上の領域を「持ち回る」事が出来ます。

 「大きな領域を持ち回る」関数呼び出しが「何回も行われる」場合、ポインタを使う場合と、使わない場合とでは、処理速度が大きく違ってくる……これは、「ポインタを使うと速い」と言われる 1つの理由です。

 

局面B「そのモノ自体を動かしたくない!」

 int a[ 100 ]; という配列があったとして、その内容を昇順に並べ替える(ソート sort、ソーティング sorting)プログラムというものを、C を学ぶ人は、1度は見たり作ったりする事でしょう。
 ソートでは、配列要素を比較する動作と、入れ替える動作が必要になりますが、要素の型が int であれば、入れ替えは単純に代入演算子 = で済みます。

 では、これが int a[ 100 ] ではなくて、下記のようだったら、どうやって入れ替えれば良いでしょうか。

 struct mydata { /* 構造体 mydata */
  char c; /* メンバ c(構造体の中の要素を「メンバ member」と呼びます) */
  int n; /* メンバ n */
  float f[ 100 ]; /* メンバ f[ ] */
 };
 struct mydata a[ 100 ]; /* これがソート対象 */

 「代入先 = 代入元 とせずに、memcpy( 代入先, 代入元, sizeof( struct mydata ) ) で入れ替える」。

 ……それも正解です。 正攻法ですね。

 しかし、もし struct mydata が巨大であれば、正攻法では処理時間が長くなってしまいます。 また、処理時間の見積もりも、struct mydata の大きさに依存して変わってきます。

 こういう場合にも、ポインタの出番です。

 まずは、struct mydata a[ 100 ] の他に、ポインタの配列 struct mydata * ap[ 100 ] を用意します。 そして、ソートする前に、あらかじめ ap の各要素に a の各要素の先頭アドレスを設定しておきます。

 int i; /* あらかじめ宣言しておく */
 ~
 /* ソートの前準備 */
 for ( i = 0; i < 100; i ++ ) {
  ap[ i ] = &a[ i ]; /* a[ i ] の先頭アドレスが ap[ i ] に格納される */
 }

 比較は、a[ ~ ] 同士を直で比較しても良いですし、*ap[ ~ ] で a[ ~ ] の内容値を参照して行っても構いません。
 そして「入れ替え」は、memcpy( ) を使わず、下記のように行います。

 struct mydata * workp; /* あらかじめ宣言しておく */
 ~
 /* 入れ替え開始 */
 workp = ap[ 入替先 ];
 ap[ 入替先 ] = ap[ 入替元 ];
 ap[ 入替元 ] = workp;
 /* 入れ替え終了 */

 この場合、入れ替え1回で発生する代入動作は、workp への代入、ap 同士の代入、workp からの代入、この3つです(32ビットシステムでは、大抵 12バイト分のコピーに過ぎません)。 そして、対象の構造体が、どんなに大きくても、このコピー量は一定です。

 ソートが終わって結果を表示する時には、ap[ ~ ] に * を付けて「参照」したものを使います。

 for ( i = 0; i < 100; i ++ ) {
  printf( "%d\n", ( *ap[ i ] ).n ); /* メンバ n を表示してみる */
 }

 これは、「指し示すもの(ポインタ)」だけを入れ替えて、「指し示される対象(実データ)」は全く動かさないという、C では定番のテクニックです。

 こうすれば、対象データの量にかかわらず、入れ替えの処理速度が一定かつ高速になります。 これも、「ポインタを使うと速い」と言われる理由の 1つです。

 ちなみに、上記の printf( ) は printf( "%d\n", ap[ i ] -> n ); とも書けます。 -> はアロー演算子と呼ばれ、左辺がポインタの時のみ使える、構造体メンバを指定するための演算子です。
 α->β は ( *α ).β と等価であり、構文糖 syntax sugar に過ぎませんが、アロー演算子を使うと、左辺がポインタである事を明示出来ます。 以下のように使い分けられています。

 α->β … αはポインタ、βはメンバ
 α.β … αは構造体の実体、βはメンバ

 

局面C「複数の値を関数の引数でやりとりしたい!」

 先ほど書いた通り、C では、引数付きの関数を呼び出す際、実引数(呼ぶ側の値)から仮引数(呼ばれ側の変数)へ値をコピーします。 つまり、引数の値のやりとりは、「呼び出し元→呼び出し先」の一方通行しかありません。
 呼び出し先の関数から返してもらいたい値が1個だけなら return 文で返せますが、複数個を返したい場合、これでは困ってしまいます。

 ここでも、ポインタの出番となります。

 複数の値を関数間でやりとりしたい、しかもそれらを引数にしたい、という場合は、呼ばれ側の関数の仮引数をポインタ型にしておきます。
 呼ぶ側では、やりとりしたい対象のアドレスを実引数として渡します。 そのアドレス値は、呼ばれ側の仮引数―ポインタ型です―にコピーされます。

 呼び出された側の関数では、何らかの結果を、その仮引数変数(ポインタですのでアドレス値が入っています)を「参照」した場所に設定します( * 演算子を使う)。
 この「参照」した場所とは、もちろん、呼ぶ側の関数で用意された「対象」が置かれているメモリ領域です。

 こうすれば、戻り値が2つあろうが3つあろうが、いくらでも、好きな個数だけ、やりとりする事が出来ます。

 時系列で書くと、以下のような動作になります。

・呼ぶ側で、結果の欲しい対象領域を用意する(いくつでも良い)。
・そのアドレスを取得して実引数とし、目的の関数を呼び出す。
・そのアドレスが、呼ばれ側の関数の仮引数にコピーされる。
・呼ばれ側の関数では、仮引数を * で参照したところに結果を設定する。
(↑まさにこの時、呼び出し元の関数の「結果の欲しい対象領域」の内容が書き換わる!)
・呼ばれ側の関数が終了し、呼び出し元の関数に戻る。
・すでに「欲しい結果値」は得られている。

 これは C でよく使われる定番テクニックです。

 呼ぶ側で構造体を用意しておいて、そのポインタを引数として渡す、というテクニックもしばしば使われます。 構造体の中には多くの内容を詰め込めるので、実用的なテクニックと言えます(実装は局面A「そのモノ自体を持ち回りたくない!」と同様ですが、目的が違うわけです)。 また、このテクニックをさらに突き詰めて発展させて出来たものが、クラスベースのオブジェクト指向です。

 

応用局面D「大きさがよく分からないモノを扱いたい!」

 「何らかの実体を扱うにおいて、その実体に触れず、その先頭アドレスを扱う」ポインタ……その重要な「使いどころ」の1つが、これです。

 たとえば、ネットワーク通信で、外部からいくつものパケットを受信するプログラムを作る、とします。 そのパケットは、固定長のヘッダと、可変長の内容から成っていて、ヘッダには内容の長さが含まれているとしましょう。 また、通信相手は、同時に最大 1000カ所まではさばける仕様である、とします。
 パケット内容長は、0 だったり 1 だったりする事もあれば、100 だったり1万だったりする事もあります。 こういう場合、内容を格納しておくための領域は、どう宣言すれば良いでしょうか。

 char packet_content[ 1000 ][ 10000 ];

 ……泥臭い実装ですが、これも1つの正しい解です。 1万バイト以上のパケットについては、ヘッダ内の内容長をチェックして受け付けないようにすれば、危険もありません。 しかもこのコードは、一般論的に言って高速です。

 しかし、この配列定義は 10MB のメモリを消費します。 パケットの多くが数百バイトだとしたら、これは壮大な無駄です。 また、「同時に最大 1000カ所」とか、「最大1万バイト」という条件が、将来的に変わったら、無駄は、さらに増えるでしょう。

 メモリを無駄に使っても、簡単・高速に動かしたい、という場合は、これでも良いのですが、無駄をなるべく省きたい場合は、ポインタの出番です。

 まずは、次のように宣言します。

 char * packet_content[ 1000 ];

 これは、char型領域を指すポインタを 1000個並べただけで、パケット内容を格納する領域はどこにもありません。 とりあえず「メモ用紙の束」だけ作っておいた、という感じです。 この配列定義が消費するメモリは、たかだか数KB です。

 パケットを 1つ受信したら、そのヘッダ内の内容長を見て、次のようにします。

 packet_content[ パケット番号 ] = malloc( 内容長 );

 malloc( ) は、「指定されたバイト長の連続したメモリの小箱を確保して(OS から借りる)、その先頭アドレスを返してくれる」関数です。 stdlib.h の中にあります。 こういう動作を、「動的メモリ確保 dynamic memory allocation」などと言います。

 こうすれば、パケット毎に、異なる長さのメモリを、弾力的に確保出来るので、無駄を非常に少なく出来ます。

 確保された領域の(0 から数えて) 3バイト目にアクセスするには、次のように書きます。

 *( packet_content[ パケット番号 ] + 3 ) = 'A'; /* 'A' を書き込んでみる */

 下記のように書いても同じです。

 packet_content[ パケット番号 ][ 3 ] = 'A';

(なぜ同じなのかは、知恵ノート「配列と文字列とポインタ」 https://khurata.hatenablog.com/entry/2019/04/04/063814 で説明していますので、気になった方はご覧下さい)

 静的 static に [ 10000 ] と確保するのに比べて、malloc( ) するのは実行時間もかかりますし、プログラムも若干複雑になりますので、「実行時間コストとメモリ消費コストはバーター(引き換え)である」と言えます。
 メモリコストを重視する場合は、実行時間コストが多少かかっても、動的 dynamic な処理を選ぶ事になりますが、その場合、ポインタは欠かせません。

 

他の言語では……

 ポインタという存在が出てこない他の言語においても、「実体を動かさず『参照』する」のは、実行時間やメモリを大きく節約できる、魅力的な機能のはずです。
 実際、近年新しく出来た言語は、ほとんどが「ポインタを実装」しています。 ただ、文法的に、それを表に出さないようにしているだけです。

 たとえば Java では、必要なオブジェクトを new で作り出しますが、作り出されたオブジェクトは、内部的には「ポインタで持ち回る」形で扱われています。

 JPanel myPanel; // myPanel の正体は C で言うところのポインタ。C っぽく書くと、struct JPanel * myPanel;
 myPanel = new JPanel( ); // new は「新しく割り当てた領域の先頭アドレス」を返す。C の malloc( ) にプラスアルファしたものと考えてよい。
 myPanel.setLayout( ~ ); // C っぽく書くと、(*myPanel).setLayout( ~ );

 他の言語でも、クラスや構造体、配列など、「ある程度大きなモノ」に付けられた「名前」は、内部的にはポインタである、という事は、よくあります。
 こういう言語では、「わざわざ * を付けて、参照である事を明示」しなくても、名前を書くだけで「参照になる」のですが、その代わり、ポインタそのものを「ポインタとして扱う」ことは出来ないわけです。 C は、その辺りを省略せず、細かく書く言語なのです。

 

まとめ

 結局のところ、C のポインタは、「より速く、よりコンパクト」なプログラムを作るためにある、と言えます。 「人間にとっては一見分かりづらい」けれども、「コンピュータにとって楽が出来る」ものだ、とも言えます。

 C は、それなりに古く、今から見ると非力なコンピュータのために作られた言語です。 C が生まれた頃、複雑かつ実用的な速度で動くプログラム(たとえば OS)は、アセンブリ言語で作られていました。
 そうしたプログラムを、なんとか高級言語で書きたい、生産性の良い、読みやすいプログラムを作りたい、という欲求が有ったのは当然でした。 しかし当時のコンピュータの性能で、「アセンブリ言語に比肩し得るプログラムを書ける高級言語」を考えれば、ポインタを実装せざるを得なかったのでしょう。

 ポインタはアセンブリ言語っぽい概念なので、C は「高級アセンブラ」と揶揄される事もありますが、他の言語にはない、独特のテイストやバランスを生み出してもいます。 そのテイスト、バランスは、結果論として得られたものですが、それゆえに、C は多くのプログラマに愛用され、今まで生き延び、実用に耐え続ける言語になっているのだと私は思います。
(転載以上)