在多大程度上认为C++指针是内存地址是可以接受的?

41

在学习C++时,或者至少是我通过C++ Primer学习时,指针被称为它们所指向元素的“内存地址”。我想知道这个说法的程度如何。

例如,如果两个元素 *p1*p2 满足条件 p2 = p1 + 1p1 = p2 + 1, 那么它们当且仅当在物理内存中相邻吗?


13
在程序中提到的“内存”,实质上是虚拟内存,其地址会被操作系统的MMU转换为实际的物理内存地址。 - Haris
8
C语言和C++语言都没有“物理性”的概念。它们只有指向一些抽象可寻址的实体——内存的方式。这种内存在实际中的实现取决于平台和执行环境。 - Kerrek SB
8
没有叫做C/C++的语言。请在这个问题中最多选择一种语言,可以是C或C++。 - fuz
2
指针p1p2的条件是如何与指针实际上是内存地址相矛盾的? - Amit Upadhyay
13
C++不是C的严格超集。 - MikeMB
显示剩余20条评论
12个回答

32

你应该将指针视为虚拟内存的地址:现代消费者操作系统和运行时环境在物理内存与指针值之间至少放置了一层抽象。

至于你的最后陈述,即使在虚拟内存地址空间中,你也不能作出这样的假设。指针算术仅适用于连续内存块(如数组)内部。虽然在 C 和 C++ 中允许将指针分配给数组(或标量)的下一个位置,但对于取消引用此类指针的行为是未定义的。在 C 和 C++ 的上下文中推测物理内存中的相邻性是毫无意义的。


2
指针算术运算不应该更普遍地正确地处理连续内存,而不仅仅是数组吗? - terence hill
1
随机分配相邻的链表节点(开个玩笑)。。顺便说一句,这比最高得票的答案更好。+1 - Haris
@terencehill: 是的,我也在考虑这个问题。我认为没有必要通过仅限于数组来限制算术运算,以使答案变得复杂。我已经重新措辞了一下。谢谢。 - Bathsheba
p1 可能在 MMU 块的末尾,而 p2 是另一块的开头,因此在虚拟空间中它们是相邻的(数组在这里不相关),但在物理空间中它们可以互不接近。因此海报对它们在物理空间中相邻的说法只是增加了他们的困惑。 - old_timer
1
现代消费者操作系统至少会放置一层抽象。几乎所有嵌入式操作系统都有使用平坦内存空间的选项。 - Sam

17

完全不是这样。

C++ 是对计算机将执行的代码的一种抽象。我们在一些地方看到这种抽象泄漏(例如需要存储的类成员引用),但总的来说,如果你只编写针对抽象的代码而不涉及其他内容,那么你会更好。

指针就是指针。它们指向某些东西。它们在实际中是否被实现为内存地址?也许。它们也可能被优化掉,或者(例如指向成员的指针)它们可能比简单的数字地址更加复杂。

当你开始将指针视为映射到内存地址的整数时,你就开始忘记例如持有指向不存在对象的指针是未定义的(你不能随便增加和减少指向任何内存地址的指针)。


“你不能随意增加或减少指针指向任何内存地址” - 当然,你可以这样做,但这不是一个好主意。 - fluffy
4
@fluffy:不仅仅是“不是一个非常好的想法”,它具有未定义的行为。所以你“可以”以同样的方式读取通过悬空指针破坏的数据,“可以”使用const_cast修改一个const对象,还可以用Stanley刀杀死一屋子人。标准不允许一个格式良好的程序做这些事情。这不仅仅是一种风格问题。 - Lightness Races in Orbit
嗯,是的,那就是我的观点。这是可能的 - 你可以。只是大多数情况下它是未定义行为,尽管并非总是(例如嵌入式系统上的内存映射I/O之类的,如Arduino或老式实模式DOS等)。 - fluffy
@fluffy:这个问题是关于C++中什么是可接受的,而不是关于计算机上物理可能性的问题。你可以编写程序来操纵内存并利用特定于平台的技巧,但这不再被视为编写C++。 - Lightness Races in Orbit

11
许多答案已经提到,指针不应被视为内存地址。请查看这些回答和这里以了解它们的理解。针对您最后的陈述:

