指针变量和引用变量之间的区别是什么?


当前回答

引用与指针非常相似,但它们是专门设计的,有助于优化编译器。

引用的设计使得编译器更容易跟踪哪些引用别名哪些变量。两个主要特性非常重要:没有“引用算术”,也没有重新分配引用。这些允许编译器在编译时找出哪些引用别名哪些变量。允许引用没有内存地址的变量,例如编译器选择放入寄存器的变量。如果获取局部变量的地址,编译器很难将其放入寄存器中。

例如:

void maybeModify(int& x); // may modify x in some way

void hurtTheCompilersOptimizer(short size, int array[])
{
    // This function is designed to do something particularly troublesome
    // for optimizers. It will constantly call maybeModify on array[0] while
    // adding array[1] to array[2]..array[size-1]. There's no real reason to
    // do this, other than to demonstrate the power of references.
    for (int i = 2; i < (int)size; i++) {
        maybeModify(array[0]);
        array[i] += array[1];
    }
}

优化编译器可能会意识到,我们正在访问一个[0]和一个[1]。它希望优化算法以:

void hurtTheCompilersOptimizer(short size, int array[])
{
    // Do the same thing as above, but instead of accessing array[1]
    // all the time, access it once and store the result in a register,
    // which is much faster to do arithmetic with.
    register int a0 = a[0];
    register int a1 = a[1]; // access a[1] once
    for (int i = 2; i < (int)size; i++) {
        maybeModify(a0); // Give maybeModify a reference to a register
        array[i] += a1;  // Use the saved register value over and over
    }
    a[0] = a0; // Store the modified a[0] back into the array
}

要进行这样的优化,需要证明在调用期间没有任何东西可以改变数组[1]。这很容易做到。i永远不小于2,所以array[i]永远不能引用array[1]。maybeModify()被给定a0作为引用(别名数组[0])。因为没有“引用”算法,编译器只需要证明maybeModify永远不会得到x的地址,并且它已经证明没有任何东西会改变数组[1]。

它还必须证明,当我们在a0中有一个[0]的临时寄存器副本时,将来的调用不可能读/写它。这通常很难证明,因为在许多情况下,引用显然从未存储在类实例这样的永久结构中。

现在用指针做同样的事情

void maybeModify(int* x); // May modify x in some way

void hurtTheCompilersOptimizer(short size, int array[])
{
    // Same operation, only now with pointers, making the
    // optimization trickier.
    for (int i = 2; i < (int)size; i++) {
        maybeModify(&(array[0]));
        array[i] += array[1];
    }
}

行为是相同的;直到现在,要证明maybeModify从未修改过数组[1]要困难得多,因为我们已经给了它一个指针;猫从袋子里出来了。现在它必须做更困难的证明:对maybeModify进行静态分析,以证明它从未写入&x+1。它还必须证明它从未保存过可以引用数组[0]的指针,这同样棘手。

现代编译器在静态分析方面越来越好,但帮助他们并使用引用总是很好的。

当然,除非进行这种巧妙的优化,编译器确实会在需要时将引用转换为指针。

编辑:在发布这个答案五年后,我发现了一个实际的技术差异,即引用不同于看待相同寻址概念的不同方式。引用可以以指针无法修改的方式修改临时对象的寿命。

F createF(int argument);

void extending()
{
    const F& ref = createF(5);
    std::cout << ref.getArgument() << std::endl;
};

通常,临时对象(例如通过调用createF(5)创建的对象)会在表达式末尾被销毁。然而,通过将该对象绑定到引用ref,C++将延长该临时对象的寿命,直到ref超出范围。

其他回答

直接答案

C++中的引用是什么?不是对象类型的特定类型实例。

C++中的指针是什么?某个特定的对象类型实例。

根据ISO C++对对象类型的定义:

对象类型是一种(可能是cv限定的)类型,它不是函数类型,不是引用类型,也不是cv void。

可能需要知道的是,对象类型是C++中类型宇宙的顶级类别。引用也是一个顶级类别。但指针不是。

