引用那些事儿

引用那些事儿

1. 引言

在C语言中,函数传参通常采用值传递方式。若要在函数内部修改传入参数的值,则需借助指针。然而,直接操作指针涉及内存地址,这种方式在一些现代编程语言中被认为存在风险,应予以屏蔽。正是基于此,“引用”这一概念应运而生,它对传指针行为进行了抽象。

C++在当时的编程环境下,也引入了引用概念,旨在简化和安全化对变量的操作。

首先明确引用的语义,引用本质上是原变量的别名。这意味着对引用的任何操作,都等同于对原变量的操作。由于原变量不存在解除引用关系并建立新引用关系的情况,所以引用一旦建立,就不能更改其绑定的对象,它完全等同于原变量。

基于引用作为别名的特性,引用必须在定义时进行初始化。因为若不初始化,引用就无从成为某个变量的别名,这在逻辑上是不完整的。

同样,引用不能绑定到空对象。从逻辑角度看,对空对象创建别名与对有效对象创建别名的行为逻辑上不应该是一样的,对空对象的操作通常是未定义的,就如同在集合中,对空集的操作往往不具备实际意义。

从编译器和汇编层面来看,引用实际上等价于常量指针。例如:

int x = 10;
int* const ref_ptr = &x;
int &ref = x;

这里refref_ptr在底层实现上具有相似性。指针ref_ptr必须指向一个有效的内存地址,同样,引用ref也必须绑定到一个有效的变量。若尝试让引用绑定到空对象,类似于定义int* const ref_ptr = nullptr; ,这不仅无实际意义,而且与空指针的概念并无本质区别。因此,引用就是不可以绑定到空对象的。通过这个方式,也可以理解引用必须初始化,不能为空。

引用与常量指针在经过编译器处理后,我就曾看过汇编代码展开,操作大部分情况都是一样的,。

在函数传参过程中,引用作为参数的别名,达成了类似通过传递 指针值 来传入参数的效果,在部分其他编程语言中,这一方式也被称作引用传递。尽管从底层机制来讲,它本质上依然属于值传递,但经过引用这种抽象处理后,传递的是参数的别名,使得编程体验如同直接传递值。这种方式具备两个显著优势:

其一,能够避免拷贝开销。当函数参数为较大对象时,若采用值传递,系统会在栈上创建该对象的副本,这一过程涉及对象成员的逐个拷贝,开销颇大。而引用传递实际上传递的是对象的地址(只不过在语法上更为简洁),并非对象本身,从而无需进行对象拷贝操作,大幅节省了时间与空间开销。

其二,通过引用传递,函数能够修改传入参数的值,显著增强了函数对外部变量的操作能力。如此一来,在函数内部可以直接对实参进行修改,为程序设计提供了更大的灵活性。

2. 临时变量的引用表示与常量引用

在函数通过引用进行传参时,会遇到传入参数为临时变量的情况。需要明确,临时变量是高级语言为方便编程而抽象出的概念。在汇编层面,不存在临时变量这一特定称谓,所有变量本质上都是内存栈区域中的一段数据,通过指针标识其存储位置,以类型确定其数据长度。

例如,同样一个接受引用传参的函数void foo(--接受Bar类型的引用--);,使用foo(Bar{})和使用Bar bar; foo(bar);两者是不一样的。这是因为编译器对两者的取地址方式不一样。在Bar bar; foo(bar);中,bar是一个具名变量,编译器可以直接获取其地址。而对于临时变量Bar{},其地址信息在高级语言层面被屏蔽,编译器需要采用特殊的机制来处理对它的引用传递。也就是说,对于临时变量的引用,和一般变量的引用,编译器有两套的做法。

那么,编译器如何判断并处理呢?当我们希望通过引用传递来省略拷贝开销时,若传入参数类型为const T&,编译器就能够自动推断并合理处理,这也叫常量引用。具体来说,编译器会识别出这种情况下对临时变量的引用仅用于读取数据,不会修改其值,因此可以安全地进行处理。