*p1和*p2具有属性p2 = p1 + 1或p1 = p2 + 1,当且仅当它们在物理内存中相邻

只有当p1p2具有相同类型或指向相同大小的类型时,才是正确的。


3
不完全正确。p1p2必须指向同一数组中相邻的元素。顺便提一下,这不是*p1*p2的属性。 - chqrlie
3
如果 p1p2 指向不同的、不属于同一数组的相同类型对象,则即使 (uintptr_t)p1 == (uintptr_t)(p2 + 1),也不能保证 p1 == p2 + 1 - chqrlie
1
现在我改变了想法。指针通常不是地址,它可能保持无地址 ID。 - haccks
3
“将指针视为内存地址”这种想法确实可以被接受。不,这是不负责任的。 - Lightness Races in Orbit
1
哈哈,你有权发表自己的意见。我只是解释了我的反对票,因为我持有不同的观点。看起来把你那么多赞的回答简单地颠倒过来有点奇怪?哦,算了。 - Lightness Races in Orbit
显示剩余8条评论

5
将指针视为内存地址是完全正确的。在我使用的所有编译器中,无论是由许多不同编译器生产厂商制造的多种不同处理器架构,指针都是内存地址。然而,编译器通过一些有趣的方式来帮助您应对这样一个事实:所有现代主流处理器中的普通内存地址都是字节地址,您指向的对象可能不只是一个字节。因此,如果我们有 T* ptr;,则 ptr++ 会执行 ((char*)ptr) + sizeof(T); 或者 ptr + n((char*)ptr) + n*sizeof(T)。这也意味着您的 p1 == p2 + 1 需要 p1p2 是相同类型的 T,因为 +1 实际上是 +sizeof(T)*1
以上“指针是内存地址”的说法只有一个例外,那就是成员函数指针。它们是“特殊的”,暂时请忽略它们是如何实现的,只需知道它们不仅仅是内存地址即可。

3
在我所使用的所有编译器中,它们都是这样的。你正在放弃抽象概念,因为所有已知的实现都是相同的。与其说“引用是指针”,不如干脆不用抽象概念。 - Lightness Races in Orbit
1
@LightnessRacesinOrbit:你似乎忘了许多程序员需要从具体事物的角度思考问题。对大多数人来说,理解指针概念已经很难了,更何况放弃那些易于操作和玩耍的简单实现示例。对于大多数情况而言,C ++ 引用被实现为带有隐式解引用的指针。除了语法更简单、方便进行运算符重载外,引用的好处在于它们不会为空。 - chqrlie
3
在我的看法中,程序员必须学会抽象思考,这样才能编写出健壮、可靠和可重复使用的代码。一直考虑实现细节充其量只是一个起点,而非一个理想的终点。@chqrlie似乎忘记了许多程序员需要考虑具体事物的情况。 - Lightness Races in Orbit
1
@LightnessRacesinOrbit:我并没有说过那个,我只是指出许多初学者需要具体的例子来理解一些概念... C语言不是最适合用抽象概念学习编程的语言。Lisp、OCaml、Prolog、F#更加适合。了解实现细节的程序员在学习C语言概念时有很强的优势,这些概念对于许多初学者来说仍然很简单但却难以掌握。 - chqrlie
我能问一下这个抽象概念给我们带来了什么好处吗?是的,它使我们有可能构建一个不这样做的编译器。但实际上,在实践中,编译器确实将指针实现为地址。不仅在C和C++中如此,而且在Pascal、Modula-{2,3}和我所知道的任何其他具有指针概念的语言中也是如此。对于我来说,作为一个试图理解编译器实际操作的人,而不是将其视为保护我免受硬件恶意侵袭的不透明屏障的人,正如上面提到的,还有很多其他更好的屏障语言。 - Mats Petersson

