小编典典

理解指针的障碍是什么,可以做些什么来克服它们?

all

为什么指针对于许多 C 或 C++
的新的甚至是老的大学生来说是一个如此混乱的主要因素?是否有任何工具或思维过程可以帮助您理解指针在变量、函数和其他级别上的工作方式?

有哪些好的实践可以让某人达到“啊哈,我明白了”的水平,而不会让他们陷入整体概念中?基本上,像场景一样钻取。


阅读 96

收藏
2022-03-15

共1个答案

小编典典

指针是一个对许多人来说一开始可能会感到困惑的概念,特别是在复制指针值并仍然引用同一个内存块时。

我发现最好的类比是将指针视为一张纸,上面有房子地址,它引用的内存块是实际房子。因此可以很容易地解释各种操作。

我在下面添加了一些 Delphi 代码,并在适当的地方添加了一些注释。我选择了 Delphi,因为我的其他主要编程语言 C#
不会以同样的方式表现出内存泄漏等问题。

如果您只想学习指针的高级概念,那么您应该忽略下面说明中标记为“内存布局”的部分。它们旨在举例说明操作后内存的外观,但它们本质上更底层。但是,为了准确解释缓冲区溢出的实际工作原理,我添加了这些图表非常重要。

免责声明:出于所有意图和目的,此解释和示例内存布局已大大简化。
如果需要在低级别处理内存,则需要了解更多开销和更多详细信息。但是,对于解释内存和指针的意图,它已经足够准确了。


假设下面使用的 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我的房子

复制指针值

只需将地址写在一张新纸上。你现在有两张纸可以带你去同一个房子,而不是两个单独的房子。任何试图按照一张纸上的地址重新排列那所房子的家具都会让人觉得
另一所房子 已经以同样的方式进行了修改,除非你能明确地发现它实际上只是一所房子。

注意 这通常是我向人们解释的最困难的概念,两个指针并不意味着两个对象或内存块。

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 +- 释放前
---[ttttNNNNNNNNNN]--- |
    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在调用 to 之后使用.Free 可能会 起作用,但这只是纯粹的运气。它很可能会在关键操作过程中在客户处发生故障。

    h <--+
    v +- 释放前
---[ttttNNNNNNNNNN]--- |
    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我的房子

第二次分配后的内存布局:

                       H
                       v
---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN]
    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 +- 在丢失指针之前
---[ttttNNNNNNNNNN]--- |
    1234我家<--+

    h(现在无处可指)<--+
                                +- 丢失指针后
---[ttttNNNNNNNNNN]--- |
    1234我家<--+

如您所见,旧数据原封不动地留在内存中,不会被内存分配器重用。分配器跟踪已使用的内存区域,除非您释放它,否则不会重用它们。


释放内存但保留(现在无效)引用

拆房子,擦掉一张纸,但你还有另一张纸,上面写着旧地址,当你去那个地址时,你不会找到房子,但你可能会找到类似废墟的东西之一。

也许你甚至会找到一所房子,但它不是你最初获得地址的房子,因此任何试图将它当作属于你的使用都可能会失败。

有时你甚至会发现相邻的地址有一个相当大的房子,占据了三个地址(Main Street 1-3),而你的地址在房子的中间。任何将大型 3
地址房屋的那部分视为单个小房屋的尝试也可能会失败。

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但仍然有旧的、过时的地址。进入不再站立的房子可能会也可能不会。

这是上面悬空指针的变体。查看它的内存布局。


缓冲区溢出

你搬进房子里的东西超出了你的承受能力,溢出到邻居的房子或院子里。等隔壁房子的主人以后回家时,他会发现各种他认为属于自己的东西。

这就是我选择固定大小数组的原因。为了做好准备,假设我们分配的第二个房子由于某种原因将放在内存中的第一个之前。换句话说,第二个房子的地址将低于第一个房子。此外,它们被分配在彼此旁边。

因此,这段代码:

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我的房子

第二次分配后的内存布局:

    h2 h1
    vv
---[ttttNNNNNNNNNN]----[ttttNNNNNNNNNN]
    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 作为对象中的链接,在下图中用四个 LLLL 标注):

    h1 h2
    vv
---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL]
    1234家+ 5678客舱+
                   | ^ |
                   +--------+ *(无链接)

基本来说,什么是内存地址?

内存地址在基本术语中只是一个数字。如果您将内存视为一个大字节数组,那么第一个字节的地址为 0,下一个字节的地址为 1,依此类推。这是简化的,但已经足够好了。

所以这个内存布局:

    h1 h2
    vv
---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN]
    1234我的房子 5678我的房子

可能有这两个地址(最左边 - 是地址 0):

  • h1 = 4
  • h2 = 23

这意味着我们上面的链表实际上可能是这样的:

    h1 (=4) h2 (=28)
    vv
---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL]
    1234首页 0028 5678客舱 0000
                   | ^ |
                   +--------+ *(无链接)

通常将“无处指向”的地址存储为零地址。


基本来说,什么是指针?

指针只是一个保存内存地址的变量。您通常可以要求编程语言给您它的编号,但大多数编程语言和运行时都试图隐藏下面有一个数字的事实,只是因为数字本身对您没有任何意义。最好将指针视为黑盒,即。您并不真正了解或关心它是如何实际实施的,只要它有效。

2022-03-15