极客时间已完结课程限时免费阅读

41|对象传参和返回的最佳实践

41|对象传参和返回的最佳实践-极客时间

41|对象传参和返回的最佳实践

讲述:吴咏炜

时长14:31大小13.26M

你好,我是吴咏炜。
第 10 讲我们讨论过,《C++ 核心指南》的 F.20 条款推荐我们优先使用返回对象的方式。在那里,我们简单地讨论了一些例外情况,并没有深入展开。同时,我们没有讨论传参形式的选择,而事实上,这两个问题是紧密相关的——尤其是考虑到出参和返回语句都是函数向外传递对象的方式。今天,我们就来深入探讨一下这个问题。

传统的对象传递方式

Herb Sutter 在 CppCon 2014 时早就总结过,传统的——即 C++98 的——对象传递方式应该是我们的基本出发点 [1]。可以用表格示意如下:
简单解释一下表格里的行列:
表格把对象的类型按复制代价分成三种,然后按出入参有四种不同的情况,分别进行讨论。
复制代价低指相当于拷贝几个整数的开销;复制代价中指大于几个整数的开销,直至约 1 KB 的连续内存,且不涉及内存分配;除此之外属于代价高的情况。
“出”指我们想要从函数中取得(返回)某个对象的情况;“入 / 出”指传递给函数且让函数修改该对象的情况;“入”指纯粹传递给函数作为参数且不修改该对象的情况;“入且保留一份”指函数会把参数指代的对象保存到某个地方,如类的成员变量或全局变量里。
当需要取得一个复制代价低到中的对象时,我们可以直接使用函数的返回值。由于 C++98 没有移动,复制代价高的对象只能使用出参的方式来返回,如容器。如果一个对象既是出参又是入参,那我们就只能使用按引用传递的出入参了。如果是纯粹的入参,那不管我们怎么使用,我们就只考虑复制代价:如果复制代价很低,比如小于等于两个指针的大小,那直接按值传递就好;否则,按 const 引用传递性能更高,明确表达了该函数不修改此入参的意图。对于入参的这两种方式,我们都无法修改调用方手里的对象。
对于表格右上角的复制代价高的对象返回,我们还有一种方式是把它分配在堆上,然后返回 X*。这样会带来内存分配的开销,但之后这个对象的传递就非常方便了,在很多场景下仍然是值得的。

现代 C++ 的对象传递方式

到了现代 C++,上面的建议仍然基本适用。不过,我们需要做一点小小的调整:
表格的形式基本不变,但我们加入了一些移动相关的情况。尤其是,对于是否可以使用函数返回值来返回对象,主要的衡量标准成了移动的代价。现在我们可以返回一个 vector,甚至一个 vectorarray(在 array 不是很大的情况下)。对于 unique_ptr 或其他只能移动的对象,我们也可以参照 int 这样的小对象来处理,在除了出入参的情况外一律使用值的方式来传递和返回。
对于右上角的大对象返回,我们之前说过可以在堆上分配并返回指针。到了现代 C++,我们对此建议的修改是,使用 unique_ptr(如果不确定是否会“共享”)或 make_shared(如果确定需要共享)——有所有权的裸指针已经不再建议使用了。
这就是我们传递对象的基本方式了。如果我们对性能有特殊需求的话——比如,设计一些供其他人使用的公共库——那我们可能需要进一步细分后面移动相关的情况。

针对移动的优化

黄色部分是我们针对移动进行的额外优化(和 Herb 不同,我觉得单独区分“入且移动一份”没什么意义——反而在概念上引发复杂性和矛盾——因而没有对这种情况单独进行讨论)。事实上,我们使用移动构造函数和移动赋值运算符(可以复习一下第 2 讲第 3 讲),正是这种针对移动的优化。
再拿 Herb 的例子来说明一下。假设我们有一个 employee 类:
class employee {
public:
void set_name(const string& name)
{
name_ = name;
}
private:
string name_;
};
考虑到我们传递的对象可能是个临时对象——如果我们传递字符串字面量的话,就会产生出一个临时的 string 对象——我们可以针对移动来优化一下:
class employee {
public:
void set_name(const string& name)
{
name_ = name;
}
void set_name(string&& name)
{
name_ = move(name);
}
}
这样,当参数是一个右值时,我们就可以使用这个右值引用的重载,直接把名字移到 name_ 里,省去了复制字符串及潜在的内存分配开销。
需要注意的是,通常只有在你设计某些基础设施、需要达到较高的优化时,才需要这么去做。对于普通的 employee 类,这么做的必要性不高(employee::set_name 不会成为一个影响你程序性能的因素吧?);而对于像 std::string 这样的基础库,那这样的优化就完全必要了。

