C指针有哪些让人困惑的地方?

178

从这里发布的问题数量来看,很明显人们在理解指针和指针算术时有一些非常基本的问题。

我很想知道为什么会这样。虽然我早在新石器时代就学习了指针,但它们从未给我带来过大问题。为了更好地回答这些问题,我想知道人们觉得困难的是什么。

所以,如果你在处理指针方面遇到了困难,或者最近突然“明白了”,那么哪些方面会使你感到困难呢?


55
他们已经被“表达式”高级语言之一的学习所削弱——我告诉你,精神上他们已经残废了——他们应该像上帝和丹尼尔·布恩(Daniel Boone)所期望的那样,从裸金属(bare metal)编程开始! - dmckee --- ex-moderator kitten
3
“程序员”可能更合适,因为即使你尽最大努力,它仍可能发展成一个讨论。 - dmckee --- ex-moderator kitten
向克里斯蒂安·贝尔致敬——“哦,为你高兴!”有些人能行,有些人不能。就这么简单。 - Jack Marchetti
2
@Sam Saffron:虽然我通常同意这更适合程序员.SE类型的问题,但老实说,如果人们愿意将“我认为这很容易”和“我讨厌看到指针”标记为垃圾邮件,那也不错。 - jkerian
3
有人必须提出这个问题:“就像一根指向月亮的手指。不要专注于手指,否则你将错过所有天上的荣耀。”——李小龙 - mu is too short
显示剩余2条评论
29个回答

152

当我开始与他们一起工作时,我遇到的最大问题是语法。

int* ip;
int * ip;
int *ip;

所有东西都是一样的。
但是:
int* ip1, ip2;  //second one isn't a pointer!
int *ip1, *ip2;

为什么?因为声明中的“指针”部分属于变量,而不是类型。

然后对该事物进行解引用使用非常类似的符号:

*ip = 4;  //sets the value of the thing pointed to by ip to '4'
x = ip;   //hey, that's not '4'!
x = *ip;  //ahh... there's that '4'

除非您确实需要获取指针...否则使用&符号!

int *ip = &x;

恭喜你保持一致性!

然后,显然只是为了证明他们有多聪明,许多库开发者使用指向指针的指针的指针,如果他们期望这些东西的数组,那么为什么不直接传递指向它的指针呢。

void foo(****ipppArr);

为了调用这个函数,我需要该整型指针指针指针数组的地址:

foo(&(***ipppArr));

半年之后,当我需要维护这段代码时,我将花费更多的时间来尝试弄清楚所有这些意思,而不是从头开始重写。(是的,可能语法有误--我已经很久没有用C了。我有点想念它,但我有点自虐倾向)


23
你对第一个语句“*ip = 4; //将ip指向的值设置为‘4’”的评论是错误的。应该改为“//将ip指向的东西的值设置为‘4’”。 - aaaa bbbb
8
在任何语言中,把太多类型堆叠在一起都不是一个好主意。你可能会觉得在C语言中写"foo(&(&(&ipppArr)))"很奇怪,但在C++中写类似于"std::map<std::pair<int,int>,std::pair<std::vector<int>,std::tuple<int,double,std::list<int>>>>"的代码也非常复杂。这并不意味着C语言中的指针或C++中的STL容器是复杂的,只是表示你需要使用更好的类型定义来让读者能够理解你的代码。 - Patrick
21
我真的无法相信一个对 语法 的误解会成为最受欢迎的回答。那是关于指针最容易的部分。 - jason
4
即使是阅读这个答案,我也很想拿一张纸画画。在C语言中,我总是画图。 - Michael Easter
20
除了大多数人认为困难外,还有哪些客观的难度衡量标准呢? - Rupert Madden-Abbott
显示剩余9条评论

87

我怀疑人们在他们的答案中过于深入。实际上,不需要了解调度、实际CPU操作或汇编级内存管理。

