12-6 按常量左值引用传递

与指向非常量(non const)的引用(只能绑定到可修改的左值)不同,指向 const 的引用可以绑定到可修改的左值、不可修改的左值和右值。因此,如果我们将一个引用参数声明为 const,那么它就可以绑定到任何类型的参数:

#include <iostream>

void printRef(const int& y) // y is a const reference
{
    std::cout << y << '\n';
}

int main()
{
    int x { 5 };
    printRef(x);   // ok: x is a modifiable lvalue, y binds to x

    const int z { 5 };
    printRef(z);   // ok: z is a non-modifiable lvalue, y binds to z

    printRef(5);   // ok: 5 is rvalue literal, y binds to temporary int object

    return 0;
}

img

通过常量引用传递与通过非常量引用传递相同的主要优点(避免复制参数),同时还能保证函数不能更改被引用的值。

例如,以下操作是不允许的,因为ref 是 const:

void addOne(const int& ref)
{
    ++ref; // not allowed: ref is const
}

img

大多数情况下,我们不希望函数修改参数的值。

最佳实践:
除非有特殊原因(例如函数需要更改参数的值),否则应优先使用 const 引用传递,而不是使用非常量引用传递。

现在我们可以理解允许 const 左值引用绑定到右值的动机了:如果没有这种能力,就无法将字面量(或其他右值)传递给使用按引用传递的函数!

将不同类型的参数传递给常量左值引用形参

在第12.4 课——const左值引用中,我们提到 const 左值引用可以绑定到不同类型的值,只要该值可以转换为引用的类型即可。这种转换会创建一个临时对象,引用形参随后可以绑定到该临时对象。

允许这样做的主要动机是,我们可以以完全相同的方式将值作为实参传递给值形参或常量引用形参:

#include <iostream>

void printVal(double d)
{
    std::cout << d << '\n';
}

void printRef(const double& d)
{
    std::cout << d << '\n';
}

int main()
{
    printVal(5); // 5 converted to temporary double, copied to parameter d
    printRef(5); // 5 converted to temporary double, bound to parameter d

    return 0;
}

img

按值传递时,我们期望进行复制,因此如果先进行转换(导致额外的复制),这很少会成为问题(编译器很可能会优化掉两个副本中的一个)。

然而,当我们不希望创建副本时,通常会使用引用传递。如果先进行转换,通常会导致创建副本(可能成本高昂),这并非最佳方案。

警告:
使用按引用传递时,请确保参数的类型与引用的类型匹配,否则将导致意外的(且可能代价高昂的)转换。

混合使用按值传递和按引用传递

具有多个参数的函数可以单独决定每个参数是按值传递还是按引用传递。

例如:

#include <string>

void foo(int a, int& b, const std::string& c)
{
}

int main()
{
    int x { 5 };
    const std::string s { "Hello, world!" };

    foo(5, x, s);

    return 0;
}

在上面的例子中,第一个参数按值传递,第二个参数按引用传递,第三个参数按常量引用传递。

何时使用按值传递,何时使用按引用传递

对于大多数 C++ 初学者来说,选择按值传递还是按引用传递并不那么显而易见。幸运的是,有一个简单的经验法则在大多数情况下都适用。

  • 基本类型和枚举类型复制成本很低,因此通常按值传递。
  • 复制类类型可能会很耗费资源(有时甚至非常耗费资源),因此通常通过常量引用传递。

最佳实践:
一般来说,基本类型按值传递,类类型按常量引用传递。
如果你不确定该怎么做,就按常量引用传递,这样遇到意外行为的可能性会更小。

提示:
以下是一些其他有趣案例的部分列表:
以下参数通常按值传递(因为这样效率更高):

  • 枚举类型(无作用域枚举和有作用域枚举)。
  • 视图和跨度(例如std::string_view,std::span)。
  • 模仿引用或(非拥有)指针的类型(例如迭代器std::reference_wrapper)。
  • 易于复制且具有值语义的类类型(例如,std::pair具有基本类型的元素std::optional,,std::expected)。

以下情况应使用引用传递:

  • 需要由函数修改的参数。
  • 不可复制的类型(例如std::ostream)。
  • 某些类型的复制会产生所有权方面的影响,我们想要避免这种情况(例如std::unique_ptr,std::shared_ptr)。
  • 具有虚函数或可能被继承的类型(由于对象切片问题,在第25.9 课——对象切片中介绍)。

