前几讲里我们已经约略地提到了返回对象的问题本讲里我们进一步展开这个话题把返回对象这个问题讲深讲透。F.20《C 核心指南》的 F.20 这一条款是这么说的 [1]F.20: For “out” output values, prefer return values to output parameters翻译一下在函数输出数值时尽量使用返回值而非输出参数这条可能会让一些 C 老手感到惊讶——在 C11 之前的实践里我们完全是采用相反的做法的啊在解释 F.20 之前我们先来看看我们之前的做法。调用者负责管理内存接口负责生成一种常见的做法是接口的调用者负责分配一个对象所需的内存并负责其生命周期接口负责生成或修改该对象。这种做法意味着对象可以默认构造甚至只是一个结构代码一般使用错误码而非异常。示例代码如下MyObj obj; ec initialize(obj); …这种做法和 C 是兼容的很多程序员出于惯性也沿用了 C 的这种做法。一种略为 C 点的做法是使用引用代替指针这样在上面的示例中就不需要使用 运算符了但这样只是语法略有区别本质完全相同。如果对象有合理的析构函数的话那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行使用更多的中间变量也就更容易犯错误。假如我们已有矩阵变量 A、B 和 C要执行一个操作RA×BC那在这种做法下代码大概会写成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那调用代码就简单了shared_ptrmatrix add( const shared_ptrmatrix lhs, const shared_ptrmatrix rhs); shared_ptrmatrix multiply( const shared_ptrmatrix lhs, const shared_ptrmatrix 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 ADestroy A我们把代码稍稍改一下A getA_named() { A a; return a; } int main() { auto a getA_named(); }这回结果有了一点点小变化。虽然 GCC 和 Clang 的结果完全不变但 MSVC 在非优化编译的情况下产生了不同的输出优化编译——使用命令行参数 /O1、/O2 或 /Ox——则不变Create AMove ADestroy ADestroy 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 ACreate AMove ADestroy ADestroy ADestroy A关于返回值优化的实验我们就做到这里。下一步我们试验一下把移动构造函数删除// A(A) { cout Move A\n; }我们可以立即看到“Copy A”出现在了结果输出中说明目前结果变成拷贝构造了。如果再进一步把拷贝构造函数也删除呢注此时是标成 delete而不是简单注释掉——否则就如我们讨论过的编译器会默认提供拷贝构造和移动构造函数是不是上面的 getA_unnamed、getA_named 和 getA_duang 都不能工作了在 C14 及之前确实是这样的。但从 C17 开始对于类似于 getA_unnamed 这样的情况即使对象不可拷贝、不可移动这个对象仍然是可以被返回的C17 要求对于这种情况对象必须被直接构造在目标位置上不经过任何拷贝或移动的步骤。回到 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 来返回对象。”也就是面向对象、工厂方法这样的情况像之前给出的 create_shape 应该这样改造。“对于移动代价很高的对象考虑将其分配在堆上然后返回一个句柄如 unique_ptr或传递一个非 const 的目标对象的引用来填充用作输出参数。”也就是说不方便移动的那就只能使用一个 RAII 对象来管理生命周期或者老办法输出参数了。“要在一个内层循环里在多次函数调用中重用一个自带容量的对象将其当作输入 / 输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。内容小结C 里已经对返回对象做了大量的优化目前在函数里直接返回对象可以得到更可读、可组合的代码同时在大部分情况下我们可以利用移动和返回值优化消除性能问题。