c++核心指南 F.20 函数尽量使用返回值传出结果,而不是用函数参数传出结果
    这一点应该会让c++老手感到惊讶(opencv的所有函数几乎都是参数传出结果),在以前几乎都是用函数参数传出结果
    下面先讲下以前的这种函数参数传出结果的方法

    1调用者负责管理内存,接口(函数)负责生成或修改内存 (函数参数传出结果)
    函数(接口)的调用者负责管理内存 分配一个对象所需的内存,并负责这个对象的声明周期。
    函数则负责生成或修改对象。

    这种做法意味着对象可以默认构造,代码一般使用错误码而非异常
    这种做法其实就是C中的函数做法,c++程序员沿用了这种做法
    在C中使用指针

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

    而在c++中使用引用,虽然语法上不同但是本质完全相同

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

    这种做法的主要问题是啰嗦难以组合,需要写更多的代码和更多的中间变量,也就更容易犯错。

    举一个例子,对于一个矩阵乘法和加法的操作

    1. error_code_t add(
    2. matrix* result,
    3. const matrix& lhs,
    4. const matrix& rhs);//矩阵加法声明 返回错误码
    5. error_code_t multiply(
    6. matrix* result,
    7. const matrix& lhs,
    8. const matrix& rhs);//矩阵乘法声明 返回错误码
    9. error_code_t ec;//声明错误码返回对象 用于返回
    10. matrix temp;
    11. ec = multiply(&temp, a, b);//调用乘法 计算a*b 结果由变量temp传出 函数返回错误码ec
    12. if (ec != SUCCESS) {
    13. goto end;//由函数返回错误码 如果出错直接goto(goto C语言里就有了,直接跳转函数流程到end符的代码那里)到错误处理代码
    14. }
    15. matrix r;
    16. ec = add(&r, temp, c);//调用加法 计算temp+c 结果由变量r传出 函数返回错误码ec
    17. if (ec != SUCCESS) {
    18. goto end;
    19. }
    20. end:
    21. // 返回 ec 或类似错误处理


    错误码和参数传出结果 这两种方式的结合是比较常见的,参数传出结果+异常处理 这种组合实际系统中不会采用这种组合

    2接口(函数)负责对象的堆上生成和内存管理(函数返回结果对象的指针)

    函数负责对象的生成和销毁,对象在堆上维护。fopen和fclose就是这样的接口,这种模式不推荐由函数生成对象,然后由调用者来delete来释放(而应该将对象的生成和销毁都交给函数负责)

    1. matrix* add(
    2. const matrix* lhs,
    3. const matrix* rhs,
    4. error_code_t* ec);//函数返回结果对象的指针 错误码由函数参数传出
    5. matrix* multiply(
    6. const matrix* lhs,
    7. const matrix* rhs,
    8. error_code_t* ec);//函数返回结果对象的指针 错误码由函数参数传出
    9. void deinitialize(matrix** mat);//释放
    10. error_code_t ec;
    11. matrix* temp = nullptr;
    12. matrix* r = nullptr;
    13. temp = multiply(a, b, &ec);
    14. if (!temp) {
    15. goto end;
    16. }
    17. r = add(temp, c, &ec);
    18. if (!r) {
    19. goto end;
    20. }
    21. end:
    22. if (temp) {//如果temp对象生成了 但是出错了,则释放temp对象
    23. deinitialize(&temp);
    24. }
    25. // 返回 ec 或类似错误处理


    代码还是有点啰嗦,比如if (!temp) {goto end;}if (!r) {goto end;}这两个判断还是啰嗦
    原因就是要处理各种不同错误路径下的资源释放问题,这里没有使用异常,因为异常在这种表达下会产生内存泄漏,除非用上一堆try catch 但这样表达又不简洁了。

    但是使用智能指针作为返回,并且使用异常进行处理。那代码还是比较ok的
    函数接受和返回的都是 shared_ptr 智能指针 还算ok

    1. shared_ptr<matrix> add(
    2. const shared_ptr<matrix>& lhs,
    3. const shared_ptr<matrix>& rhs);
    4. shared_ptr<matrix> multiply(
    5. const shared_ptr<matrix>& lhs,
    6. const shared_ptr<matrix>& rhs);
    7. auto r = add(multiply(a, b), c);

    调用这些接口必须要使用 shared_ptr,也算一个限制。另外,对象永远是在堆上分配的,在很多场合,也会有一定的性能影响。


    2接口(函数)直接返回对象(函数返回结果对象)

    1. #include <armadillo>//现代C++特性的开源线性代数库 推荐
    2. #include <iostream>
    3. using arma::imat22;
    4. using std::cout;
    5. int main()
    6. {
    7. imat22 a{{1, 1}, {2, 2}};//imat22 元素为整型 大小固定为2X2的举证
    8. imat22 b{{1, 0}, {0, 1}};
    9. imat22 c{{2, 2}, {1, 1}};
    10. imat22 r = a * b + c;//op函数直接返回对象
    11. cout << r;
    12. }

    函数直接返回对象,让这段代码直观且容易理解。特别是因为函数直接返回对象,使矩阵加法乘法可以组合在一行里写出来,无需中间变量保存暂时的结果。性能也ok,执行的过程中没有发生复制,计算结果直接放到结果变量r上,同时因为矩阵大小已知 在这不需要动态内存来存size可能变化的矩阵,只需要临时对象保存就行(对象数据存在栈上)。这两点也是非常好的优点。

    如何返回一个对象
    一个被用来返回的对象,应当是可移动&拷贝 构造&赋值 的类对象。
    如果一个对象可以 移动&拷贝 构造&赋值 + 可默认无参构造我们称它为半正则对象
    当我们返回对象时,我们应尽量让返回的这个对象的类满足半正则要求。

    1. class matrix {
    2. public:
    3. // 普通构造
    4. matrix(size_t rows, size_t cols);
    5. // 半正则要求的构造
    6. matrix();//默认无参构造
    7. // 半正则要求
    8. matrix(const matrix&);//拷贝构造
    9. matrix(matrix&&);//移动构造
    10. matrix& operator=(const matrix&);//拷贝赋值
    11. matrix& operator=(matrix&&);//移动赋值
    12. };
    13. matrix operator*(const matrix& lhs,
    14. const matrix& rhs)//矩阵乘法
    15. {
    16. if (lhs.cols() != rhs.rows()) {
    17. throw runtime_error(
    18. "sizes mismatch");
    19. }
    20. matrix result(lhs.rows(),
    21. rhs.cols());
    22. // 具体计算过程
    23. return result;
    24. }

    没有返回值优化(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个例子非常好

    1. #include <iostream>
    2. using namespace std;
    3. // Can copy and move
    4. class A {
    5. public:
    6. A() { cout << "Create A\n"; }
    7. ~A() { cout << "Destroy A\n"; }
    8. A(const A&) { cout << "Copy A\n"; }
    9. A(A&&) { cout << "Move A\n"; }
    10. };
    11. A getA_unnamed()
    12. {
    13. return A();
    14. }
    15. int main()
    16. {
    17. auto a = getA_unnamed();
    18. }

    在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()

    1. A getA_named()
    2. {
    3. A a;
    4. return a;
    5. }
    6. int main()
    7. {
    8. auto a = getA_named();
    9. }


    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将失效

    1. #include <stdlib.h>
    2. A getA_duang()
    3. {
    4. A a1;
    5. A a2;
    6. if (rand() > 42) {
    7. return a1;
    8. } else {
    9. return a2;
    10. }
    11. }
    12. int main()
    13. {
    14. auto a = getA_duang();
    15. }

    输出
    Create A // A a1;
    Create A //A a2;
    //c++11及之后 将用a构造匿名对象 给优化没了
    Move A //auto a = 移动或拷贝返回对象 去构造栈上对象a 因为定义了移动,所以优先移动
    Destroy A
    Destroy A
    Destroy A

    如果代码改为

    1. A getA_duang2()
    2. {
    3. A a1;
    4. A a2;
    5. return (rand() > 42 ? a1 : a2);
    6. }

    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