为什么不总是将返回值分配给const引用?

46

假设我有一个函数:

Foo GetFoo(..)
{
  ...
}

假设我们不知道这个函数如何实现,也不知道Foo的内部结构(它可以是非常复杂的对象)。但是我们知道函数返回值为Foo,并且我们希望将此返回值用作const。

问题:将此函数的返回值存储为const &总是一个好主意吗?

答案:这取决于函数返回值的大小和复杂程度。对于小而简单的对象,按值返回并将其存储为const可能是更好的选择。对于大型或复杂的对象,使用const &可能更有效,以避免不必要的拷贝和对象的创建/销毁。但是,应该意识到使用引用会增加代码的复杂性,并可能导致悬空引用的问题。因此,请根据具体情况进行决策。

const Foo& f = GetFoo(...);

与其...不如...

const Foo f = GetFoo(...);

我知道编译器会执行返回值优化,可能会移动对象而不是复制它们,因此最终const&可能没有任何优势。但我的问题是,是否存在缺点?为什么我不应该养成肌肉记忆,始终使用const&存储返回值,既不必依赖于编译器优化,也不必担心即使移动操作对于复杂对象来说也可能很昂贵。

将这个问题推到极致,为什么我不应该在我的代码中对所有不可变变量始终使用const&?例如:

const int& a = 2;
const int& b = 2;
const int& c = c + d;

除了更冗长之外,还有什么缺点吗?


5
如果这个函数返回一个引用,语义就会有所不同。如果你得到了你不想要的东西,询问错误可能会给你带来很大的麻烦。 - David Schwartz
我认为上述代码对于返回的引用也可以起作用,只要我想将返回值视为不可变即可。 - Shital Shah
5
将一个简单类型(如 int)的引用保留下来可能比使用一个 int 变量更低效。 - Mark Ransom
decltype(auto)在C++14中是否是可行的替代方案? - John McFarlane
请注意,C++17的“保证复制省略”大大减少了使用const … &…=…;的动机,因此我们不需要问这个问题,可以使用引用来表达身份重要性的语义。 - Davis Herring
显示剩余2条评论
4个回答

38

称引用折叠为“优化”是一个误解。编译器允许不进行折叠,但也允许以手动进位的按位操作序列实现a+b整数加法。

这样做的编译器是有敌意的,拒绝折叠的编译器也一样。

折叠不像其他优化那样,其他优化依赖于as-if规则(行为可能会改变,只要它表现得符合标准即可)。折叠可能会改变代码的行为。

至于为什么使用const&甚至右值&&是个坏主意,引用是对象的别名。无论哪种方式,您都没有(本地)保证对象不会在其他地方被操纵。事实上,如果函数返回&const&&&,那么对象必须以另一个身份存在于其他地方。因此,您的“本地”值实际上是对某个未知远程状态的引用:这使得本地行为难以推理。

相反,值不能被别名。您可以在创建后形成这样的别名,但是根据标准,const局部值不能被修改,即使存在别名。

推理本地对象很容易。推理分布式对象很难。引用在类型中是分布式的:如果您在引用和值之间进行选择,并且值没有明显的性能成本,请始终选择值。

具体点说:

Foo const& f = GetFoo();

该行代码可能是一个指向类型为 Foo 的临时变量或从 GetFoo() 返回的派生变量的引用绑定,或者是绑定到存储在 GetFoo() 中的其他东西的引用。 我们无法从这一行得出结论。

Foo const& GetFoo();

对比

Foo GetFoo();

f有不同的含义,以产生实际效果。

Foo f = GetFoo();

always会创建一份副本。除非通过f进行修改,否则不会修改f本身(当然,如果其构造函数将指向自身的指针传递给其他人,则除外)。

如果我们有

const Foo f = GetFoo();

我们甚至有保证,修改(f的非mutable部分)是未定义行为。 我们可以假设f是不可变的,实际上编译器也会这样假设。

对于const Foo&情况,如果底层存储不是const,则修改f可能是被定义的行为。因此,我们不能假设f是不可变的,只有当编译器能够检查到所有具有有效派生指针或引用的代码,并确定它们都没有改变它时,编译器才会假定它是不可变的(即使您只是传递const Foo&,如果原始对象是非const Foo,则可以使用const_cast<Foo&>来修改它)。

简而言之,不要过早地悲观并假设省略“不会发生”。 目前只有很少几个编译器不会自动优化,而且你几乎肯定不会在它们上构建一个严肃的项目。


