为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?
有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。
当前回答
在我的第一节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)
通常将“不指向任何地方”的地址存储为零地址。
用基本术语来说,什么是指针?
指针只是一个保存内存地址的变量。你通常可以问编程 语言给你它的数字,但大多数编程语言和运行时试图 隐藏下面有数字的事实,只是因为数字本身没有 对你来说真的很重要。最好把指针想象成一个黑盒。 你并不真正知道或关心它是如何实际实现的,只要它是 的工作原理。
上面的一些回答断言“指针并不难”,但并没有直接解决“指针难”的来源。几年前,我辅导一年级的CS学生(只教了一年,因为我显然学得很差),我很清楚指针的概念并不难。难的是理解为什么以及什么时候需要指针。
我不认为您可以将这个问题(为什么以及何时使用指针)与解释更广泛的软件工程问题分开。为什么每个变量都不应该是全局变量,以及为什么应该把类似的代码分解成函数(也就是使用指针将它们的行为专门化到它们的调用位置)。
指针的复杂性超出了我们可以轻易教授的范围。让学生们互相指指点点和使用写有家庭住址的纸都是很好的学习工具。他们在介绍基本概念方面做得很好。事实上,学习指针的基本概念对于成功使用指针是至关重要的。然而,在产品代码中,通常会遇到比这些简单演示所能封装的复杂得多的场景。
我参与过的系统中,我们有一个结构指向另一个结构指向另一个结构。其中一些结构还包含嵌入式结构(而不是指向其他结构的指针)。这就是指针真正令人困惑的地方。如果你有多个间接层,你最终会得到这样的代码:
widget->wazzle.fizzle = fazzle.foozle->wazzle;
它很快就会让人感到困惑(想象更多的线,可能还有更多的关卡)。再加上指针数组和节点到节点的指针(树、链表),情况就更糟了。我曾见过一些非常优秀的开发人员在开始开发这样的系统时迷失了方向,甚至是那些非常了解基础知识的开发人员。
Complex structures of pointers don't necessarily indicate poor coding, either (though they can). Composition is a vital piece of good object-oriented programming, and in languages with raw pointers, it will inevitably lead to multi-layered indirection. Further, systems often need to use third-party libraries with structures which don't match each other in style or technique. In situations like that, complexity is naturally going to arise (though certainly, we should fight it as much as possible).
我认为大学为帮助学生学习指针所能做的最好的事情就是使用良好的演示,并结合需要使用指针的项目。对于指针的理解,一个困难的项目要比上千个演示做得多。演示可以让您对指针有一个浅显的理解,但要深刻地理解指针,您必须真正地使用它们。
它之所以如此难以理解,并不是因为它是一个复杂的概念,而是因为语法不一致。
int *mypointer;
您首先了解到变量创建的最左边部分定义了变量的类型。在C和c++中,指针声明不是这样工作的。相反,他们说变量指向左边的类型。在这种情况下:*mypointer指向int类型。
我没有完全掌握指针,直到我尝试在c#中使用它们(不安全),它们以完全相同的方式工作,但具有逻辑和一致的语法。指针本身就是一个类型。这里mypointer是一个指向int型的指针。
int* mypointer;
甚至不要让我开始函数指针…
通过迭代器来掌握它是个不错的方法。但继续看,你会发现亚历山大开始抱怨他们。
许多前c++开发人员(在抛弃语言之前从未理解迭代器是一个现代指针)跳转到c#,仍然相信他们有不错的迭代器。
嗯,问题是所有迭代器都与运行时平台(Java/CLR)试图实现的目标完全不一致:新的、简单的、人人都是dev的用法。这可能是好事,但他们在紫书里说过一次,甚至在C之前说过
间接。
这是一个非常强大的概念,但如果你一直这样做,就不会如此了。迭代器很有用,因为它们有助于算法的抽象,这是另一个例子。编译时是算法的地方,非常简单。你知道代码+数据,或者用其他语言c#:
IEnumerable + LINQ + Massive Framework = 300MB运行时惩罚间接的糟糕,拖动应用程序通过引用类型的实例堆..
“Le Pointer很便宜。”