5
操作系统向您的程序提供物理机器的抽象(即您的程序在虚拟机中运行)。因此,您的程序无法访问计算机的任何物理资源,包括CPU时间、内存等;它只需要向操作系统请求这些资源。
在内存方面,您的程序工作在由操作系统定义的虚拟地址空间中。该地址空间具有多个区域,如堆栈、堆、代码等。指针的值表示该虚拟地址空间中的地址。实际上,两个指向连续地址的指针将指向该地址空间中的连续位置。
然而,该地址空间被操作系统分割为页面和段,根据需要从内存中交换出来,因此您的指针可能指向连续的物理内存位置,也可能不是,而且在运行时不可能确定这一点。这还取决于操作系统用于分页和分段的策略。
总之,指针是内存地址。但是,它们是虚拟内存空间中的地址,操作系统决定如何将其映射到物理内存空间。
就您的程序而言,这不是问题。抽象的一个原因是使程序相信它们是机器的唯一用户。想象一下,如果编写程序时需要考虑其他进程分配的内存会遇到什么样的问题——您甚至不知道哪些进程将与您的程序同时运行。此外,这是一种实施安全的好技术:由于它们在两个不同(虚拟)内存空间中运行,因此您的进程不能(至少不应该能够)恶意地访问另一个进程的内存空间。

4

与其他变量一样,指针存储数据,可以是内存地址,其中存储其他数据。

因此,指针是一个具有地址并可能保存地址的变量。

请注意,指针不一定总是保存地址。它可能保存非地址ID/句柄等。因此,将指针称为地址并不明智。


关于您的第二个问题:

指针算术适用于连续的内存块。如果p2 = p1 + 1并且两个指针具有相同的类型,则p1p2指向一个连续的内存块。因此,地址p1p2保持相邻。


2
@user5648283:不,这不是真的,指针是其值为地址的变量。 - chqrlie
是的,但您的措辞与OP的一样含糊不清:如果 p2 == p1 + 1,那么 p1p2 指向 内存中相邻的对象。指针 p1p2 本身可以在内存中的任何位置,甚至可能根本不在内存中。 - chqrlie
1
“变量是命名的内存位置,用于存储数据。”这是极度简化的说法,忽略了整个抽象及其目的。 - Lightness Races in Orbit
@LightnessRacesinOrbit;在这种情况下不需要过多扩展。 - haccks
1
@haccks:没错。答案是“不”。你用“变量是存储数据的内存位置”的理由回答了“是”,这是错误的,因为它是一个过于简单化的概括,忽略了整个抽象及其目的。 - Lightness Races in Orbit
显示剩余4条评论

4
我认为这个答案的想法是正确的,但术语使用不当。C指针提供的是与抽象相反的东西。
抽象提供了一个相对容易理解和推理的思维模型,即使硬件更加复杂、难以理解或更难推理。
C指针则恰恰相反。它们考虑了硬件可能存在的困难,即使实际的硬件通常更简单、更易于推理。它们将你的推理限制在允许的最复杂硬件部件的联合上,而不管手头的硬件有多简单。
C++指针增加了一个C不包括的功能。它允许比较同类型的所有指针的顺序,即使它们不在同一个数组中。这允许更多的思维模型,即使它不能完全匹配硬件。

“抽象的确切相反面”是一个有趣的想法,但我无法理解它。当你说“限制推理”的时候,你只描述了抽象的一个关键特征。指针是否比某些可能的硬件范例更简单并不重要。重要的是硬件所做的事情始终是无关紧要的——这就是抽象。我同意,当抽象模型不比一种体系结构更简单时,它可能被视为违反直觉,但被抽象掉的不是复杂性,而是所有体系结构的细节 - BartoszKP
@BartoszKP:抱歉,我必须不同意。要作为抽象,它必须提供比硬件更抽象的模型。在这种情况下,所提供的模型比几乎所有当前硬件提供的模型都不够抽象。仅仅因为与硬件不同并不意味着它是一种抽象。 - Jerry Coffin
我并没有说仅仅“不同”就足够了 - 我的意思是,只要使一些细节变得无关紧要,就可以达到抽象的程度。Ignotum per ignotum ;p - BartoszKP
@BartoszKP:但这里确实没有发生这种情况。它不是使一些细节变得无关紧要,而是迫使人们考虑所有架构的所有细节的联合,即任何人都知道的所有架构(即基本上所有东西),而不管它们中的大多数是否适用于任何一个案例,几乎没有一个适用于大多数情况。 - Jerry Coffin
这些是不同的细节,看起来与你所说的一样 :) 这可能看起来像一个微妙的文字游戏,但我认为在这里它是有意义的:抽象模型的细节,也许会使它更加复杂,但它们允许你忽略相应的细节是否存在于底层架构中以及以什么配置存在。你完全不依赖它们。 - BartoszKP
一个好的抽象将提供一个比被抽象的模型更容易理解的模型。C语言提供的是一个相反的好的抽象,但我认为它仍然是一个抽象。 - supercat