然而,若传入参数类型为T&,虽然编译器理论上能够采用特定方式获取临时变量的地址,但这种做法存在问题。因为临时变量在传入函数后通常不会在其他地方被使用,对其进行修改在逻辑上不合理,这种逻辑上不合理的行为可能为软件工程引入潜在bug,以及误导开发者。所以,为保证程序的稳定性和正确性,编译器不允许使用T&引用临时变量进行修改操作。

综上,常量引用和一般的引用无特殊区别,只是如果是常量的引用,编译器能特殊处理一下,对于临时变量也能够正确处理。

3. 所有权的移动与右值引用

在C++编程中,存在一种为实现资源移动而引入的重要机制——右值引用。以std::thread为例,当为其绑定的函数传递若干参数时,若期望以移动的方式操作这些参数,此时函数的形参需定义为T&&

这里的T&&表示右值引用,它向编译器表明,传入的将是一个右值,而右值在大多数情况下是临时变量的数据。使用右值引用的核心意义在于实现数据所有权的转移。与传统的参数值传递和参数引用传递方式不同,右值引用传递着重于资源所有权的移动,而非简单的数据拷贝或对已有数据的引用。

例如,在处理动态分配的内存等资源时,通过右值引用,可将资源的所有权从一个对象高效地转移到另一个对象,避免了不必要的拷贝操作,从而提升程序性能。假设我们有一个自定义类Resource,它管理着一块动态分配的内存:

class Resource {
public:
    Resource() : data(new int[10]) {}
    ~Resource() { delete[] data; }
    // 移动构造函数,利用右值引用实现资源移动
    Resource(Resource&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }
private:
    int* data;
};

在上述代码中,Resource类的移动构造函数利用右值引用Resource&& other,将other对象的资源(即data指针所指向的内存)转移到当前对象,同时将other对象的data指针置空,确保资源的正确管理和高效转移。

这种右值引用传递行为,为C++程序员提供了一种更灵活、高效的资源管理方式,在现代C++编程中具有重要地位。

4. 完美转发

在C++编程中,当涉及函数模板进行参数传递时,如果每次都要手动区分是普通引用传递方式还是右值引用传递行为,确实会显得十分繁琐且容易出错。例如,在一个通用的函数模板中,可能需要根据传入参数的类型(左值或右值)来决定是进行拷贝、引用还是移动操作,手动处理这些情况会使代码变得复杂且难以维护。

这时,“完美转发”这一机制就应运而生。完美转发指的是在函数模板中,能够将参数按照其原本的类型(无论是左值还是右值)转发给其他函数,同时保持参数的所有属性(如const属性等)不变。

它主要通过std::forward这一标准库函数以及函数模板的引用折叠规则来实现。例如,假设有如下代码:

#include <iostream>
#include <utility>

// 被转发的目标函数
void targetFunction(int& num) {
    std::cout << "左值被转发到目标函数: " << num << std::endl;
}

void targetFunction(int&& num) {
    std::cout << "右值被转发到目标函数: " << num << std::endl;
}

// 用于完美转发的函数模板
template<typename T>
void forwardingFunction(T&& param) {
    targetFunction(std::forward<T>(param));
}

int main() {
    int value = 10;
    forwardingFunction(value); // 传入左值
    forwardingFunction(20);   // 传入右值
    return 0;
}

在上述代码中,forwardingFunction函数模板通过std::forward<T>(param)将参数param完美转发给targetFunction。当传入左值时,std::forward会将其转发为左值引用传递给targetFunction;当传入右值时,则转发为右值引用传递。这样,无论传入的是左值还是右值,targetFunction都能接收到与原始参数类型和属性完全一致的参数,实现了参数的“完美转发”。

完美转发的作用非常显著,它使得编写通用的函数模板变得更加简洁高效,能够在不同场景下正确处理各种类型的参数,同时避免了不必要的对象拷贝,提升了程序的性能。在现代C++库的实现中,完美转发被广泛应用,例如在std::thread的构造函数中,就利用完美转发将参数正确地传递给线程执行函数,确保了线程相关操作的高效性和灵活性。

99. quiz

