1. ホーム
  2. c++

[解決済み】ポインターを理解するための障壁と、それを克服するためにできることは?[終了しました]

2022-03-24 19:31:38

質問内容

なぜポインターは、CやC++を学ぶ多くの新入生、そして古参の大学生にとっても混乱の主因となるのでしょうか? 変数、関数、それ以上のレベルでポインタがどのように機能するかを理解するのに役立ったツールや思考プロセスはありますか?

全体的な概念にとらわれず、「ああ、わかった」というレベルまで持っていくために、何かよい練習方法はありますか?基本的には、ドリルのようなシナリオです。

どのように解決するのか?

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

ポインターを家の住所が書かれた紙片、参照するメモリブロックを実際の家と考えると、最も良い例えになることがわかりました。そうすれば、さまざまな操作を簡単に説明することができる。

以下にDelphiのコードと、適宜コメントを追加しています。私がメインで使っているC#というプログラミング言語では、メモリーリークのような現象が起きないため、Delphiを選びました。

もし、あなたがポインタの高度な概念だけを学びたいのであれば、以下の説明の中の「"Memory layout"」と書かれた部分は無視した方が良いでしょう。これらは、操作後のメモリがどのように見えるかの例を示すためのもので、本来はもっと低レベルのものです。しかし、バッファオーバーランが実際にどのように機能するかを正確に説明するためには、これらの図を追加することが重要だったのです。

免責事項:この説明と例のメモリは、いかなる目的であれ、その使用は禁止されています。 レイアウトは大幅に簡略化されています。もっとオーバーヘッドがあり、もっと多くの詳細があります。 を使用する必要があります。しかし、ここでは メモリとポインタの説明という意味では、十分な精度です。


以下で使用するTHouseクラスは、このようなものだとします。

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

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

メモリ上では、家の割り当てに伴うオーバーヘッドが発生しますが、以下、このように説明します。

---[ttttNNNNNNNNNN]---
     ^ ^
     | |
     | +- FName配列
     |
     +- オーバーヘッド

tttt"エリアはオーバーヘッドで、通常8バイトや12バイトなど、さまざまな種類のランタイムや言語に対してより多くのオーバーヘッドが存在します。この領域に格納された値は、メモリアロケータやコアシステムルーチン以外では決して変更されないようにする必要があります。


メモリの割り当て

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

つまり、起業家がスポットを選ぶのである。

THouse.Create('My house');

メモリレイアウト。

---[ttttNNNNNNNNNN]---
    1234私の家


アドレスで変数を保持する

新居の住所を紙に書いてください。この紙があなたの家への参照になります。この紙がなければ、あなたは迷子になり、すでにその家の中にいるのでなければ、家を見つけることができません。

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

メモリレイアウト。

    h
    v
---[ttttNNNNNNNNNN]---
    1234私の家


ポインタの値をコピーする

新しい紙に住所を書くだけです。これで、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]---
    1234マイホーム
    ^
    h2


メモリの解放

家を取り壊す。その後、希望すればその紙を新しい住所に再利用できますし、もう存在しない家の住所を忘れるためにクリアすることもできます。

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

ここでは、まず家を建てて、その住所を把握する。それから家に何かをして(読者のために練習として残しておいた...コードを使って)、それから家を解放している。最後に、変数から住所を消去します。

メモリのレイアウト

    h <--+
    v +- フリー前
---[ttttNNNNNN]--- | |
    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 かもしれない は働きますが、それは単なる運です。ほとんどの場合、顧客のところで、重要な操作の最中に失敗することになるでしょう。

    h <--+
    v +- フリー前
---[ttttNNNNNN]--- | |
    1234私の家 <--+)

    h <--+
    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
    v
---[ttttNNNNNNNNNN]---
    1234私の家

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

                       h
                       v
---[tttNNNNNN]---[tttNNNNNN]です。
    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;

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

メモリレイアウトです。

    h <--+
    v +- ポインタを失う前
---[ttttNNNNNN]---|日本経済新聞社
    1234私の家 <--+)

    h (今はどこも指していない) <--+.
                                +- ポインターを失った後
---[ttttNNNN]--- | | | | | | | | | | | | | | | | | | | | | | | | | | ]です。
    1234マイホーム <--+)

ご覧の通り、古いデータはそのままメモリ上に残され、そのデータを使用することはありません。 は、メモリアロケータによって再利用されます。アロケータはどの を使用しない限り、再利用されることはありません。 を解放します。


メモリは解放するが、(今は無効な)参照は保持する。

家を壊して、紙切れの1枚を消しますが、もう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つ目の家を割り当てた場合、何らかの理由でその家の前に置かれます。 メモリ上の最初の1つです。つまり、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]
                        5678私の家

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

    h2 h1
    v v
---[tttNNNNNN]----[tttNNNNNN]----[tttNNNN]です。
    1234もう一つの我が家どこかで
                        ^---+--^
                            |
                            +- 上書き

クラッシュの原因となりやすいのは、重要な部分を上書きしてしまった場合です。 は、ランダムに変更されるべきではありません。例えば h1-houseの名前の一部が変更されても問題ないかもしれません。 プログラムをクラッシュさせるという点では、そのオーバーヘッドを上書きすることで オブジェクトを使用しようとすると、ほとんどの場合、クラッシュします。 に格納されているリンクを上書きするのと同じように のオブジェクトの中に、他のオブジェクトが含まれています。


リンクリスト

紙に書かれた住所をたどっていくと、ある家にたどり着き、その家には新しい住所が書かれた別の紙があり、次の家の住所が書かれているというように、連鎖していきます。

var
    h1, h2: THouse;
begin
    h1 := THouse.Create('Home');
    h2 := THouse.Create('Cabin');
    h1.NextHouse := h2;

ここでは、自宅の家から小屋へのリンクを作成します。ある家に NextHouse を参照して、それが最後の1つであることを意味します。すべての家を訪問するには、次のようなコードを使用することができます。

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
---[ttttNNNNNNLLL]----[ttttNNNNNNLLLLL]
    1234Home + 5678Cabin + (1234ホーム + 5678キャビン)
                   
                   +--------+ * (リンクなし)


基本的なことですが、メモリーアドレスとは何ですか?

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

このメモリレイアウトですね。

    h1 h2
    v
---[tttNNNNNN]---[tttNNNNNN]です。
    1234わたしの家 5678わたしの家

この2つのアドレスがあるかもしれません(一番左の-がアドレス0)。

  • h1 = 4
  • h2 = 23

つまり、上のリンクリストは、実際には次のようになる可能性があります。

    h1 (=4) h2 (=28)
    v v
---[ttttNNNNNNLLL]----[ttttNNNNNNLLLLL]
    1234Home 0028 5678Cabin 0000
                   
                   +--------+ * (リンクなし)

どこも指していないアドレスは、ゼロアドレスとして保存するのが一般的です。


基本的なことですが、ポインターとは何ですか?

ポインターは、メモリアドレスを保持する変数に過ぎません。一般的には、プログラミングに しかし、ほとんどのプログラミング言語とランタイムは、その番号を提供しようとします。 その下に数字があることを隠します。数字そのものは意味がないからです。 ということである。ポインターはブラックボックスと考えるのが一番です。 つまり、ポインタが実際にどのように実装されているかは知らないし、気にもしない。 は動作します。