当我教学时,我发现以下学生理解方面的漏洞是问题最常见的根源:

  1. 堆 vs 栈存储。仅仅是通俗易懂地理解这个概念,很多人都不明白。
  2. 栈帧。只需了解专门用于局部变量的栈的一般概念,以及它为什么是“栈”即可……像存放返回位置、异常处理程序细节和之前的寄存器等细节可以安全地留到某个人尝试构建编译器时再去了解。
  3. “内存就是内存就是内存” 强制类型转换只会改变操作符的版本或者给特定内存块分配更多的空间。当人们谈论“哪个(基本)变量X 真正是什么时,你就知道你正在处理这个问题。

我的大多数学生能够理解一个简化的内存块的绘画,通常是当前作用域下栈的局部变量部分。通常为各个位置提供明确的虚构地址会有所帮助。

总之,我想说的是,如果你想理解指针,你必须理解变量,以及在现代架构中它们实际上是什么。


15
在我看来,理解堆栈和堆与了解低级CPU细节一样不必要。堆栈和堆只是实现细节。ISO C规范中没有提到“堆栈”一词,K&R也没有。 - sigjuice
4
@sigjuice:你的反对意见与问题和答案本身的重点不符。A)K&R C是过时的语言。B)ISO C并不是唯一具有指针的语言,我的第1和第2点是针对非基于C语言的语言开发的。C)95%的体系结构(而不是语言)使用堆栈系统,它足够普遍,以至于异常都是相对于它来解释的。D)问题的重点是“为什么人们不理解指针”,而不是“我该如何解释ISO C”。 - jkerian
9
@John Marchetti:更何况问题是“人们对指针的问题的根本问题是什么”,我认为询问与指针相关的问题的人们不会对“你其实不需要知道”作为答案感到满意。显然他们不同意 :) - jkerian
4
可能已经过时了,但是K&R(即The C Programming Language一书的作者)所解释指针的三四页内容并不需要具备实现细节。了解实现细节有多种好处,但在我看来,它不应成为理解语言的关键结构的先决条件。 - sigjuice
3
通常为各个地点提供明确的虚构地址是有帮助的。+1 - fredoverflow
显示剩余6条评论

52

正确理解指针需要了解底层计算机的架构。

如今很多程序员并不知道他们的机器是如何工作的,就像大多数会开车的人对发动机一无所知一样。


18
@dmckee:那我错了吗?有多少Java程序员能够处理segfault呢? - Robert Harvey
5
"segfaults与手动挡有关系吗?" — 一位Java程序员 - Tom Anderson
6
@Robert:这是真心的赞美之意。讨论这个话题很难避免伤害到人们的感情。我担心我的评论引发了你原本设法避免的冲突。我的错。 - dmckee --- ex-moderator kitten
32
我不同意;你不需要理解底层架构才能得到指针(它们本质上是一种抽象)。 - jason
11
在C语言中,指针本质上是一个内存地址。如果不了解机器的架构,安全地使用指针是不可能的。请参阅http://en.wikipedia.org/wiki/Pointer_(computing)和http://boredzo.org/pointers/#definition。 - Robert Harvey
显示剩余16条评论

44

当处理指针时,那些感到困惑的人通常分为两类。我曾经(还是现在?)都属于这两类。

array[]阵营

这是一群人,他们根本不知道如何从指针符号转换为数组符号(或者甚至不知道它们之间有关系)。以下是四种访问数组元素的方式:

  1. 使用数组名称进行数组符号(索引)表示法
  2. 使用指针名称进行数组符号(索引)表示法
  3. 使用指针名称和指针符号(*)进行指针表示法
  4. 使用数组名称和指针符号(*)进行指针表示法

 

int vals[5] = {10, 20, 30, 40, 50};
int *ptr;
ptr = vals;

array       element            pointer
notation    number     vals    notation

vals[0]     0          10      *(ptr + 0)
ptr[0]                         *(vals + 0)