按价值传递与按引用传递的成本比较(高级)

并非所有类类型都需要按引用传递(例如std::string_view,通常按值传递的类)。您可能想知道为什么我们不全部都按引用传递。在本节(可选阅读)中,我们将讨论按值传递和按引用传递的成本,并完善我们的最佳实践,说明何时应该使用哪种方式。

首先,我们需要考虑初始化函数参数的成本。在按值传递的情况下,初始化意味着创建一个副本。复制对象的成本通常与以下两点成正比:

对象的大小。占用内存越多的对象,复制所需时间就越长。
任何额外的设置成本。某些类类型在实例化时会进行额外的设置(例如,打开文件或数据库,或者分配一定量的动态内存来保存可变大小的对象)。每次复制对象时,都必须支付这些设置成本。
另一方面,将引用绑定到对象总是很快的(速度与复制基本类型的速度大致相同)。

其次,我们需要考虑使用函数形参的成本。在设置函数调用时,编译器可以通过将按值传递的参数(如果其大小较小)的引用或副本放入 CPU 寄存器(访问速度快)而不是 RAM(访问速度慢)来进行优化。

每次使用值形参时,运行中的程序可以直接访问被复制实参的存储位置(CPU寄存器或RAM)。但是,当使用引用形参时,通常需要额外的步骤。运行中的程序必须首先直接访问分配给该引用的存储位置(CPU寄存器或RAM),以确定被引用的对象。只有这样,它才能访问被引用对象(在RAM中)的存储位置。

因此,每次使用值形参都是一次 CPU 寄存器或 RAM 访问,而每次使用引用参数都是一次 CPU 寄存器或 RAM 访问加上一次 RAM 访问。

第三,编译器有时能更有效地优化按值传递的代码,而不是按引用传递的代码。尤其是在存在别名风险(即两个或多个指针或引用可以访问同一个对象)的情况下,优化器必须更加保守。由于按值传递会导致实参值的复制,因此不会出现别名,这使得优化器可以更加积极地进行优化。

现在我们可以回答为什么我们不把所有东西都通过引用传递这个问题了:

  • 对于复制成本低廉的对象,复制的成本与绑定的成本类似,但访问对象的速度更快,编译器也更有可能进行更好的优化。
  • 对于复制成本很高的对象,复制成本会凌驾于其他性能因素之上。

最后一个问题是,我们如何定义“复制成本低”?这个问题没有绝对的答案,因为它取决于编译器、用例和架构。但是,我们可以总结出一个好的经验法则:如果一个对象占用的内存“字”不超过两个(一个“字”近似于一个内存地址的大小),并且没有设置成本,那么它的复制成本就很低。

以下程序定义了一个类似函数的宏,可用于确定某种类型(或对象)的复制成本是否较低:

#include <iostream>

// Function-like macro that evaluates to true if the type (or object) is equal to or smaller than
// the size of two memory addresses
#define isSmall(T) (sizeof(T) <= 2 * sizeof(void*))

struct S
{
    double a;
    double b;
    double c;
};

int main()
{
    std::cout << std::boolalpha; // print true or false rather than 1 or 0
    std::cout << isSmall(int) << '\n'; // true

    double d {};
    std::cout << isSmall(d) << '\n'; // true
    std::cout << isSmall(S) << '\n'; // false

    return 0;
}

顺便提一下……
我们在这里使用类似预处理器函数的宏,以便我们可以提供对象或类型名称作为参数(因为 C++ 函数不允许将类型作为参数传递)。

然而,很难判断一个类类型的对象是否需要设置成本。除非您确定某个类不需要设置成本,否则最好假设大多数标准库类都需要设置成本。

提示:
sizeof(T) <= 2 * sizeof(void*)如果复制类型为 T 的对象没有额外的设置成本,则复制该对象的成本很低。

对于函数参数,大多数情况下最好使用std::string_view,相比其他方法 const std::string&

在现代 C++ 中经常会遇到一个问题:编写带有字符串参数的函数时,参数的类型应该是字符串const std::string&还是其他类型std::string_view?

大多数情况下,std::string_view 是更好的选择,因为它能高效地处理更广泛的参数类型。此外, std::string_view 参数还允许调用者传入子字符串,而无需先将该子字符串复制到自己的字符串中。

void doSomething(const std::string&);
void doSomething(std::string_view);   // prefer this in most cases

