是否返回对象

概述

《C++ 核心指南》的 F.20 这一条款:For “out” output values, prefer return values to output parameters,即在函数输出数值时,尽量使用返回值而非输出参数。

之前的做法是调用者负责管理内存,接口负责生成,比如:

MyObj obj;
error_code = initialize(&obj);

这种做法和 C 是兼容的,一种略为 C++ 点的做法是使用引用代替指针。

如果对象有合理的析构函数的话,那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行,使用更多的中间变量,也就更容易犯错误。

接口直接返回对象

一个用来返回的对象,通常应当是可移动构造 / 赋值的,一般也同时是可拷贝构造 / 赋值的。如果这样一个对象同时又可以默认构造,就称其为一个半正则(semiregular)的对象。我们应当尽量让我们的类满足半正则这个要求。

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&);即有移动支持时使用移动,没有移动支持时则拷贝。

返回值优化(消除拷贝)

看下面的测试代码(编译器版本gcc version 8.1.0 (x86_64-posix-seh-rev0, Built by MinGW-W64 project))

class Obj{
public:
    Obj(){ cout<< "Obj" <<endl;}
    ~Obj(){ cout<< "~Obj" <<endl;}
    Obj(const Obj&){ cout<< "const Obj&" <<endl;}
    Obj(Obj&&){ cout<< "Obj&&" <<endl;}
};

Obj f()
{
    return Obj();
}

int main()
{   
    auto obj = f();
    return 0;
}

输出

Obj ~Obj

改动一下

Obj f()
{
    Obj obj;
    return obj;
}

GCC和Clang输出不变,MSVC 在非优化编译的情况下产生了不同的输出(优化编译使用命令行参数 /O1、/O2 或 /Ox则不变),即返回内容被移动构造了。

Obj

Obj&&

~Obj

~Obj

再改动一下

Obj f(bool f)
{
    Obj obj1, obj2;
    if(f) return obj1;
    else return obj2;
}

输出

Obj Obj Obj&& Obj Obj ~Obj

如果删除Obj(Obj&&){ cout<< "Obj&&" <<endl;}

输出

Obj Obj const Obj& Obj Obj ~Obj

如果把拷贝构造函数也删除(注:此时是标成 = delete,而不是简单注释掉,注释掉的话编译器会默认提供拷贝构造和移动构造函数),在 C++14 及之前f()函数不能工作,但从 C++17 开始,即使对象不可拷贝、不可移动,对象仍然可以被返回的!C++17 要求这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤。

如果输出改为三元表达式

Obj f(bool f)
{
    Obj obj1, obj2;
    return f ? obj1 : obj2;
}

输出

Obj Obj const Obj& Obj Obj ~Obj

可见这种情况下三元表达式性能不如if-else语句。

NVO、NRVO和std::move

RVO即Return Value Optimization,是一种编译器优化技术,可以把通过函数返回创建的临时对象给去掉,可以达到少调用拷贝构造的操作。

NRVO,即Named Return Value Optimization。

class Obj
{
};

Obj f1() // RVO
{
    return Obj(); 
}

Obj f2() // NRVO
{
   Obj obj;
   return obj; 
}

std::move 会禁止 NRVO。