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

为什么指针是许多刚开始学习C或c++,甚至上了大学的学生困惑的主要因素?有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?

有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。

87344 次浏览

在C/ c++语言中,指针为什么是许多新、甚至老大学生困惑的主要因素?

一个值的占位符的概念——变量——映射到我们在学校教的东西——代数。如果不理解内存在计算机中是如何物理布局的,就无法画出一个现有的并行图,而且没有人会考虑这种事情,直到他们处理低级别的事情——在C/ c++ /字节通信级别。

有没有什么工具或思维过程可以帮助你理解指针在变量、函数和级别之外是如何工作的?

地址框。我记得当我学习在微型计算机上编程BASIC时,有一些漂亮的书,里面有游戏,有时你必须在特定的地址中插入值。他们有一张图片,上面有一堆盒子,标有0、1、2……它解释说,只有一个小的东西(一个字节)可以装在这些盒子里,而它们有很多——一些计算机有多达65535!他们紧挨着,都有一个地址。

有什么好的实践可以让人达到“啊哈,我懂了”的水平,而不会让他们陷入整体概念中?基本上,就是模拟场景。

为了演习?创建一个结构体:

struct {
char a;
char b;
char c;
char d;
} mystruct;
mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';


char* my_pointer;
my_pointer = &mystruct.b;
cout << 'Start: my_pointer = ' << *my_pointer << endl;
my_pointer++;
cout << 'After: my_pointer = ' << *my_pointer << endl;
my_pointer = &mystruct.a;
cout << 'Then: my_pointer = ' << *my_pointer << endl;
my_pointer = my_pointer + 3;
cout << 'End: my_pointer = ' << *my_pointer << endl;

与上面的例子相同,除了在C中:

// Same example as above, except in C:
struct {
char a;
char b;
char c;
char d;
} mystruct;


mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';


char* my_pointer;
my_pointer = &mystruct.b;


printf("Start: my_pointer = %c\n", *my_pointer);
my_pointer++;
printf("After: my_pointer = %c\n", *my_pointer);
my_pointer = &mystruct.a;
printf("Then: my_pointer = %c\n", *my_pointer);
my_pointer = my_pointer + 3;
printf("End: my_pointer = %c\n", *my_pointer);

输出:

Start: my_pointer = s
After: my_pointer = t
Then: my_pointer = r
End: my_pointer = u

也许这通过例子解释了一些基础知识?

我不认为指针是一个特别棘手的概念——大多数学生的心理模型都映射到这样的东西,一些快速的盒子草图会有帮助。

困难之处在于,至少在我过去的经历和看到其他人处理的过程中,在C/ c++中指针的管理可能是不必要的复杂。

我不明白指针有什么好困惑的。它们指向内存中的一个位置,也就是存储内存地址的位置。在C/ c++中,你可以指定指针所指向的类型。例如:

int* my_int_pointer;

表示my_int_pointer包含指向包含int类型的位置的地址。

指针的问题在于它们指向内存中的某个位置,因此很容易回溯到不应该在的某个位置。作为证明,看看C/ c++应用程序中大量的安全漏洞,这些漏洞来自缓冲区溢出(指针的增量超过分配的边界)。

指针让很多人感到困惑的原因是它们大多没有计算机架构背景。由于许多人似乎不知道计算机(机器)是如何实际实现的——在C/ c++中工作似乎很陌生。

一个练习是要求他们实现一个简单的基于字节码的虚拟机(在任何他们选择的语言中,python都很适合这个),其中的指令集集中于指针操作(加载、存储、直接/间接寻址)。然后要求他们为该指令集编写简单的程序。

任何需要比简单加法稍微多一点的东西都会涉及到指针,它们肯定会得到指针。

我认为人们在这方面遇到麻烦的主要原因是因为它通常不是以一种有趣和吸引人的方式来教授的。我想看到一个讲师从人群中找来10个志愿者,给他们每人一把1米的尺子,让他们站在一定的位置上,用尺子指着彼此。然后通过移动人们(以及他们的标尺指向哪里)来显示指针算术。这将是一种简单但有效(且最重要的是令人难忘)的呈现概念的方式,而不会陷入机制中。