值传参?

对以上的代码有一种简化的写法,值得探讨一下。它就是值传参:
class employee {
public:
void set_name(string name)
{
name_ = move(name);
}
}
这里我们通过 string 的值传参,把两种情况合成了一种。当传进来的 string 是一个左值时,我们先进行一次拷贝构造,然后进行了一次移动赋值;当传进来的 string 是一个右值时,我们先进行一次移动构造,然后进行了一次移动赋值。这样,似乎我们以一次移动为代价,把两种情况归一了。看起来似乎还不错?
事实上,第 2 讲smart_ptr 的赋值运算符,我就是以这种方式来实现的。这种用法有一个专门的名字,叫“拷贝并交换惯用法”(copy-and-swap idiom)[2]。它能优雅地归并拷贝和移动赋值运算符,取消自赋值检查,并实现强异常安全性。
但是,如果我们考虑到 set_name 有可能被多次重复调用的话(虽然对于这个类似乎并不太会发生),那这个实现对于左值有一个潜在的重大缺陷:不能充分利用已经分配的内存。因此,容器和字符串的标准实现中都不使用这种方式来赋值。我们也需要记住 Howard Hinnant 的话:“不要盲目地认为构造和赋值具有相同的开销。”—— 一般而言,容器和字符串的拷贝赋值开销小于拷贝构造。
当我们采用最平常不过的 const string& 的传参形式时,在函数体内是一个拷贝赋值操作。当 name_ 的已分配空间比新名字的长度大时,我们不需要任何新的内存分配,拷贝赋值操作会直接把字符串复制到目标字符串缓冲区里。仅当目标缓冲区空间不足时,我们才会需要新的内存分配。可想而知,在典型的赋值场景下,在几次分配之后,缓冲区就足够大了,我们就不再会需要分配内存,因此我们后面就不再会有内存的分配和释放操作。
而当我们采用 string 的值传参时,对于左值参数,我们每次都必然会发生一次内存分配操作(通常还伴随着老的 name_ 的内存释放)。因此,在有重复调用的场景下,值传参可能并不合适。
不过,这也意味着,值传参的方式对于构造函数是非常合适的(对象构造不可能发生多次)。我们完全可以写:
class employee {
public:
employee(string name)
: name_(std::move(name))
{}
};
事实上,这也是 clangd 会提示我们做的一个现代化(modernize)的更改 [3]。下面图里的提示来自 Vim 插件 YouCompleteMe(它内部使用 clangd)[4]
如果你的构造函数有多个参数的话,这样写的好处尤其明显——因为如果我们使用左值和右值的重载的话,重载的数量会随着参数的数量而指数式上升!

“不可教授”的极致性能传参方式

有没有注意到 set_name 两种重载形式非常接近?它和我在第 3 讲里讨论完美转发时用的例子非常像,两个函数体内部唯一的区别就只是对 std::move 的调用了。我们确实可以把这个函数改造成模板形式,然后使用完美转发。考虑到参数不能是任意类型,我们再用 enable_if 加上了可对 string 赋值的限制。最终代码如下:
class employee {
public:
template <
typename S,
typename = enable_if_t<
is_assignable_v<string&, S>>>
void set_name(S&& name)
{
name_ = forward<S>(name);
}
};
对于已经读到这里(第 41 讲)的你来说,这种方式也不算太复杂吧?但是,它确实不简单了。写出这样代码的人需要了解 C++ 的下列知识点:
string 有重用缓冲区空间的可能
知道转发引用和完美转发
知道 SFINAE 和 enable_if
知道标准库已经提供了相应的类型特征
难怪乎,Bjarne(C++ 之父)看到这样的代码,都感叹这“不可教授(Unteachable!)”了。
除了写这样代码的难度问题外,该代码还有下列问题:
对于不同形式的实参,会实际生成多份函数实例(本例中函数简单可内联,还问题不大)
实现代码必须放在头文件里(至少在可以用 C++20 模块之前)
因为这是个函数模板,它不能是虚函数
如果这些问题都可以接受的话,那我们获得的好处,就是性能了。Herb 实测了一些场景,这种写法确实可以获得最高的性能。他的测试结果总结在下图里:
这张图里的四种不同颜色的柱子就是我们讨论过的几种不同的对象传递方式:
使用 const string&
使用 const string&string&&
使用 string 值传参
使用完美转发
对于 string 的左值(第一、二种情况)和右值(第三、四种情况),我们可以看到只有中等大小的左值情况下 string 值传参性能比较差,其他各种方式差异并不大。小字符串左值 string 值传参没有问题的原因是,string 一般都有小字符串优化,对于较短的字符串不需要进行堆上内存分配,因此左值值传参的问题要在字符串较长时才会暴露出来。而到了使用字符指针传参(第五、六种情况)给 set_name 这样的函数时,前三种方式都会临时构造一个 string,会多发生一次字符串复制和 / 或堆上内存分配;只有最后一种方式没有这种额外开销,本质上直接调用了 string::operator=(const char*)
再强调一下,如果你的类不是处于代码瓶颈上、或潜在可能处于代码瓶颈上(当你设计某种基础库时),这样的大招很可能是不必要的。但 C++ 允许你在真正必要的时候写出这样的代码,让使用代码的人轻轻松松地获得性能的提升——他们并不需要关心 set_name 这样的函数的实现细节。

