Table of Contents generated with DocToc
C++提供了值传递和引用传递,通常来说原则是较小的对象采用值传递,稍大或者很大的对象采用引用传递,C++11有了移动语义。现在有了多种通过引用传递的选择:
const T&
不能修改。T&
可以修改。T&&
可以移动。
为已经确定的类型选择哪一种参数传递方式都已经足够复杂,在模板中,类型是未知的,这进一步使参数传递方式的选择变得困难。
尽管如此,除非有很好的理由,通常推荐在函数模板中采用值传递,除非对于以下情况:
- 无法拷贝或者移动的值。(C++17开始可以值传递没有拷贝或者移动构造的临时对象,因为一定会复制消除。C++17起核心语言规定了不再有临时对象用于复制和移动。)
- 用于返回的参数。
- 模板仅仅转发参数(
T&&
)。 - 对象较大,引用传递改善明显。
这章讨论函数模板中的各种传参方式,通用的建议是值传递,讨论不采用值传递的情况。在阅读之前需要详细了解值类别,附录B有介绍,很简单,不详细讨论。
- 值传递时,原则上每个参数都会被拷贝,每个传递进函数的参数成为实参的一个拷贝,通常来说是使用拷贝构造函数初始化。
- 拷贝构造函数的调用是昂贵的,但是使用移动语义的话会让复制变得廉价。
- 值传递通常只有传递左值时会发生拷贝,传递纯右值因为复制消除没有任何拷贝发生,传递亡值则会发生移动。
- 但是通常来说传递左值才是常态。
值传递会退化:
- 也就是说数组会退化为指针。
- 函数会退化为函数指针。
- 顶层
const volatile
会被移除。 auto
初始化时也会这样。- 这是从C语言中继承来的机制,有好处有缺陷。通常这种退化简化了字符串字面量的处理,但是函数内部无法区分是传递的数组还是指针。后续会讨论数组和字符串字面量的传递。
引用传递不会退化,也永远不会发生拷贝。但是某些时候不能传递,某些时候推导出来的结果类型可能会有些问题。
const引用传递:
- 永远不会发生拷贝。
- 对小对象比如
int
可能适得其反,不过性能影响不会那么严重。 - 引用传递时,底层实现都是使用指针,在当今普遍的64位环境中可以简单等价为8个字节的传递。
- 引用的潜在影响并不仅仅只是传递指针那么简单。因为引用其实就是指针,所以在函数中用到参数时会根据地址去取,但因为外部可能会在中途改变这个值,所以每次都需要去取,这样可能一些优化就做不了,还有可能会有潜在的缓存失效造成的性能下降。但通常来说如果不是性能极端敏感,现在这个硬件性能,倒不用考虑这么多,真到考虑的时候还得做严格的profiling。
- 这些影响一定程度上可以被inline缓和,比较小的模板函数可能会内联展开(函数模板通常实现会比较小),这时候主调和被调其实被编译到了一起,这些影响也就不存在了。
引用传递不会退化:
- 所以传递数组是不会被退化为指针。
- 并且
const volatile
会得到保留。如果使用const T&
作为参数并传递了const
对象,那么推导出来的T
将不会带有const
。
使用非const
引用:
- 需要修改参数,或者将返回值通过参数传出时。
- 不能传递右值进去。
- 模板中给非
const
引用传递const
变量或者const
右值可能是可行的,这时会将模板参数类型推导为const
。如果函数模板中进行了修改则会编译报错。 - 这种时候的处理方式可以是:
- 使用
static_assert
配合std::is_const
检测T
是否是const
,是的话断言失败报错。 - 使用
enable_if
或者concepts对const
参数禁用模板。
- 使用
使用万能引用:
- 注意模板参数用于
&&
时,含义是万能引用。 - 可以传递任何类型的参数进去。
- 此时遵循引用的推导规则,不会退化。
C++11中,甚至可以让用户决定,是否要给一个参数值传递的函数模板传递引用。
- 这是通过
std::ref std::cref
做到的。 std::ref std::cref
是函数,会返回一个std::reference_wrapper<>
类型对象,对对象进行了包装。- 使用对象初始化它时,会将对象地址保存下来。
- 这个对象可以隐式转化为它包装的对象的引用。所以使用原始对象的地方都可以使用这个对象。
- 对这个对象的修改会影响它包装的源对象。
- 通过这个对象就在值传递的参数上实现了引用传递的效果。
- 其中也仅仅是保存了一个指针,值传递的开销并不大。
- 注意
std::ref std::cref
通常来说只在从模板函数调用非模板函数的场景良好工作。- 比如直接输出这个对象或者比较这个对象和原始类型对象就会失败。
- 这意味着如果两个参数类型是同样的模板参数,那么不能一个传原始对象,一个传入包装后的对象,推导会发生冲突。
std::reference_wrapper<>
更类似与其他语言中的引用,一个可以修改的引用。如果需要在容器中保存引用可以用这个对象包装,但是记住最终你都需要将它转换回去。
内建数组和字符串字面量会遇到的一个问题就是是否退化的问题:
- 值传递退化。
- 引用传递不会退化。
两者都有好有坏:
- 退化则失去了大小这个信息。
- 不退化对于不同长度的字符串字面量则会推导成不同大小的数组,从而实例化出不同的函数。或者两个参数同类型,但是传入不同长度字符串字面量则会导致实例化失败。
- 通常来说对于数组我们需要保留大小。
- 但对于字符串字面量则需要退化。
数组和字符串字面量:
- 为了让数组不被退化为指针,可以选择为数组实现重载,使用
enable_if
或者concepts禁用其他模板,并且多个数组函数参数需要多个不同模板参数作为大小(还考虑字符串字面量怎么处理)。
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2])
{
T* pa = arg1; // decay manually
T* pb = arg2; // decay manually
...
}
- 但是这就需要实现多种形式:不定大小还是固定大小。
- 另一个解决方案是使用
enable_if
,为数组实现一个专用的重载。
template<typename T, typename = std::enable_if_t<std::is_array_v<T>>>
void foo(T&& arg1, T&& arg2)
{
...
}
- 其实因为这个是特殊处理,实践中更好的处理方式可能是为这个重载选择一个新名称。
返回值同样可以选择返回引用还是值:
- 返回引用通常来说场景较少,通常只用于一些比较固定或者特殊的场景:
operator[] operator=
等访问元素或者返回自己。front back
或者其他为了保证成员写权限的时候。- 链式调用返回自身引用的,比如
operator<< operator>>
。
- 返回引用一定要注意引用生命周期,防止出现悬垂引用(dangling reference)。
- 所以某些时候需要保证函数模板返回值类型:
- 但经由推导确定类型时很多时候并不能得到保证,比如万能引用传入左值会推导为左值引用,如果用作返回值那么就是返回引用。
- 甚至值传递但是显式指定模板参数为引用也会出现这个问题。
- 为了保证返回值类型:
- 可以使用
std::remove_reference<>
移除返回类型中可能存在的引用修饰。std::decay<>
也可以实现同样效果,但做的事情比单纯移除引用更多一点。 - 另一个方法是返回值使用
auto
,让编译器自动推导。因为auto
初始化时会自动退化,利用这一点去掉引用。
- 可以使用
传值:
- 简单,退化字符串字面量和数组。
- 大型对象效率不佳,不过调用方可以选择使用
std::ref std::cref
传递引用,但需要谨慎处理。
传引用:
- 大型对象通常效率更好。
- 主要针对以下情况:
- 左值到左值引用。
- 右值到右值引用。
- 或者两者到万能引用。
- 所有情况都不退化,所以可能需要针对字符串字面量和数组特殊考虑。
- 还需要考虑这种情况时模板参数会被默认推导为引用的影响,比如用在返回值时可能需要退化。
通用建议:
- 默认情况下,定义函数模板参数为值传递。
- 字符串字面量工作良好。
- 小对象拷贝成本不高,临时对象有复制消除。
- 调用方还可以使用
std::ref std::cref
避免大对象的拷贝。
- 如果有其他理由,则选择引用传递:
- 输出或者输入输出参数。声明为左值引用,可能需要避免接受
const
对象,使用前述的手段。 - 如果模板函数是用来转发参数的,那么使用万能引用。可以考虑使用
std::decay std::common_type
来协调字符串字面量和内建数组。
- 输出或者输入输出参数。声明为左值引用,可能需要避免接受
- 如果有更多的信息,可以不遵从建议,按照自己的想法来。不过永远不要想当然按直觉来做,如果你认为某种做法改善了性能,做profiling证实这一点。即使是专家根据直觉来都是可能会出错的。
- 通常的实践建议都是不要浪费时间提前优化,而是实际跑起来之后做profiling然后优化性能瓶颈。
不要过于泛化:
- 通常来说,函数模板都不是为了任意类型准备的,或多或少都需要有约束,不要尝试在一个函数中处理所有情况。
- 将函数定义得过于泛化,可能会有副作用,比如效率损失。
- 定义得过于泛化,会让选择变得困难,而实际上可能不会实例化太多类型。
例子:
std::make_pair<>
在C++98,C++03,C++11的定义都进行了更改。- 因为需要考虑字符串字面量、是否退化、是否支持
std::ref std::cref
包装、移动语义、万能引用、完美转发等诸多问题,实现并不简单。
- 测试函数模板时,可以使用不同长度的字符串字面量。
- 值传递会退化参数,引用传递不会。
- 使用
std::decay
以在引用传递时手动退化参数。 std::ref std::cref
在某些情况下允许传递引用到声明为值传递的函数模板中。- 函数模板中采用值传递,除非有更好的原因选择引用。
- 确保返回值通常是值类型(除非你就是要返回引用),特别是引用传递参数将参数类型推导为引用时。
- 当性能很重要时,去测量不同实践的性能表现,而不是凭直觉。