指针和引用在复合类型的上下文中一起提到。这基本上是由于从(和扩展的)C继承的声明器语法的性质,它没有引用。(此外,自从C++11以来,有不止一种类型的引用声明器,而指针仍然是“unityped”:&+&&vs.*。)因此,在这种情况下,用类似C风格的“扩展”来起草一种特定于语言的语言是有一定道理的。(我仍然认为,声明器的语法浪费了大量的语法表达能力,使人类用户和实现都感到沮丧。因此,它们都不适合内置于新的语言设计中。不过,这是PL设计的一个完全不同的主题。)

否则,指针可以被限定为具有引用的特定类型是无关紧要的。除了语法相似性之外,它们共享的公共财产太少了,所以在大多数情况下没有必要将它们放在一起。

注意,上面的语句只提到“指针”和“引用”作为类型。关于它们的实例(如变量),有一些有趣的问题。还有太多的误解。

顶级类别的差异已经揭示了许多与指针无关的具体差异:

对象类型可以具有顶级cv限定符。引用不能。根据抽象机器语义,对象类型的变量确实占用了存储空间。引用不必占用存储空间(有关详细信息,请参阅下面的误解部分)。...

关于引用的其他一些特殊规则:

复合声明符对引用的限制更大。引用可以折叠。基于模板参数推导过程中引用折叠的&&参数特殊规则(作为“转发引用”)允许参数的“完美转发”。引用在初始化时有特殊规则。声明为引用类型的变量的生存期可以通过扩展与普通对象不同。顺便说一句,其他一些上下文(如涉及std::initializer_list的初始化)遵循引用生命周期扩展的一些类似规则。这是另一罐蠕虫。...

误解

语法糖

我知道引用是语法糖,所以代码更容易读写。

从技术上讲,这显然是错误的。引用不是C++中任何其他特性的语法糖,因为它们不能被没有任何语义差异的其他特性完全替换。

(类似地,lambda-expressions不是C++中任何其他功能的语法糖,因为它不能用捕获变量的声明顺序这样的“未指定”财产精确模拟,这可能很重要,因为这些变量的初始化顺序可能很重要。)

在严格意义上,C++只有几种语法糖。一个实例是(继承自C)内置(非重载)运算符[],它的定义与内置运算符unary*和binary+的特定组合形式具有相同的语义财产。

存储

因此,指针和引用都使用相同的内存量。

上面的说法完全错误。为了避免这种误解,请查看ISO C++规则:

来自[intro.object]/1:

……一个物体在其建造期间、在其整个生命周期和在其毁灭期间占据一个存储区域。。。

来自[dcl.ref]/4:

未指定引用是否需要存储。

请注意,这些是语义财产。

语用学

即使在语言设计的意义上,指针不足以与引用放在一起,但仍有一些争论使得在某些其他上下文中(例如,在对参数类型进行选择时)在它们之间进行选择是有争议的。

但这并不是全部。我的意思是,你需要考虑的不仅仅是指针和引用。

如果你不必坚持这种过于具体的选择,在大多数情况下,答案很简单:你没有必要使用指针,所以你不需要。指针通常很糟糕,因为它们暗示了太多你不期望的东西,而且它们依赖于太多的隐含假设,破坏了代码的可维护性和(甚至)可移植性。不必要地依赖指针绝对是一种糟糕的风格,在现代C++的意义上应该避免。重新考虑一下你的目的,你最终会发现在大多数情况下,指针是最后一种功能。

有时语言规则明确要求使用特定类型。如果您想使用这些功能,请遵守规则。复制构造函数需要特定类型的cv-引用类型作为第一个参数类型。(通常它应该是常量限定的。)移动构造函数需要特定类型的cv-&&引用类型作为第一个参数类型。(通常不应有限定符。)运算符的特定重载需要引用或非引用类型。例如:重载运算符=作为特殊成员函数需要类似于复制/移动构造函数的第一个参数的引用类型。后缀++需要伪int。...如果您知道传递值(即使用非引用类型)就足够了,请直接使用它,特别是在使用支持C++17强制复制省略的实现时。(警告:然而,详尽地解释必要性可能非常复杂。)如果您想使用所有权操作一些句柄,请使用unique_ptr和shared_ptr之类的智能指针(如果您需要自制指针不透明,甚至可以使用它们),而不是原始指针。如果您在一个范围内进行一些迭代,请使用迭代器(或标准库尚未提供的一些范围),而不是原始指针,除非您确信原始指针在非常特定的情况下会做得更好(例如,对于较少的头部依赖性)。如果您知道通过值传递就足够了,并且需要一些显式的可空语义,请使用包装器(如std::optional),而不是原始指针。如果您知道由于上述原因,传递值并不理想,并且您不希望使用可为null的语义,请使用{lvalue,rvalue,forward}-引用。即使您确实需要像传统指针那样的语义,也通常有更合适的方法,例如库基础TS中的observer_ptr。

