一个C++引用在内存上是什么样子?

56

给定:

int i = 42;
int j = 43;
int k = 44;

通过查看变量地址,我们知道每个变量占用4个字节(在大多数平台上)。

然而,考虑到:

int i = 42;
int& j = i;
int k = 44;

我们将看到变量i确实占用了4个字节,但是j没有占用任何空间,而k再次在堆栈上占用了4个字节。

这里发生了什么?看起来j在运行时根本不存在。那么作为函数参数收到的引用呢?它必须在堆栈上占用一些空间......

还有,既然我们谈到了这个,为什么我不能定义一个数组或引用?

int&[] arr = new int&[SIZE]; // compiler error! array of references is illegal

8
你是如何知道 j 取值为 "none" 的?是使用 sizeof() 还是调试器检查?(如果是后者,可能只是优化的结果。) - Jim Buck
1
@yuval 回答你的问题- 为什么我不能定义数组或引用。如果我引用cpp primer第2.3.1章节的话:引用不是一个对象,而是已经存在的对象的另一个名称。我们可以声明对象的数组,但不能声明引用的数组。 - Euler
9个回答

60

无论在何处遇到j引用,它都将被替换为i的地址。因此,基本上引用内容的地址在编译时就已经解析了,不需要在运行时像指针一样对其进行解引用。

仅为澄清我所说的i的地址是什么意思:

void function(int& x)
{
    x = 10;
}

int main()
{
    int i = 5;
    int& j = i;

    function(j);
}
在上述代码中,j 不应该在 主堆栈 上占用空间,但是 函数 的引用 x 会在其堆栈上占用空间。这意味着当使用 j 作为参数调用 函数 时,将在 函数 的堆栈上推送 i 的地址。编译器不能也不应该为 j主堆栈 上保留空间。
对于数组部分,标准规定如下:

C++ Standard 8.3.2/4:

不能有对引用的引用、引用的数组和引用的指针。

为什么数组引用是非法的?

5
因为编译器“知道”i的地址,所以它不会占用任何堆栈空间,也不需要存储它。 - Peter Ruderman
6
你可以将引用变量视为另一个变量的同义词。它不需要更多的存储空间,因为它不是一个真正的“东西”,只是一个现有东西的新名称。 另一方面,引用参数本质上是一个指针值,并且需要指针的内存空间。 - Darryl
5
重点是,它不仅仅是“i的地址”,而是i的另一个名称。在某些情况下,这个“另一个名称”必须被实现为指针,通过存储i的地址来完成,这需要占用一些字节,但这只是一个实现细节,不是引用概念的一部分。 - jalf
“引用内容地址在编译时解析”这句话意味着编译器在编译时已经知道引用的地址。但是,我认为编译器在编译源文件时并不知道局部变量和动态分配变量的地址,因为它们是在运行时才分配的。那么引用是如何工作的呢? - Julien Zakaib
这就是将C++程序作为实际计算机代码的一对一翻译来阅读时所遇到的问题。它并不是实际的计算机代码,而是一个程序的描述。@TonyParker,这只是一个没有意义的问题。 - Lightness Races in Orbit
显示剩余5条评论

52

C++引用在内存中的表现形式是什么?

并没有具体的表现形式。C++标准只规定了引用应该如何表现其行为,而不是如何实现。

一般情况下,编译器通常将引用实现为指针。但它们通常具有有关引用指向的对象的更多信息,并将其用于优化。

请记住,引用的唯一要求是作为所引用对象的别名。因此,如果编译器遇到以下代码:

int i = 42;
int& j = i;
int k = 44;

编译器所看到的不是"创建一个指向变量 i 的指针"(尽管在某些情况下,编译器可以选择这样实现),而是"在符号表中做个记号,表示 j 现在是 i 的别名"。

编译器不需要为 j 创建一个新变量,它只需要记住,每当从现在开始引用 j 时,它应该将其交换并使用 i

至于创建引用数组,你无法这样做,因为这没有意义。

当你创建一个数组时,所有元素都会被默认构造。什么是默认构造引用?它指向什么?引用的整个含义在于,在初始化后,它们被重新初始化以引用另一个对象,此后它们就不能被重新赋值。

因此,如果可能的话,你最终会得到一个引用 无所指 的数组。而且你无法将它们更改为引用 某些东西,因为它们已经被初始化了。


7
在ISO C++中,“引用不是对象”。因此,它不需要任何内存表示,它只是一个别名。 - Pavel Minaev
值得注意的是,如果引用是类成员,则没有其他方法可以使用类似指针的存储方式,否则(即使您可以完全优化它)您的类大小将是不可预测的,这通常是不允许的(考虑填充/对齐以获得反例,尽管这些在ABI规范中是确定性的) - Lightness Races in Orbit
这要看情况。例如,如果成员引用始终初始化为同一对象的另一个成员字段,则编译器可以将其视为别名而不使用存储。 - Pavel Minaev

24

很抱歉我使用汇编语言来解释,但我认为这是最好的理解引用的方式。

#include <iostream>

using namespace std;

int main()
{
    int i = 10;
    int *ptrToI = &i;
    int &refToI = i;

    cout << "i = " << i << "\n";
    cout << "&i = " << &i << "\n";

    cout << "ptrToI = " << ptrToI << "\n";
    cout << "*ptrToI = " << *ptrToI << "\n";
    cout << "&ptrToI = " << &ptrToI << "\n";

    cout << "refToI = " << refToI << "\n";
    //cout << "*refToI = " << *refToI << "\n";
    cout << "&refToI = " << &refToI << "\n";

    return 0;
}

这段代码的输出如下所示

