为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
当前回答
我发现一个有助于解释指针的类比是超链接。大多数人都能理解,网页上的链接“指向”互联网上的另一个页面,如果你可以复制和粘贴这个超链接,那么它们都将指向相同的原始网页。如果你去编辑原始页面,然后按照这些链接(指针)中的任何一个,你会得到新的更新页面。
其他回答
对于许多人来说,指针这个概念一开始可能会令人困惑,特别是当涉及到复制指针值并仍然引用相同的内存块时。
我发现最好的类比是把指针看作一张纸,上面有一个房子地址,它引用的内存块就是实际的房子。因此,各种操作都很容易解释。
我在下面添加了一些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)
通常将“不指向任何地方”的地址存储为零地址。
用基本术语来说,什么是指针?
指针只是一个保存内存地址的变量。你通常可以问编程 语言给你它的数字,但大多数编程语言和运行时试图 隐藏下面有数字的事实,只是因为数字本身没有 对你来说真的很重要。最好把指针想象成一个黑盒。 你并不真正知道或关心它是如何实际实现的,只要它是 的工作原理。
I think that the main reason that people have trouble with it is because it's generally not taught in an interesting and engaging manner. I'd like to see a lecturer get 10 volunteers from the crowd and give them a 1 meter ruler each, get them to stand around in a certain configuration and use the rulers to point at each other. Then show pointer arithmetic by moving people around (and where they point their rulers). It'd be a simple but effective (and above all memorable) way of showing the concepts without getting too bogged down in the mechanics.
一旦你学了C和c++,对某些人来说似乎就更难了。我不确定这是因为他们最终把他们没有正确掌握的理论应用到实践中,还是因为在这些语言中指针操作天生就更难。我不太记得我自己的转变,但我知道Pascal中的指针,然后转到C,完全迷失了。
一个带有一组很好的图表的教程示例极大地帮助理解指针。
Joel Spolsky在他的《面试指南》文章中提出了一些关于理解指针的好观点:
出于某种原因,大多数人似乎生来就没有大脑中理解指针的那部分。这是一个天赋问题,而不是技能问题——它需要一种复杂的双重间接思维形式,而有些人就是做不到。
每个C/ c++初学者都有同样的问题,出现这个问题不是因为“指针很难学”,而是因为“解释指针的人以及如何解释”。有的学习者从口头或视觉上理解它,最好的解释方法是用“训练”样例(适用于口头和视觉样例)。
其中“火车头”是一个指针,它不能容纳任何东西,“马车”是“火车头”试图拉(或指向)的东西。之后,你可以对“马车”本身进行分类,它可以容纳动物、植物或人(或它们的混合)。
我发现Ted Jensen的“C中的指针和数组教程”是学习指针的极好的资源。它分为10节课,从解释指针是什么(以及它们是用来干什么的)开始,到以函数指针结束。http://web.archive.org/web/20181011221220/http://home.netcom.com:80/~tjensen/ptr/cpoint.htm
接着,Beej的《网络编程指南》教授了Unix套接字API,从中你可以开始做一些真正有趣的事情。http://beej.us/guide/bgnet/