【问题标题】:Why not always assign return values to const reference?为什么不总是将返回值分配给 const 引用?
【发布时间】:2016-06-06 03:42:12
【问题描述】:

假设我有一些功能:

Foo GetFoo(..)
{
  ...
}

假设我们既不知道这个函数是如何实现的,也不知道 Foo 的内部结构(例如,它可能是非常复杂的对象)。但是我们确实知道该函数按值返回 Foo,并且我们希望将此返回值用作 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;

除了比较冗长,还有什么缺点吗?

【问题讨论】:

  • 如果函数返回引用怎么办?语义是不同的,如果你得到你不想要的东西,要求你得到它可能会严重烧伤你。
  • 我认为只要我想将返回的值视为不可变的,上述方法也适用于返回的引用。
  • 保持对像int 这样的简单类型的引用可能比使用int 变量效率低。
  • 那是一个缺点。所以我想除了缩放器之外的任何东西都应该没问题?
  • 你永远不应该用它自己的未初始化值来初始化一个变量(无论是否为 const)。确定这就是你想问的?

标签: c++


【解决方案1】:

将省略称为“优化”是一种误解。允许编译器不这样做,但也允许他们将a+b 整数加法实现为手动进位的按位操作序列。

这样做的编译器将是敌对的:拒绝忽略的编译器也是如此。

省略不像“其他”优化,因为它们依赖于 as-if 规则(只要行为符合标准,行为就可能发生变化)。省略可能会改变代码的行为。

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

另一方面,值不能有别名。您可以在创建后形成这样的别名,但在标准下不能修改 const 本地值,即使它存在别名。

推理本地对象很容易。关于分布式对象的推理是困难的。引用按类型分布:如果您在引用或值之间进行选择,并且该值没有明显的性能成本,总是选择值。

具体来说:

Foo const& f = GetFoo();

可以是对Foo 类型的临时对象的引用绑定,也可以是从GetFoo() 返回的派生对象,是绑定到GetFoo() 中存储的其他内容的引用。我们无法从那条线看出。

Foo const& GetFoo();

Foo GetFoo();

实际上使f 具有不同的含义。

Foo f = GetFoo();

总是 创建一个副本。不修改“通过”f 的任何内容都不会修改 f(当然,除非它的 ctor 将指向自身的指针传递给其他人)。

如果我们有

const Foo f = GetFoo();

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

const Foo& 的情况下,修改f 可以定义为行为如果底层存储不是const。所以我们不能假设f 是不可变的,并且编译器只会假设它是不可变的,如果它可以检查所有具有有效派生指针或对f 的引用并确定它们都没有改变它(即使你只是绕过const Foo&,如果原始对象是非const Foo,则const_cast<Foo&> 是合法的并对其进行修改。

简而言之,不要过早地悲观并假设省略“不会发生”。当前很少有编译器会在不明确关闭的情况下不使用它,而且您几乎肯定不会在它们上构建一个严肃的项目。

【讨论】:

  • 很好的答案!谢谢
【解决方案2】:

这些有语义差异,如果你要求的东西不是你想要的,如果你得到它,你就会遇到麻烦。考虑this code

#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(const Bar&)
酒吧::~酒吧
酒吧::巴兹
酒吧::~酒吧

这是一场灾难:
酒吧::酒吧
酒吧::~酒吧
酒吧::巴兹

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

问你想要什么,这样如果你得到你想要的,你就不会被搞砸。

【讨论】:

  • 为什么sn-p中的代码和链接的代码差别这么大?
  • @Zaibis:问题是参考,而不是const
  • @Zaibis:问题在于是否使用引用,而不是是否使用 const。看看在问题中,提出的两个替代方案是分配给const Foo&amp;const Foo,不同之处在于引用的使用,而不是 const 的使用。
  • 这似乎回答了与最初提出的问题不同的问题。这描述了当您定义返回引用(或不返回引用)的函数时会发生什么。问题是关于使用函数并将其返回值分配给引用(或不分配)。
  • @DavidSchwartz - 问题的例子是该方法返回 value 。您的示例方法通过引用返回,因此您的第二个示例将是一场灾难。如果 Foo::getBar 按值返回,您的第二个示例将不会是一场灾难。 (因此,这就是为什么我说你没有回答最初的问题。)
【解决方案3】:

基于 @David Schwartz 在 cmets 中所说的内容,您需要确保语义不会改变。您打算将值视为不可变是不够的,您从中获取它的函数也应将其视为不可变,否则您会感到惊讶。

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

【讨论】:

    【解决方案4】:

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

    Initializer 是一个完全类型为C 的左值

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

    (1) 引入了一个独立对象(在C 类型的复制语义允许的范围内),而 (2) 创建另一个对象的别名,并受制于该对象发生的所有更改(包括其报废)。

    Initializer 是从C 派生的类型的左值

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

    (1) 是从 foo() 返回的内容的切片版本。 (2) 绑定到派生对象,可以享受多态行为的好处(如果有的话),但有被别名问题咬住的风险。

    Initializer 是从C 派生的类型的右值

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

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

    Initializer 是可转换为 C 类型左值的类型的右值

    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) 则从下一条语句立即开始出现问题,因为它为死对象的子对象起别名!

    【讨论】:

      猜你喜欢
      • 2019-08-05
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-11-01
      • 1970-01-01
      • 1970-01-01
      • 2017-03-04
      • 2020-07-17
      相关资源
      最近更新 更多