函数传参string_浅谈C++传参的效率问题

本文的主要观点来自于《Effective Modern C++》

假设我们有一个类叫做Student,他有一个std::string类型的成员变量name,和一个std::vector 类型的成员变量ranks记录ta历次考试的名次,那么这个类大概长成这个样子:

class Student {    std::string name;    std::vector<unsigned> ranks;};

之后,你很可能会想为其设计一个构造函数。

传值

如果你是一个刚学C++不久的萌新,你可能写出这样的代码:

class Student {    std::string name;    std::vector<unsigned> ranks; public:    Student(std::string _name, std::vector<unsigned> _ranks) : name(_name), ranks(_ranks){}};

如果你的第一反应的确是这样的实现,或者你干脆不知道上面的构造函数后面怎么还会有冒号的,脑海里充斥着“这是啥呀,咋回事呀”,建议还是先补一补C++基础(摊手)

这个实现的问题在于_name_ranks传值进来的,仅仅在传递参数的时候就(可能)做了一次copy,而在真正的初始化成员变量时又做了一次copy,简直慢的难以忍受(什么?你说你不追求这种“鸡毛蒜皮”的效率?那你写个毛线的C++啊,Meyers老师的话送给你

33cfea593314e023b8eba9ee4414e47b.png

传const左值引用

如果你是一个生活在C++11之前时代的程序员,则你的实现会是下面这样,使用const引用

class Student {    std::string name;    std::vector<unsigned> ranks; public:    Student(const std::string& _name, const std::vector<unsigned>& _ranks) : name(_name), ranks(_ranks){}};

比起之前完全传值的“萌新”实现,你每个成员变量都节省了一次copy操作,可喜可贺,但是,在modern C++眼里,这样还是太慢了

组合传const左值引用或右值引用

在Modern C++的时代,大家对于必然会存在的copy非常的不满,于是,右值引用和std::move那一套就降临了,它解救了万千C++程序员于水火之中!(所有C++程序员都在大喊:“还不够快!还能更快!快!“

本文无意详细解释何为右值,何为右值引用,以及std::move在干什么。

你可简单将右值理解为“在这人世间匆匆来临又匆匆逝去的可怜虫”,如下面代码第二行括号内的部分就是一个右值,它只是一个临时存在的东西,它在这里的唯一存在意义就是提供值用于初始化s2,在s2初始化完毕后,你再也找不到它了(当然,我个人建议如果你真的不知道什么是右值的话,还是去查一下吧。一个简单的口诀是:一切有“名字”的东西,都不是右值。甚至有名字的右值引用也不是右值而是左值,这个口诀会有一些例外,但一般来说是很实用的)

std::string s1 = "fuck";

std::string s2 = (s1 + " you!");

而右值引用则是可以绑定在右值上的引用(当然const左值引用也是可以的),写作这个样子:std::string&&,很简单,多了一个&号而已。

根据函数重载的规则,右值引用比起const左值引用对右值有更强的亲和力,就像下面的代码所说的那样

void fuck(const std::string& s); //const left-value referencevoid fuck(std::string&& s);   //right-value referencestd::string you = "you";fuck(you);      //call const left-value reference versionfuck(you + "!"); //call right-value reference version

因为右值的特点是“转眼就会死的”,所以当你把它传进构造函数用于构造成员变量时,你把它骨灰都给扬了也是可以的

具体来讲,如果你用一个右值vector来初始化vector变量,你可以不做任何元素层面的拷贝,而是直接把右值vector里面用于管理资源的指针的值赋给被初始化的vector里的指针,被初始化对象就转瞬间获得了所有的元素,之后右值vector再放弃所有元素(将指针置为空)即可。这种行为我们称之为move

很显然,move要比copy要快得多。但是因为move行为对用作初始化的对象的破坏性,我们不能随便用它。要么直接用在一个右值上用,要么我们要显式地告诉编译器,我们愿意让一个左值的骨灰也被扬了以换取效率,这便是std::move所做的,如果vector v是 左值,那么std::move(v)这个表达式便是一个右值

那么对于上文中Student的构造函数,我们可以做出这样的扩展:

class Student {    std::string name;    std::vector<unsigned> ranks; public:    Student(const std::string& _name, const std::vector<unsigned>& _ranks) : name(_name), ranks(_ranks){}    Student(const std::string& _name, std::vector<unsigned>&& _ranks) : name(_name), ranks(std::move(_ranks)){}    Student(std::string&& _name, const std::vector<unsigned>& _ranks) : name(std::move(_name)), ranks(_ranks){}    Student(std::string& _name, std::vector<unsigned>& _ranks) : name(std::move(_name)),ranks(std::move(_ranks)){}};

非常的完美,不是吗?极致的高效率!

可惜这一方案也有致命的缺点:参数的个数越多,需要重载的种类就越多,而且甚至是指数级的数量关系。一旦参数数量较大,你要维护的重载函数的数量会多到难以忍受。

模版+完美转发

class Student {      std::string name;      std::vector ranks; public:     template      Student(S&& _name, V&& _ranks) : name(std::forward(_name)), ranks(std::forward(_ranks)){}

};

熟悉模版和完美转发的人很快就能写出上面这样的代码。

要想看懂上面的代码,需要模版类型推断、universial reference以及std::forward也即完美转发的相关知识,这里不给出详细解释。大概意思是说,完美转发会让实参是左值时转发给成员变量构造函数的也是左值,实参是右值时转发给成员变量构造函数的也是右值,也即是说,效率与上一节完全一致。

看上去非常的完美,不是吗?很遗憾,仍然不是,这种方法有如下缺点:

  • object code冗余:模版函数的实现一般在头函数中。而模版实际做的是代码生成,它不只会生成上一节中的四种函数,对于_name传进来的实参是const char*等可以转换成std::string的类型的情况,模版也会为之生成一个版本的函数,这些生成的函数一般都在头文件中,导致object code变得很臃肿

  • non-deduction context:Student student("Tom", {1, 2, 3});将无法通过编译,因为{1, 2, 3}的类型不能被模版直接推断;而用上一章节的方法是可以t通过编译的

  • 报错信息泥石流:如果你有幸见识过因为模版导致的编译错误信息,你就知道那是多么恐怖的事情,很有可能几行代码就能产生一堆篇幅足以当政治课论文的报错信息(笑)

  • universial refercence过于贪婪:如果你想给ranks加一个默认值,比如没有成绩,也就是空的vector,那么你将遭遇非常恐怖的事情:universial reference是实在太猛了,绝大多数函数在重载竞争的时候都干不过它,只有”正正好好“的匹配才能胜过它。这会导致下面代码里的问题,显然student2是想要调用Student的默认复制构造函数,但因为student不是const的左值,因此不算“正正好好”,就会被univerisial reference抢走,然后boom!

    class Student {    std::string name;    std::vector<unsigned> ranks; public:    Student(const Student& student) = default;      template<typename S, typename V = std::vector<unsigned>>    Student(S&& _name, V&& _ranks = std::vector<unsigned>{}) : name(std::forward<S>(_name)), ranks(std::forward<V>(_ranks)){}};Student student("Tom", vector<unsigned>{1 ,2, 3});Student student2{student};  //Will call universial reference version!!!

轮回?还是传值?

上面的方法总有这样和那样的苦恼,于是,有人又想出了下面这种方案

class Student {  std::string name;  std::vector<unsigned> ranks; public:  Student(std::string _name, std::vector<unsigned> _ranks) : name(std::move(_name)), ranks(std::move(_ranks)){}};

这种方案的效率比起之前两节的版本稍低一点:无论实参是左值还是右值,每个参数都会多做一次move。

具体而言:

  • 左值实参:先copy到形参,再做一次move

  • 右值实参:先move到形参,再做一次move

但是这会避免上面章节中提到的种种问题,而move往往效率是很高的,看上去我们做了一次完美的trade-off:一点点性能损失,带来了清爽的代码体验。

然而……C++里真的有完美可言吗?这个方法也有一些问题!

  • 不是所有的move都很快:尽管我们接触到的许多类型的move都很快,但也有一些类型的move操作并不快(比如std::array),你需要清楚地了解参数类型的move操作的效率,否则可能带来严重的性能问题

  • 不是所有的类型都可以copy:这个方法预设了左值实参传进来时会先做一次copy,但有一些类型是不允许copy的(比如std::unique_ptr)

  • 有时你要写的不是构造函数,而是类似于一个std::string strpush_backstd::vector<:string>的逻辑,而一个很常见的需求是只有某个谓词pred对于str成立时(也即pred(str)==true)才进行push_back,如果不成立则不做任何事情。如果你传引用进来,pred没能成立的话,你只是挥挥手不带走一片云彩;但如果你传值进来,pred即便没能成立,你也需要对str做析构,而str的析构涉及deallocate。如果统计上pred不成立的概率较高,则也会带来不可忽视的性能问题

综上所述,不存在一种完美的方案解决传参的效率问题。你需要对你编码所涉及的各种类型的行为都很熟悉,然后综合上述几种方式,为其设计最佳的方案,这很难,而且很耗精力。

因此,我的意见是,不要写C++了(

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值