如何声明一个变量来存储通过引用返回的对象?

3

C++ 参考文献仍然让我感到困惑。假设我有一个创建 Foo 类型对象并通过引用返回它的函数/方法。(我认为如果我想要返回对象,它不能是分配在堆栈上的局部变量,所以我必须使用 new 在堆上分配它):

Foo& makeFoo() {
    ...
    Foo* f = new Foo;
    ...
    return *f;
}

当我想要将在另一个函数的本地变量中创建的对象存储时,类型应该是Foo吗?

void useFoo() {

    Foo f = makeFoo();
    f.doSomething();
}

还是Foo&

void useFoo() {

    Foo& f = makeFoo();
    f.doSomething();
}

既然两种语法都是正确的,那么这两种变体之间有显著的区别吗?


4
我假设如果想要返回对象,它不能是分配在栈上的局部变量,所以我必须使用new在堆上分配它。这会让问题变得更加复杂。不要通过引用返回变量:通过值来返回它,并让优化器(或C++11移动语义)完成它们的工作。 - Lightness Races in Orbit
实际上Foo& f = makeFoo();版本可能不会编译。 - Lightness Races in Orbit
@LightnessRacesinOrbit 这只是暂时的吗? - Luchian Grigore
@LightnessRacesinOrbit "让C++11的移动语义发挥作用" 你能指出一些解释C++11移动语义在这里相关的资源吗? - clstaudt
4
@cls: 任何正确解释移动语义的资源都应该让您了解它在这里的相关性(尝试维基百科:“move semantics”)。整个重点是减少不必要的复制成本。只需确保您的 Foo 对象设计利用它即可。主要是要确保对象的“大小”不在对象本身中,而是在外部引用。一个典型的例子是 std::vector,内部仅是一些指针。但是这些指针可以引用大量的数据。通过简单地复制和将指针设置为 null 即可移动向量。 - Benjamin Lindley
显示剩余4条评论
3个回答

5
是的,第一个将复制返回的引用,而第二个将是对makeFoo返回的引用。

请注意,使用第一种版本会导致内存泄漏(很可能),除非您在拷贝构造函数中进行某些黑暗魔法。

嗯,第二个也会导致泄漏,除非您调用delete &f;

底线:不要这样做。只需跟随大众的做法,按值返回或使用智能指针。


1
那么我应该保持一切不变,只是将 makeFoo 的返回类型从 Foo& 改为 Foo - clstaudt
@cls:你在堆栈上分配了Foo并返回了一个副本!因此没有涉及到new - Skalli
@Skalli 如果Foo是一个大对象,而我没有足够的内存或时间来复制它怎么办? - clstaudt
1
@cls编译器可能会将其优化掉。如果您真的担心,可以返回std::shared_ptr<Foo> - Luchian Grigore
@cls:Luchian Grigore 更快,他是对的:使用智能指针。有几种不同的智能指针可供使用,每种都专注于特定的用例。std::shared_ptr<Foo> 应该是最适合您的用例。 - Skalli
智能指针是否适用于使用OpenMP进行并行编程? - clstaudt

3

你的第一段代码做了很多工作:

void useFoo() {
    Foo f = makeFoo();  // line 2
    f.doSomething();
}

考虑到第二行,一些有趣的事情发生了。首先,编译器将发出代码以使用类的默认构造函数在f处构造一个Foo对象。然后,它将调用makeFoo(),该函数也会创建一个新的Foo对象并返回对该对象的引用。编译器还必须发出代码,将makeFoo()的临时返回值复制到f处的对象中,然后销毁临时对象。完成第2行后,调用f.doSomething()。但是,在useFoo()返回之前,我们也要销毁f处的对象,因为它超出了作用域。
您的第二个代码示例效率更高,但实际上可能是错误的。
void useFoo() {
    Foo& f = makeFoo();   // line 2
    f.doSomething();
}

考虑到该示例中的第二行,我们意识到我们不需要为f创建对象,因为它只是一个引用。 makeFoo()函数返回一个新分配的对象,并且我们保留对它的引用。我们通过该引用调用doSomething()。但是,当useFoo()函数返回时,我们从未销毁makeFoo()为我们创建的新对象,导致内存泄漏。

有几种不同的方法可以解决这个问题。如果您不介意额外的构造函数、创建、复制和销毁,则可以使用第一个代码片段中的引用机制。(如果您有平凡的构造函数和析构函数,并且没有(或很少)要复制的状态,则不太重要。)您可以返回指针,这强烈暗示调用者负责管理所引用对象的生命周期。

如果你返回一个指针,就意味着调用者必须管理对象的生命周期,但你并没有强制执行。总有一天,某个人会搞错。因此,你可以考虑创建一个包装类来管理引用并提供访问器来封装对象的管理。(如果你想的话)甚至可以将这种包装类嵌入到Foo类本身中。这种类型的包装类在其通用形式下被称为“智能指针”。如果你正在使用STL,则会在 std::unique_ptr模板类中找到一个智能指针实现。

1
我基本上同意,但你可以在最后一段中提供一个提示,使用智能指针。这将减少复制成本,并在智能指针超出范围时释放对象。 - Skalli
真的,Skalli。我为此做了一些优化。 - MikeB
实际上,第一个 useFoo 的描述是错误的。编译器不会发出代码来默认构造 f。它只会分配足够的内存来使用从 makeFoo 复制构造 f 的结果。 - Bart van Ingen Schenau
@Bart van Ingen Schenau,这是标准保证的吗?还是实现定义的?或者这取决于class foo实现了哪些构造函数和运算符? - MikeB
@MikeB:标准保证在useFoo中不会使用默认构造函数。当内存被分配时的详细信息取决于实现。 - Bart van Ingen Schenau

2

函数不应该返回一个新对象的引用,因为这会创建一个新值。当你创建一个新值时,应该返回一个值或指针。通常情况下,返回一个值是更好的选择,因为几乎所有的编译器都会使用RVO/NRVO来消除额外的复制。

返回一个值:

Foo makeFoo(){
    Foo f;
    // do something
    return f;
}

// Using it
Foo f = makeFoo();

返回指针:

Foo* makeFoo(){
    std::unique_ptr<Foo> p(new Foo());  // use a smart pointer for exception-safety
    // do something
    return p.release();
}

// Using it
Foo* foo1 = makeFoo();                 // Can do this
std::unique_ptr<Foo> foo2(makeFoo());   // This is better

针对你的第二个例子,为什么不直接返回一个 unique_ptr,并将是否放弃安全的决定留给调用者呢? - Benjamin Lindley
1
我更喜欢将如何使用它的决定留给调用者。返回一个原始指针让他们可以将其放入任何类型的智能指针中。如果函数和调用者没有编译成完全相同版本的标准库,那么你也不太可能遇到ABI问题。 - Dirk Holsopple
返回unique_ptr还允许将其放入任何他们想要的智能指针中(他们可以像您在函数中所做的那样调用release),并且它是自我记录的。当函数返回原始指针时,我无法做出任何关于需要采取哪些操作来确保其被正确管理的假设。它可能是使用new或malloc动态分配的,也可能根本不需要进行管理。至于您的第二个观点,我只能说我个人从未遇到过任何此类问题,但我不会从不可信度争论。 - Benjamin Lindley

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