1. 什么时候需要用move

  • 对于callback函数来说:在使用callback函数时,有时传递的对象可能较大,如果采用传统的拷贝方式传递给callback函数,会带来较大的性能开销,因为拷贝操作需要复制对象的所有数据成员。而使用std::move,可以将对象的资源所有权直接转移给callback函数,避免了不必要的拷贝操作,显著提高性能。例如,假设有一个包含大量数据的自定义类BigData,当把BigData对象作为参数传递给callback函数时:
#include <iostream>
#include <functional>
#include <string>

class BigData {
public:
    BigData() : data(new std::string(10000, 'a')) {}
    ~BigData() { delete data; }
    // 为演示方便,省略拷贝和移动构造函数等
private:
    std::string* data;
};

void callbackFunction(BigData data) {
    // 处理数据
}

int main() {
    BigData bd;
    // 使用std::move避免拷贝
    callbackFunction(std::move(bd));
    return 0;
}

这里使用std::move的意义在于BigData data就不是通过拷贝构造函数来创建的,而是通过移动构造函数来创建的,这样就避免了不必要的资源拷贝。 如果BigData也都还是值类型数据,其实不用std::move性能也差不了多少,但如果BigData是一个复杂的对象,包含了动态分配的内存或其他资源,那么使用std::move就能显著提高性能。

  • 对于将对象插入到容器来说:当向容器中插入对象时,若对象较大,深拷贝会消耗大量的时间和资源。使用std::move可以直接将对象的资源移动到容器中,避免深拷贝。例如,向std::vector中插入自定义对象:
#include <iostream>
#include <vector>
#include <string>

class MyClass {
public:
    MyClass() : data(new std::string(10000, 'a')) {}
    ~MyClass() { delete data; }
    // 移动构造函数
    MyClass(MyClass&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }
    // 为演示方便,省略拷贝构造函数等
private:
    std::string* data;
};

int main() {
    std::vector<MyClass> vec;
    MyClass obj;
    // 使用std::move将obj的资源直接移动到vec中
    vec.push_back(std::move(obj));
    return 0;
}

2. 浅拷贝和移动的性能开销上有区别吗?

浅拷贝和移动在性能开销上不存在明显区别,主要区别在于资源管理和对象的所有权转移。

浅拷贝只是简单地复制对象中的指针成员,使得多个对象共享同一块资源。虽然这种方式在拷贝过程中速度较快,因为它不涉及资源的重新分配和数据的逐个复制,但它存在资源管理的风险,比如多个对象共享资源可能导致双重释放问题。

而移动操作则是将资源的所有权从一个对象转移到另一个对象,源对象不再拥有该资源。在移动过程中,通常只是进行少量的指针操作(如修改指针指向等),避免了深拷贝时对资源的重新分配和数据复制,因此性能开销相对较小。特别是当对象包含大量数据或动态分配的资源时,移动操作的性能优势更为明显。

3. 区别通用引用和右值引用

在C++编程里,清晰区分通用引用和右值引用意义重大,这对准确运用移动语义、完美转发等关键特性起着决定性作用。

通用引用的产生必须同时满足两个条件:

  1. 必须处于函数模板内的模板参数推导环境,或者是变量推导的情境。
  2. 其格式需为 T&&,此处的 T 是模板参数,既不能是 const T& 这样的形式,也不能是诸如 std::vector<T>&& 这类具体类型。

接下来通过具体示例详细阐释两者的区别:

// 右值引用示例
void f(Widget&& param);
template<typename T>

void f(std::vector<T>&& param)

Widget&& var1 = Widget();

// 通用引用示例
template<typename T>
void f(T&& param);

auto&& var2 = var1;

深入理解通用引用和右值引用的差异,对合理运用C++的移动语义与完美转发特性极为关键。在实际编程中,右值引用主要用于实现移动语义,能够高效地把临时对象的资源所有权进行转移,从而避免不必要的拷贝操作。而通用引用在实现完美转发过程中扮演着重要角色,它能够依据传入参数实际的类型(左值或者右值),精确地将参数转发给其他函数,同时完整保留参数的所有属性。例如,在一些通用库的设计与实现里,通过合理运用通用引用和右值引用,可以打造出高效且通用的函数模板,显著提升代码的性能以及复用性。

