右值引用

右值引用

左值和右值

何为左值右值?

左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。

基于上述特征可以使用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值

    int kk = 0;
    auto a = &1; // lvalue required as unary '&' operand
    auto a2 = &kk;
    auto a3 = &++kk;
    auto a4 = &kk++; // lvalue required as unary '&' operand

x++和++x虽然都是自增操作,但是却分为不同的左右值。其中x++是右值,因为在后置++操作中编译器首先会生成一份x值的临时复制,然后才对x递增,最后返回临时复制内容。而++x则不同,它是直接对x递增后马上返回其自身,所以++x是一个左值。

通常字面量都是一个右值,除字符串字面量以外

int x = 1; set_val(6); auto p = &"hello world";

编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会为其开辟内存空间,所以我们可以使用取地址符&来获取字符串字面量的内存地址

int x = 1;

int get_val()
{
    return x;
}

auto y = &get_val();// lvalue required as unary '&' operand

变量x是一个左值,但是它经过函数返回以后变成了一个右值。原因和x++类似,在函数返回的时候编译器并不会返回x本身,而是返回x的临时复制。

void set_val(int val)
{
  int *p = &val;
  x = val;
}

对于set_val函数,该函数接受一个参数并且将参数的值赋值到x中。set_val(6);实参6是一个右值,但是进入函数之后形参val却变成了一个左值,我们可以对val使用取地址符。

左值引用

当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。

常量左值引用除了能引用左值,还能够引用右值。这点常应用于复制构造函数和复制赋值运算符函数的函数形参列表,两个函数的形参都是一个常量左值引用。

class X {
public:
  X() {}
  X(const X&) {}
  X& operator = (const X&) { return *this; }
};

X make_x()
{
  return X();
}

int main() 
{
  X x1;
  X x2(x1);
  X x3(make_x());
  x3 = make_x();
}

如果去掉const则 X x3(make_x());和x3 = make_x();会报错因为在此时make_x()返回右值,而去掉const后复制构造函数和复制赋值运算符函数无法接受左值。而声明为常量左值引用后既可以应用左值,又可以引用右值,还不会创建临时对象,而是直接使用传入的 X 对象,这样可以避免额外的拷贝操作,提高了效率。

缺点:一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)。

右值引用

右值引用是一种引用右值且只能引用右值的方法,右值引用是在类型后添加&&。

#include <iostream>

class X
{
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X &x) { std::cout << "X copy ctor" << std::endl; }
    X &operator=(const X &x)
    {
        std::cout << "X assignment operator" << std::endl;
        return *this;
    }
    ~X() { std::cout << "X dtor" << std::endl; }
    void show() { std::cout << "show X" << std::endl; }
};

X make_x()
{
    X x1;
    return x1;
}

int main()
{
    X &&x2 = make_x();
    x2.show();
    return 0;
}

如果将X &&x2 = make_x()这句代码替换为X x2 =make_x()在没有进行任何优化的情况下应该是3次构造(我也觉得是三次,但是不知道为什么编译器优化全部关了还是两次,少一次拷贝构造),首先make_x函数中x1会默认构造一次,然后return x1会使用复制构造产生临时对象,接着X x2 =make_x()会使用复制构造将临时对象复制到x2,最后临时对象被销毁。

运行上面的代码结果如下

X ctor
X copy ctor
X dtor
show X
X dtor

从运行结果可以看出上面的代码只发生了两次构造。第一次是make_x函数中x1的默认构造,第二次是return x1引发的复制构造。不同的是,由于x2是一个右值引用,引用的对象是函数make_x返回的临时对象,因此该临时对象的生命周期得到延长,所以我们可以在X &&x2 = make_x()语句结束后继续调用show函数而不会发生任何问题。

移动语义

#include <iostream>
class BigMemoryPool {
public:
  static const int PoolSize = 4096;
  BigMemoryPool() : pool_(new char[PoolSize]) {}
  ~BigMemoryPool()
  {
      if (pool_ != nullptr) {
            delete[] pool_;
      }
  }

  BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
  {
      std::cout << "copy big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
  }

private:

  char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
  return pool;
}

BigMemoryPool make_pool()
{
  BigMemoryPool pool;
  return get_pool(pool);
}

int main()
{
  BigMemoryPool my_pool = make_pool();
}

上面的代码共调用了3次拷贝构造函数

1.get_pool返回的BigMemoryPool临时对象调用复制构造函数复制了pool对象。

2.make_pool返回的BigMemoryPool临时对象调用复制构造函数复制了get_pool返回的临时对象。

3.main函数中my_pool调用其复制构造函数复制make_pool返回的临时对象。

