10 到底应不应该返回对象?
你好,我是吴咏炜。
前几讲里我们已经约略地提到了返回对象的问题,本讲里我们进一步展开这个话题,把返回对象这个问题讲深讲透。
F.20
《C++ 核心指南》的 F.20 这一条款是这么说的 [1]:
F.20: For “out” output values, prefer return values to output parameters
翻译一下:
在函数输出数值时,尽量使用返回值而非输出参数
这条可能会让一些 C++ 老手感到惊讶——在 C++11 之前的实践里,我们完全是采用相反的做法的啊!
在解释 F.20 之前,我们先来看看我们之前的做法。
调用者负责管理内存,接口负责生成
一种常见的做法是,接口的调用者负责分配一个对象所需的内存并负责其生命周期,接口负责生成或修改该对象。这种做法意味着对象可以默认构造(甚至只是一个结构),代码一般使用错误码而非异常。
示例代码如下:
这种做法和 C 是兼容的,很多程序员出于惯性也沿用了 C 的这种做法。一种略为 C++ 点的做法是使用引用代替指针,这样在上面的示例中就不需要使用 &
运算符了;但这样只是语法略有区别,本质完全相同。如果对象有合理的析构函数的话,那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行,使用更多的中间变量,也就更容易犯错误。
假如我们已有矩阵变量 $\mathbf{A}$、$\mathbf{B}$ 和 $\mathbf{C}$,要执行一个操作
$$
\mathbf{R} = \mathbf{A} \times \mathbf{B} + \mathbf{C}
$$
那在这种做法下代码大概会写成:
error_code_t add(
matrix* result,
const matrix& lhs,
const matrix& rhs);
error_code_t multiply(
matrix* result,
const matrix& lhs,
const matrix& rhs);
…
error_code_t ec;
…
matrix temp;
ec = multiply(&temp, a, b);
if (ec != SUCCESS) {
goto end;
}
matrix r;
ec = add(&r, temp, c);
if (ec != SUCCESS) {
goto end;
}
…
end:
// 返回 ec 或类似错误处理
理论上该方法可以有一个变体,不使用返回值,而使用异常来表示错误。实践中,我从来没在实际系统中看到过这样的代码。
接口负责对象的堆上生成和内存管理
另外一种可能的做法是接口提供生成和销毁对象的函数,对象在堆上维护。fopen
和 fclose
就是这样的接口的实例。注意使用这种方法一般不推荐由接口生成对象,然后由调用者通过调用 delete
来释放。在某些环境里,比如 Windows 上使用不同的运行时库时,这样做会引发问题。
同样以上面的矩阵运算为例,代码大概就会写成这个样子:
matrix* add(
const matrix* lhs,
const matrix* rhs,
error_code_t* ec);
matrix* multiply(
const matrix* lhs,
const matrix* rhs,
error_code_t* ec);
void deinitialize(matrix** mat);
…
error_code_t ec;
…
matrix* temp = nullptr;
matrix* r = nullptr;
temp = multiply(a, b, &ec);
if (!temp) {
goto end;
}
r = add(temp, c, &ec);
if (!r) {
goto end;
}
…
end:
if (temp) {
deinitialize(&temp);
}
// 返回 ec 或类似错误处理
可以注意到,虽然代码看似稍微自然了一点,但啰嗦程度却增加了,原因是正确的处理需要考虑到各种不同错误路径下的资源释放问题。这儿也没有使用异常,因为异常在这种表达下会产生内存泄漏,除非用上一堆 try
和 catch
,但那样异常在表达简洁性上的优势就没有了,没有实际的好处。
不过,如果我们同时使用智能指针和异常的话,就可以得到一个还不错的变体。如果接口接受和返回的都是 shared_ptr<matrix>
,那调用代码就简单了:
shared_ptr<matrix> add(
const shared_ptr<matrix>& lhs,
const shared_ptr<matrix>& rhs);
shared_ptr<matrix> multiply(
const shared_ptr<matrix>& lhs,
const shared_ptr<matrix>& rhs);
…
auto r = add(multiply(a, b), c);
调用这些接口必须要使用 shared_ptr
,这不能不说是一个限制。另外,对象永远是在堆上分配的,在很多场合,也会有一定的性能影响。
接口直接返回对象
最直接了当的代码,当然就是直接返回对象了。这回我们看实际可编译、运行的代码:
#include <armadillo>
#include <iostream>
using arma::imat22;
using std::cout;
int main()
{
imat22 a{{1, 1}, {2, 2}};
imat22 b{{1, 0}, {0, 1}};
imat22 c{{2, 2}, {1, 1}};
imat22 r = a * b + c;
cout << r;
}
这段代码使用了 Armadillo,一个利用现代 C++ 特性的开源线性代数库 [2]。你可以看到代码非常简洁,完全表意(imat22
是元素类型为整数的大小固定为 2 x 2 的矩阵)。它有以下优点:
- 代码直观、容易理解。
- 乘法和加法可以组合在一行里写出来,无需中间变量。
- 性能也没有问题。实际执行中,没有复制发生,计算结果直接存放到了变量
r
上。更妙的是,因为矩阵大小是已知的,这儿不需要任何动态内存,所有对象及其数据全部存放在栈上。
Armadillo 是个比较复杂的库,我们就不以 Armadillo 的代码为例来进一步讲解了。我们可以用一个假想的 matrix
类来看看返回对象的代码是怎样编写的。
如何返回一个对象?
一个用来返回的对象,通常应当是可移动构造/赋值的,一般也同时是可拷贝构造/赋值的。如果这样一个对象同时又可以默认构造,我们就称其为一个半正则(semiregular)的对象。如果可能的话,我们应当尽量让我们的类满足半正则这个要求。
半正则意味着我们的 matrix
类提供下面的成员函数:
class matrix {
public:
// 普通构造
matrix(size_t rows, size_t cols);
// 半正则要求的构造
matrix();
matrix(const matrix&);
matrix(matrix&&);
// 半正则要求的赋值
matrix& operator=(const matrix&);
matrix& operator=(matrix&&);
};
我们先看一下在没有返回值优化的情况下 C++ 是怎样返回对象的。以矩阵乘法为例,代码应该像下面这样:
matrix operator*(const matrix& lhs,
const matrix& rhs)
{
if (lhs.cols() != rhs.rows()) {
throw runtime_error(
"sizes mismatch");
}
matrix result(lhs.rows(),
rhs.cols());
// 具体计算过程
return result;
}
注意对于一个本地变量,我们永远不应该返回其引用(或指针),不管是作为左值还是右值。从标准的角度,这会导致未定义行为(undefined behavior),从实际的角度,这样的对象一般放在栈上可以被调用者正常覆盖使用的部分,随便一个函数调用或变量定义就可能覆盖这个对象占据的内存。这还是这个对象的析构不做事情的情况:如果析构函数会释放内存或破坏数据的话,那你访问到的对象即使内存没有被覆盖,也早就不是有合法数据的对象了……
回到正题。我们需要回想起,在[第 3 讲] 里说过的,返回非引用类型的表达式结果是个纯右值(prvalue)。在执行 auto r = …
的时候,编译器会认为我们实际是在构造 matrix r(…)
,而“…”部分是一个纯右值。因此编译器会首先试图匹配 matrix(matrix&&)
,在没有时则试图匹配 matrix(const matrix&)
;也就是说,有移动支持时使用移动,没有移动支持时则拷贝。
返回值优化(拷贝消除)
我们再来看一个能显示生命期过程的对象的例子:
#include <iostream>
using namespace std;
// Can copy and move
class A {
public:
A() { cout << "Create A\n"; }
~A() { cout << "Destroy A\n"; }
A(const A&) { cout << "Copy A\n"; }
A(A&&) { cout << "Move A\n"; }
};
A getA_unnamed()
{
return A();
}
int main()
{
auto a = getA_unnamed();
}
如果你认为执行结果里应当有一行“Copy A”或“Move A”的话,你就忽视了返回值优化的威力了。即使完全关闭优化,三种主流编译器(GCC、Clang 和 MSVC)都只输出两行:
Create A
Destroy A
我们把代码稍稍改一下:
这回结果有了一点点小变化。虽然 GCC 和 Clang 的结果完全不变,但 MSVC 在非优化编译的情况下产生了不同的输出(优化编译——使用命令行参数 /O1
、/O2
或 /Ox
——则不变):
Create A
Move A
Destroy A
Destroy A
也就是说,返回内容被移动构造了。
我们继续变形一下:
#include <stdlib.h>
A getA_duang()
{
A a1;
A a2;
if (rand() > 42) {
return a1;
} else {
return a2;
}
}
int main()
{
auto a = getA_duang();
}
这回所有的编译器都被难倒了,输出是:
Create A
Create A
Move A
Destroy A
Destroy A
Destroy A
关于返回值优化的实验我们就做到这里。下一步,我们试验一下把移动构造函数删除:
我们可以立即看到“Copy A”出现在了结果输出中,说明目前结果变成拷贝构造了。
如果再进一步,把拷贝构造函数也删除呢(注:此时是标成 = delete
,而不是简单注释掉——否则,就如我们在[第 9 讲] 讨论过的,编译器会默认提供拷贝构造和移动构造函数)?是不是上面的 getA_unnamed
、getA_named
和 getA_duang
都不能工作了?
在 C++14 及之前确实是这样的。但从 C++17 开始,对于类似于 getA_unnamed
这样的情况,即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的!C++17 要求对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤 [3]。
回到 F.20
理解了 C++ 里的对返回值的处理和返回值优化之后,我们再回过头看一下 F.20 里陈述的理由的话,应该就显得很自然了:
A return value is self-documenting, whereas a
&
could be either in-out or out-only and is liable to be misused.返回值是可以自我描述的;而
&
参数既可能是输入输出,也可能是仅输出,且很容易被误用。
我想我对返回对象的可读性,已经给出了充足的例子。对于其是否有性能影响这一问题,也给出了充分的说明。
我们最后看一下 F.20 里描述的例外情况:
- “对于非值类型,比如返回值可能是子对象的情况,使用
unique_ptr
或shared_ptr
来返回对象。”也就是面向对象、工厂方法这样的情况,像[第 1 讲] 里给出的create_shape
应该这样改造。 - “对于移动代价很高的对象,考虑将其分配在堆上,然后返回一个句柄(如
unique_ptr
),或传递一个非 const 的目标对象的引用来填充(用作输出参数)。”也就是说不方便移动的,那就只能使用一个 RAII 对象来管理生命周期,或者老办法输出参数了。 - “要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入/输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。
内容小结
C++ 里已经对返回对象做了大量的优化,目前在函数里直接返回对象可以得到更可读、可组合的代码,同时在大部分情况下我们可以利用移动和返回值优化消除性能问题。
课后思考
请你考虑一下:
- 你的项目使用了返回对象了吗?如果没有的话,本讲内容有没有说服你?
- 这讲里我们没有深入讨论赋值;请你思考一下,如果例子里改成赋值,会有什么样的变化?
欢迎留言和我交流你的想法。
参考资料
[1] Bjarne Stroustrup and Herb Sutter (editors), “C++ core guidelines”, item F.20. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-out (非官方中文版可参见 https://github.com/lynnboy/CppCoreGuidelines-zh-CN)
[2] Conrad Sanderson and Ryan Curtin, Armadillo. http://arma.sourceforge.net/
[3] cppreference.com, “Copy elision”. https://en.cppreference.com/w/cpp/language/copy_elision
[3a] cppreference.com, “复制消除”. https://zh.cppreference.com/w/cpp/language/copy_elision
- 小一日一 👍(50) 💬(5)
我认为老师应该讲一下NRVO/RVO与std::move()的区别,这个问题曾经困扰过我,从stackoverflow的问题来看,学习c++11时大多数人都思考过这个问题:https://stackoverflow.com/questions/4986673/c11-rvalues-and-move-semantics-confusion-return-statement
2019-12-18 - 小白兔纸白又白 👍(13) 💬(2)
请问有何方法可以测试编译器是否为一个类提供了右值引用的版本的成员函数
2019-12-31 - 怪兽 👍(8) 💬(1)
吴老师,求助,我把getA_duang函数修改为: A getA_duang() { A a1; A a2; return (rand() > 42 ? a1 : a2); } 得到的结果是: Create A Create A Copy A Destroy A Destroy A Destroy A 请问为什么用条件表达式,得到的结果是 Copy A,而用你范例中的 if 语句判断就能得到 Move A呢?
2021-08-24 - Milittle 👍(8) 💬(1)
Armadillo这个库我用过,挺好用。语法可以和Matlab互转,如果有用Matlab的小伙伴 可以推荐使用 性能杠杠的
2020-02-25 - 空气 👍(7) 💬(1)
我在工作中使用引用出参的场景之一是同时返回多个对象,如果使用返回值就要封装很多不同结构体。请问老师这种场景建议怎么实现?
2020-01-04 - 阿白 👍(6) 💬(1)
老师看到你和别的同学讨论的问题,return :?中:?表达式返回的是左值引用,所以调用的是拷贝构造函数。但是return a1,a1是个标识表达式是个左值为什么就是调用移动构造。我不明白在函数声明返回值为值类型,但是:?返回一个左值引用的时候为什么调用的是拷贝构造? A getA_duang() { A a1; A a2; return (rand() > 42 ? a1 : a2); } Create A Create A Copy A Destroy A Destroy A Destroy A auto a = getA_duang();
2021-11-03 - 木瓜777 👍(6) 💬(1)
项目中一直使用您说的老方法,目前看编译器有优化的话,后面会逐步考虑采用返回对象的方法! 有个问题问下,如果要返回空对象,该如何做? 是直接采用空的构造函数?
2019-12-18 - 泰伦卢 👍(4) 💬(3)
请问老师这个C++20什么时候发布编译器之类的啊?还是说已经有了?
2019-12-18 - 怪兽 👍(3) 💬(1)
老师,请教2个疑惑: 1. 在返回值优化(拷贝消除)小节里,A(const A&) = delete;了,但A(A&&)只是注释掉,不是说编译器会提供默认的移动构造函数吗?为什么getA_named也不行了?不是优先匹配移动构造吗? A getA_named() { A a; return a; } 2. 哪种情况下移动的代价高?我理解移动的代价都很低,至少比拷贝低吧?
2021-05-18 - 花晨少年 👍(3) 💬(4)
我们继续变形一下: #include <stdlib.h> A getA_duang() { A a1; A a2; if (rand() > 42) { return a1; } else { return a2; } } int main() { auto a = getA_duang(); } 这回所有的编译器都被难倒了,输出是: Create A Create A Move A Destroy A Destroy A Destroy A ——————— 老师这个结果应该还是会有优化在的吧?如果完全没有优化应该是两个移动才对,a1或者a2移动给返回值是一次,返回值移动给a又是一次,如果真是这样,哪次被优化掉了?第二次吗
2019-12-21 - TJJ 👍(2) 💬(1)
用成员函数返回值,可以搞出链式调用,用起来很方便
2023-03-28 - Cafba 👍(2) 💬(1)
老师,想请教一下这里出现两次构造,但析构了三次的原因,我的思考是对于auto a = getA_duang(); 函数体内两个临时对象生成与析构对应着两次,当返回时因为移动构造,不需要拷贝,直接将一个临时对象的所有权转移给目标a(且此时保证着临时对象仍然处于可析构状态),此时这个目标a不需要构造函数初始化,但仍然析构,因此少了构造的一次析构了三次,这样理解哪里有问题 Create A Create A Move A Destroy A Destroy A Destroy A
2022-06-05 - zhengfan 👍(2) 💬(1)
吴老师您好。 抱歉我之前的的提问描述的不太清楚。您文中清楚地描述了semi-regular是“如何”定义的。我好奇的是,这样定义的意义是啥,对于copiable增加一个alias的意义何在?对应的regular定义是什么? 在网上仔细搜索了一下,似乎找到了源头: https://www.slideshare.net/ilio-catallo/regular-types-in-c 这一个ppt描述了regular的定义,以及由此引申出来的semi-regular。这里的各种定义是非常严谨的,类似于数域及其满足操作的扩展。 https://www.modernescpp.com/index.php/c-20-define-the-concept-regular-and-semiregular 这篇文章结合了C++ std20中concept介绍了一下regular和semi-regular定义的实操。其中深度使用了您介绍的SFINAE。 从中似乎隐然可以看出C++成为一个公式语言的可能性。
2020-07-20 - 王旧业 👍(1) 💬(1)
在 C++14 及之前确实是这样的。但从 C++17 开始,对于类似于 getA_unnamed 这样的情况,即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的 ----这样的情况就是指能被返回值优化的吗?
2021-02-16 - zhengfan 👍(1) 💬(1)
吴老师您好。 1. 您能否介绍一下定义所谓“半正则对象”的意义? 2. 我测试了一下您在介绍移动一章提到的使用 auto&& 延长prvalue生命周期的方法: auto&& obj = get_obj(); 发现其和使用auto获得这个prvalue的代码是完全等价的: Obj obj = get_obj(); 这种等价不仅限于在获得Obj实例之后的使用范围和方式完全一样,其在get_obj()内部对Obj的构造和传递(无论是否激活NRVO)也完全一样。当然在汇编层面还是有一些不同,就是看不太懂T_T 3. 另外,我测试了一下NRVO的代码,发现了一个挺有意思的情况,在C++14下,形如 A get_A_directly() { return A{}; } 这样的代码,确实只使用了默认构造了一次,没有其它移动和复制。但是如果A不提供移动和delete掉复制构造,编译不能通过。GCC和clang都如此。“未使用但必须提供”,哈哈,好像违背了“未使用就不需要负担成本”的原则:)
2020-07-19