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

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


当前回答

在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#,仍然相信他们有不错的迭代器。

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

间接。

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

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

“Le Pointer很便宜。”

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

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

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

int puts(const char *s);

目前,忽略const。传递给puts()的形参是一个指针,即指针的值(因为C中的所有形参都是按值传递的),指针的值是它所指向的地址,或者简单地说,是一个地址。因此,当我们写put (strA);正如我们所看到的,我们正在传递strA[0]的地址。

当我读到这些文字的那一刻,乌云散开,一束阳光带着指教般的理解将我笼罩。

即使你是一个VB . net或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)

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


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

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

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

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

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

邮政信箱号码。

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

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