C++右值和移动

值分左右

C++表达式的值类别:
image

这些名词的字面含义:

  • 一个lvalue是通常可以放在等号左边的表达式,左值
  • 一个rvalue是通常只能放在等号右边的表达式,右值
  • 一个glvalue是generalized lvalue,广义左值
  • 一个xvalue是expiring value,将亡值
  • 一个prvalue是pure rvalue,纯右值

暂时抛开这些概念,只看其中两个:lvaueprvalue

左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有:

  • 变量、函数或数据成员的名字
  • 返回左值引用的表达式,如++x、x=1、cout<< " "
  • 字符串字面量如"hello world"

在函数调用时,左值可以绑定到左值引用 参数,如T&。一个常量只能绑定到常左值引用,如const T&

反之,纯右值prvalue是没有标识符、不可以取地址的表达式,一般也称之为"临时对象"。 最常见的情况有:

  • 返回非引用类型的表达式,如x++x+1make_shared<int>(42)
  • 除字符串字面量之外的字面量,如43,true

在C++11之前,右值可以绑定到常左值引用(const lvalue reference)的参数,如const T&,但不可以绑定到非常左值引用(non-const lvalue reference),如T&。
从C++11 开始,C++语言里多了一种引用类型--右值引用。右值引用的形式是 T&&,比左值引用多一个&符。跟左值引用一样,可以使用constvolatile来进行修饰,但最常见的情况是,不会用constvolatile来修饰右值,本节就属于这种情况。

引入一种额外的引用类型当然增加了语言的复杂性,但也带来了很多优化的可能性。由于C++有重载,就可以根据不同的引用类型,来选择不同的重载函数,来完成不同的行为。

template <typename U>
smart_ptr(const smart_ptr<U>& other) noexcept
{
  ptr_ = other.ptr_;
  if (ptr_) {
    other.shared_count_->add_count();
    shared_count_ =
      other.shared_count_;
  }
}
template <typename U>
smart_ptr(smart_ptr<U>&& other) noexcept
{
  ptr_ = other.ptr_;
  if (ptr_) {
    shared_count_ =
      other.shared_count_;
    other.ptr_ = nullptr;
  }
}

很好奇,使用右值引用的第二个重载函数中的变量other算是左值还是右值?
根据定义,other是变量的名字,变量有标识符、有地址,所以它还是一个左值 -- 虽然它的类型是右值引用。

尤其重要的是,拿这个 other 去调用函数时,它匹配的也会是左值引用。也就是说,类型是右值引用的变量是一个左值!这点可能有点反直觉,但跟 C++ 的其他方面是一致的。毕竟对于一个右值引用的变量,你是可以取地址的,这点上它和左值完全一致。

来看下下面的代码:

smart_ptr<shape> ptr1{new circle()};
smart_ptr<shape> ptr2 = std::move(ptr1);

第一个表达式里的 new circle() 就是一个纯右值;但对于指针,我们通常使用值传递,并不关心它是左值还是右值。

第二个表达式里的std::move(ptr),它的作用是把一个左值引用强制转换成一个右值引用,而不改变其内容。从使用的角度,std::move(ptr1)等价于static_cast<smart_ptr<shape>&&>(ptr1)。因此std::move(ptr1)的结果是指向ptr1的一个右值引用,这样构造ptr2时就会选择上面第二个重载。

可以把 std::move(ptr1) 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别,C++ 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同,xvalue 仍然是不能取地址的——这点上,xvalue 和 prvalue 相同。所以,xvalue 和 prvalue 都被归为右值 rvalue。我们用下面的图来表示会更清楚一点:
image

另外请注意,“值类别”(value category)和“值类型”(value type)是两个看似相似、却毫不相干的术语。
前者指的是上面这些左值、右值相关的概念,后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。

在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(&)和指针(*)才是引用类型。在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型。

生命周期和表达式类型

一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象,当然这个对象的生命周期也在那时结束。
那临时对象(prvalue)呢?在这儿,C++ 的规则是:一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生
先看一个没有生命周期延长的基本情况:

process_shape(circle(), triangle());

生成了临时对象,一个圆和一个三角形,它们会在 process_shape 执行完成并生成结果对象后被销毁。