一旦你学了C和c++,对某些人来说似乎就更难了。我不确定这是因为他们最终把他们没有正确掌握的理论应用到实践中,还是因为在这些语言中指针操作天生就更难。我不太记得我自己的过渡,但我在Pascal中知道指针,然后转移到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             <--+

正如你所看到的,旧的数据在内存中是完整的,而不是 被内存分配器重用。分配器会跟踪它 内存区域已被使用,并且不会重用它们,除非您 自由。< / p >


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

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

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

有时你甚至会发现邻近的地址上有一个相当大的房子,占据了三个地址(主街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)

通常将“不指向任何地方”的地址存储为零地址。


用基本术语来说,什么是指针?

指针只是一个保存内存地址的变量。你通常可以问编程 语言给你它的数字,但大多数编程语言和运行时试图 隐藏下面有数字的事实,只是因为数字本身没有 对你来说真的很重要。最好把指针想象成一个黑盒。 你并不真正知道或关心它是如何实际实现的,只要它是 作品。< / p >

一个带有一组很好的图表的教程示例极大地帮助理解指针

Joel Spolsky在他的面试指南文章中提出了一些关于理解指针的好观点:

出于某种原因,大多数人似乎生来就没有大脑中理解指针的那部分。这是一个天赋问题,而不是技能问题——它需要一种复杂的双重间接思维形式,而有些人就是做不到。

在我的第一节compp Sci课上,我们做了以下练习。当然,这是一个大约有200名学生的演讲厅……

教授在黑板上写道:int john;

约翰站起来

教授写道:int *sally = &john;

莎莉站起来,指着约翰

教授:int *bill = sally;

比尔站起来,指着约翰

教授:int sam;

山姆站起来

教授:bill = &sam;

比尔现在指向山姆。

我想你已经明白了。我想我们花了一个小时来做这个,直到我们复习了指针赋值的基础知识。

指针的复杂性超出了我们可以轻易教授的范围。让学生们互相指指点点和使用写有家庭住址的纸都是很好的学习工具。他们在介绍基本概念方面做得很好。的确,学习基本的概念是至关重要的成功使用指针。然而,在产品代码中,通常会遇到比这些简单演示所能封装的复杂得多的场景。

我参与过的系统中,我们有一个结构指向另一个结构指向另一个结构。其中一些结构还包含嵌入式结构(而不是指向其他结构的指针)。这就是指针真正令人困惑的地方。如果你有多个间接层,你最终会得到这样的代码:

widget->wazzle.fizzle = fazzle.foozle->wazzle;

它很快就会让人感到困惑(想象更多的线,可能还有更多的关卡)。再加上指针数组和节点到节点的指针(树、链表),情况就更糟了。我曾见过一些非常优秀的开发人员在开始开发这样的系统时迷失了方向,甚至是那些非常了解基础知识的开发人员。

指针的复杂结构也不一定表明编码很差(尽管可能)。复合是优秀的面向对象编程的重要组成部分,在使用原始指针的语言中,它将不可避免地导致多层间接。此外,系统经常需要使用第三方库,这些库的结构在风格或技术上彼此不匹配。在这样的情况下,复杂性自然会出现(当然,我们应该尽可能地对抗它)。

我认为大学为帮助学生学习指针所能做的最好的事情就是使用良好的演示,并结合需要使用指针的项目。对于指针的理解,一个困难的项目要比上千个演示做得多。演示可以让您对指针有一个浅显的理解,但要深刻地理解指针,您必须真正地使用它们。

我不认为指针本身令人困惑。大多数人都能理解这个概念。现在你能想到多少个指针或者你能适应多少个间接层次。不需要太多就能让人崩溃。它们可能会被程序中的错误意外更改,这也会使它们在代码中出错时很难调试。

我找到了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/

我喜欢家庭地址的比喻,但我一直认为地址是邮箱本身。通过这种方式,您可以可视化解除指针引用(打开邮箱)的概念。

例如,下面是一个链表: 1)论文开头写上地址 2)去纸上的地址 3)打开邮箱,找到一张新纸,上面写着下一个地址