有些情况下,使用const std::string&参数可能更合适:

  • 如果您使用的是 C++14 或更早版本,std::string_view则此功能不可用。
  • 如果你的函数需要调用其他接受 C 风格字符串或std::string参数的函数,那么const std::string&可能是一个更好的选择,因为std::string_view不能保证以空字符结尾(C 风格字符串函数期望如此),而且不能有效地转换回std::string。

最佳实践:
除非你的函数调用其他需要 C 风格字符串或参数的函数,否则最好使用std::string_view(按值传递)字符串而不是const std::string& 和 std::string.

为什么形参 std::string_viewconst std::string& 更有效 (高级参数)

在 C++ 中,字符串参数通常是字符串std::string、字符串std::string_view或 C 风格的字符串/字符串字面量。

温馨提示:

  • 如果参数的类型与对应参数的类型不匹配,编译器将尝试隐式地将参数转换为与参数类型匹配的类型。
  • 转换值会创建一个转换后类型的临时对象。
  • 创建(或复制)a 的std::string_view成本很低,因为std::string_view它不会复制它正在查看的字符串。
  • 创建(或复制)一个对象std::string可能会很耗费资源,因为每个std::string对象都会复制该字符串。

下表显示了尝试传递每种类型时发生的情况:

实参类型(Argument Type) std::string_view 形参(paramenter) const std::string& 形参(paramenter)
std::string 低成本转换 低成本引用绑定
std::string_view 低成本拷贝 低成本显式转换为std::string
C-style string/literal 低成本转换 低成本转换

带有一个std::string_view值形参:

  • 如果我们传入一个std::string实参,编译器会将转换std::string为std::string_view,这开销很小,所以没问题。
  • 如果我们传入一个std::string_view实参,编译器会将该参数复制到参数中,这开销很小,所以没问题。
  • 如果我们传入 C 风格的字符串或字符串字面量,编译器会将它们转换为字符串std::string_view,转换成本很低,所以没问题。

如您所见,std::string_view它能以低成本处理所有三种情况。

带有const std::string&形参:

  • 如果我们传入一个std::string实参,形参将引用绑定到该实参,这开销很小,所以没问题。
  • 如果我们传入一个std::string_view参数,编译器会拒绝进行隐式转换,并产生编译错误。我们可以使用static_cast显式转换(到std::string),但这种转换开销很大(因为std::string会复制正在查看的字符串)。转换完成后,参数将引用绑定到结果,这本身开销很小。但是,为了进行转换,我们创建了一个开销很大的副本,所以这并不是一个理想的方案。
  • 如果我们传入一个 C 风格的字符串或字符串字面量,编译器会隐式地将其转换为字符串std::string,这会增加计算成本。所以这种方法也不太理想。

因此,const std::string&形参只能处理std::string实参的成本很低。

用代码表示就是同样的内容:

#include <iostream>
#include <string>
#include <string_view>

void printSV(std::string_view sv)
{
    std::cout << sv << '\n';
}

void printS(const std::string& s)
{
    std::cout << s << '\n';
}

int main()
{
    std::string s{ "Hello, world" };
    std::string_view sv { s };

    // Pass to `std::string_view` parameter
    printSV(s);              // ok: inexpensive conversion from std::string to std::string_view
    printSV(sv);             // ok: inexpensive copy of std::string_view
    printSV("Hello, world"); // ok: inexpensive conversion of C-style string literal to std::string_view

    // pass to `const std::string&` parameter
    printS(s);              // ok: inexpensive bind to std::string argument
    printS(sv);             // compile error: cannot implicit convert std::string_view to std::string
    printS(static_cast<std::string>(sv)); // bad: expensive creation of std::string temporary
    printS("Hello, world"); // bad: expensive creation of std::string temporary

    return 0;
}

img

此外,我们还需要考虑在函数内部访问参数的成本。由于std::string_view参数本身就是一个普通对象,因此可以直接访问其所指向的字符串。而访问std::string&参数则需要额外的步骤,即先获取被引用的对象,才能访问其中的字符串。

最后,如果我们想传入一个现有字符串(任何类型)的子字符串,创建子std::string_view 字符串的成本相对较低,然后可以轻松地将其传递给std::string_view 形参。相比之下,将子字符串传递给 const std::string& 则成本更高,因为子字符串最终必须复制到引用参数绑定的 std::string 中。

posted @ 2025-12-09 16:05  游翔  阅读(30)  评论(0)    收藏  举报