很棒的答案!谢谢。 - Waqar

30

这些有语义差异,如果你请求的不是你想要的,但最终得到了它,那么会给你带来麻烦。请考虑这段代码

#include <stdio.h>

class Bar
{
    public:
    Bar() { printf ("Bar::Bar\n"); }
    ~Bar() { printf ("Bar::~Bar\n"); }
    Bar(const Bar&) { printf("Bar::Bar(const Bar&)\n"); }
    void baz() const { printf("Bar::Baz\n"); }
};

class Foo
{
    Bar bar;

    public:
    Bar& getBar () { return bar; }
    Foo() { }
};

int main()
{
    printf("This is safe:\n");
    {
        Foo *x = new Foo();
        const Bar y = x->getBar();
        delete x;
        y.baz();
    }
    printf("\nThis is a disaster:\n");
    {
        Foo *x = new Foo();
        const Bar& y = x->getBar();
        delete x;
        y.baz();
    }
    return 0;
}

输出结果为:

这是安全的:
Bar::Bar
Bar::Bar(const Bar&)
Bar::~Bar
Bar::Baz
Bar::~Bar

这是灾难性的:
Bar::Bar
Bar::~Bar
Bar::Baz

请注意我们在销毁Bar后调用了Bar::Baz。哎呀。

要求你想要什么,这样你就不会受到损失,如果你得到了你所要求的。


请问您能解释一下问题在哪里吗?根据我的理解,即使OP没有声明为const,这个问题也仍然存在,我错过了什么吗? - dhein
3
问题在于引用,而不是const - user2357112
1
@Zaibis:这个问题是关于是否使用引用,而不是是否使用const。请看问题中提出的两种替代方案,分别是将值分配给const Foo&const Foo,它们在使用引用上有所不同,而不是在使用const上。 - user2357112
3
这似乎回答了与最初提出的问题不同的问题。这描述了在定义返回引用(或非引用)函数时会发生什么。而问题是关于“使用”该函数并将其返回值赋给引用(或非引用)的情况。 - Steve Folly
2
@DavidSchwartz - 这个问题的例子是该方法通过_value_返回。您的示例方法通过引用返回,因此您的第二个示例将是一场灾难。如果Foo :: getBar按值返回,则您的第二个示例不会成为灾难。 (因此,这就是我说您没有回答原始问题的原因。) - Steve Folly
显示剩余3条评论

14

在评论中所说的@David Schwartz的基础上,你需要确保语义不会改变。仅仅把值看作是不可变的还不够,提供该值的函数也应该将其视为不可变,否则你会感到惊讶。

image.SetPixel(x, y, white_pixel);
const Pixel &pix = image.GetPixel(x, y);
image.SetPixel(x, y, black_pixel);
cout << pix;

4
在所考虑的情况下(选择变量类型时),const C&const C 之间的语义差异会影响程序,在下列情况中必须考虑它们,不仅在编写新代码时,而且在后续维护过程中也必须考虑,因为对源代码进行某些更改可能会改变变量定义属于此分类的位置。

初始化程序是恰好类型为 C 的左值

const C& foo();
const C  a = foo(); // (1)
const C& b = foo(); // (2)

(1)引入一个独立的对象(在类型C的复制语义允许的范围内),而(2)创建一个别名到另一个对象,并且受到该对象发生的所有更改的影响,包括该对象的生命周期结束。

初始化器是从C派生出来的lvalue类型

struct D : C { ... };
const D& foo();
const C  a = foo(); // (1)
const C& b = foo(); // (2)

(1) 是从foo()返回的切片版本。 (2) 绑定到派生对象上,可以享受多态行为的好处(如果有的话),但可能会被别名问题所困扰。

初始化程序是从C派生的类型的rvalue

struct D : C { ... };
D foo();
const C  a = foo(); // (1)
const C& b = foo(); // (2)

对于(1),与以前的情况没有区别。对于(2),再也没有别名问题了!常量引用绑定到派生类型的临时对象上,其生命周期延伸到封闭范围的末尾,并自动调用正确的析构函数(~D())。(2)可以享受多态的好处,但要付出比C更多的额外资源的代价。

初始化程序是可转换为C类型lvalue的rvalue

struct B {
    C c;
    operator const C& () const { return c; }
};
const B foo();
const C  a = foo(); // (1)
const C& b = foo(); // (2)

(1) 复制并继续执行,而 (2) 则从下一条语句立即陷入麻烦,因为它是死对象的子对象的别名!


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