vals[1]     1          20      *(ptr + 1)
ptr[1]                         *(vals + 1)

vals[2]     2          30      *(ptr + 2)
ptr[2]                         *(vals + 2)

vals[3]     3          40      *(ptr + 3)
ptr[3]                         *(vals + 3)

vals[4]     4          50      *(ptr + 4)
ptr[4]                         *(vals + 4)

这里的想法是,通过指针访问数组似乎非常简单和直接,但是这种方法可以做出许多非常复杂和巧妙的事情。其中一些甚至会让有经验的C/C++程序员困惑,更不用说缺乏经验的新手。

引用指针指向指针人群

这篇文章是一篇很棒的介绍它们之间差异的文章,我将引用并借用其中的一些代码 :)

举个小例子,如果你遇到下面这样的代码,很难理解作者想要做什么:

//function prototype
void func(int*& rpInt); // I mean, seriously, int*& ??

int main()
{
  int nvar=2;
  int* pvar=&nvar;
  func(pvar);
  ....
  return 0;
}

或者,稍微轻微一些,像这样:

//function prototype
void func(int** ppInt);

int main()
{
  int nvar=2;
  int* pvar=&nvar;
  func(&pvar);
  ....
  return 0;
}

最后,我们用所有这些废话解决了什么问题呢?什么都没有。

现在我们已经看到了指向指针和引用的指针的语法。它们之间是否有任何优势?恐怕没有。对于一些程序员来说,使用其中之一只是个人喜好。一些使用引用指针的人说语法更加“简洁”,而一些使用指向指针的人则说,指向指针的语法使那些阅读你正在做什么的人更清楚。

指针的复杂性和似乎与引用的互换性(这常常是指针的另一个警告和新手错误),使理解指针变得困难。出于完整性的考虑,也很重要的一点是,指向引用的指针在C和C++中是非法的,原因涉及到-语义。

正如前面的答案所提到的,很多时候你会遇到那些自认为很聪明地使用******awesome_var->lol_im_so_clever()的高级程序员,而我们中的大多数人可能有时也会写出这样的罪行,但这不是好代码,肯定也不能维护。

好吧,这个答案比我想象的要长...


6
我认为你在这里回答了一个C语言问题的C++答案...至少是第二部分。 - detly
1
什么?当传递数组时,我只看到指向指针的指针 - 你的第二个例子并不适用于大多数体面的C代码。此外,你把C拖入了C++的混乱中 - 在C中不存在引用。 - new123456
在许多合法的情况下,您必须处理指向指针的指针。例如,在处理指向返回指针的函数的函数指针时。另一个例子是:一个可以容纳其他结构体变量数量的结构体。还有很多其他情况... - David Titarenco
3
在C语言中,指向引用的指针是非法的,更准确地说是“不存在”。 - Kos
有人能详细解释一下 void func(int*& rpInt); 这部分的含义或提供任何参考资料吗?我真的不明白。 - ajaysinghnegi
显示剩余2条评论

30

个人认为,C语言中大多数概念(特别是指针)的教学质量很差,这主要归咎于参考材料和教师水平的问题。我一直在威胁要写自己的C语言书籍,名为《世界上最不需要另一本C语言编程书籍》,但是我没有时间和耐心去做。因此,我会在这里闲逛,并向人们提供标准库的随机引用。

同时,C语言最初的设计假定程序员对计算机体系结构有相当详细的了解,因为在日常工作中无法避免(由于存储器非常紧张,处理器速度非常缓慢,所以必须了解所编写的代码对性能的影响)。


3
好的,“是的。'int foo = 5; int *pfoo = &foo; 看看这有多有用?好的,继续往下...’ 直到我自己编写双向链表库之前,我并没有真正使用过指针。” - John Lopez
2
我曾经辅导过CS100的学生,他们中的许多问题都是通过以易懂的方式讲解指针而得到解决的。 - benzado
1
+1 是指历史背景。由于我是在那个时候之后开始的,所以我从未遇到过这种情况。 - Lumi

