undefined

《C++核心指南》的F.20 条款:在函数输出数值时,尽量使用返回值而非输出参数

之前的做法:

  1. 调用者负责管理内存,接口负责生成,类似如下:

    1
    2
    MyObj obj;
    ec = initialize(&obj);

    缺点:啰嗦、难于组合。需要写更多的代码行,使用更多的中间变量,也就容易犯错误

  2. 接口负责对象的堆上生成和内存管理

    接口提供生成和销毁对象的函数,对象在堆上维护。fopen 和 fclose 就是这样的接口的实例。注意:使用这种方法一般不推荐接口生成对象,然后由调用者通过调用 delete 来释放。在某些环境中,比如 windows 上使用不同的运行时库时,这样做会引发问题。

    缺点:需要正确处理不同错误路径下的资源释放问题。也可以使用智能指针规避。对象永远在堆上分配,很多场合有一定的性能影响。

  3. 接口直接返回对象

    优点:

    • 代码直观,容易理解
    • 无需中间变量
    • 性能也没有问题。实际执行中,没有复制发生。不需要动态内存,所有对象及其数据全部放在栈上

如何返回一个对象

一个用来返回的对象,通常应当时可移动构造/赋值的,一般也同时时可拷贝构造/赋值的。如果这样一个对象同时又可以默认构造,我们就称其为一个半正则的对象。

如下这种情况,在没有返回值优化的情况下C++是怎么返回对象的

1
2
3
4
5
6
7
8
9
10
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;
}

注意对于一个本地变量,永远不应该返回其引用或者指针。返回非引用类型的表达式结果是个纯右值。在执行 auto r = xxx 的时候,编译器会认为我们实际是在构造对象,并且参数是纯右值,是移动构造,再没有移动构造函数则会试图匹配拷贝构造。

返回值优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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();
}
// 输出
Create A
Destroy A

上述代码,即使完全关闭优化,三种主流编译器(GCC、Clang、MSVC)都只输出两行

1
2
3
4
A getA_named(){ 
A a;
return a;
}

做一点变化,GCC和Clang 依旧只输出 Create A Destroy A ,MSVC 编译器有些不同,会调用移动构造,并且析构两次。

1
2
3
4
5
6
7
8
9
10
A getA_duang()
{
A a1;
A a2;
if (rand() > 42) {
return a1;
} else {
return a2;
}
}

如果带有分支,那所有的编译器都没法做优化,只能移动构造。

  • 如果把移动构造函数 delete 掉,那编译器就调用 拷贝构造
  • 如果把移动/拷贝构造函数都 delete 掉,在C++14之前此函数无法正常工作。但从C++17开始,对于类似 getA_unnamed 这样的情况,即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的。C++17 要求对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动。

结论

因此在函数输出数值时,尽量使用返回值而非输出参数,返回值是可以自我描述的;而 & 参数即可以是输入输出,也可能是仅输出,且很容易被误用

例外情况

  • “对于非值类型,比如返回值可能是子对象的情况,使用 unique_ptr 或 shared_ptr 来返回对象。”也就是面向对象、工厂方法这样的情况,
  • “对于移动代价很高的对象,考虑将其分配在堆上,然后返回一个句柄(如 unique_ptr),或传递一个非 const 的目标对象的引用来填充(用作输出参数)。”也就是说不方便移动的,那就只能使用一个 RAII 对象来管理生命周期,或者老办法输出参数了。
  • “要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入 / 输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。

C++ 里已经对返回对象做了大量的优化,目前在函数里直接返回对象可以得到更可读、可组合的代码,同时在大部分情况下我们可以利用移动和返回值优化消除性能的问题