C++编程:传值还是传const&
以下的代码是错的。试着找出里面的错误吧。
void foo(const std::shared_ptr<Widget>& widget);
经验法则
作为C++程序员,你大概记得在刚跳进这粪坑的时候,问过的这样的问题:
你:请问老师,string
周围的这些是啥?为什么int
可以直接是int
,但string
却必须是const string&
?
老师:吧啦吧啦吧啦,(在黑板上写写画画),吧啦吧啦吧啦。
你:哦我懂了(你当然懂了),老师,那既然这样的话我什么时候应该用哪个?
老师:这好办。所有这些内置的类型比如int
、bool
、char
应该直接传值。但其它类型比如结构体或者类,就需要用const xxx&
的方式传递引用。哦对了,string
它并不是一个内置类型,它是个类,而且你看,你的 Visual Studio 都把它显示成了别的颜色。
或者你并没有老师,你有的是个先辈,或者你干脆就是自己百度谷歌然后抄 stackoverflow 的那种类型的家伙。不论如何,你选择了一个非常简单的规则,这作为初学者而言,“大道从简”是一件好事,因为它可以让你知道如何快速上手,但有时候也会让你被问题卡住。
但这还没完,你会在学习中再次回到这个问题上来。因为你有机会读到Scott Meyers写的《高效开发C++:55种方式改善你的程序和设计》(Effective C++: 55 Specific Ways to Improve Your Programs and Designs)。其中就有:“第20则:传参应该多传引用少传值。”这里说的正好就是这个问题。你一边读一边发出“我懂了”的声音,一边看到这样的两行总结:
请记住:
- 应该尽可能传引用而不是传值。它更加高效而且避免你的程序代码运行起来上下不搭边。
- 但是这条法则并不适用于内置类型、STL遍历器、函数对象。对于这些东西,通常情况下传值更合理。
我打赌你读到“STL遍历器”之前就跳过了内容直接看下一条了。
并且我相信你发现了我们实际上讨论的是对于智能指针std::shared_ptr
应当传值还是传const
引用的问题。
对于机器而言是原型,对于你而言是接口
函数原型定义了函数的返回值和参数的个数与类型。你在看“声明”的时候看到的基本就是这些玩意儿。
std::istream& getline(std::istream& input_stream, std::string& str);
这个是简化了的众所周知的std::getline
函数。这样的声明包含了对于编译器而言必要的信息。编译器需要知道输入参数的数量,它们的大小和顺序,从而生成机器指令来完成调用。与此同时,它还包含了对于人类,也就是这个函数的使用者而言的有价值的信息。人类靠直觉判断出str
是一个输出参数,用来接收函数的返回结果,也就是这个函数读出来的“行”。当然如果它是const std::string& str
,那就完全不是这回事儿了。但实际上加上去的const
修饰并不会让编译器产生的机器码和不加const
修饰的时候那个函数相关的机器码有什么不同。事实上,你的编译器在生成机器码的时候并不管函数参数有没有const
修饰。“Const正确性”对于C++而言是一个很美好的概念。它定义接口的人和使用接口的人之间的契约。
总之就是你定义你的接口,让你的用户知道你的接口是干嘛的,你是怎么设计它的。
回到代码
当我们说代码是错的,并不是说代码有Bug。要么代码性能不行,要么代码“有方言”。对于“代码方言”这一点,它在技术上是好东西。它让你知道虽然代码能运行(因此不是Bug),但是会警告你代码有深层次的问题。这些“代码方言”的说法因语言而异,因行业而异。
为了理解代码错在了哪,让我们先快速回顾一下std::shared_ptr
是什么。它是一个智能指针,用 引用计数 来包装一个指针,并且提供了像指针的接口。在任何时候,引用计数都可以指示出有多少shared_ptr
在 共同拥有 这个资源。在每次增加一个拥有者的时候,引用计数的数值增加,而在拥有者被销毁(生命周期外,或者被析构)的时候引用计数会减少。最终,在引用计数减到零的时候,原始指针被delete,资源被释放。
在考虑到这一点的情况下,我们再看一遍函数,响亮地想出来!
void foo(... widget);
我们知道类型里肯定某处会写个Widget
,但它具体是如何的呢?其实是要看情况的。我会问我自己以下三个问题:
- 我是否需要在函数里修改原对象?
- 复制一个Widget是否昂贵?
- 我是否需要负责管理对象的生命周期?
这些问题有8(2³)个可能的答案组合,但是由于它们之间互相关联,因此其中一些组合是无关的。
Q1 |
Q2 |
Q3 |
参数 |
Yes |
- |
Yes |
std::shared_ptr<Widget> widget |
Yes |
- |
No |
Widget& widget |
No |
Yes |
Yes |
std::shared_ptr<const Widget> widget |
No |
Yes |
No |
const Widget& widget |
No |
No |
- |
Widget widget |
在继续之前,我建议做个实验:从右往左反过来看这张表,先看参数,再尝试回答上面列出的问题。想象一下,你阅读的代码里正好有这么一个函数,它使用了这样的参数,思考一下这样使用参数的意图。
因此,不仅特定的情况决定了接口的设计,而且接口的设计也解释了特定的情况。考虑到这一点,让我们最终回到我们从开头的时候讨论的代码。
为什么我们的表种没有const std::shared_ptr<Widget>&
的情况?是我们忘了试验它嘛?让我们通过反向思维的方式来找出答案:如果有人设计了这样的接口,他的理由是什么。
void foo(const std::shared_ptr<Widget>& widget);
我会如此解读:
- 我需要一个指向
Widget
的指针,但不是一般的指针,我要一个智能的,具体来说就是共享的指针。
- 但它并不是一个
shared_ptr<const Widget>
,因为我会修改原对象,所以请注意。
- 还记得我说过要共享指针吗?我其实并不想复制一个共享指针并成为一个共有者。基本上,我并不会去使用它的共享的部分相关的功能,但如果这个对象没有被封装进共享指针,则它应该被封装进共享指针。如果是这样,通常就会造成 双重释放异常 。
这不仅没有道理,而且它的方言听起来还像个合法代码。它带来了不确定性和潜在的新错误。
结论
当你经常编写C++,你就会经常看到const&,以至于你习惯了它、不去质疑它的正确性。绝大多数情况下使用const&是对的。让shared_ptr如此特别的是其独特的理念。但这并不是C++语言和STL中唯一的理念。对它们应当采取新的观点,比如去思考std::remove
的算法为何要erase配套(Erase–remove惯用法)
多观察“第20则:传参应该多传引用少传值。”提到的“这条法则并不适用于内置类型、STL遍历器、函数对象”。其实可能有更多的地方不应该适用这条规则呢。总之,多读书可以显著改变生活。
马丁·福勒(Martin Fowler)曾经有句著名的话:“任何傻瓜都可以编写计算机可以理解的代码。然而只有好的程序员才能编写人类可以理解的代码。”代码除了指示机器如何工作以外,它还有除此以外的职能。我们需要设计出能展现用途、理念的,清晰而又富有表现力的界面。对于正确合理的计算机开发非常重要。