27

这里有一篇非常好的文章支持指针在 Joel Spolsky 的网站上很难使用 - Java 学校的危险

[免责声明 - 我并非 Java 反对者。]


2
@Jason - 这是正确的,但并不否定这个论点。 - Steve Townsend
4
Spolsky并不是在说Java学校是人们觉得指针难的原因,他是说这些学校造就了计算机科学学位持有者中不懂指针的人。 - benzado
1
@benzado - 说得好 - 如果我的简短帖子写成“支持指针难度的一篇好文章”,那它会更好。这篇文章暗示了一个观点,即“拥有来自‘好学校’的计算机科学学位”不再像过去那样是开发者成功的好预测因素,而“理解指针”(和递归)仍然是。 - Steve Townsend
1
@Steve Townsend:我认为您没有理解Spolsky先生的观点。 - jason
2
@Steve Townsend:Spolsky先生认为,Java学校正在培养一代不懂指针和递归的程序员,而不是因为Java学校普及导致指针难以理解。正如您所说,“有一篇很好的文章解释了为什么这很难”,并且链接到了该文章,看起来您持有后一种观点。如果我错了,请原谅我。 - jason
显示剩余6条评论

25

如果你没有相关知识作为基础,大多数事情都很难理解。当我教授计算机科学时,当我让学生们开始编程一个非常简单的“机器”时,它变得更容易了。这个机器是一台模拟的十进制计算机,具有十进制操作码,其存储器由十进制寄存器和十进制地址组成。他们会输入非常短的程序,例如添加一系列数字以获得总和。然后他们会逐步执行程序并观察发生的情况。他们可以按住“回车”键观看其快速运行。

我相信几乎每个SO上的人都想知道为什么这么基础的东西有用。我们忘记了不懂编程时的感受。玩这样一个玩具计算机可以建立编程所必需的概念,例如计算是一个逐步的过程,使用少量基本原语来构建程序,并且将内存变量的概念视为存储数字的位置,其中变量的地址或名称与其包含的数字不同。输入程序的时间和运行程序的时间是有区别的。我认为学习编程就像穿越一系列“减速带”,例如非常简单的程序,然后是循环和子例程,然后是数组,然后是顺序I/O,然后是指针和数据结构。通过参考计算机在底层实际执行的内容,所有这些都更容易学习。

最后,在学习C语言时,指针很难理解,尽管K&R做了非常好的解释。我学习它们的方式是知道如何阅读它们——从右到左。就像当我看到int *p时,在我的脑海中会说“p指向一个int”。C语言被发明出来是作为汇编语言的一步进化,这也是我喜欢它的原因——它接近于“基础”。指针就像其他任何东西一样,如果你没有相关知识作为基础,就很难理解。


1
学习这个的好方法是编程8位微控制器。它们很容易理解。以Atmel AVR控制器为例,它们甚至被gcc支持。 - Xenu
对我来说,它是在时光的迷雾中在MIT定制的8位计算机(“Maybe”)。 - QuantumMechanic
1
@Quantum:CARDIAC-不错,我之前没听说过。"Maybe"-让我猜猜,Sussman(等人)让人们阅读Mead-Conway的书并制作自己的LSI芯片时,是这个意思吗?那是在我在那里工作之后的一段时间。 - Mike Dunlavey
不完全正确。这是一台8位机器,使用离散TTL作为6.004的一部分构建,使用了Steve Ward的《计算结构》书籍。我喜欢这门课程。在一个学期里,你从讨论如何用几个晶体管构建“AND”门,到讨论操作系统中的虚拟内存和分页。你(在指导下)学习了纳米代码和微代码,通过改变微代码,你可以使相同的硬件从基于堆栈的指令集变成更典型的基于寄存器的指令集。太酷了。 - QuantumMechanic
@Quantum:我好羡慕啊 :) 我也很想上那门课,但我只是个愚蠢的机械工程师。(四杆连杆,任何人都知道?沸腾传热?路面过渡曲线?旋转活塞发动机呢?) - Mike Dunlavey
显示剩余3条评论

