为什么返回函数局部变量的引用不会导致编译错误?

39

以下代码会引发未定义行为。

int& foo()
{
  int bar = 1234;
  return bar;
}

使用g++会发出警告:

warning: reference to local variable ‘bar’ returned [-Wreturn-local-addr]

clang++也一样:

warning: reference to stack memory associated with local variable 'bar' returned [-Wreturn-stack-address]

为什么这不是编译错误(忽略-Werror)?

是否存在可以返回本地变量引用的情况是有效的?

编辑正如指出的那样,规范要求此代码可编译。那么,为什么规范不禁止这样的代码?


3
如果函数中的代码有很多分支,那么可能很难弄清楚。 - juanchopanza
4
有很多现有的程序存在这个bug,但它们仍然可以正常运行,所以将其视为错误会导致不兼容性变更 :) - Hans Passant
1
@Basile:在这种情况下,您可以使用返回的值(因为它是引用而不是指针)。 - paxdiablo
3
什么指针? - Angew is no longer proud of SO
@LokiAstari,我哪里说过我不是呢? - Drew Noakes
显示剩余2条评论
6个回答

40

我认为要求使程序无效(即将其编译错误)会使标准变得更加复杂,而收益却很小。你必须在标准中明确指出这种情况何时应该被诊断,并且所有编译器都必须实现它们。

如果你指定得太少,它将不太有用。而编译器可能已经检查到这一点并发出警告,而真正的程序员会使用 -Wall_you_can_give_me -Werror 进行编译。

如果你指定得太多,编译器实现标准将变得困难(或不可能)。

考虑以下这个类(你只有头文件和库):

class Foo
{
  int x;

public:
  int& getInteger();
};

而这段代码:

int& bar()
{
  Foo f;
  return f.getInteger();
}

现在,应该将标准书写为使其不规范吗?可能不需要,如果Foo是这样实现的:

#include "Foo.h"

int global;

int& Foo::getInteger()
{
  return global;
}

同时,可以这样实现:

#include "Foo.h"

int& Foo::getInteger()
{
  return x;
}
当然,这会给你一个悬挂引用。
我的观点是,编译器不能真正知道返回引用是否可以,除了一些琐碎的情况(返回对函数作用域自动变量或非引用类型参数的引用)。 我认为没有必要为此复杂化标准。尤其是因为大多数编译器已经将其作为实现质量问题发出警告。

2
这些例子真的有助于解释所涉及的复杂性。谢谢。似乎一些语言(D,Rust)具有明确的生命周期说明符,可以直接解决这个问题。 - Drew Noakes
我怀疑确定引用是否为本地变量实际上可能是停机问题。虽然我不100%确定。 - Mysticial
1
@Mysticial:确定引用实际上是否是本地引用将是停机问题。另一方面,我认为可以定义有用的引用语义,将引用参数分类为短暂的、可返回的或可持久化的,禁止将局部变量作为参数传递给可持久化参数,并将适用于函数返回值的最强限制应用于传递给可返回参数的参数。这样的规则将不允许一些在实践中永远不会创建悬空引用的构造,但将允许大多数有用的场景。 - supercat
1
@HongxuChen: 编写一个函数,该函数接受一个引用并同时执行两个计算。如果第一个计算先完成,则函数返回传入的引用。如果第二个计算先完成,则返回对静态对象的引用。如果第一个计算永远不是第一个完成的,则不会有未定义行为。即使在没有任何计算完成的情况下,也将出现Undefined Behavior,并且编译器可以假定至少有一个计算最终会完成,但确定哪个计算会获胜将成为停机问题。 - supercat
1
@HongxuChen:如果上述方法被一个方法调用,该方法将其传递给一个本地变量的引用,然后将返回值传递给其调用者,如果内部方法返回对传入参数的引用,则外部方法仅返回对其本地变量的引用;确定是否会发生这种情况相当于停机问题。 - supercat
显示剩余2条评论

7
此外,因为您可能想要获取当前堆栈指针(在您的特定实现中意味着什么),所以需要这个函数:
```` void *get_sp(void) { __asm__("mov %rsp,%rax"); } ```` 请注意,此代码是具有平台依赖性的,并且只适用于使用x86_64体系结构的计算机。
 void* get_stack_pointer (void) { int x; return &x; };

据我所知,如果不对结果指针进行解引用操作,这并不是未定义行为。

比起这个,这种方式更加兼容性强:

 void* get_stack_pointer (void) { 
    register void* sp asm ("%esp"); return sp; }

关于为什么你可能想要获取堆栈指针:有些情况下你有正当理由去获取它,比如保守式的Boehm垃圾收集器需要扫描堆栈(因此需要堆栈指针和底部地址)。
如果你返回一个C++引用,并且只使用一元运算符&取该引用的地址,那么获得这样的地址是合法的。这是唯一合法的操作(在我看来)。
另一个获取堆栈指针的原因是为了获得一个非NULL指针地址(例如,您可以将其哈希),与任何堆、局部或静态数据不同。但是,您可以使用(void*)1(void*)-1来实现此目的。
因此,编译器只对此发出警告是正确的。
我猜测C++编译器应该会接受。
int& get_sp_ref(void) { int x; return x; }