4. 理解std::move和std::forward

在C++编程世界里,std::movestd::forward作为两个关键的函数模板,虽在运行时不生成实际可执行代码,但在类型转换方面发挥着举足轻重的作用,并非直观上的“移动”或“转发”动作。

std::move旨在无条件地把参数转换为右值,这一转换为移动语义的实现创造了条件,使得在合适场景下能够高效转移资源所有权,避免不必要的拷贝操作。其实现原理可通过以下伪代码理解:

template<typename T>
typename remove_reference<T>::type&& move(T&& param) {
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
}

这里,remove_reference<T>是C++标准库中的类型萃取工具,负责移除类型T的引用部分。如此,无论param最初是左值引用还是右值引用,最终都能返回一个右值引用类型,达成将参数转换为右值的目标。

std::forward则有所不同,它专为实现完美转发而设计,仅在特定条件满足时执行类型转换。当函数模板需将参数原封不动传递给另一函数,且要保留参数的左值或右值属性时,std::forward便派上用场。其伪代码如下:

template<typename T>
T&& forward(typename remove_reference<T>::type& param) {
    return static_cast<T&&>(param);
}

template<typename T>
T&& forward(typename remove_reference<T>::type&& param) {
    static_assert(!std::is_lvalue_reference<T>::value, "Can't forward rvalue as lvalue.");
    return static_cast<T&&>(param);
}

上述代码中,第一个模板函数处理左值参数,第二个处理右值参数。借助这种方式,std::forward能依据传入参数实际类型(左值或右值),精准转发至目标函数,确保参数属性不变,实现完美转发。例如在函数模板中,传入左值时,std::forward以左值引用形式转发;传入右值时,则以右值引用形式转发,满足不同场景下参数传递的精确需求。

在实际应用中,右值引用通常绑定到可移动对象(如临时对象)上,若形参为右值引用,其绑定对象应具备可移动性。而通用引用和右值引用在转发时遵循不同规则:

  • 通用引用转发时,需使用std::forward进行向右值的有条件强制类型转换。因其可能绑定左值或右值,std::forward可按需将左值按左值转发,右值按右值转发,实现完美转发。
  • 右值引用转发时,应使用std::move进行向右值的无条件强制类型转换。由于右值引用已绑定右值,std::move可保证资源高效移动。 若将这两种转发方式用反,极可能引发问题,如代码冗余或运行期错误。对通用引用用std::move,会无条件转为右值,破坏左值特性,导致不必要移动;对右值引用用std::forward,因条件转换特性,可能无法正确移动资源,造成性能损失或逻辑错误。

通常,“move”操作在资源转移方面比“copy”操作效率更高,但在局部对象返回场景中,这一观点需谨慎对待。例如:

Widget MakeWidget() {
    Widget w;
    return w;
    // 此情况编译器会启用返回值优化(RVO),看似复制,实际仅调用一次拷贝构造函数
}

Widget MakeWidget() {
    Widget w;
    return std::move(w);
    // 此代码会导致负优化,因不满足RVO条件
}

RVO优化启动需满足两个条件:一是局部对象类型与函数返回值类型相同;二是返回的即为局部对象本身。第二段代码因使用std::move破坏了RVO条件,导致负优化。所以,当局部对象满足RVO条件时,不应使用std::movestd::forward,以保障程序性能。 正确理解并运用std::movestd::forward以及它们与通用引用、右值引用的关系,对编写高效、可靠的C++代码至关重要。

7. 避免重载通用引用

在C++编程中,应尽量避免重载通用引用,主要原因在于通用引用(尤其是在模板中)可能引发一些难以预料的匹配问题。

以如下代码为例:

template<typename T>
void log(T&& name) {}

void log(int name) {}

short a;
log(a);

