Skip to content

Latest commit

 

History

History

07ByValueOrByReference

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

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参数禁用模板。

使用万能引用:

  • 注意模板参数用于&&时,含义是万能引用。
  • 可以传递任何类型的参数进去。
  • 此时遵循引用的推导规则,不会退化。

使用std::ref()和std::cref()

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在某些情况下允许传递引用到声明为值传递的函数模板中。
  • 函数模板中采用值传递,除非有更好的原因选择引用。
  • 确保返回值通常是值类型(除非你就是要返回引用),特别是引用传递参数将参数类型推导为引用时。
  • 当性能很重要时,去测量不同实践的性能表现,而不是凭直觉。