なぜポインターは、C または C++ を初めて学ぶ多くの学生、さらには大学レベルのベテラン学生にとって、混乱の主な要因となっているのでしょうか? 変数、関数、およびそれ以降のレベルでポインターがどのように機能するかを理解するのに役立つツールや思考プロセスはありますか?
全体的な概念にとらわれずに、「ああ、わかった」というレベルに誰かを導くために実行できる良い練習方法は何ですか? 基本的には、ドリルのようなシナリオです。
ベストアンサー1
ポインターは、特にポインター値をコピーして同じメモリ ブロックを参照する場合、多くの人にとって最初は混乱を招く可能性がある概念です。
ポインタを家の住所が書かれた紙、それが参照するメモリ ブロックを実際の家と考えるのが、最も適切な例えだと私は考えています。こうすることで、あらゆる種類の操作を簡単に説明できます。
以下に Delphi コードをいくつか追加し、適切な場所にコメントも追加しました。私が Delphi を選択したのは、他の主なプログラミング言語である C# ではメモリ リークなどの現象が同じように発生しないからです。
ポインタの高レベルの概念だけを学習したい場合は、以下の説明で「メモリ レイアウト」とラベル付けされた部分は無視してください。これらは、操作後のメモリがどのようになるかの例を示すことを目的としていますが、本質的には低レベルのものです。ただし、バッファ オーバーランが実際にどのように機能するかを正確に説明するために、これらの図を追加することが重要でした。
免責事項: あらゆる意図と目的において、この説明とメモリ レイアウトの例は大幅に簡略化されています。低レベルでメモリを扱う必要がある場合は、さらに多くのオーバーヘッドと、さらに多くの詳細を知る必要があります。ただし、メモリとポインターを説明するという目的には十分正確です。
以下で使用する THouse クラスが次のようになっていると仮定します。
type
THouse = class
private
FName : array[0..9] of Char;
public
constructor Create(name: PChar);
end;
house オブジェクトを初期化すると、コンストラクタに指定された名前がプライベート フィールド FName にコピーされます。固定サイズの配列として定義されているのには理由があります。
メモリでは、ハウスの割り当てに関連するオーバーヘッドが発生します。これを次のように示します。
---[ttttNNNNNNNNN]--- ^ ^ | | | +- FName配列 | +- オーバーヘッド
「tttt」領域はオーバーヘッドであり、通常は、8 バイトや 12 バイトなど、さまざまなタイプのランタイムや言語でこれより多くなります。この領域に格納される値は、メモリ アロケータまたはコア システム ルーチン以外によって変更されないようにする必要があります。変更されると、プログラムがクラッシュするリスクがあります。
メモリを割り当てる
起業家に家を建ててもらい、その家の住所を教えてもらう。現実世界とは対照的に、メモリ割り当てでは割り当てる場所を指定することはできませんが、十分なスペースのある適切な場所を見つけて、割り当てられたメモリの住所を報告します。
つまり、起業家が場所を選ぶのです。
THouse.Create('My house');
メモリレイアウト:
---[ttttNNNNNNNNN]--- 1234私の家
アドレスを変数に保存する
新しい家の住所を紙に書きます。この紙は家を探すための参考になります。この紙がないと、迷子になって家を見つけることができません。すでに家にいる場合は別です。
var
h: THouse;
begin
h := THouse.Create('My house');
...
メモリレイアウト:
h ヴ ---[ttttNNNNNNNNN]--- 1234私の家
ポインタ値をコピーする
新しい紙に住所を書いてください。これで、別々の家ではなく、同じ家に行くための 2 枚の紙ができました。1 枚の紙の住所に従ってその家の家具を配置し直そうとすると、実際には 1 軒の家だけであることが明確にわかる場合を除き、もう 1 軒の家も同じように変更されたように見えます。
注:これは通常、人々に説明するのが最も難しい概念です。2 つのポインターは 2 つのオブジェクトまたはメモリ ブロックを意味するものではありません。
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := h1; // copies the address, not the house
...
h1 ヴ ---[ttttNNNNNNNNN]--- 1234私の家 ^ h2
メモリを解放する
家を取り壊します。その後、必要に応じてその紙を新しい住所として再利用したり、もう存在しない家の住所を忘れるために消去したりできます。
var
h: THouse;
begin
h := THouse.Create('My house');
...
h.Free;
h := nil;
ここで、まず家を構築し、そのアドレスを取得します。次に、家に対して何らかの処理を行い (読者の練習問題として残しておいた ... コードを使用します)、その後、家を解放します。最後に、変数からアドレスをクリアします。
メモリレイアウト:
時間 <--+ v +- フリーの前 ---[ttttNNNNNNNNN]--- | 1234私の家 <--+ h (今はどこにも指していない) <--+ +- フリー後 ---------------------- | (注: メモリがまだ残っている可能性があります xx34私の家 <--+ いくつかのデータが含まれています)
ぶら下がったポインタ
あなたは起業家に家を破壊するように指示しましたが、紙から住所を消すのを忘れました。後でその紙を見ると、家がもうそこにないことを忘れていて、家を訪問しましたが、失敗しました (以下の無効な参照に関する部分も参照してください)。
var
h: THouse;
begin
h := THouse.Create('My house');
...
h.Free;
... // forgot to clear h here
h.OpenFrontDoor; // will most likely fail
h
呼び出しの後にを使用すると機能.Free
するかもしれませんが、それは単なる運の問題です。ほとんどの場合、顧客の場所での重要な操作の途中で失敗します。
時間 <--+ v +- フリーの前 ---[ttttNNNNNNNNN]--- | 1234私の家 <--+ 時間 <--+ v +- フリー後 ---------------------- | xx34私の家 <--+
ご覧のとおり、h はまだメモリ内のデータの残りを指していますが、完全ではない可能性があるため、以前と同じように使用すると失敗する可能性があります。
メモリーリーク
紙切れを失くしてしまい、家を見つけることができません。しかし、家はまだどこかに建っており、後で新しい家を建てたいときに、その場所を再利用することはできません。
var
h: THouse;
begin
h := THouse.Create('My house');
h := THouse.Create('My house'); // uh-oh, what happened to our first house?
...
h.Free;
h := nil;
ここでは、変数の内容をh
新しい家の住所で上書きしましたが、古い家はまだどこかに立っています。このコードの後、その家に到達する方法はなくなり、家はそのまま残されます。言い換えると、割り当てられたメモリは、アプリケーションが終了するまで割り当てられたままになり、終了した時点でオペレーティング システムによって破棄されます。
最初の割り当て後のメモリレイアウト:
h ヴ ---[ttttNNNNNNNNN]--- 1234私の家
2 回目の割り当て後のメモリ レイアウト:
h ヴ ---[ttttNNNNNNNNNNN]---[ttttNNNNNNNNNNN] 1234私の家 5678私の家
このメソッドを取得するより一般的な方法は、上記のように上書きするのではなく、何かを解放するのを忘れることです。Delphi の用語では、これは次のメソッドで発生します。
procedure OpenTheFrontDoorOfANewHouse;
var
h: THouse;
begin
h := THouse.Create('My house');
h.OpenFrontDoor;
// uh-oh, no .Free here, where does the address go?
end;
このメソッドが実行されると、変数内に家の住所が存在する場所がなくなりますが、家はまだそこにあります。
メモリレイアウト:
時間 <--+ v +- ポインタを失う前 ---[ttttNNNNNNNNN]--- | 1234私の家 <--+ h (今はどこにも指していない) <--+ +- ポインタを失った後 ---[ttttNNNNNNNNN]--- | 1234私の家 <--+
ご覧のとおり、古いデータはメモリ内にそのまま残されており、メモリ アロケータによって再利用されることはありません。アロケータはメモリのどの領域が使用されたかを追跡し、解放しない限りそれらを再利用しません。
メモリを解放するが、(無効な)参照を保持する
家を解体し、紙片の 1 枚を消します。ただし、古い住所が書かれた別の紙もあります。その住所に行っても、家は見つかりませんが、家の廃墟に似たものが見つかるかもしれません。
家が見つかるかもしれませんが、それは元々住所を教えられた家ではないので、自分の家であるかのように使用しようとすると、ひどく失敗する可能性があります。
場合によっては、隣の住所に 3 つの住所 (メイン ストリート 1 ~ 3) を占めるかなり大きな家が建っていて、自分の住所がその家の真ん中に来ることもあります。3 つの住所を占める大きな家のその部分を 1 つの小さな家として扱おうとすると、ひどく失敗する可能性があります。
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := h1; // copies the address, not the house
...
h1.Free;
h1 := nil;
h2.OpenFrontDoor; // uh-oh, what happened to our house?
ここでは、 の参照により家が取り壊され、h1
整理h1
されましたが、h2
古い、時代遅れの住所が残っています。もはや建っていない家へのアクセスは、機能する場合と機能しない場合があります。
これは、上記のダングリング ポインタのバリエーションです。メモリ レイアウトを確認してください。
バッファオーバーラン
家の中に収まりきらないほど多くの物を運び込み、隣の家や庭に溢れ出してしまう。隣の家の持ち主が後から帰宅すると、自分の物だと思ってしまうような様々な物を見つけることになる。
これが、固定サイズの配列を選択した理由です。状況を説明すると、割り当てる 2 番目の家が、何らかの理由でメモリ内で最初の家よりも前に置かれると仮定します。つまり、2 番目の家は最初の家よりも低いアドレスになります。また、2 つの家は隣り合って割り当てられます。
したがって、このコードは次のようになります。
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := THouse.Create('My other house somewhere');
^-----------------------^
longer than 10 characters
0123456789 <-- 10 characters
最初の割り当て後のメモリレイアウト:
h1 ヴ -----------------------[ttttNNNNNNNNNNN] 5678私の家
2 回目の割り当て後のメモリ レイアウト:
h2 h1 vv ---[ttttNNNNNNNNNNN]----[ttttNNNNNNNNNNN] 1234私のもう一つの家 ^---+--^ | +- 上書きされました
クラッシュの原因となることが多いのは、保存したデータの重要な部分を上書きする場合です。この部分はランダムに変更すべきではありません。たとえば、h1-house の名前の一部が変更されても、プログラムがクラッシュするという点では問題にならないかもしれませんが、オブジェクトのオーバーヘッドを上書きすると、壊れたオブジェクトを使用しようとしたときにクラッシュする可能性が高くなります。また、オブジェクト内の他のオブジェクトに保存されているリンクを上書きするとクラッシュする可能性も高くなります。
リンクリスト
紙に書かれた住所をたどっていくと、ある家にたどり着きます。その家には、次の家の新しい住所が書かれた別の紙があり、それが連鎖していきます。
var
h1, h2: THouse;
begin
h1 := THouse.Create('Home');
h2 := THouse.Create('Cabin');
h1.NextHouse := h2;
ここで、ホームハウスからキャビンへのリンクを作成します。チェーンをたどって、参照のない家NextHouse
、つまり最後の家まで進むことができます。すべての家を訪問するには、次のコードを使用できます。
var
h1, h2: THouse;
h: THouse;
begin
h1 := THouse.Create('Home');
h2 := THouse.Create('Cabin');
h1.NextHouse := h2;
...
h := h1;
while h <> nil do
begin
h.LockAllDoors;
h.CloseAllWindows;
h := h.NextHouse;
end;
メモリ レイアウト (オブジェクト内のリンクとして NextHouse が追加され、下の図の 4 つの LLLL で示されています):
h1 h2 vv ---[ttttNNNNNNNNNLLLL]----[ttttNNNNNNNNNLLLL] 1234ホーム + 5678キャビン + | ^ | +--------+ * (リンクなし)
基本的に、メモリアドレスとは何でしょうか?
メモリ アドレスは、基本的には単なる数字です。メモリを大きなバイト配列と考えると、最初のバイトのアドレスは 0、次のバイトのアドレスは 1 というように、上に向かって続きます。これは単純化されていますが、十分です。
つまり、このメモリレイアウトは次のようになります。
h1 h2 vv ---[ttttNNNNNNNNNNN]---[ttttNNNNNNNNNNN] 1234私の家 5678私の家
次の 2 つのアドレスがある可能性があります (左端はアドレス 0 です)。
- h1 = 4
- h2 = 23
つまり、上記のリンク リストは実際には次のようになります。
h1 (=4) h2 (=28) vv ---[ttttNNNNNNNNNLLLL]----[ttttNNNNNNNNNLLLL] 1234ホーム 0028 5678キャビン 0000 | ^ | +--------+ * (リンクなし)
「どこにも指していない」アドレスは、ゼロアドレスとして保存するのが一般的です。
基本的に、ポインタとは何でしょうか?
ポインタは、メモリ アドレスを保持する変数にすぎません。通常はプログラミング言語にその番号を尋ねることができますが、ほとんどのプログラミング言語とランタイムは、その番号自体が実際には意味を持たないため、その下に番号があるという事実を隠そうとします。ポインタはブラック ボックスと考えるのが最善です。つまり、実際にどのように実装されているかはわからず、気にする必要もなく、機能するだけです。