在此例中,当调用log(a)时,会出现精确匹配的情况。由于通用引用T&&能够匹配各种类型,包括short类型,所以它会与void log(int name)函数竞争。在这种情况下,编译器通常会优先选择模板函数log(T&& name)。这是因为模板函数的匹配规则相对灵活,只要类型能够推导成功就会参与匹配,从而导致调用了并非开发者预期的函数。

此外,在重载过程中,通用引用模板还会与拷贝构造函数、移动构造函数竞争,产生复杂的情况。以下面的Person类为例:

class Person {
public:
    template<typename T>
    explicit Person(T&& n) : name(std::forward<T>(n)) {} // 完美转发构造函数

    explicit Person(int idx); // 形参为int的构造函数

    // 默认拷贝构造函数(编译器自动生成)
    Person(const Person& rhs);

    // 默认移动构造函数(编译器生成)
    Person(Person&& rhs);
};

Person p("Nancy");
// 以下代码会编译失败
// 原因是这里试图通过拷贝构造函数创建cloneOfP,但由于p不是const类型,
// 完美转发构造函数会参与竞争,且它的匹配优先级在非const对象时较高,
// 从而导致与拷贝构造函数的匹配不是最优解,最终编译失败
Person cloneOfP(p);

在上述代码中,当尝试通过拷贝构造函数创建cloneOfP对象时,由于p不是const类型,完美转发构造函数Person(T&& n)会参与竞争。在这种竞争环境下,完美转发构造函数的匹配优先级相对较高,使得拷贝构造函数的匹配不再是最优解,进而导致编译失败。实际应用中,这种竞争情况可能因具体代码场景的不同而更加复杂,可能涉及到不同类型的参数、继承关系等多种因素,开发者很难准确预测和控制编译器的选择,从而增加了代码出错的风险。因此,为了提高代码的可读性、可维护性以及稳定性,应尽量避免重载通用引用。

9. 理解引用折叠

在C++中,当实参传递给函数模板时,模板形参的推导结果会包含实参是左值还是右值的信息。以下通过具体示例进行说明:

template<typename T>
void func(T&& param);

Widget WidgetFactory() { // 返回右值
    return Widget();
}

Widget w;
func(w);               // T的推导结果是左值引用类型,即T被推导为Widget&
func(WidgetFactory()); // T的推导结果是非引用类型(注意,这里不是右值),即T被推导为Widget

在C++语言规则中,“引用的引用”这种形式是不允许直接书写的。然而,如上述例子中,当T被推导为Widget&时,函数声明就变成了void func(Widget& && param);,出现了左值引用与右值引用叠加的情况。这表明在实际的编译过程中,编译器确实会遇到类似“引用的引用”的情况(尽管开发者不能在代码中直接使用这种形式)。

针对这种情况,C++有特定的引用折叠规则:

  • 如果两个引用中至少有一个是左值引用,那么折叠后的结果就是左值引用;只有当两个引用都是右值引用时,折叠结果才是右值引用。例如,int& &折叠后为int&int& &&折叠后同样为int&,而int&& &&折叠后为int&&

引用折叠通常会在以下四种语境中发生:

  • 模板实例化:如上述函数模板func的例子,在实例化过程中根据实参类型推导T的类型时,可能出现引用折叠。当传递左值时,T被推导为左值引用类型,与模板参数T&&结合就可能触发引用折叠。
  • auto类型生成:当使用auto关键字根据表达式推断类型时,如果涉及到引用,可能发生引用折叠。例如,auto&& var1 = w;w为左值),这里var1的类型推导就可能涉及引用折叠,最终var1为左值引用。
  • 创建和运用typedef和别名声明:在使用typedef或别名声明时,如果涉及多层引用,也可能引发引用折叠。例如,typedef int& IntRef; IntRef&& var2;这里var2的类型推导就遵循引用折叠规则。
  • decltypedecltype表达式在某些情况下也会导致引用折叠。比如,int i; decltype((i))&& var3 = i;,由于decltype((i))的结果是int&,与&&结合就会发生引用折叠,var3最终为左值引用。

通过理解引用折叠的概念、规则以及其发生的语境,开发者能更好地把握C++中类型推导和引用相关的机制,编写出更健壮的代码。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好