插入一些实际的代码,就可以演示这一行为:

#include <stdio.h>

class shape {
public:
  virtual ~shape() {}
};

class circle : public shape {
public:
  circle() { puts("circle()"); }
  ~circle() { puts("~circle()"); }
};

class triangle : public shape {
public:
  triangle() { puts("triangle()"); }
  ~triangle() { puts("~triangle()"); }
};

class result {
public:
  result() { puts("result()"); }
  ~result() { puts("~result()"); }
};

result
process_shape(const shape& shape1,
              const shape& shape2)
{
  puts("process_shape()");
  return result();
}

int main()
{
  puts("main()");
  process_shape(circle(), triangle());
  puts("something else");
}

输出结果可能会是(circle 和 triangle 的顺序在标准中没有规定):

main()
circle()
triangle()
process_shape()
result()
~result()
~triangle()
~circle()
something else

可以看到结果的临时对象最后生成、最先析构。

为了方便对临时对象的使用,C++ 对临时对象有特殊的生命周期延长规则。这条规则是:

如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。

对上面的代码只要改一行就能演示这个效果。把 process_shape 那行改成:

result&& r = process_shape(
  circle(), triangle());

看到不同的结果:

main()
circle()
triangle()
process_shape()
result()
~triangle()
~circle()
something else
~result()

现在 result 的生成还在原来的位置,但析构被延到了 main 的最后。

需要万分注意的是,这条生命期延长规则只对 prvalue 有效,而对 xvalue 无效。如果由于某种原因,prvalue 在绑定到引用以前已经变成了 xvalue,那生命期就不会延长。不注意这点的话,代码就可能会产生隐秘的 bug。比如,我们如果这样改一下代码,结果就不对了:

#include <utility>  // std::move
…
result&& r = std::move(process_shape(
  circle(), triangle()));

这时的代码输出就回到了前一种情况。虽然执行到 something else 那儿我们仍然有一个有效的变量 r,但它指向的对象已经不存在了,对 r 的解引用是一个未定义行为。由于 r 指向的是栈空间,通常不会立即导致程序崩溃,而会在某些复杂的组合条件下才会引致问题……

移动的意义

对于 smart_ptr,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销——在引用计数指针的场景下,这个开销并不大。 移动构造和拷贝构造的差异仅在于:

  • 少了一次other.shared_count_->add_count()的调用

  • 被移动的指针被清空,因而析构时也少了一次shared_count_->reduce_count()的调用

在使用容器类的情况下,移动更有意义。

可以尝试分析一下下面这个假想的语句(假设 name 是 string 类型):

string result =
  string("Hello, ") + name + ".";

在 C++11 之前的年代里,这种写法是绝对不推荐的。因为它会引入很多额外开销,执行流程大致如下:

  • 1.调用构造函数 string(const char*),生成临时对象 1;"Hello, " 复制 1 次。
  • 2.调用 operator+(const string&, const string&),生成临时对象 2;"Hello, " 复制 2 次,name 复制 1 次。
  • 3.调用 operator+(const string&, const char*),生成对象 3;"Hello, " 复制 3 次,name 复制 2 次,"." 复制 1 次。
  • 4.假设返回值优化能够生效(最佳情况),对象 3 可以直接在 result 里构造完成。
  • 5.临时对象 2 析构,释放指向 string("Hello, ") + name 的内存。
  • 6.临时对象 1 析构,释放指向 string("Hello, ") 的内存。

C++是一门追求性能的语言,一个合格的C++程序员会这样写:

string result = "Hello, ";
result += name;
result += ".";

这样的话,只会调用构造函数一次和 string::operator+= 两次,没有任何临时对象需要生成和析构,所有的字符串都只复制了一次。但显然代码就啰嗦多了——尤其如果拼接的步骤比较多的话。
从 C++11 开始,这不再是必须的。同样上面那个单行的语句,执行流程大致如下:

  • 1.调用构造函数 string(const char*),生成临时对象 1;"Hello, " 复制 1 次。
  • 2.调用 operator+(string&&, const string&),直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2;name 复制 1 次。
  • 3.调用 operator+(string&&, const char*),直接在临时对象 2 上面执行追加操作,并把结果移动到 result;"." 复制 1 次。
  • 4.临时对象 2 析构,内容已经为空,不需要释放任何内存。
  • 5.临时对象 1 析构,内容已经为空,不需要释放任何内存。