字符串的特殊处理

我们上面最后完美转发的大招实际上是因为字符串有特殊性——常见的字符串字面量不是一个 string 对象。那我们上面讨论的这些对象传递方式,对于字符串有什么其他需要定制的地方吗?
还真有。最主要的原因是,C++17 引入了 string_view(见第 36 讲)。对于“出”、“出 / 入”和“入且保留一份”的情况,我们仍然可以使用上面的建议(包括选择是不是使用完美转发)。不过,对于纯入参的情况,或者在“入且保留一份”的情况下你不打算使用移动优化的话,现在使用 string_view 是一个对字符串字面量更为友好的选择。
使用 string_view 的话,我们可以把 set_name 实现成:
class employee {
public:
void set_name(string_view name)
{
name_ = name;
}
};
使用这一形式的话,我们代码的性能只会在使用字符串右值时略有损失,如使用 emp.set_name(get_name_by_id(…)) 这样的代码。通常这不会是一个问题。
如果真需要极致优化的话,你仍然可以使用之前的方式,不过,需要注意,对于字符串字面量,形参 string_viewstring&& 会导致重载有二义性。你需要使用完美转发的方式,或者使用形参为 const char*const string&string&& 的三个重载。

内容小结

本讲我们讨论了对象传递的各种方式。对于大部分的情况,较为传统的对象传递方式仍然是较为合理的默认值。对于追求极致性能的情况,我们则可以使用重载、移动和完美转发来进行优化。

课后思考

如果我们需要对 set_name 成员函数写出 noexcept 说明的话,各个不同版本应分别怎么写?(答案可参考 Herb 的演讲。)
期待你的思考,也欢迎在留言区与我交流探讨!

参考资料

[1] Herb Sutter, “Back to the Basics! Essentials of Modern C++ Style” (CppCon 2014). https://www.youtube.com/watch?v=xnqTKD8uD64
[2] Stack Overflow, GManNickG’s answer to “What is the copy-and-swap idiom?”. https://stackoverflow.com/a/3279550/816999
[3] LLVM project, clangd. https://clangd.llvm.org/
[4] Val Markovic et al., YouCompleteMe. https://ycm-core.github.io/YouCompleteMe/
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 2

提建议

上一篇
40 | 如何在编译期遍历数据?
 写留言

精选留言(3)

  • Geek_1a7863
    2023-01-19 来自河北
    在“不可教授”的极致性能传参方式小节中,模板参数 enable_if_t<is_assignable_v<string&, S>>>,为什么要用string的引用不用string呢??

    作者回复: 因为你写 x = y 的时候,x 的类型就应该是个左值引用。这里表达的就是允许从 S 类型(转发引用)到 string&(右值引用)的赋值。

    共 2 条评论
  • 李聪磊
    2022-09-12 来自北京
    这篇文章把各种情况梳理的太棒了,受益匪浅!
  • 王小白白白
    2022-09-02 来自上海
    当我们采用 string 的值传参时,对于左值参数,我们每次都必然会发生一次内存分配操作(通常还伴随着老的 name_ 的内存释放。 老师,这里的一次内存分配操作, 是指构造string参数吗

    作者回复: 是的,每次都构造了一个新的string。