ポインターを理解する上での障壁は何ですか?また、それらを克服するために何ができるでしょうか?

ポインターは、多くの人にとって最初は混乱する可能性がある概念です。特に、ポインター値をコピーして同じメモリ ブロックを参照する場合は特にそうです。

最良の例えは、ポインタを家の住所が記載された一枚の紙と見なし、ポインタが参照するメモリ ブロックを実際の家と見なすことです。したがって、あらゆる種類の操作を簡単に説明できます。

以下にいくつかの Delphi コードを追加し、必要に応じていくつかのコメントを追加しました。 Delphi を選択したのは、私の他のメイン プログラミング言語である C# ではメモリ リークなどの現象が同じように発生しないためです。

ポインターの高レベルの概念だけを学びたい場合は、以下の説明で「メモリ レイアウト」とラベル付けされた部分を無視してください。これらは、操作後にメモリがどのように見えるかの例を示すことを目的としていますが、本質的にはより低レベルです。ただし、バッファ オーバーランが実際にどのように機能するかを正確に説明するために、これらの図を追加することが重要でした。

免責事項:すべての意図と目的のために、この説明とメモリ レイアウトの例は大幅に簡略化されています。低レベルでメモリを処理する必要がある場合は、より多くのオーバーヘッドと、より多くの詳細を知る必要があります。ただし、メモリとポインタを説明するという意図からすれば、十分正確です。

以下で使用される THouse クラスが次のようになっていると仮定しましょう:

type
    THouse = class
    private
        FName : array[0..9] of Char;
    public
        constructor Create(name: PChar);
    end;

家のオブジェクトを初期化すると、コンストラクターに与えられた名前がプライベート フィールド FName にコピーされます。固定サイズの配列として定義されているのには理由があります。

メモリでは、家の割り当てに関連するオーバーヘッドが発生します。これを以下に示します。

---[ttttNNNNNNNNNN]---
     ^   ^
     |   |
     |   +- the FName array
     |
     +- overhead

「tttt」領域はオーバーヘッドです。通常、8 バイトや 12 バイトなど、さまざまなタイプのランタイムや言語ではこれよりも多くなります。この領域に格納されている値は、メモリ アロケータまたはコア システム ルーチン以外によって変更されないようにすることが不可欠です。そうしないと、プログラムがクラッシュする危険があります。

メモリを割り当てる

起業家に家を建ててもらい、家の住所を教えてもらいます。現実世界とは対照的に、メモリ割り当てはどこに割り当てるかを伝えることはできませんが、十分なスペースがある適切な場所を見つけて、割り当てられたメモリにアドレスを報告します。

つまり、起業家がその場所を選択します。

THouse.Create('My house');

メモリ レイアウト:

---[ttttNNNNNNNNNN]---
    1234My house

アドレスで変数を保持

新しい家の住所を紙に書き留めます。この紙はあなたの家への参照として役立ちます。この紙切れがなければ、あなたは道に迷ってしまい、すでに家にいない限り家を見つけることができません.

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...

メモリ レイアウト:

    h
    v
---[ttttNNNNNNNNNN]---
    1234My house

ポインタ値をコピー

新しい紙に住所を書くだけです。これで、2 つの別々の家ではなく、同じ家に行くことができる 2 枚の紙ができました。ある紙の住所をたどり、その家の家具を再配置しようとすると、別の家のように見えます。 実際に 1 つの家であることを明示的に検出できない限り、同じ方法で変更されています。

注意 これは通常、私が人々に説明するのに最も苦労する概念です。2 つのポインターは、2 つのオブジェクトまたはメモリ ブロックを意味するわけではありません。

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('My house');
    h2 := h1; // copies the address, not the house
    ...
    h1
    v
---[ttttNNNNNNNNNN]---
    1234My house
    ^
    h2

メモリの解放

家を取り壊します。必要に応じて、後でその紙を新しい住所に再利用することも、紙を消してもう存在しない家の住所を忘れることもできます。

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...
    h.Free;
    h := nil;

ここで私はまず家を建て、その住所を把握します。それから私は家に何かをします(それを使用して、...コード、読者の演習として残しました)、それから私はそれを解放します。最後に、変数からアドレスをクリアします。

メモリ レイアウト:

    h                        <--+
    v                           +- before free
---[ttttNNNNNNNNNN]---          |
    1234My house             <--+

    h (now points nowhere)   <--+
                                +- after free
----------------------          | (note, memory might still
    xx34My house             <--+  contain some data)

ダングリング ポインタ

あなたは起業家に家を破壊するように言いましたが、紙から住所を消し忘れました。後で一枚の紙を見ると、その家がもうそこにないことを忘れて、家を訪れて失敗した結果になっています (以下の無効な参照に関する部分も参照してください)。