性能上,所有的字符串只复制了一次;虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉到额外的内存分配和释放,是相当廉价的。程序员只需要牺牲一点点性能,就可以大大增加代码的可读性。而且,所谓的性能牺牲,也只是相对于优化得很好的 C 或 C++ 代码而言——这样的 C++ 代码的性能仍然完全可以超越 Python 类的语言的相应代码。

此外很关键的一点是,C++ 里的对象缺省都是值语义。在下面这样的代码里:

class A {
  B b_;
  C c_;
};

从实际内存布局的角度,很多语言——如 Java 和 Python——会在 A 对象里放 B 和 C 的指针(虽然这些语言里本身没有指针的概念)。
而** C++ 则会直接把 B 和 C 对象放在 A 的内存空间里**。这种行为既是优点也是缺点。
优点: 是因为它保证了内存访问的局域性,而局域性在现代处理器架构上是绝对具有性能优势的。
缺点: 是因为复制对象的开销大大增加:在 Java 类语言里复制的是指针,在 C++ 里是完整的对象。这就是为什么 C++ 需要移动语义这一优化,而 Java 类语言里则根本不需要这个概念。

一句话总结,移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率

所有的现代 C++ 的标准容器都针对移动进行了优化。

如何实现移动?

要让你设计的对象支持移动的话,通常需要下面几步:

  • 你的对象应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝——如 unique_ptr)。
  • 你的对象应该有 swap 成员函数,支持和另外一个对象快速交换成员
  • 在你的对象的名空间下,应当有一个全局的 swap 函数,调用成员函数 swap 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 swap 函数。
  • 实现通用的 operator=。
  • 上面各个函数如果不抛异常的话,应当标为 noexcept。这对移动构造函数尤为重要。

具体写法可以参考实现C++智能指针 中实现的 smart_ptr:

  • smart_ptr 有拷贝构造和移动构造函数(虽然此处我们的模板构造函数严格来说不算拷贝或移动构造函数)。移动构造函数应当从另一个对象获取资源,清空其资源,并将其置为一个可析构的状态。
smart_ptr(const smart_ptr& other) noexcept
{
  ptr_ = other.ptr_;
  if (ptr_) {
    other.shared_count_
      ->add_count();
    shared_count_ =
      other.shared_count_;
  }
}
template <typename U>
smart_ptr(const smart_ptr<U>& other) noexcept
{
  ptr_ = other.ptr_;
  if (ptr_) {
    other.shared_count_
      ->add_count();
    shared_count_ =
      other.shared_count_;
  }
}
template <typename U>
smart_ptr(smart_ptr<U>&& other) noexcept
{
  ptr_ = other.ptr_;
  if (ptr_) {
    shared_count_ =
      other.shared_count_;
    other.ptr_ = nullptr;
  }
}
  • smart_ptr 有 swap 成员函数。
void swap(smart_ptr& rhs) noexcept
{
  using std::swap;
  swap(ptr_, rhs.ptr_);
  swap(shared_count_,
       rhs.shared_count_);
}
  • 有支持 smart_ptr 的全局 swap 函数。
template <typename T>
void swap(smart_ptr<T>& lhs,
          smart_ptr<T>& rhs) noexcept
{
  lhs.swap(rhs);
}
  • smart_ptr 有通用的 operator= 成员函数。注意为了避免让人吃惊,通常我们需要将其实现成对 a = a; 这样的写法安全。下面的写法算是个小技巧,对传递左值和右值都有效,而且规避了 if (&rhs != this) 这样的判断。
  smart_ptr&
  operator=(smart_ptr rhs) noexcept
  {
    rhs.swap(*this);
    return *this;
  }

不要返回本地变量的引用

有一种常见的 C++ 编程错误,是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。 理论上来说,程序出任何奇怪的行为都是正常的。

在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。
从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用 std::move 对于移动行为没有帮助,反而会影响返回值优化。

举例:

#include <iostream>  // std::cout/endl
#include <utility>   // std::move

using namespace std;

class Obj {
public:
  Obj()
  {
    cout << "Obj()" << endl;
  }
  Obj(const Obj&)
  {
    cout << "Obj(const Obj&)"
       << endl;
  }
  Obj(Obj&&)
  {
    cout << "Obj(Obj&&)" << endl;
  }
};

Obj simple()
{
  Obj obj;
  // 简单返回对象;一般有 NRVO
  return obj;
}

Obj simple_with_move()
{
  Obj obj;
  // move 会禁止 NRVO
  return std::move(obj);
}

Obj complicated(int n)
{
  Obj obj1;
  Obj obj2;
  // 有分支,一般无 NRVO
  if (n % 2 == 0) {
    return obj1;
  } else {
    return obj2;
  }
}

int main()
{
  cout << "*** 1 ***" << endl;
  auto obj1 = simple();
  cout << "*** 2 ***" << endl;
  auto obj2 = simple_with_move();
  cout << "*** 3 ***" << endl;
  auto obj3 = complicated(42);
}

输出通常为:

*** 1 ***
Obj()
*** 2 ***
Obj()
Obj(Obj&&)
*** 3 ***
Obj()
Obj()
Obj(Obj&&)

也就是,用了 std::move 反而妨碍了返回值优化。

引用坍缩和完美转发

引用坍缩(又称“引用折叠”)这个概念在泛型编程中是一定会碰到的

对于一个实际的类型 T,它的左值引用是 T&,右值引用是 T&&。那么:

  • 1.是不是看到 T&,就一定是个左值引用?
  • 2.是不是看到 T&&,就一定是个右值引用?

对于前者的回答为“是”,对于后者的回答为“否”
关键在于,在有模板的代码里,对于类型参数的推导结果可能是引用。我们可以略过一些繁复的语法规则,要点是:

  • 对于 template foo(T&&) 这样的代码,如果传递过去的参数是左值,T 的推导结果是左值引用;如果传递过去的参数是右值,T 的推导结果是参数的类型本身。

  • 如果 T 是左值引用,那 T&& 的结果仍然是左值引用——即 type& && 坍缩成了 type&

  • 如果 T 是一个实际类型,那 T&& 的结果自然就是一个右值引用。

之前提过,右值引用变量仍然会匹配到左值引用上去。下面的代码会验证这一行为:

void foo(const shape&)
{
  puts("foo(const shape&)");
}

void foo(shape&&)
{
  puts("foo(shape&&)");
}

void bar(const shape& s)
{
  puts("bar(const shape&)");
  foo(s);
}

void bar(shape&& s)
{
  puts("bar(shape&&)");
  foo(s);
}

int main()
{
  bar(circle());
}

输出为:

bar(shape&&)
foo(const shape&)

如果我们要让 bar 调用右值引用的那个 foo 的重载,我们必须写成:

foo(std::move(s));

或:

foo(static_cast<shape&&>(s));

可如果两个 bar 的重载除了调用 foo 的方式不一样,其他都差不多的话,我们为什么要提供两个不同的 bar 呢?

事实上,很多标准库里的函数,连目标的参数类型都不知道,但我们仍然需要能够保持参数的值类别:左值的仍然是左值,右值的仍然是右值。这个功能在 C++ 标准库中已经提供了,叫 std::forward它和 std::move 一样都是利用引用坍缩机制来实现。此处,我们不介绍其实现细节,而是重点展示其用法。我们可以把我们的两个 bar 函数简化成:

template <typename T>
void bar(T&& s)
{
  foo(std::forward<T>(s));
}

对于下面这样的代码:

circle temp;
bar(temp);
bar(circle());

现在的输出是:

foo(const shape&)
foo(shape&&)

因为在 T 是模板参数时,T&& 的作用主要是保持值类别进行转发,它有个名字就叫“转发引用”(forwarding reference) 。因为既可以是左值引用,也可以是右值引用,它也曾经被叫做“万能引用”(universal reference)。

参考:
右值和移动究竟解决了什么问题?

posted @ 2022-09-30 11:24  牛犁heart  阅读(96)  评论(0编辑  收藏  举报