mmxingye

导航

10 | 到底应不应该返回对象?

F.20


《C++ 核心指南》的 F.20 这一条款是这么说的 :

F.20: For “out” output values, prefer return values to output parameters

翻译一下:

在函数输出数值时,尽量使用返回值而非输出参数

这条可能会让一些 C++ 老手感到惊讶——在 C++11 之前的实践里,我们完全是采用相反的做法的啊!
在解释 F.20 之前,我们先来看看我们之前的做法。

调用者负责管理内存,接口负责生成

一种常见的做法是,接口的调用者负责分配一个对象所需的内存并负责其生命周期,接口负责生成或修改该对象。这种做法意味着对象可以默认构造(甚至只是一个结构),代码一般使用错误码而非异常。
非常的C风格
示例代码如下:


MyObj obj;
ec = initialize(&obj);
…

这种做法和 C 是兼容的,很多程序员出于惯性也沿用了 C 的这种做法。一种略为 C++ 点的做法是使用引用代替指针,这样在上面的示例中就不需要使用 & 运算符了;但这样只是语法略有区别,本质完全相同。如果对象有合理的析构函数的话,那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行,使用更多的中间变量,也就更容易犯错误。
假如我们已有矩阵变量 A、B 和 C,要执行一个操作

R=A×B+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 或类似错误处理

可以注意到,虽然代码看似稍微自然了一点,但啰嗦程度却增加了,原因是正确的处理需要考虑到各种不同错误路径下的资源释放问题(你可能要结合goto和if语句来进行判断)。这儿也没有使用异常,因为异常在这种表达下会产生内存泄漏,除非用上一堆 try 和 catch,但那样异常在表达简洁性上的优势就没有了,没有实际的好处。

不过,如果我们同时使用智能指针和异常的话,就可以得到一个还不错的变体。如果接口接受和返回的都是 shared_ptr,那调用代码就简单了:


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++ 特性的开源线性代数库。你可以看到代码非常简洁,完全表意(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),从实际的角度,这样的对象一般放在栈上可以被调用者正常覆盖使用的部分,随便一个函数调用或变量定义就可能覆盖这个对象占据的内存。这还是这个对象的析构不做事情的情况:如果析构函数会释放内存或破坏数据的话,那你访问到的对象即使内存没有被覆盖,也早就不是有合法数据的对象了……

回到正题。返回非引用类型的表达式结果是个纯右值(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

我们把代码稍稍改一下:


A getA_named()
{
  A a;
  return a;
}

int main()
{
  auto a = getA_named();
}

返回内容被移动构造了。(具体看编译器的行为,有的编译器仍然可以优化)

关于返回值优化的实验我们就做到这里。下一步,我们试验一下把移动构造函数删除


  // A(A&&) { cout << "Move A\n"; }

我们可以立即看到“Copy A”出现在了结果输出中,说明目前结果变成拷贝构造了。

即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的!C++17 要求对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤

回到 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 对象来管理生命周期,或者老办法输出参数了。
  • “要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入 / 输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。

posted on 2022-04-10 21:41  独立树  阅读(103)  评论(0编辑  收藏  举报