プログラミング作法に関する世界観3C(ポインタとメモリ管理)です。Cの世界観4(配列とポインタ)も参照のこと。
ポインタに限らず、参照型の変数は、「同じ領域を違う名前が参照する」という考え方をします。このため、メモリアドレス(変数の場所)をポインタとして操作するC言語だけではなく、PHPなどでも同様に参照型の考え方を良く用いるため、C言語プログラマではなくても、「参照型」という考え方は知っておきましょう。
たとえば、C言語で
int x = 10;
とするのを、「int型の変数xに整数値10を代入する」と表現しますが、この時、変数とは「名札」とか「値を入れる箱」などと言った説明がされると思います。
先の例で言えば、xという「名札」のついた「箱」に、10という「整数値」を入れます。
この時、なんとなく、「名札=箱」だと思っていると思いますが、ポインタを使うと、ひとつの「箱」に対して複数の「名札」を付けることができます。
たとえば、
int *y = &x; *y = 20; printf("%d", x); /* 20 */
とすると、int型変数へのポインタyは、xと「同じ箱」を指しながら、「別の名前」を使っています。そのため、yの参照先を変えると、xの参照先も変わるのです。
プログラミングにおけるポインタを理解するためには、このように「名札と箱は必ずしもイコールではない」と考える必要があります。
なぜポインタを使うのか、という答えは、ポインタでしか作れないデータ構造や、ポインタを使った方が作りやすい関数などがあるためです。特に、連結リストを作ったり、あるデータ構造体の一部として別の変数を参照したい場合、あるいはカーネルのファイルバッファにアクセスしたい場合など、さまざまな低レベルな部分でポインタを使います。
そう、ポインタとは「名札ごとに箱を作るのではなく、たくさんの名札で箱を共有する(値の共有ではない)」ということです。そして、この箱がメモリアドレスなのです。
僕は、ポインタは変数を名前で管理するのとは別に、絶対的な位置によって管理・参照する方法だと考えると分かりやすいと思います。
Javaにポインタはありませんが、もしもJavaにポインタがあったとしたら、以下のようになるでしょう。
Address adr = str.getAddress(); adr.setValue("text"); System.out.println(str); // textと表示される。
int num = 1;のように、スコープがはっきりと分かっている変数はスタックで確保され、malloc()などの動的に確保したメモリ領域はヒープで確保される。
スタックは、上にどんどん積み上げていくイメージで、最後に積んだデータを最初に解放する。
ヒープは、より動的に確保するために、malloc()を使ってデータを確保するが、自分の手でデータをfree()で解放しなければならない。
後日注記:ヒープ領域は実行中に大きさが変わる。また、スコープを抜けてもmalloc()したメモリを自分のタイミングでfree()するまでメモリ上に残り続ける。また、サイズが実行するまで分からなくても、実行中にサイズを動的に決められる。そして、スタック領域には扱える大きさに制限があるが、ヒープはそのような制限がない。そのため、サイズの分からない、スコープにかかわらず残り続けてほしいような、巨大なデータを確保するのに向いている。
2023.05.17編集
Linuxでは、プログラムがメモリ上にロードされると、カーネルがメモリアドレス空間にテキスト領域(プログラムコードを格納する)、ヒープ領域、スタック領域を各プロセスごとに割り当てる。
このうち、ヒープ領域とスタック領域がプログラムのデータの格納のために使われる。
機械語の論理アドレスは、カーネルによって仲介され、メモリ上の物理アドレスへと翻訳される。それぞれに割り当てられたメモリは「ページ」という小さな単位でカーネルによってメモリ上に領域を割り当てられる。
Linuxカーネル(メモリ管理)も参照のこと。
ポインタは、動的に確保したメモリ領域を配列としてアクセスしたり、配列の要素にひとつひとつ順番にアクセスするために使うこともある。
動的なメモリ確保の例は、以下のようになる。
Javaでは、ガーベッジコレクションがあるため、malloc()やfree()のようなメモリ管理に煩わされることが無い。
だが、C++のようなガーベッジコレクションがない言語でも、最近はスマートポインタを使うことで勝手に使われないメモリを破棄してくれる方法が存在する。
Rustでは、ムーブセマンティクスと所有権の考え方によって、GCを使わずにして不要なメモリを削除する方式を提供した。
また、ガーベッジコレクション(GC)がある言語でも、GCのやり方はそれぞれの言語によって違う。参照カウント方式、マーク・アンド・スイープ方式、コピーGC方式などが存在する。
Java(2C.ガーベッジコレクションと例外)も参照のこと。
ゲームなどでカスタムメモリアロケータを自前で実装する方法には以下が参考になる。
ポインタの使いどころとして、「なんであれ変数のアドレスを必要とする場合は意外と多い」というのが言えます。
たとえば、文字列や配列をアドレスからアクセスする場合に、その文字列や配列の「最初の位置」に、「アドレスというラベル」を保持することで、そのラベルから実体へアクセスします。
動的なメモリ確保についても同じで、確保した動的メモリ領域に「アドレスというラベル」からアクセスするのです。
言ってみれば、一種の「ファイル名」あるいは「インデックス」のようなものです。アドレスという「その場所を指し示す位置情報」があることで、どこからでもその位置情報にアクセスできるのです。
これは、たとえば、引数のポインタや参照型変数、あるいはオブジェクト指向の動的なインスタンスあるいはクラス内の動的メンバについても同様で、「位置情報を持っていることで、どこからでもダイレクトにアクセスすることができる」また「ダイレクトにアクセスする変数を自分で削除しない限り永続的に保持することができる」ということを意味します。
データ構造などでも、「位置情報を保管した変数からアクセスする」ということができます。たとえば連結リストの次のメンバをポインタで保持することで、「位置情報をメンバとして維持し続けることができる」のです。これは、単にポインタというだけではなく、「そのポインタを操作する関数処理までを考えた上でのデータ構造」であると言えます。ここから、ポインタは単なる位置情報ではなく、アルゴリズムによって処理されるデータ構造になり、話はオブジェクト指向のカプセル化まで広がっていくのです。
自分の書いたブログ「神々とともに生きる詩人」2021/01/21より。
ムーブセマンティクスにおいては、変数をコピーした時に、同じだけの領域を確保して複製するのではなく、ポインタだけをコピーして、元の変数は使えなくなる。
ポインタしかコピーしなくても、ある場所から別の場所に変数を移動したように見えるため、ムーブセマンティクスと呼ばれる。
巨大なベクター配列をコピーする際などに、効率的にデータを別の場所から参照できる。
常に2つの変数を保持しなくても、あっちやらこっちやらと移動させて使うイメージである。
後日注記:このムーブセマンティクスの説明は、明らかに間違っている。正しくは以下のようなページを参照してほしい。
自分の書いたブログ「神々とともに生きる詩人」2021/01/21より。
僕は、ポインタは配列やリストと関係が深いと思う。
配列はメモリ上の連続データであるため、ポインタを通じて連続的にアクセスできる。
文字列でも、構造体でも同様。
このように、メモリ上の連続したデータに対する、カーソルとして利用する。
また、連結リストでは、今の要素と次の要素を紐付けする役割を、ポインタが担う。
それから、ポインタは低レベル処理と、参照型変数として使われる。
システムのバッファを保持・参照したり、低レベル処理の中で動的にメモリ確保や参照や操作や解放をするために、ポインタは使われる。
ある意味、C言語における自由度とパフォーマンスを確保するために、利便性や安全性を犠牲にした結果である。
ヒープ領域の確保や、実行時にしかサイズの分からないメモリ確保に、ポインタは使われる。
最後に、参照としての利用。
関数の内部から、呼び出せた時にはじめて決定される、参照型変数やコールバック関数としての利用である。
単なるリンクだけでなく、関数型プログラミングとしての側面や、あるいは、コピーの際のコストの軽減、オブジェクト指向や準グローバル変数としての利用も考えられる。
ポインタの用途の例について、より正しい内容はCode Reading ~オープンソースから学ぶソフトウェア開発技法~ (プレミアムブックス版)が参考になります。上記は、この本を読んだうえで、自分なりに違う内容を書きました。
C言語では、メモリ上の物理データと、そのアドレス(番地)を指し示すポインタがあって、はじめてデータが成り立ちます。
一見すると、「物理データだけでいいじゃないか」と思われるかもしれませんが、C言語ではそうではありません。
まず、メモリ上のそれぞれのアドレスに格納される「物理データ」があり、その物理データをひとつひとつメモリアドレスを指し示すことで参照する「ポインタ」があります。
物理データとポインタは、このようにセットで使います。物理データだけではなく、ポインタがあることによって、C言語のデータへのアクセスは成り立つのです。
なので、なんらかのポインタがあった場合、そのポインタはNullポインタでない限り、どこかにある物理データを指し示しています。そして、この指し示しているポインタが指し示す先は、新しいメモリアドレスをポインタに代入することで変えられます。実際の物理データを変更しなくても、ポインタの指し示す場所を変えるだけで、効率的に別々のデータにアクセスできます。そのため、物理データを破壊することなく、メモリに存在するさまざまな物理データに自由にアクセスできます。
ポインタは、低水準のOSのようなプログラムを書くために必須となります。OSのような低レベルのソフトウェアでは、メモリを直接操作できなければならないからです。C/C++よりも高水準の言語であるJavaにはポインタは存在しません。C#の場合、unsafeと呼ばれる低レベルのメモリを「破壊」することは、明示的にunsafeコードであることを記述しない限り、基本的にできません。JavaやC#ではその代わり、ガーベッジコレクションのような自動的なメモリの解放機構を使えます。逆に、C/C++では低レベルのメモリを扱うことができますが、ガーベッジコレクション機構のようなものは、自分で参照カウントを実装したりしない限り、デフォルトでは使えません(一応、C++にはスマートポインタがあります)。
結局のところ、ポインタの利点とは、なんであろうがポインタにすれば動くということです。
たとえば、以下のようなコードがあります。
struct object x; for(;;){ func(x); }
このコードで、func()の中から参照できるのは「その場その場のx」だけです。
ですが、func()の中で、xのさまざまな情報にアクセスしたい時があります。場合によっては書き換えることもあるでしょう。
このような時には、ポインタにしてしまいましょう。
struct object x; for(;;){ func(&x); }
そう、こうするだけで、関数の中から外側のデータになんでもアクセスできてしまうのです。
ポインタの使い時は、そんな感じです。難しく考える必要はありません。「ポインタにすればなんであろうが動く」のです。
実際のところ、ポインタは、共通の参照先データを指し示す別名として使います。
複数の関数から、共通の「同じ場所」にあるデータをどこからでも操作する場合に、同じ参照先を指し示す「別名」として操作します。
ポインタを使うメリットは、さまざまな場所から「同じ場所」を参照することができること、別の場所から参照だけではなく「変更」できること、「巨大なデータの一部だけ」を参照できること、複製するよりも「オーバーヘッドが小さい」ことです。
ポインタはC言語の機能ですが、Javaのようなオブジェクト指向の言語ではポインタがありません。その代わり、オブジェクトは参照型変数として扱われます。このような言語であっても、基本はポインタと同じで、参照型のオブジェクトを「同じ場所」を指すポインタとして使います。クラスの中のメンバ変数も、同様に「同じ場所」をそれぞれのメソッドから指し示すポインタであると考えられます。
同じデータにアクセスし、参照し、変更するということは、このようなポインタのない言語であっても、プログラミングを行う上で基本となります。