1
一些回答没有提到一类特定的指针 - 成员指针。这些绝对不是内存地址。

成员指针。成员指针并不因为是成员而特殊。 - SergeyA
我认为这是指成员函数指针(我在我的答案中有涉及到)。 - Mats Petersson
@MatsPetersson,指向非函数成员的指针也是特殊的。 - SergeyA
从技术上讲,C++标准涉及到指向成员的指针运算符和指向T的成员的指针,这是你所提到的特定事物,它可以是成员也可以不是成员。这有点令人困惑,因为一个类中还可以有指针成员,以及指向特定实例成员的指针,这两者都是实际的指针(它们保存地址)。 - chqrlie

1

除非编译器优化掉指针,否则它们是存储内存地址的整数。它们的长度取决于代码正在编译的计算机,但通常可以将它们视为int。

实际上,您可以通过使用printf()打印存储在它们上面的实际数字来检查它们。

但请注意,type *指针的增量/减量操作是通过sizeof(type)完成的。使用此代码自行查看(已在Repl.it上进行测试):

#include <stdio.h>

int main() {
    volatile int i1 = 1337;
    volatile int i2 = 31337;
    volatile double d1 = 1.337;
    volatile double d2 = 31.337;
    volatile int* pi = &i1;
    volatile double* pd = &d1;
    printf("ints: %d, %d\ndoubles: %f, %f\n", i1, i2, d1, d2);
    printf("0x%X = %d\n", pi, *pi);
    printf("0x%X = %d\n", pi-1, *(pi-1));
    printf("Difference: %d\n",(long)(pi)-(long)(pi-1));
    printf("0x%X = %f\n", pd, *pd);
    printf("0x%X = %f\n", pd-1, *(pd-1));
    printf("Difference: %d\n",(long)(pd)-(long)(pd-1));
}

所有变量和指针都声明为volatile,以便编译器不会将它们优化掉。还要注意我使用了递减,因为这些变量被放置在函数堆栈中。
输出结果如下:
ints: 1337, 31337
doubles: 1.337000, 31.337000
0xFAFF465C = 1337
0xFAFF4658 = 31337
Difference: 4
0xFAFF4650 = 1.337000
0xFAFF4648 = 31.337000
Difference: 8

请注意,这段代码可能无法在所有编译器上运行,特别是如果它们不按相同顺序存储变量。然而,重要的是指针值实际上可以被读取和打印,并且减量可能会根据指针引用的变量的大小进行减少。
还要注意,符号&*实际上是用于引用(“获取此变量的内存地址”)和取消引用(“获取此内存地址的内容”)的操作符。
这也可以用于一些很酷的技巧,比如通过将float*强制转换为int*来获取浮点数的IEEE 754二进制值:
#include <iostream>

int main() {
    float f = -9.5;
    int* p = (int*)&f;

    std::cout << "Binary contents:\n";
    int i = sizeof(f)*8;
    while(i) {
        i--;
        std::cout << ((*p & (1 << i))?1:0);
   } 
}

结果为:

Binary contents:
11000001000110000000000000000000 

本例来源于https://pt.wikipedia.org/wiki/IEEE_754。 在任何转换器上检查。


特别是示例展示了未定义的行为。 - MikeMB
这只是一个例子,就像“嘿,我可以看到内存位置的内容,就好像它是我想要的任何类型一样”。 - Ronan Paixão

0

你提供的特定示例:

例如,如果两个元素*p1和*p2具有属性p2 = p1 + 1或p1 = p2 + 1,那么它们是否仅在物理内存中相邻?

这将在没有平面地址空间的平台上失败,例如PIC。要访问PIC上的物理内存,您需要一个地址和一个银行号,但后者可以从外部信息(如特定源文件)派生。因此,在来自不同银行的指针上进行算术运算会产生意想不到的结果。


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