在当前语言中无法解决以下唯一的例外:

当您在上面实现智能指针时,可能必须处理原始指针。特定的语言互操作例程需要指针,如运算符new。(然而,cv void*与普通对象指针相比仍有很大的不同和安全性,因为它排除了意外的指针算法,除非您依赖于void*上的一些非一致扩展,如GNU的。)函数指针可以从lambda表达式转换而不需要捕获,而函数引用则不能。对于这种情况,您必须在非泛型代码中使用函数指针,即使您故意不希望值为空。

因此,在实践中,答案是显而易见的:当有疑问时,避免使用指针。只有在有非常明确的理由认为没有其他更合适的时候,才必须使用指针。除了上面提到的一些例外情况外,这些选择几乎总是不是纯C++特定的(但可能是特定于语言实现的)。此类实例可以是:

您必须为旧式(C)API服务。您必须满足特定C++实现的ABI要求。您必须基于特定实现的假设,在运行时与不同的语言实现(包括各种程序集、语言运行时和某些高级客户端语言的FFI)进行互操作。在某些极端情况下,您必须提高翻译(编译和链接)的效率。在某些极端情况下,您必须避免符号膨胀。

语言中立警告

如果你通过谷歌搜索结果(不是C++特有的)看到这个问题,这很可能是错误的地方。

C++中的引用相当“奇怪”,因为它本质上不是一级的:它们将被视为被引用的对象或函数,因此它们没有机会支持一些一级操作,例如独立于被引用对象的类型而成为成员访问运算符的左操作数。其他语言可能对其引用有类似的限制,也可能没有。

C++中的引用可能不会保留不同语言之间的含义。例如,引用通常并不意味着像C++中那样的值具有非空财产,因此这种假设在某些其他语言中可能不起作用(并且很容易找到反例,例如Java、C#…)。

一般来说,在不同编程语言中的引用之间仍然可以有一些常见的财产,但让我们把它留给SO中的其他一些问题。

(附带说明:这个问题可能比任何“类C”语言都要早,比如ALGOL 68与PL/I。)

另一个区别是,可以有指向void类型的指针(这意味着指向任何对象的指针),但禁止引用void。

int a;
void * p = &a; // ok
void & p = a;  //  forbidden

我不能说我真的很满意这种特殊的差异。我更希望它能被允许有意义地引用任何有地址的东西,否则引用行为相同。它将允许使用引用定义一些C库函数的等价物,如memcpy。

引用是常量指针。int*const a=&b与int&a=b相同。这就是为什么没有const引用,因为它已经是const,而const的引用是const int*consta。当使用-O0编译时,编译器将在这两种情况下将b的地址放在堆栈上,并且作为类的成员,它也将出现在堆栈/堆上的对象中,与您声明了常量指针时相同。使用-Ofast,可以免费优化此功能。常量指针和引用都被优化了。

与常量指针不同,无法获取引用本身的地址,因为它将被解释为它引用的变量的地址。因此,在Ofast上,表示引用的常量指针(被引用变量的地址)将始终在堆栈外进行优化,但如果程序绝对需要实际常量指针的地址(指针本身的地址,而不是指针指向的地址),即您打印常量指针的位置,那么const指针将被放置在堆栈上,以便它有一个地址。

否则它是相同的,即当您打印它指向的地址时:

#include <iostream>

int main() {
  int a =1;
  int* b = &a;
  std::cout << b ;
}

int main() {
  int a =1;
  int& b = a;
  std::cout << &b ;
}
they both have the same assembly output
-Ofast:
main:
        sub     rsp, 24
        mov     edi, OFFSET FLAT:_ZSt4cout
        lea     rsi, [rsp+12]
        mov     DWORD PTR [rsp+12], 1
        call    std::basic_ostream<char, std::char_traits<char> >& std::basic_ostream<char, std::char_traits<char> >::_M_insert<void const*>(void const*)
        xor     eax, eax
        add     rsp, 24
        ret
--------------------------------------------------------------------
-O0:
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-12], 1
        lea     rax, [rbp-12]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     rsi, rax
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(void const*)
        mov     eax, 0
        leave
        ret