在线性链表中,最后一个邮箱中没有任何内容(列表的末尾)。在循环链表中,最后一个邮箱具有其中第一个邮箱的地址。

请注意,第3步是发生解引用的地方,当地址无效时,您将崩溃或出错。假设你可以走到一个无效地址的邮箱前,想象那里有一个黑洞或什么东西,把世界翻个底朝天:)

我发现一个有助于解释指针的类比是超链接。大多数人都能理解网页上的链接“指向”互联网上的另一个页面,如果你能复制&粘贴该超链接,然后它们将指向相同的原始网页。如果你去编辑原始页面,然后按照这些链接(指针)中的任何一个,你会得到新的更新页面。

为了使事情更混乱一点,有时你必须使用句柄而不是指针。句柄是指向指针的指针,因此后端可以移动内存中的内容以整理堆。如果指针在例程中间发生了变化,那么结果是不可预测的,因此您首先必须锁定句柄,以确保没有任何事情发生。

http://arjay.bc.ca/Modula-2/Text/Ch15/Ch15.8.html#15.8.5谈论它比我更连贯一点。: -)

我认为,使指针难以学习的原因是,直到你熟悉了指针的概念,即“在这个内存位置是一组表示int型,double型,字符等的位”。

当你第一次看到一个指针时,你并不知道那个内存位置上有什么。“你的意思是,它包含地址?”

我不同意“要么得到要么得不到”的观点。

当你开始发现它们的真正用途时(比如不要将大结构传递到函数中),它们就会变得更容易理解。

邮政信箱号码。

它是一条信息,允许你访问其他东西。

(如果你计算邮政信箱号码,你可能会有问题,因为信进了错误的信箱。如果有人搬到另一个州——没有转发地址——你就有一个悬浮指针。另一方面,如果邮局转发邮件,那么你就有了指向指针的指针。)

指针的问题不在于概念。这涉及到执行和语言。当老师们认为是指针的概念有困难,而不是术语,或者C和c++对这个概念的复杂混乱时,就会产生更多的困惑。因此,大量的努力被用于解释这个概念(就像这个问题的公认答案),而对我这样的人来说,这几乎是浪费,因为我已经理解了所有这些。这只是解释了问题的错误部分。

为了让你知道我是怎么来的,我是一个非常理解指针的人,我可以在汇编语言中熟练地使用它们。因为在汇编语言中,它们不被称为指针。它们被称为地址。当我在C语言中编程和使用指针时,我犯了很多错误,并感到非常困惑。我还没弄清楚。让我给你们举个例子。

当一个api说:

int doIt(char *buffer )
//*buffer is a pointer to the buffer

它想要什么?

它可能想要:

表示缓冲区地址的数字

(为此,我应该说doIt(mybuffer)还是doIt(*myBuffer)?)

表示缓冲区地址的一种数字

(是doIt(&mybuffer)doIt(mybuffer)doIt(*mybuffer)吗?)

表示缓冲区地址的地址的数字

(可能是doIt(&mybuffer)。还是doIt(&&mybuffer) ?甚至doIt(&&&mybuffer))

诸如此类,涉及到的语言并没有使它变得清晰,因为它涉及到“指针”和“引用”这些词,它们对我来说没有“x保存到y的地址”和“这个函数需要到y的地址”那么多的意义和清晰度。另外,答案还取决于“mybuffer”到底是什么,以及doIt打算用它做什么。该语言不支持在实践中遇到的嵌套级别。就像当我必须将一个“指针”交给一个创建新缓冲区的函数时,它会修改指针指向缓冲区的新位置。它是否真的需要指针,或者指向指针的指针,这样它就知道去哪里修改指针的内容。大多数时候我只能猜“指针”是什么意思,大多数时候我都猜错了,不管我有多少猜测的经验。

“指针”太重载了。指针是指向值的地址吗?或者它是一个将地址保存到值的变量。当一个函数需要一个指针时,它是想要指针变量保存的地址,还是指针变量的地址? 我困惑。< / p >

我认为这可能是语法问题。指针的C/ c++语法似乎不一致,而且比实际需要的更复杂。