18

在阅读K&R中的描述之前,我并不理解指针。在那之前,指针对我来说是没有意义的。我看了很多人写的东西,其中说“不要学指针,它们很令人困惑,会让你头痛并导致动脉瘤”,因此我一直回避学习,创造了这种不必要的难以理解的氛围。

否则,我大多数的想法是,你为什么要定义一种变量,必须跨过重重障碍才能得到它的值,如果你想把东西赋给它,你必须做奇怪的事情才能将值放入其中。我认为,变量的整个目的是用来存储一个值,所以我想,为什么有人想要将其复杂化呢?“所以使用指针,您必须使用*运算符来获取其值???这是什么样的变量?” 我想,毫无意义,双关语未打算。

它被复杂化的原因是因为我不理解指针是指向某些东西的地址。如果您解释指针是一个地址,即它是包含指向其他东西的地址的东西,并且可以操纵该地址以进行有用的操作,我认为这可能会消除困惑。

一个需要使用指针访问/修改PC端口,使用指针算术来寻址不同的内存位置,并查看更复杂的C代码来修改其参数的类使我放弃了指针毫无意义的想法。


4
如果你的工作资源(包括RAM、ROM、CPU)有限,例如在嵌入式应用中,指针会更加合理。 - Nick T
+1 对Nick的评论表示赞同 - 特别是传递结构体。 - new123456

12

这里有一个指针/数组的例子让我感到困惑。假设你有两个数组:

uint8_t source[16] = { /* some initialization values here */ };
uint8_t destination[16];

你的目标是使用memcpy()从源destination复制uint8_t内容。猜一下以下哪个可以实现这个目标:

memcpy(destination, source, sizeof(source));
memcpy(&destination, source, sizeof(source));
memcpy(&destination[0], source, sizeof(source));
memcpy(destination, &source, sizeof(source));
memcpy(&destination, &source, sizeof(source));
memcpy(&destination[0], &source, sizeof(source));
memcpy(destination, &source[0], sizeof(source));
memcpy(&destination, &source[0], sizeof(source));
memcpy(&destination[0], &source[0], sizeof(source));
答案(剧透!)是它们全部都一样。 "destination"、"&destination" 和 "&destination[0]" 都是相同的值。"&destination" 是与其他两个不同的类型,但它仍然是相同的值。对于"source"的排列也是如此。
顺便说一下,我个人更喜欢第一个版本。

我也更喜欢第一个版本(标点符号较少)。 - sigjuice
我也是,但是你真的需要小心sizeof(source),因为如果source是一个指针,那么它的大小将不会是你想要的。我有时候(但并不总是)写成sizeof(source[0]) * number_of_elements_of_source,只是为了远离这个错误。 - Mike Dunlavey
destination、&destination 和 &destination [0] 完全不同 - 但是当它们在 memcpy 中使用时,每个都会通过不同的机制转换为相同的 void*。然而,当作为 sizeof 的参数使用时,您将获得两个不同的结果,并且可能会有三个不同的结果。 - gnasher729
我认为需要取地址运算符吗? - MarcusJ

7

我应该先说一下C和C++是我学习的第一种编程语言。我开始学习C语言,然后在学校学了很多C++,最后又回到C语言中成为熟练者。

在学习C语言时,让我困惑的第一件事就是指针:

char ch;
char str[100];
scanf("%c %s", &ch, str);

