C++中的左值和右值以及一些延伸问题

50 阅读8分钟

C++中的左值和右值以及一些延伸问题

公众号:阿Q技术站

1、左值和右值

1.1、左值

可以出现在赋值运算符左侧的表达式。简而言之,左值代表的是内存中的某个位置,即它指向了某个具体的存储位置。左值是持久的,它在程序的某个地方有明确的地址,并且可以多次访问。

特性:
  • 可以取地址:左值总是可以使用 & 操作符取地址。
  • 可以被赋值:左值是赋值操作的目标。
  • 持久性:左值可以在程序中的多个地方被引用。
示例
int x = 10; // x 是一个左值
x = 20;      // x 作为左值出现在赋值语句的左侧
1.2、右值

不能出现在赋值运算符左侧的表达式,通常表示一个临时值或不可寻址的值。右值一般是没有持久性的数据,通常是由表达式产生的。

特性
  • 不能取地址:右值通常不能使用 & 操作符取地址。
  • 无法赋值:右值不可以作为赋值的目标。
  • 临时性:右值通常是临时生成的值,计算完成后即不再存在。
示例
int x = 10;   // 10 是右值
x = x + 1;    // x + 1 是右值,表示一个临时结果
区别
特性左值右值
存在形式持久存在,具有名称和内存地址临时存在,通常不能被访问
赋值行为作为赋值操作的目标(左侧)不能作为赋值操作的目标(右侧)
可取地址性可以取地址(例如使用 &不能取地址(除非它是一个右值引用)
例子变量、数组元素、解引用的指针等字面量、表达式结果、临时对象等

2、左值引用和右值引用

2.1、左值引用

左值引用是传统的引用类型,使用 & 来定义。它只能绑定到左值。通常指向一个已经存在的左值,即具有明确内存地址且可以取地址的对象。

语法
type& var = expression;
特性
  • 可以多次引用同一个对象或变量。
  • 可以通过引用修改原对象。
示例
int x = 10;
int& ref = x;  // 左值引用
ref = 20;       // 通过引用修改 x
std::cout << x; // 输出 20

左值引用的关键是它引用的是一个已存在的、具有持久性的对象或变量。你可以通过引用修改对象的值,并且引用会持续到作用域结束。

2.2、右值引用

右值引用是 C++11 引入的全新概念,它通过 && 语法来定义。它只能绑定到右值。通常是临时对象或字面量。

语法
type&& var = expression;
特性
  • 右值引用是为了避免不必要的复制。
  • 它允许转移资源(通过移动语义)。
  • 右值引用通常用于优化性能,尤其是在涉及大量数据的对象创建、销毁和传递时。
示例
int&& ref = 10;  // 右值引用,绑定到临时对象
ref = 20;         // 修改右值引用指向的临时对象
std::cout << ref; // 输出 20
区别
特性左值引用右值引用
语法type& vartype&& var
目标引用一个持久存在的对象引用一个临时对象或临时值
使用场景修改变量,避免不必要的拷贝移动资源、优化性能、完美转发
取地址性可以取地址(例如 &var通常不能取地址,除非是右值引用
生命周期生命周期与对象一致生命周期通常较短,通常在移动后就不再使用
可以赋值可以赋值(例如 var = 10不能直接赋值,但可以用来接管资源

3、移动语义和完美转发

移动语义和完美转发是 C++11 引入的两个非常重要的概念,它们极大地提升了 C++ 在处理资源和性能方面的能力,尤其是在处理临时对象时。

3.1、移动语义
3.1.1、为什么需要移动语义?

在 C++ 中,复制对象可能会带来性能上的问题,尤其是当对象包含了大数据量的资源时(如动态分配的内存、文件句柄、数据库连接等)。每次复制对象时,都会进行资源的深拷贝,这不仅消耗了时间,还增加了内存开销。

例如,考虑一个包含动态内存分配的类:

class MyClass {
public:
    int* data;
​
    MyClass(int val) {
        data = new int(val);
    }
​
    ~MyClass() {
        delete data;
    }
};

如果我们创建一个对象并将其复制到另一个对象,data 中的内存就会被复制:

MyClass a(10);
MyClass b = a;  // 复制构造函数,可能会深拷贝内存

这种深拷贝会产生性能开销。如果对象很大或者包含很多资源时,这个开销尤为严重。

为了避免这种开销,C++11 引入了 移动语义(Move Semantics) ,允许我们 “移动” 资源,而不是复制它们。

3.1.2、 移动构造函数

用于将一个临时对象的资源“转移”到另一个对象,而不是进行深拷贝。当一个对象被从一个右值传递给另一个对象时,移动构造函数会被调用。

class MyClass {
public:
    int* data;

    MyClass(int val) {
        data = new int(val);
    }

    ~MyClass() {
        delete data;
    }

    // 移动构造函数
    MyClass(MyClass&& other) noexcept {
        data = other.data;  // 转移资源
        other.data = nullptr;  // 将源对象的资源置空
    }
};

MyClass(MyClass&& other) 是移动构造函数,它将 other 的资源转移到新对象中,并将 otherdata 设置为 nullptr,防止它在析构时删除原有的资源。

3.1.3、移动赋值运算符

与移动构造函数类似,移动赋值运算符用于将一个右值对象的资源转移到另一个对象,并释放目标对象原有的资源。

class MyClass {
public:
    int* data;

    MyClass(int val) {
        data = new int(val);
    }

    ~MyClass() {
        delete data;
    }

    // 移动赋值运算符
    MyClass& operator=(MyClass&& other) noexcept {
        if (this != &other) {
            delete data;           // 释放当前对象的资源
            data = other.data;     // 转移资源
            other.data = nullptr;   // 将源对象的资源置空
        }
        return *this;
    }
};

operator=(MyClass&& other) 通过移动资源而非复制资源,将 otherdata 赋给当前对象,并将 otherdata 设置为 nullptr,防止内存泄漏。

3.1.4、应用

假设有一个容器类,它在添加新元素时需要进行内存分配:

#include <iostream>
#include <vector>

class MyClass {
public:
    int* data;
    
    MyClass(int val) {
        data = new int(val);
    }
    
    ~MyClass() {
        delete data;
    }

    MyClass(MyClass&& other) noexcept {
        data = other.data;
        other.data = nullptr;
    }
};

int main() {
    std::vector<MyClass> vec;
    vec.push_back(MyClass(10));  // 使用移动构造函数
}

push_back 使用了移动构造函数,而不是复制构造函数,因此减少了内存分配和复制的开销。

小结

  • 性能提升:移动语义通过转移资源而不是复制资源,显著减少了内存分配和释放的开销。
  • 避免不必要的复制:在需要临时对象时,C++11 通过右值引用使得这些对象的资源可以被安全转移。
3.2、完美转发
3.2.1、为什么需要完美转发?

在模板编程中,函数通常希望能够将它们的参数转发给其他函数。问题是,我们需要确保参数的左值或右值属性被保持——即,如果传入的是左值,转发时它应该继续作为左值;如果是右值,转发时应该作为右值。这个问题就是完美转发解决的。

3.2.2、实现

通过 右值引用std::forward 实现。std::forward 保证了参数的值类别得以保留,它根据模板参数类型 T 自动决定是转发为左值还是右值。

#include <iostream>
#include <utility>

template <typename T>
void wrapper(T&& arg) {
    another_function(std::forward<T>(arg));
}

void another_function(int& x) {
    std::cout << "左值被转发:" << x << std::endl;
}

void another_function(int&& x) {
    std::cout << "右值被转发:" << x << std::endl;
}

int main() {
    int x = 10;
    wrapper(x);           // 左值被转发
    wrapper(20);          // 右值被转发
}
  • wrapper(x) 传递了一个左值,std::forward<T>(arg) 将其作为左值转发给 another_function
  • wrapper(20) 传递了一个右值,std::forward<T>(arg) 将其作为右值转发给 another_function

std::forward<T>(arg) 会确保根据传入参数的值类别(左值或右值)来进行正确的转发。

小结

  • 保持值类别:完美转发保证了参数的左值或右值属性被保持。这意味着,如果你传递给一个函数的是右值,它会被当作右值转发,而不是转换为左值。
  • 高效的参数传递:完美转发避免了不必要的复制,通过转发时保持右值的特性,可以实现更高效的资源管理。
3.3、完美转发和移动语义结合

在 C++11 中,完美转发移动语义通常是一起使用的。

例如,考虑一个将对象转发到另一个函数的模板:

template <typename T>
void process(T&& arg) {
    some_function(std::forward<T>(arg));  // 完美转发
}

当我们调用 process(MyClass()) 时,std::forward<T> 保证了临时对象(右值)会被以右值的方式转发给 some_function。如果调用 process(x) 时,std::forward<T> 会保持 x 作为左值进行转发。