具有讽刺意味的是,真正帮助我理解指针的是c++ 标准模板库中迭代器的概念。这很讽刺,因为我只能假设迭代器被认为是指针的泛化。

有时候,只有当你学会忽略树木时,你才能看到森林。

通过迭代器来掌握它是个不错的方法。但继续看,你会发现亚历山大开始抱怨他们。

许多前c++开发人员(在抛弃语言之前从未理解迭代器是一个现代指针)跳转到c#,仍然相信他们有不错的迭代器。

嗯,问题是所有迭代器都与运行时平台(Java/CLR)试图实现的目标完全不一致:新的、简单的、人人都是dev的用法。这可能是好事,但他们在紫书里说过一次,甚至在C之前说过

间接。

这是一个非常强大的概念,但如果你一直这样做,就不会如此了。迭代器很有用,因为它们有助于算法的抽象,这是另一个例子。编译时是算法的地方,非常简单。你知道代码+数据,或者用其他语言c#:

IEnumerable + LINQ + Massive Framework = 300MB运行时惩罚间接的糟糕,拖动应用程序通过引用类型的实例堆..

“Le Pointer很便宜。”

它如此难以理解的原因并不是因为它是一个很难的概念,而是因为语法不一致

int *mypointer;

您首先了解到变量创建的最左边部分定义了变量的类型。在C和c++中,指针声明不是这样工作的。相反,他们说变量指向左边的类型。在本例中:*mypointer 指向int类型的

我没有完全掌握指针,直到我尝试在c#中使用它们(不安全),它们以完全相同的方式工作,但具有逻辑和一致的语法。指针本身就是一个类型。这里Mypointer is一个指向int类型的指针。

int* mypointer;

甚至不要让我开始函数指针…

当我只懂c++的时候,我可以使用指针。从试错中,我知道在某些情况下应该做什么,不应该做什么。但是让我完全理解的是汇编语言。如果您对自己编写的汇编语言程序进行了一些严肃的指令级调试,那么您应该能够理解很多东西。

这种混淆来自于在“指针”概念中混合在一起的多个抽象层。程序员不会对Java/Python中的普通引用感到困惑,但指针的不同之处在于它们暴露了底层内存架构的特征。

清晰地分离抽象层是一个很好的原则,而指针做不到这一点。

起初,我很难理解指针的原因是,许多解释都包含了很多关于引用传递的废话。所有这些都混淆了问题。当你使用指针形参时,你是通过值传递仍然;但是这个值恰好是一个地址,而不是int型。

其他人已经链接到本教程,但我可以强调我开始理解指针的时刻:

C语言中指针和数组教程:第三章-指针和字符串 .

我认为理解指针的主要障碍是糟糕的老师。

几乎每个人都被教导关于指针的谎言:它们是只不过是内存地址,或者它们允许你指向任意位置

当然,他们很难理解,危险,半魔法。

这些都不是真的。指针实际上是相当简单的概念,只要你坚持c++语言对它们的描述,并且没有赋予它们“通常”在实践中工作的属性,但仍然不能被语言保证,因此不是指针实际概念的一部分。

几个月前,我试图在这篇博文中对此进行解释——希望它能帮助到一些人。

(注意,在有人对我说学究之前,是的,c++标准确实说指针代表内存地址。但它并没有说“指针是内存地址,而且只是内存地址,可以与内存地址互换使用或认为”。区别很重要)

每个C/ c++初学者都有同样的问题,出现这个问题不是因为“指针很难学”,而是因为“解释指针的人以及如何解释”。一些学习者从口头或视觉上收集它,最好的解释方法是使用“训练”的例子(适用于口头和视觉例子)。

其中“机车”是一个指针,不能持有任何东西,“马车”是“火车头”试图拉(或指向)的东西。之后,你可以对“马车”本身进行分类,它可以容纳动物、植物或人(或它们的混合)。

我想我应该在这个列表中添加一个类比,当我作为计算机科学导师解释指针时(回到过去),我发现它非常有用;首先,让我们:


做好准备:

考虑一个有3个车位的停车场,这些车位是编号的:

-------------------
|     |     |     |
|  1  |  2  |  3  |
|     |     |     |