这种困惑主要源于在我了解指针之前,已经被引导使用变量的引用作为输出参数。我记得我跳过了《C语言入门经典》中的前几个例子,因为它们太简单,结果从未能让我写的第一个程序运行起来(很可能是因为这个原因)。
令人困惑的是,&ch实际上意味着什么以及为什么str不需要它。
熟悉这一点后,我接下来记得困惑的是动态分配。我意识到,如果没有某种类型的动态分配,拥有数据的指针并没有什么用处,因此我写了类似以下的东西:
char * x = NULL;
if (y) {
     char z[100];
     x = z;
}

我曾尝试动态分配内存空间,但并没有成功。我不确定它是否会起作用,但我不知道还有其他方法。

后来我了解到mallocnew,但它们对我来说就像是魔法般的内存生成器。我对它们的工作原理一无所知。

后来我再次学习递归(之前我自学过,但现在在上课),我问底层是如何工作的——单独的变量存储在哪里。我的教授说“在堆栈上”,许多事情对我来说变得清晰了。我以前听过这个术语,并实现过软件堆栈。我以前也听别人长时间提到“堆栈”,但已经忘记了。

大约在这个时候,我也意识到在C中使用多维数组可能会非常混乱。我知道它们的工作原理,但很容易陷入困境,因此我决定尽可能绕过它们。我认为问题主要是语法问题(特别是传递给函数或从函数返回)。

由于接下来一两年我在学校里写C++,因此我有了使用指针进行数据结构的经验。在这里,我遇到了新的麻烦——混淆指针。我会有多个级别的指针(例如node ***ptr;)使我困扰。我会错误地解引用指针多次,最终通过试错来确定需要多少个*

在某些时候,我了解了程序的堆是如何工作的(有点,但足够好,不再让我夜不能寐)。我记得读到,在某个系统上,如果你在malloc返回指针之前几个字节查看,你可以看到实际分配了多少数据。我意识到malloc中的代码可以向操作系统请求更多内存,而这些内存不是我的可执行文件的一部分。了解malloc的工作原理非常有用。

不久之后,我上了一门汇编课程,它没有像大多数程序员想象的那样教我很多关于指针的知识。它确实让我更加思考我的代码可能被转换成什么汇编语言。我一直试图编写高效的代码,但现在我对此有了更好的了解。

我还上了几门课,必须写一些lisp。在写lisp时,我不像在C中那样关注效率。如果编译,我对这些代码可能被转换成什么一无所知,但我确实知道使用大量本地命名符号(变量)会使事情变得更容易。在某个时候,我用一点lisp写了一些AVL树旋转代码,在C++中由于指针问题很难编写。我意识到我对我认为过多的本地变量的厌恶阻碍了我在C++中编写该程序和其他几个程序的能力。

我还上过编译器课程。在这门课上,我提前学习了高级内容,并了解了静态单赋值(SSA)和死变量,虽然这些并不重要,但它教会了我合理的编译器可以处理不再使用的变量。我已经知道更多的变量(包括指针)以及正确类型和良好命名对于我理顺思路有所帮助,但现在我也知道出于效率原因避免使用它们,甚至比我那些不太关注微观优化的教授说的更加愚蠢。
因此,对我来说,了解程序的内存布局很有帮助。考虑代码的符号含义和硬件实现都有所帮助。使用具有正确类型的本地指针也很有帮助。我经常编写类似以下的代码:
int foo(struct frog * f, int x, int y) {
    struct leg * g = f->left_leg;
    struct toe * t = g->big_toe;
    process(t);

这样,如果我弄错了指针类型,编译器的错误信息会非常明显地表明问题所在。如果我写成:

int foo(struct frog * f, int x, int y) {
    process(f->left_leg->big_toe);

如果在其中任何指针类型上出现错误,编译器的错误将更加难以解决。我会因沮丧而倾向于试错更改,并可能使情况变得更糟。

1
+1。非常透彻和有见地。我已经忘记了scanf,但是现在你提到它,我记得我曾经有同样的困惑。 - Joe White

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接