第二次和第三次的复制构造是影响性能的主要原因。在这个过程中都有临时对象参与进来,而临时对象本身只是做数据的复制。

使用移动语义进行优化

class BigMemoryPool {
public:
  static const int PoolSize = 4096;
  BigMemoryPool() : pool_(new char[PoolSize]) {}
  ~BigMemoryPool()
  {
      if (pool_ != nullptr) {
            delete[] pool_;
      }
  }

  BigMemoryPool(BigMemoryPool&& other)
  {
      std::cout << "move big memory pool." << std::endl;
      pool_ = other.pool_;
      other.pool_ = nullptr;
  }

  BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
  {
      std::cout << "copy big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
  }

private:

  char *pool_;
};

移动构造函接受的是一个右值,其核心思想是通过转移实参对象的数据以达成构造目标对象的目的,在函数中没有了复制构造中的内存复制,取而代之的是简单的指针替换操作。它将实参对象的pool_赋值到当前对象,然后置空实参对象以保证实参对象析构的时候不会影响这片内存的生命周期。

运行 BigMemoryPool my_pool = make_pool();结果如下

copy big memory pool.
move big memory pool.
move big memory pool.

后面两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值(临时对象),对于右值编译器会优先选择使用移动构造函数去构造目标对象。当移动构造函数不存在的时候才会退而求其次地使用复制构造函数。

同样的也有移动赋值运算符函数,编译器对于赋值源对象是右值的情况会优先调用移动赋值运算符函数,如果该函数不存在,则调用复制赋值运算符函数。

BigMemoryPool& operator=(BigMemoryPool&& other)
{
    std::cout << "move(operator=) big memory pool." << std::endl;
    if (pool_ != nullptr) {
        delete[] pool_;
    }
    pool_ = other.pool_;
    other.pool_ = nullptr;
    return *this;
}

值类别

左值对应前文描述的左值,纯右值对应前文描述的右值。

什么是将亡值?

将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用的转换产生的。

将亡值产生的途径

第一种是使用类型转换将泛左值转换为该类型的右值引用:

static_cast<BigMemoryPool&&>(my_pool)

第二种在C++17标准中引入,我们称它为临时量实质化,指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这也符合纯右值的概念,而这里的临时对象就是一个将亡值。

struct X {
  int a;
};

int main()
{
  int b = X().a;
}

X()是一个纯右值,访问其成员变量a却需要一个泛左值,所以这里会发生一次临时量实质化,将X()转换为将亡值,最后再访问其成员变量a。

将左值转换为右值

通过static_cast将左值转换为将亡值,实现被右值引用绑定。

int i = 0;
int &&k = static_cast<int&&>(i);    // 编译成功

转化后i依然有着和转换之前相同的生命周期和内存地址。转化最大作用是让左值使用移动语义。

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(pool);
}

int main()
{
  move_pool(make_pool());
}

在上面的代码中,move_pool函数的实参是make_pool函数返回的临时对象,也是一个右值,move_pool的形参是一个右值引用,但是在使用形参pool构造my_pool的时候还是会调用复制构造函数而非移动构造函数。为了让my_pool调用移动构造函数进行构造,需要将形参pool强制转换为右值:

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}

函数模板std::move也可以帮助我们将左值转换为右值,由于它是使用模板实现的函数,因此会根据传参类型自动推导返回类型,省去了指定转换类型的代码。

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(std::move(pool));
}

万能引用和引用折叠

如果一个变量或者参数被声明为T&&,其中T是被推导的类型,那这个变量或者参数就是一个万能引用。

void foo(int &&i) {}    // i为右值引用

template<class T>
void bar(T &&t) {}        // t为万能引用

int get_val() { return 5; }
int &&x = get_val();      // x为右值引用
auto &&y = get_val();     // y为万能引用

C++11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出最终类型

只要有左值引用参与进来,最后推导的结果就是一个左值引用。只有实际类型为非引用和右值引用才可以推到出右值引用。

完美转发

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
  std::cout << typeid(t).name() << std::endl;
}

template<class T>
void normal_forwarding(T t)
{
  show_type(t);
}

int main()
{
  std::string s = "hello world";
  normal_forwarding(s);
}

normal_forwarding的转发缺陷在于每调用一次normal_forwarding(T t) 就会造成一次额外的复制,改为void normal_forwarding (const T &t)可以避免不必要的复制,而且既可以接受左值又可以接受右值,但是后续无法修改字符串。使用万能引用改善常量左值引用带来的常量性缺点。

template<class T>
void perfect_forwarding(T &&t)
{
  show_type(static_cast<T&&>(t));
}

posted @   紫冰凌  阅读(23)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)
点击右上角即可分享
微信分享提示