《Effective Modern C++》学习笔记 - Item 41: 考虑对一定会被拷贝,并且移动开销小的参数按值传递

  • 有些函数的参数从设计上就是要被 “拷贝” 的,例如将参数拷贝加入类的容器中,具体可能是通过拷贝或移动的操作实现。为了效率,一般应该对左值参数做拷贝,右值参数做移动,例如下面的代码:
class MyString {
public:
    MyString() : str("default MyString.") {}

    MyString(const MyString& rhs) : str(rhs.get()) {
        cout << "MyString: copy ctor" << endl;
    }
    MyString(MyString&& rhs) : str(std::move(rhs.get())) {
        cout << "MyString: move ctor" << endl;
    }
    std::string get() const { return str; }
private:
    std::string str;
};

class Widget {
public:
	Widget() { names.reserve(5); } // 为保证后面实验中 push_back 不出现 vector 空间不足扩容导致出现额外的拷贝,先预留一些空间
	
    void addName(const MyString& newName) {
        names.push_back(newName);
    }
    void addName(MyString&& newName) {
        names.push_back(std::move(newName));
    }
private:
    std::vector<MyString> names;
};

int main()
{
    Widget w;
    MyString s;
    w.addName(s);
    cout << "===================" << endl;
    w.addName(std::move(s));

    return 0;
}
  • 这从功能上没有问题,效率也很好,但是有一点麻烦:需要声明、实现、文档记录、维护两个功能几乎相同的函数,而且编译的目标码中也会存在两份函数。一种替代方式是使用万能引用参数:
class Widget {
public:
	template<typename T> // take lvalues and rvalues;
	void addName(T&& newName)
	{
		names.push_back(std::forward<T>(newName)); // copy lvalues, move rvalues;
	} // see Item 25 for use of std::forward
};

但是这样的缺点也很明显:允许的参数范围太大了,如果入参是不合适的类型,那么编译器产生的错误信息可能非常晦涩难懂;另外目标码中仍会存在两份甚至更多份函数。

  • 那么有没有一种一个函数解决两种情况的方案呢?答案就在本节的标题中:考虑 按值传递(pass-by-value)
class Widget {
public:
    void addName(MyString newName) {
        names.push_back(std::move(newName));
    }
private:
    std::vector<MyString> names;
};

由于此时 newName 与入参无关,后面也没有再使用此参数,因此可以将其作为右值处理。但这种做法与我们刚开始入门C++时学到的是完全相反的。下面我们就来具体分析三种做法分别的开销:

  • 函数重载:入参无论为左值还是右值,参数绑定时都有对应的版本,绑定没有开销。左值版本,newName 被用于拷贝构造一个新的 MyString 并加入容器;右值版本,newName 被用于移动构造一个新的 MyString 并加入容器。总结:左值一次拷贝,右值一次移动
    在这里插入图片描述

  • 万能引用:绑定也无开销,左值和右值因为被通过 std::forward 完美转发,也分别拷贝和移动构造一个新的 MyString 并加入容器,开销与函数重载相同:左值一次拷贝,右值一次移动

  • 按值传递:无论入参是左值还是右值,参数 newName 都必须被构造出来,左值和右值分别对应一次拷贝和移动。在函数中,newName 总是被当作右值处理,再加一次移动操作。总结:左值一次拷贝+一次移动,右值两次移动。
    在这里插入图片描述


  • 本节标题的描述方式其实蕴含了以下 4 条信息:
  1. 你可以 考虑 使用按值传参,但这不是一个强制性的意见,因为这样做毕竟在开销上是比前两种方法大的。
  2. 只对 能够被拷贝的参数 考虑按值传参。对于 move-only 的类型,实际上根本不需要写两个重载函数,只用右值参数版本即可,也没有任何理由用开销更大的按值传参了。
  3. 只对 移动开销很小的类型 考虑按值传参。以上分析已经展示了按值传参与其它两种方法的开销差异:多一次移动操作。如果参数类型的移动操作开销很大,那么用这种方式就是得不偿失了。
  4. 只对 一定会被拷贝 的参数考虑按值传参。如果函数从逻辑上不一定会拷贝参数(例如上面的例子,如果逻辑是先检查字符串的长度,如果在一定区间内才进行插入),甚至大部分时候都不会,那么以上的开销对比就会变成 一次拷贝或移动 + 一定几率一次移动 VS 一定几率一次拷贝或移动,差距更大了。
  • 即使 2、3、4 的条件都满足,考虑是否使用按值传参时也还需要注意三个问题:

  • 如果调用不只一层,而是一条较长的 调用链,那么每层一次额外的移动操作可能就是无法接受的,对于性能要求严格的应用尤其如此。

  • 拷贝赋值与拷贝构造有区别。以上分析是针对拷贝构造,拷贝赋值的情况更加复杂,在有些情况下按值传参与重载之间的开销差距会 更大,具体与左值版本函数被调用的比例、类本身是否使用动态分配(堆内存)、其它优化设计等等有关。

  • 如果入参可能是参数类型的 派生类对象,那么就不能使用按值传参,因为会引发对象的 切割(slicing),入参对象只能保持基类的部分。这本质上就是 C++ 值语义没有多态性 的体现。虽然这条与效率无关,但至关重要,直接影响程序的正确性。
    笔者注:有趣的是,本书前作《Effective C++》中 Item 20 提倡的是 “使用按常引用传参代替按值传参”,其主要根据之一也是这个问题。因为 C++98 中没有移动语义,因此那时按引用传参也不存在重载的问题。)

总结

  1. 对于可以而且一定会被拷贝、移动开销很小的参数,按值传参的性能几乎与按引用传参相同,实现上更简单,而且生成的目标码体积更小。
  2. 拷贝赋值可能比拷贝构造的开销大很多(原书这里疑似写反了)。
  3. 由于对象切割问题的存在,按值传参一般不适合用于基类类型的参数。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值