指针已经在堆栈外进行了优化,在这两种情况下,指针甚至都没有在-Ofast上取消引用,而是使用编译时值。

作为对象的成员,它们在-O0到-Ofast上是相同的。

#include <iostream>
int b=1;
struct A {int* i=&b; int& j=b;};
A a;
int main() {
  std::cout << &a.j << &a.i;
}

The address of b is stored twice in the object. 

a:
        .quad   b
        .quad   b
        mov     rax, QWORD PTR a[rip+8] //&a.j
        mov     esi, OFFSET FLAT:a //&a.i

当通过引用传递时,在-O0上,传递被引用变量的地址,因此它与通过指针传递相同,即常量指针包含的地址。On Ofast如果函数可以内联,则编译器会在内联调用中对其进行优化,因为动态范围是已知的,但在函数定义中,参数总是作为指针(期望引用引用的变量的地址)被解引用,其中它可能被另一个转换单元使用,而编译器不知道动态范围,当然,除非函数声明为静态函数,否则它不能在转换单元之外使用,然后它通过值传递,只要它没有在函数中通过引用进行修改,那么它将传递您传递的引用所引用的变量的地址,如果调用约定中有足够多的易失性寄存器,则将在一个寄存器中传递,并保持在堆栈之外。

虽然引用和指针都用于间接访问另一个值,但引用和指针之间有两个重要的区别。第一个是引用总是引用一个对象:在没有初始化引用的情况下定义引用是错误的。赋值行为是第二个重要区别:赋值给引用会更改引用绑定的对象;它不会将引用重新绑定到另一个对象。初始化后,引用始终引用同一基础对象。

考虑这两个程序片段。首先,我们将一个指针分配给另一个指针:

int ival = 1024, ival2 = 2048;
int *pi = &ival, *pi2 = &ival2;
pi = pi2;    // pi now points to ival2

在赋值ival之后,pi所寻址的对象保持不变。赋值会更改pi的值,使其指向不同的对象。现在考虑一个分配两个引用的类似程序:

int &ri = ival, &ri2 = ival2;
ri = ri2;    // assigns ival2 to ival

此赋值更改了ri引用的值ival,而不是引用本身。赋值后,两个引用仍然引用其原始对象,并且这些对象的值现在也相同。

关于引用和指针的一些关键相关细节

指针

使用一元后缀声明符运算符声明指针变量*指针对象被分配一个地址值,例如,通过分配给数组对象、使用一元前缀运算符的对象地址或分配给另一个指针对象的值指针可以重新分配任意次数,指向不同的对象指针是保存指定地址的变量。它占用的内存存储量等于目标机器体系结构的地址大小例如,可以通过增量或加法运算符对指针进行数学操作。因此,可以使用指针等进行迭代。要获取或设置指针引用的对象的内容,必须使用一元前缀运算符*来取消引用它

工具书类

引用在声明时必须初始化。引用使用一元后缀声明符运算符&声明。初始化引用时,可以使用它们将直接引用的对象的名称,而不需要一元前缀运算符&一旦初始化,引用就不能通过赋值或算术操作指向其他对象无需取消引用该引用以获取或设置其引用的对象的内容对引用的赋值操作操作它指向的对象的内容(初始化后),而不是引用本身(不改变它指向的位置)对引用的算术运算操作它指向的对象的内容,而不是引用本身(不会改变它指向的位置)在几乎所有的实现中,引用实际上都存储为被引用对象的内存中的地址。因此,它占用的内存大小与目标机器体系结构的地址大小相同,就像指针对象一样

尽管指针和引用的实现方式几乎相同,但编译器对它们的处理方式不同,导致了上述所有差异。

文章

我最近写的一篇文章比我在这里展示的要详细得多,对这个问题非常有帮助,特别是关于记忆中的事情是如何发生的:

数组、指针和引擎罩下的引用深度文章