var
    h: THouse;
begin
    h := THouse.Create('My house');
    ...
    h.Free;
    ... // forgot to clear h here
    h.OpenFrontDoor; // will most likely fail

h の使用 .Free の呼び出し後 かも うまくいきますが、それは単なる運です。ほとんどの場合、重要な操作の途中で、顧客の場所で失敗します。

    h                        <--+
    v                           +- before free
---[ttttNNNNNNNNNN]---          |
    1234My house             <--+

    h                        <--+
    v                           +- after free
----------------------          |
    xx34My house             <--+

ご覧のとおり、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
    v
---[ttttNNNNNNNNNN]---
    1234My house

2 回目の割り当て後のメモリ レイアウト:

                       h
                       v
---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN]
    1234My house       5678My house

このメソッドを取得するより一般的な方法は、上記のように上書きするのではなく、何かを解放するのを忘れることです。 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;

このメソッドが実行された後、変数には家の住所が存在する場所はありませんが、家はまだそこにあります.

メモリ レイアウト:

    h                        <--+
    v                           +- before losing pointer
---[ttttNNNNNNNNNN]---          |
    1234My house             <--+

    h (now points nowhere)   <--+
                                +- after losing pointer
---[ttttNNNNNNNNNN]---          |
    1234My house             <--+

ご覧のとおり、古いデータはメモリ内にそのまま残り、メモリ アロケータによって再利用されません。アロケータはメモリのどの領域が使用されたかを追跡し、解放しない限り再利用しません。

メモリを解放しますが、(現在は無効な) 参照を保持します

家を取り壊し、紙片の 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 番目の家は最初の家よりも低い住所になります。また、それらは互いに隣接して割り当てられます。

したがって、このコード:

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
                        v
-----------------------[ttttNNNNNNNNNN]
                        5678My house

2 回目の割り当て後のメモリ レイアウト:

    h2                  h1
    v                   v
---[ttttNNNNNNNNNN]----[ttttNNNNNNNNNN]
    1234My other house somewhereouse
                        ^---+--^
                            |
                            +- overwritten

クラッシュの原因となることが最も多いのは、保存したデータの重要な部分を上書きするときです。たとえば、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
    v                       v
---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL]
    1234Home       +        5678Cabin      +
                   |        ^              |
                   +--------+              * (no link)

基本的に、メモリ アドレスとは何ですか?

メモリアドレスは、基本的には単なる数字です。メモリをバイトの大きな配列と考えると、最初のバイトはアドレス 0、次のバイトはアドレス 1 というように上に続きます。これは単純化されていますが、十分です。

したがって、このメモリ レイアウト:

    h1                 h2
    v                  v
---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN]
    1234My house       5678My house

次の 2 つのアドレスがある可能性があります (一番左 - アドレス 0):

  • h1 =4
  • h2 =23

つまり、上記のリンクされたリストは実際には次のようになります:

    h1 (=4)                 h2 (=28)
    v                       v
---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL]
    1234Home      0028      5678Cabin     0000
                   |        ^              |
                   +--------+              * (no link)

「どこも指していない」アドレスをゼロアドレスとして格納するのが一般的です。

基本的にポインタとは何ですか?

ポインタは、メモリアドレスを保持する単なる変数です。通常、プログラミング言語にその番号を与えるように依頼できますが、ほとんどのプログラミング言語とランタイムは、番号自体が実際には意味を持たないという理由だけで、その下に番号があるという事実を隠そうとします。ポインタをブラック ボックスと考えるのが最善です。つまり、ポインタが機能する限り、実際にどのように実装されているかを本当に知らないか、気にする必要はありません。


私の最初の Comp Sci クラスでは、次の演習を行いました。確かに、これは約 200 人の学生がいる講堂でした...

教授はボードに次のように書いています:int john;

ジョンが立ち上がる

教授は次のように書いています:int *sally = &john;

サリーが立ち上がり、ジョンを指差す

教授:int *bill = sally;

ビルは立ち上がり、ジョンを指差す

教授:int sam;

サムが立ち上がる

教授:bill = &sam;

ビルは今、サムを指しています。

私はあなたがアイデアを得ると思います。ポインター割り当ての基本を説明するまで、これに約 1 時間費やしたと思います。


ポインターを説明するのに役立つ例えは、ハイパーリンクです。ほとんどの人は、Web ページ上のリンクがインターネット上の別のページを「指している」ことを理解できます。そのハイパーリンクをコピーして貼り付けることができれば、両方とも同じ元の Web ページを指すことになります。元のページに移動して編集すると、これらのリンク (ポインター) のいずれかをたどると、新しく更新されたページが表示されます。