void show_sp(void) { 
   std::cout << (&(get_sp_ref())) << std::endl; }

这可能对指针是正确的,但我问的是关于引用的。 - Drew Noakes
4
引用在很大程度上只是指针的语法糖。对于引用而言,“取消引用指针”的等效操作是“左值到右值转换”,而进行该操作会导致未定义行为。 - Jonathan Wakely
移除了无用的 static_cast。谢谢。 - Basile Starynkevitch
1
据我所知,如果您不解引用生成的指针,则不会出现未定义行为。从技术上讲,我认为您拥有的内容是可以安全调用的,但是在C语言中,即使仅仅将结果存储在另一个变量中,也肯定是未定义的行为,我认为C++遵循了C的规则。按照这些规则,您的最后一个示例肯定是无效的。 - user743382
如果您不对生成的指针执行任何操作,则此函数无法实现“您可能想要获取堆栈指针”的目标。 - M.M
显示剩余2条评论

7

出于同样的原因,C语言允许返回已经释放了的内存块的指针。

这在语言规范上是有效的。这是一个非常糟糕的想法(而且根本没有被保证能够正常工作),但它仍然有效,因为它并没有被禁止。

如果你问为什么标准允许这种做法,可能是因为当引用被引入时,它们的工作方式就是这样。每个版本的标准都有一些指导方针需要遵循(例如最小化“破坏性更改”的可能性,即使现有正确格式的程序变得无效),标准是用户和实现者之间的协议,其中委员会中实现者的人数肯定比用户多 :-)

把这个想法推向潜在的变化可能值得一试,看看ISO会有何反应,但我怀疑这将被视为那些“破坏性更改”之一,因此非常可疑。


2
那么没有技术上的原因吗?指针和引用是不同的东西。指针更加灵活,允许很多无效的用法。如果你在处理指针,那就是你玩的游戏。然而,对于引用,有一定的保证。我希望编译器能够拒绝这样的代码。主要是希望学习一些我之前没有见过的边缘情况,以便在某些情况下可能会有用。 - Drew Noakes
1
@Drew,编译器不可能拒绝这段代码,因为标准规定它是可以的。gcc正确地警告你正在做一些危险的事情,但如果它拒绝这段代码,那么它就不符合标准。 - paxdiablo
3
@paxdiablo,我理解OP的问题是“为什么标准没有规定这个格式不正确?” - Angew is no longer proud of SO
@Angew,是的,那样措辞更好。谢谢。 - Drew Noakes
根据语言规范,返回指向已释放内存块的指针是无效的。(也许你的意思是“它不是非法的”?) - M.M
Matt,它是有效的,因为你被允许去做它,就像a++ + ++a一样是有效的。我所说的有效是指它不是非法的。它是未定义行为的事实是另一个问题。我会澄清的。 - paxdiablo

3
为了进一步解释之前的答案,ISO C++标准本身并没有区分警告和错误;当提到编译器必须在看到有问题的程序时发出什么内容时,它只是使用“诊断”这个术语。引用 N3337 1.4,第1和第2段:
可诊断规则集包括此国际标准中的所有句法和语义规则,但不包括那些包含明确注释“无需诊断”的规则或被描述为导致“未定义行为”的规则。虽然此国际标准仅规定了C++实现的要求,但如果将这些要求作为程序、程序部分或程序执行的要求来表述,则通常更易于理解。这些要求具有以下含义:如果一个程序不违反此国际标准中的任何规则,则符合要求的实现应在其资源限制内接受并正确执行该程序;如果程序违反了任何可诊断规则或出现了在此标准中描述为“有条件支持”的构造,而实现不支持该构造,则符合要求的实现应发出至少一条诊断消息;如果程序违反了不需要诊断的规则,则此国际标准对实现不对该程序施加任何要求。

1
其他答案没有提到的一点是,如果从未调用该函数,则此代码是可以的。
编译器不需要诊断函数是否可能被调用。例如,您可能设置一个程序,寻找费马大定理的反例,并在找到一个时调用此函数。编译器拒绝这样的程序是错误的。

按照这种推理,只要我从未调用它,这个函数就应该编译通过吗?void neverCalled() { !"£$%^&*()_+; } - Drew Noakes
@DrewNoakes 这是一个语法错误。编译器必须诊断出任何不符合标准的代码(当然,是根据标准来判断)。 - M.M

0

将引用返回到局部变量是一个不好的主意,然而有些人可能会创建需要这样做的代码,所以编译器应该只是警告而不将有效(有效结构)的代码视为错误。

Angew已经发布了一个使用局部变量实际上是全局的示例。然而还有一些其他(我个人认为更好的)示例。

Object& GetSmth()
{
    Object* obj = new Object();
    return *obj;
}

在这种情况下,对本地对象的引用是有效的,调用者在使用后应该释放内存。

重要提示 我不鼓励也不推荐使用这种编码风格,因为它是糟糕的,通常很难理解发生了什么,并且会导致一些问题,比如内存泄漏或崩溃。这只是一个示例,说明为什么不能将此特定情况视为错误。


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