i = 10
&i = 0xbf9e52f8
ptrToI = 0xbf9e52f8
*ptrToI = 10
&ptrToI = 0xbf9e52f4
refToI = 10
&refToI = 0xbf9e52f8

让我们来看一下反汇编(我使用了GDB。这里的8、9和10是代码行号)

8           int i = 10;
0x08048698 <main()+18>: movl   $0xa,-0x10(%ebp)

这里$0xa是我们要分配给i的十进制10。而-0x10(%ebp)表示ebp寄存器上的内容减去16(十进制)。 -0x10(%ebp)指向栈上i的地址。

9           int *ptrToI = &i;
0x0804869f <main()+25>: lea    -0x10(%ebp),%eax
0x080486a2 <main()+28>: mov    %eax,-0x14(%ebp)

i的地址分配给ptrToIptrToI再次在堆栈上位于地址-0x14(%ebp),即ebp - 20(十进制)。

10          int &refToI = i;
0x080486a5 <main()+31>: lea    -0x10(%ebp),%eax
0x080486a8 <main()+34>: mov    %eax,-0xc(%ebp)

现在问题来了!比较第9行和第10行的反汇编代码,你会发现-0x14(%ebp)在第10行被替换为-0xc(%ebp)-0xc(%ebp)refToI的地址。它分配在堆栈上。但是你永远无法从你的代码中获取这个地址,因为你不需要知道这个地址。

所以说,引用确实占用内存。在本例中,它是堆栈内存,因为我们将其分配为局部变量。

它占用多少内存?与指针一样多。

现在让我们看看如何访问引用和指针。为简单起见,我只显示了代码片段的一部分。

16          cout << "*ptrToI = " << *ptrToI << "\n";
0x08048746 <main()+192>:        mov    -0x14(%ebp),%eax
0x08048749 <main()+195>:        mov    (%eax),%ebx
19          cout << "refToI = " << refToI << "\n";
0x080487b0 <main()+298>:        mov    -0xc(%ebp),%eax
0x080487b3 <main()+301>:        mov    (%eax),%ebx

现在比较上述两行代码,你会发现它们非常相似。 -0xc(%ebp)refToI的实际地址,但你永远无法访问它。

简单来说,如果你将引用视为普通指针,那么访问引用就像获取引用指向地址处的值。这意味着以下两行代码将给出相同的结果。

cout << "Value if i = " << *ptrToI << "\n";
cout << "Value if i = " << refToI << "\n";

现在来比较一下:

15          cout << "ptrToI = " << ptrToI << "\n";
0x08048713 <main()+141>:        mov    -0x14(%ebp),%ebx
21          cout << "&refToI = " << &refToI << "\n";
0x080487fb <main()+373>:        mov    -0xc(%ebp),%eax

我猜你能看出这里正在发生什么。如果你请求 &refToI

  1. 返回地址为-0xc(%ebp)的存储单元中的内容。
  2. -0xc(%ebp)refToI 所在的地址,其内容就是 i 的地址。

最后一件事。为什么这行被注释了?

// cout << "*refToI = " << *refToI << "\n";

因为*refToI是不允许的,会在编译时产生错误。


2
在这段特定的代码中,引用占用内存而不是别名有什么原因吗?能否提供编译器版本和编译选项将会很好。 - artin

12
在实践中,引用与指针等效,只不过对于允许使用引用的额外约束可以使编译器在更多情况下“优化它”(当然,这取决于编译器的智能程度、其优化设置等等)。

8

你不能定义一个引用数组,因为没有语法来初始化它们。C++不允许未初始化的引用。至于你的第一个问题,编译器没有义务为不必要的变量分配空间。没有办法让j指向另一个变量,所以在函数作用域中它实际上只是i的别名,编译器就是这样处理它的。


7

在其他地方只是简单提到的一个问题 - 如何让编译器为引用分配一些存储空间:

class HasRef
{
    int &r;

public:
    HasRef(int &n)
        : r(n) { }
};

这将阻止编译器将其视为编译时别名 (即同一存储的替代名称)。

这个答案需要更多的上下文。不清楚这段代码是如何实现拒绝编译器优化引用的效果的。例如,仅仅创建一个HasRef实例并不能达到这个目标,因为编译器可以将其优化为无操作。 - cdhowie

3

只有在需要物理表现时(例如作为聚合体的成员),引用才会实际存在。

由于上述原因,拥有引用数组是非法的。但是,您可以创建具有引用成员的结构体/类数组。

我相信有人会指出涉及所有这些内容的标准条款。


3

这不是固定的 - 编译器在实现引用时有很大的自由度。在您的第二个示例中,它将j视为i的别名,不需要其他内容。在传递ref参数时,它也可以使用堆栈偏移量,因此没有开销。但在其他情况下,它可能会使用指针。


1

关于引用是什么以及为什么和如何通过编译器优化其存储,大部分已经在其他答案中讲解过了。 然而,在一些评论中错误地陈述了对于引用变量(与函数中的引用参数相对应),引用始终只是别名,永远不需要额外的内存。 如果引用始终引用同一个变量,则这是正确的。 但是,如果引用可以引用不同的内存位置,并且编译器无法提前确定引用哪个位置,它将需要为其分配内存,就像以下示例中所示:

#include <ctime>
#include <iostream>
int i = 2;
int j = 3;
int& k = std::time(0)%2==1 ? i : j;

int main(){
    std::cout << k << std::endl;
}

如果你在godbolt上尝试这个(https://godbolt.org/z/38x1Eq83o),你会发现,例如在x86-64上的gcc将保留8个字节给k,以存储指向ij的指针,具体取决于std::time的返回值。


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