为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
当前回答
当我只懂c++的时候,我可以使用指针。从试错中,我知道在某些情况下应该做什么,不应该做什么。但是让我完全理解的是汇编语言。如果您对自己编写的汇编语言程序进行了一些严肃的指令级调试,那么您应该能够理解很多东西。
其他回答
上面的一些回答断言“指针并不难”,但并没有直接解决“指针难”的来源。几年前,我辅导一年级的CS学生(只教了一年,因为我显然学得很差),我很清楚指针的概念并不难。难的是理解为什么以及什么时候需要指针。
我不认为您可以将这个问题(为什么以及何时使用指针)与解释更广泛的软件工程问题分开。为什么每个变量都不应该是全局变量,以及为什么应该把类似的代码分解成函数(也就是使用指针将它们的行为专门化到它们的调用位置)。
我认为,使指针难以学习的原因是,直到你熟悉了指针的概念,即“在这个内存位置是一组表示int型,double型,字符等的位”。
当你第一次看到一个指针时,你并不知道那个内存位置上有什么。“什么意思,里面有地址?”
我不同意“要么得到要么得不到”的观点。
当你开始发现它们的真正用途时(比如不要将大结构传递到函数中),它们就会变得更容易理解。
这种混淆来自于在“指针”概念中混合在一起的多个抽象层。程序员不会对Java/Python中的普通引用感到困惑,但指针的不同之处在于它们暴露了底层内存架构的特征。
清晰地分离抽象层是一个很好的原则,而指针做不到这一点。
在我的第一节compp Sci课上,我们做了以下练习。当然,这是一个大约有200名学生的演讲厅……
教授在黑板上写道:int john;
约翰站起来
教授写道:int *sally = &john;
莎莉站起来,指着约翰
int *bill = sally;
比尔站起来,指着约翰
教授:int sam;
山姆站起来
教授:bill = &sam;
比尔现在指向山姆。
我想你已经明白了。我想我们花了一个小时来做这个,直到我们复习了指针赋值的基础知识。
对于许多人来说,指针这个概念一开始可能会令人困惑,特别是当涉及到复制指针值并仍然引用相同的内存块时。
我发现最好的类比是把指针看作一张纸,上面有一个房子地址,它引用的内存块就是实际的房子。因此,各种操作都很容易解释。
我在下面添加了一些Delphi代码,并在适当的地方添加了一些注释。我之所以选择Delphi,是因为我的另一种主要编程语言c#不会以同样的方式显示内存泄漏之类的问题。
如果你只想学习指针的高级概念,那么你应该忽略下面解释中标记为“内存布局”的部分。它们的目的是提供操作后内存可能是什么样子的示例,但它们在本质上更低级。但是,为了准确地解释缓冲区溢出是如何工作的,添加这些图非常重要。
免责声明:出于所有意图和目的,本解释和示例内存 布局大大简化。会有更多的开销和更多的细节 需要知道是否需要在底层基础上处理内存。然而,对于 解释内存和指针的意图,是足够准确的。
让我们假设下面使用的THouse类是这样的:
type
THouse = class
private
FName : array[0..9] of Char;
public
constructor Create(name: PChar);
end;
初始化house对象时,给构造函数的名称被复制到私有字段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
复制指针值
把地址写在一张新纸上就行了。你现在有两张纸,可以让你去同一间房子,而不是两间不同的房子。任何试图从一份文件中找到地址并重新安排那所房子的家具的尝试都会让人觉得另一所房子也以同样的方式进行了修改,除非你能明确地发现它实际上只是一所房子。
这通常是我最难向人们解释的概念,两个指针并不意味着两个对象或内存块。
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
在调用. free之后使用h可能会起作用,但这只是纯粹的运气。最有可能的是,它会在客户的地方,在一个关键的操作中失败。
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
第二次分配后的内存布局:
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号),而你的地址就在房子的中间。任何试图把大的三地址房子的那一部分当作一个单独的小房子的尝试也可能会失败。
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] 5678My house
第二次分配后的内存布局:
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作为链接,用 下图中的四个LLLL):
h1 h2 v v ---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL] 1234Home + 5678Cabin + | ^ | +--------+ * (no link)
用基本术语来说,什么是内存地址?
内存地址在基本术语中只是一个数字。如果你想到记忆 作为一个大的字节数组,第一个字节的地址为0,下一个字节的地址为0 地址1,以此类推。这是简化的,但已经足够好了。
这个内存布局:
h1 h2 v v ---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN] 1234My house 5678My house
可能有这两个地址(最左边-是地址0):
H1 = 4 H2 = 23
这意味着我们上面的链表实际上可能是这样的:
h1 (=4) h2 (=28) v v ---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL] 1234Home 0028 5678Cabin 0000 | ^ | +--------+ * (no link)
通常将“不指向任何地方”的地址存储为零地址。
用基本术语来说,什么是指针?
指针只是一个保存内存地址的变量。你通常可以问编程 语言给你它的数字,但大多数编程语言和运行时试图 隐藏下面有数字的事实,只是因为数字本身没有 对你来说真的很重要。最好把指针想象成一个黑盒。 你并不真正知道或关心它是如何实际实现的,只要它是 的工作原理。