在某种程度上,这就像内存位置,它们是连续的和连续的。有点像数组。现在它们中没有汽车,所以它就像一个空数组(parking_lot[3] = {0})。


添加数据

停车场永远不会空着太久……如果有,那就没有意义了,也没有人会去建造。假设随着时间推移,停车场里停满了3辆车,一辆蓝色的,一辆红色的,一辆绿色的

   1     2     3
-------------------
| o=o | o=o | o=o |
| |B| | |R| | |G| |
| o-o | o-o | o-o |

这些车都是同一类型(车),所以一种思考方法是,我们的车是某种数据(比如int),但它们有不同的值(blueredgreen;它可以是一种颜色。


进入指针

现在如果我带你到这个停车场,让你给我找一辆蓝色的车,你伸出一根手指,指着点1的一辆蓝色的车。这就像获取一个指针并将其分配给一个内存地址(int *finger = parking_lot)

你的手指(指针)不是我问题的答案。看在< < em > / em >你的手指告诉我什么,但如果我看你的手指是指向(解除指针引用),我可以找到我正在寻找的汽车(数据)。


重新分配指针

现在我可以让你找到一辆红色的车,你可以把你的手指转向一辆新车。现在您的指针(与之前的指针相同)正在向我显示相同类型(汽车)的新数据(可以找到红色汽车的停车位)。

指针没有物理上的变化,它仍然是你的手指,只是它显示给我的数据发生了变化。(“车位”地址)


双指针(或指向指针的指针)

这也适用于多个指针。我可以问指向红色汽车的指针在哪里,你可以用另一只手用一根手指指向第一个手指。(这类似于int **finger_two = &finger)

现在如果我想知道蓝色的车在哪里,我可以顺着食指的方向到第二根手指,到那辆车(数据)。


悬空指针

现在让我们假设你感觉自己很像一座雕像,你想一直用手指着那辆红色的车。如果那辆红色汽车开走了怎么办?

   1     2     3
-------------------
| o=o |     | o=o |
| |B| |     | |G| |
| o-o |     | o-o |

你的指针仍然指向红色汽车,但不再是。假设一辆新车停在那里……一辆橙色的汽车。现在如果我再问你,“红色的车在哪里”,你仍然指着那里,但现在你错了。那不是红色的车,那是橙色的。


指针的算术

好的,你仍然指着第二个停车位(现在被橙色车占据了)

   1     2     3
-------------------
| o=o | o=o | o=o |
| |B| | |O| | |G| |
| o-o | o-o | o-o |

我现在有个新问题…我想知道下一个停车位上的车的颜色。你可以看到你指向点2,所以你只要加1,你就指向下一个点。(finger+1),现在因为我想知道那里的数据是什么,你必须检查那个点(不仅仅是手指),这样你就可以遵从指针(*(finger+1)),看到那里有一辆绿色的汽车(该位置的数据)

上面的一些回答断言“指针并不难”,但并没有直接解决“指针难”的来源。几年前,我辅导一年级的CS学生(只教了一年,因为我显然学得很糟糕),我很清楚指针的的想法并不难。难的是理解你为什么以及什么时候需要一个指针

我不认为您可以将这个问题(为什么以及何时使用指针)与解释更广泛的软件工程问题分开。为什么每个变量都应该是一个全局变量,以及为什么应该把类似的代码分解到函数中(也就是使用指针将它们的行为特殊化到它们的调用位置)。

我喜欢用数组和下标来解释它——人们可能不熟悉指针,但他们通常都知道下标是什么。

所以我说,假设RAM是一个数组(你只有10个字节的RAM):

unsigned char RAM[10] = { 10, 14, 4, 3, 2, 1, 20, 19, 50, 9 };

然后,指向变量的指针实际上只是该变量在RAM中的第一个字节的索引。

因此,如果你有一个指针/索引unsigned char index = 2,那么值显然是第三个元素,或数字4。指向指针的指针是指将该数字本身用作索引,如RAM[RAM[index]]

我会在纸上的列表上画一个数组,然后用它来显示一些东西,比如指向同一个内存的许多指针、指针算术、指针到指针等等。