c++核心指南 F.20 函数尽量使用返回值传出结果,而不是用函数参数传出结果
这一点应该会让c++老手感到惊讶(opencv的所有函数几乎都是参数传出结果),在以前几乎都是用函数参数传出结果
下面先讲下以前的这种函数参数传出结果的方法
1调用者负责管理内存,接口(函数)负责生成或修改内存 (函数参数传出结果)
函数(接口)的调用者负责管理内存 分配一个对象所需的内存,并负责这个对象的声明周期。
函数则负责生成或修改对象。
这种做法意味着对象可以默认构造,代码一般使用错误码而非异常
这种做法其实就是C中的函数做法,c++程序员沿用了这种做法
在C中使用指针
MyObj obj;
ec = initialize(&obj);
…
而在c++中使用引用,虽然语法上不同但是本质完全相同
MyObj obj;
ec = initialize(obj);
…
这种做法的主要问题是啰嗦难以组合,需要写更多的代码和更多的中间变量,也就更容易犯错。
举一个例子,对于一个矩阵乘法和加法的操作
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);//调用乘法 计算a*b 结果由变量temp传出 函数返回错误码ec
if (ec != SUCCESS) {
goto end;//由函数返回错误码 如果出错直接goto(goto C语言里就有了,直接跳转函数流程到end符的代码那里)到错误处理代码
}
matrix r;
ec = add(&r, temp, c);//调用加法 计算temp+c 结果由变量r传出 函数返回错误码ec
if (ec != SUCCESS) {
goto end;
}
…
end:
// 返回 ec 或类似错误处理
错误码和参数传出结果 这两种方式的结合是比较常见的,参数传出结果+异常处理 这种组合实际系统中不会采用这种组合。
2接口(函数)负责对象的堆上生成和内存管理(函数返回结果对象的指针)
函数负责对象的生成和销毁,对象在堆上维护。fopen和fclose就是这样的接口,这种模式不推荐由函数生成对象,然后由调用者来delete来释放(而应该将对象的生成和销毁都交给函数负责)。
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) {//如果temp对象生成了 但是出错了,则释放temp对象
deinitialize(&temp);
}
// 返回 ec 或类似错误处理
代码还是有点啰嗦,比如if (!temp) {goto end;}if (!r) {goto end;}这两个判断还是啰嗦
原因就是要处理各种不同错误路径下的资源释放问题,这里没有使用异常,因为异常在这种表达下会产生内存泄漏,除非用上一堆try catch 但这样表达又不简洁了。
但是使用智能指针作为返回,并且使用异常进行处理。那代码还是比较ok的
函数接受和返回的都是 shared_ptr 智能指针 还算ok
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,也算一个限制。另外,对象永远是在堆上分配的,在很多场合,也会有一定的性能影响。
2接口(函数)直接返回对象(函数返回结果对象)
#include <armadillo>//现代C++特性的开源线性代数库 推荐
#include <iostream>
using arma::imat22;
using std::cout;
int main()
{
imat22 a{{1, 1}, {2, 2}};//imat22 元素为整型 大小固定为2X2的举证
imat22 b{{1, 0}, {0, 1}};
imat22 c{{2, 2}, {1, 1}};
imat22 r = a * b + c;//op函数直接返回对象
cout << r;
}
函数直接返回对象,让这段代码直观且容易理解。特别是因为函数直接返回对象,使矩阵加法乘法可以组合在一行里写出来,无需中间变量保存暂时的结果。性能也ok,执行的过程中没有发生复制,计算结果直接放到结果变量r上,同时因为矩阵大小已知 在这不需要动态内存来存size可能变化的矩阵,只需要临时对象保存就行(对象数据存在栈上)。这两点也是非常好的优点。
如何返回一个对象
一个被用来返回的对象,应当是可移动&拷贝 构造&赋值 的类对象。
如果一个对象可以 移动&拷贝 构造&赋值 + 可默认无参构造,我们称它为半正则对象。
当我们返回对象时,我们应尽量让返回的这个对象的类满足半正则要求。
class matrix {
public:
// 普通构造
matrix(size_t rows, size_t cols);
// 半正则要求的构造
matrix();//默认无参构造
// 半正则要求
matrix(const matrix&);//拷贝构造
matrix(matrix&&);//移动构造
matrix& operator=(const matrix&);//拷贝赋值
matrix& operator=(matrix&&);//移动赋值
};
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;
}
在没有返回值优化(RVO return value optim)情况下
在之前有讲过,返回非引用类型的表达式结果是个纯右值(prvalue) (x=1),++x,?:表达式返回的是左值引用(左值引用对象是个左值!!),auto r = 函数调用,编译器会认为我们是在构造r对象matrix r(函数返回的对象);,因为函数返回对象是匿名的纯右值,因此编译器会先去找matrix的移动构造函数,没有移动构造的话再去找拷贝构造。
受保证的复制消除 如果 return 表达式 是纯右值,那么直接以该表达式初始化结果对象。其中在类型相匹配时并不涉及复制或移动构造函数(复制消除)。 |
---|
不算被返回对象result的构造次数,
c++11以前在返回的过程中存在两次构造 1 用result去构造一个真正用于返回的匿名对象 2 用匿名对象 去构造 被函数初始化的对象r
C++11 开始 如果没有返回值优化 只会进行一次移动或拷贝构造 将局部变量移动或拷贝到用户调用的栈上,返回值优化(RVO)仍可以发生,但在没有返回值优化的情况下,编译器将试图把局部对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move
进行干预——使用std::move
对于移动行为没有帮助,反而会影响返回值优化。”C++11开始,除了显示地加入-fno-elide-constructors编译器选项取消优化,使用返回值 构造 真正用于返回的临时对象这一步永远不会发生。
因为返回的是一个局部对象变量,我们永远不应该返回其引用或指针 不管其为左值还是右值。
返回值优化(拷贝消除) —- 举的4个例子非常好
#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();
}
在c++17标准中即使完全关闭优化,三种主流编译器(GCC、Clang 和 MSVC)都只输出两行: 依然有返回值优化RVO生效,
Create A //只在A()时构造一次
Destroy A
回值优化RVO生效 下面两次构造都省略了
1用A()去构造一个真正用于返回的匿名对象(c++11以后不存在这一步) 2 用匿名对象 去构造 被函数初始化的对象a (c++11 在没有RVO的情况下 只会做一步移动或拷贝返回对象 构造函数返回位置上的栈上对象)
相当于将代码优化为 auto a = A(); //直接将getA_unnamed()函数中构造返回的A() 取个别名叫a
将函数getA_unnamed()改为getA_named()
A getA_named()
{
A a;
return a;
}
int main()
{
auto a = getA_named();
}
c++17 GCC和CLANG 在关闭优化的情况下
返回结果 依旧只有两条
Create A //只在A a;时构造一次
Destroy A
但MSVC在非优化编译(优化使用/01,/02,/0x命令行参数),即不使用01,/02,/0x命令行参数
Create A//A a;
//c++11 将用a构造匿名对象 给优化没了
Move A//auto a = 移动或拷贝返回对象 去构造栈上对象a 因为定义了移动,所以优先移动
Destroy A// 析构局部变量a 注意!!!!! 对auto a变量的构造早于 局部变量的析构
Destroy A
再变一下,将代码改为分支return 则所有编译器的RVO将失效
#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 // A a1;
Create A //A a2;
//c++11及之后 将用a构造匿名对象 给优化没了
Move A //auto a = 移动或拷贝返回对象 去构造栈上对象a 因为定义了移动,所以优先移动
Destroy A
Destroy A
Destroy A
如果代码改为
A getA_duang2()
{
A a1;
A a2;
return (rand() > 42 ? a1 : a2);
}
auto a = getA_duang2();得到的结果是:
Create A
Create A
Copy A
Destroy A
Destroy A
Destroy A
getA_duang2()的返回类型是A类,而非A类的引用(如果返回引用 则不会调用构造函数 直接就是取别名了)。
这里有点复杂就先不细讲了,函数返回一个局部变量是有特殊规则的,函数返回类型为对象值类型 实际返回一个类对象(像getA_duang) 优先调用移动构造来初始化接受函数返回值的栈上对象。函数返回类型为对象值类型 实际返回一个类对象的左值引用(像getA_duang2) 调用拷贝构造来初始化接受函数返回值的栈上对象。
https://zh.cppreference.com/w/cpp/language/return
https://zh.cppreference.com/w/cpp/language/overload_resolution
函数按值返回这个对象具体分两种情况:
发生 NRVO/RVO 的情况下,返回值对象就是 return 语句中所用对象(其生存期已得到延续);
不发生 NRVO/RVO 的情况下,返回值对象是 return 语句中所用对象的副本(生存期长于函数调用时期)。
C++11 开始,返回值优化(RVO)仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move
进行干预——使用std::move
对于移动行为没有帮助,反而会影响返回值优化。”C++11开始,除了显示地加入-fno-elide-constructors编译器选项取消优化,使用返回值 构造 真正用于返回的临时对象这一步永远不会发生。
c++17以前对编译器加-fno-elide-constructors取消优化也会构造返回匿名对象,而c++17就算取消优化依旧是不会构造这个匿名对象。
c++17对于一个不可拷贝、不可移动(不提供拷贝和移动 并设置为delete)的对象依然可以被返回(17以前直接报错)
c++17要求对象必须被直接构造在目标位置上(比如说函数调用者的栈上),省略中间的所有多余的构造。
在17中直接返回对象是很高效的,不会有多余的构造
下面讲一下F20里描述的,不直接返回对象的情况
1“对于非值类型,比如返回值可能是子类对象的情况,使用 unique_ptr 或 shared_ptr 来返回对象。”也就是面向对象、工厂方法这样的情况,像[第 1 讲] 里给出的 create_shape 应该这样改造。
2“对于移动代价很高的对象(对象太大,没法做移动优化(如 sizeof 达到数百个字节以上)),考虑将其分配在堆上,然后返回一个句柄(如 unique_ptr),或传递一个非 const 的目标对象的引用来填充(用作输出参数)。”也就是说不方便移动的,那就只能使用一个 RAII 对象来管理生命周期,或者老办法输出参数了。
3“要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入 / 输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。
我在工作中使用引用出参的场景之一是同时返回多个对象,如果使用返回值就要封装很多不同结构体。请问老师这种场景建议怎么实现?
作者回复: 如果都是返回而非修改的话,可以使用 pair、tuple、tie 和第 8 讲讨论的结构化绑定。
cpp标准和gcc编译器版本的对应关系
https://en.cppreference.com/w/cpp/compiler_support