(1)左值引用和右值引用在声明的时候就要初始化

从语义上来讲,引用不能独立存在,必须先有一个被引用者,引用才可以存在。

这点引用和指针是不一样的,指针变量可以独立存在,可以先声明一个指针,再给指针赋值。

(2)左值引用不能用右值初始化,右值引用不能用左值初始化。

左值引用能被右值初始化的特殊情况,就是const左值引用。这就是函数形参声明,经常见到const &类型的原因,可以接收左值,也可以接收右值。

(3)右值引用是左值

1左值和左值引用

在右值引用出现之前,左值引用也不叫左值引用,就叫引用。现在一般也直接将左值引用称作引用,将右值引用称作右值引用。左值引用类似于c语言中的指针,c++ 引入左值引用,在一定程度上可以代替指针(引用不能完全代替指针,指针使用更加灵活,比如指针的加减运算)。

什么是左值,简单来说,左值是等号左边的值,是可以取地址的。

如下代码,是左值引用的例子:

(1)左值引用需要在声明的时候初始化

(2)左值引用不能指向一个右值(左值引用是一个指针,指针当然需要指向一个左值,右值是临时的,没有存储空间,这种情况下,左值引用指向哪?),但是const 左值引用可以用一个右值进行初始化(跟声明了一个常量一样)。

#include <iostream>
#include <string>

int main() {
  int a = 10;
  int &b = a;

  printf("a = %d, b = %d\n", a, b);
  b = 20;
  printf("a = %d, b = %d\n", a, b);
  // int &c; // 编译错误,左值引用在声明的时候必须初始化

  // int &d = 10; // 编译错误,左值引用不能使用一个右值初始化
  const int &e = 10;
  printf("&a = %p, &b = %p, &e = %p\n", &a, &b, &e);
  return 0;
}

下图打印出了a和e的地址,从地址可以看出来,对于 e 来说,虽然是用一个右值进行初始化,但是这个右值也是保存在栈上的(e的地址和a、b的地址是挨着的)。const 左值引用,类似于如下两句代码,只不过栈上这块内存是编译器内部分配的,开发者不感知。

int temp = 10;

int &e = temp ;

const左值引用能使用一个右值进行初始化,这种情况在调用函数传参的时候也会遇到。形参是const左值引用,实参是右值,这是最常用的方式。

如果函数形参是一个非const左值引用的话,那么调用函数的时候,如果直接传右值,会报编译错误;形参是const左值引用的话,那么可以直接传右值。当我们看到一个函数的形参是const 左值引用的时候,总是习以为常,其实const左值引用还起到这样的作用,即可以直接传右值。

#include <iostream>
#include <string>

void Test(const int &data, int a, int b) {
  int la = 10;
  printf("data = %d\n", data);
  int lb = 20;
  printf("&data = %p, &a = %p, &b = %p\n", &data, &a, &b);
  printf("&la = %p, &lb = %p\n", &la, &lb);
}

int main() {
  int a = 10;
  Test(a, 10, 20);
  printf("----------------\n");
  Test(100, 30, 40); // 如果形参不是 const 左值引用,只是普通的引用,那么这句话编译会报错
  int b = 20;
  printf("main, &a = %p, &b = %p\n", &a, &b);
  return 0;
}

代码运行结果如下,从运行结果来看,当传值100的时候,实参保存在了 main 函数的栈里,从100的地址来看,和main函数中的变量b保存在了同一个地址上,编译器还实现了内存的分时复用。

2 右值和右值引用

右值是不能取地址的,等号右边的值,临时的值。

(1)Test函数的第一个形参是右值引用,这样的话在调用函数的时候,第一个实参可以直接传右值,而不需要像左值引用那样,形参必须是const左值引用,才可以传右值。

#include <stdlib.h>
#include <stdio.h>
#include <iostream>
#include <string>

void Test(int &&a, int b, int c) {
  int la = 100;
  printf("a = %d, b = %d, c = %d\n", a, b, c);
  printf("&a = %p, &b = %p, &c = %p\n", &a, &b, &c);
  int lb = 100;
  printf("&la = %p, &lb = %p\n", &la, &lb);
  return;
}

int main() {
  int a = 100;
  Test(1, 2, 3);
  Test(4, 5, 6);
  Test(7, 8, 9);
  int b = 200;
  printf("\n--------------------------------\n");
  printf("main, &a = %p, &b = %p\n", &a, &b);

  int &&c = 10;
  printf("&c = %p\n", &c);
  c = 100;
  printf("c = %d, &c = %p\n", c, &c);
  return 0;
}

(2)运行结果如下,在函数Test中打印了形参a、b、c的地址,发现b和c的存储是挨着的, a和它俩并不是挨存储的。a存储在main函数的栈里(和main函数中的局部变量a和b的地址挨着),并且多次调用的时候,使用的是一块相同的地址。多次调用复用一块地址。

使用右值引用的时候,稍有不慎就很容易出现bug。c++ 中引用的出现本来就是为了代替c语言中的指针,简化使用的,但是随着版本的迭代,引用却越来越复杂。

(3)右值引用,只不过是 c++ 中的一个语法,右值引用的底层实现和const左值引用是类似的,也是有自己的存储内存的(废话,一个值或者一个变量当然要保存在内存中),上边的例子就是保存在栈上,只不过这块内存是编译器来分配的,开发者不感知;右值引用和const左值引用的区别是,前者可以修改,后者不可以修改,右值引用可以使用右值赋值,右值引用可以被修改。

(4)右值引用本身是一个左值,因为右值引用在=左边,可以取地址

如下代码,左值引用可以使用a进行初始化,a和b指向同一块内存区域。

#include <iostream>
#include <string>

int main() {
  int &&a = 10;
  int &b = a;
  printf("a = %d, b = %d\n", a, b);
  b = 100;
  printf("a = %d, b = %d, &a = %p, &b = %p\n", a, b, &a, &b);
  return 0;
}

(5)右值引用不能使用左值进行初始化

类似于左值引用在声明的时候需要初始化,右值引用在声明的时候也需要初始化。 

非const左值引用不能使用右值初始化,同样的,右值引用也不能使用左值进行初始化。

如下面的代码 int &&c = a; int &&d = b,这两行代码都会导致编译错误(右值需要具有临时性,只有具有这样的特点,才可以赋值给一个右值引用,a和b是左值,不具备临时性)。可以使用std::move将左值转换为右值,从打印地址来看,c和a指向同一个地址,d和b指向同一个地址。一个右值引用不能用来初始化一个右值引用,因为前者是左值。

#include <stdlib.h>
#include <stdio.h>
#include <iostream>
#include <string>

int main() {
  int a = 10;
  int &&b = 20;

  // int &&c = a; // cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
  // int &&d = b; // cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
  printf("a = %d, b = %d, &a = %p, &b = %p\n", a, b, &a, &b);
  int &&c = std::move(a);
  int &&d = std::move(b);
  c = 100;
  d = 200;
  printf("a = %d, b = %d, &a = %p, &b = %p\n", a, b, &a, &b);
  printf("c = %d, d = %d, &c = %p, &d = %p\n", c, d, &c, &d);
  return 0;
}

从std::move的官方说明也能看出来,std::move可以将变量类型转变为右值引用类型。

3 为什么要引入右值引用

右值引用,理解和使用起来,都有点难度,并且使用时容易出错,那为什么还要引入右值引用呢,使用右值引用对代码能带来什么优化?

3.1 移动语义

右值引用,最明显的意义是支持移动语义。在右值引用之前,c++中是不支持移动语义的,比如我要移动一个对象,那么会首先进行拷贝,将旧对象的数据拷贝到新对象中,然后旧对象销毁。这种方式我们习以为常,但这种方式的效率却是比较低下的。假想我们搬家的场景,我们会先把物品再重新买一份然后把旧物品销毁吗还是把物品从旧家移动到新家?当然是后者。所以我们很多习以为常的事情,并不一定是最合理的。

c++中移动语义通过std::move来实现,移动的意思就是将对象中的内容移动到新对象中了,那么旧对象就不再使用了。

如下代码,是将std::string进行移动,将str移动到str1中。

#include <iostream>
#include <string>
#include <stdlib.h>
#include <stdio.h>

int main() {
  std::string str = "hello world, hello world";
  std::cout << "1, str " << str << ", str size " << str.size() << std::endl;
  printf("str addr = %p\n", str.c_str());
  std::string str1 = std::move(str);
  std::cout << "2, str " << str << ", str size " << str.size() << ", capacity " << str.capacity() << std::endl;
  printf("str1 addr = %p, str addr = %p\n", str1.c_str(), str.c_str());

  std::string str2 = str1;
  printf("str2 addr = %p\n", str2.c_str());
  return 0;
}

运行结果如下,从运行结果我们可以得到以下几点:

(1)str通过std::move被移动到str1之后,str中就没有字符串了,size是0

(2)打印出了str和str1的字符串的地址,可以看到两者是相等的,这也能说明,在str移动到str1 的过程中,没有进行拷贝,而是将字符串的地址赋值给了 str1

(3)str2使用str1进行赋值,没有使用std::move,那么就是通过拷贝来完成的。

(4)上边的代码,字符串长度大于15,这样才能观察到移动的现象;如果字符串长度不大于 15 的话,每个std::string都会有一个自带的数组来保存字符串,这种情况下仍然会拷贝,而不是移动,因为当字符串不大于15的时候,std::string不会申请内存,而是使用自带的字符串。

3.2 移动构造

移动构造也是一种构造方式,形参是右值引用数据类型。移动构造在如下3种情况下会调用:

(1)显示使用std::move构造一个新对象时

(2)使用std::move进行函数传参时

(3)函数返回对象的时候。这种情况下默认会有返回值优化,看不到移动的过程,如果编译的时候将返回值优化关闭,则可以看到移动过程

#include <iostream>
#include <string>

class Test {
public:
    Test(int data) : data_(data) {
        std::cout << "constructor " << data_ << std::endl;
    }

    Test(const Test& o) : data_(o.data_) {
        std::cout << "copy constructor " << data_ << std::endl;
    }

    Test(Test&& o) noexcept : data_(o.data_) {
        std::cout << "move constructor " << data_ << std::endl;
    }

    void PrintData() {
      std::cout << "data = " << data_ << std::endl;
    }

private:
    int data_;
};

void func1(Test t) {
  t.PrintData();
}

Test func2(Test t) {
  return t;
}

Test func3() {
  Test t(10);
  return t;
}

int main() {
    Test t1(1);
    Test t2(std::move(t1)); // 显示调用 std::move() 进行构造
    func1(std::move(t2)); // 显式调用 std::move() 进行传参

    Test t3(3);
    func2(t3); // 函数返回 t 对象

    Test t4 = func3(); // 默认有返回值优化,只构造一次,如果编译时使用 -fno-elide-constructors 关闭返回值优化,则在函数内构造一次,返回和赋值的时候后 move 两次
    return 0;
}

移动构造函数在什么情况下才会默认生成 ? 移动构造默认生成的限制条件是比较多的,不像拷贝构造函数,用户不显式声明的话,编译器就会隐式生成。以下条件都满足的情况下,编译器才会生成移动构造函数。

(1)用户没有定义拷贝构造函数。如果用户定义了拷贝构造函数,那么编译器不会生成移动构造函数。用户定义了拷贝构造函数,说明有些资源的拷贝需要用户来实现,使用编译器默认的拷贝方式是不行的。

(2)用户没有定义拷贝赋值运算符。如果用户定义了拷贝赋值运算符,那么编译器不会生成移动构造函数。原因同(1)。

(3)用户没有自定义移动构造函数。如果用户自定义了移动构造函数,那么编译不再默认生成。

(4)用户没有定义析构函数。如果用户定义了析构函数,那么编译器不会生成默认的移动构造函数。如果用户自己定义了析构函数,那么也说明用户需要自己处理资源的释放,使用编译器默认的资源释放方式是不合理的。

std::vector中的数据使用一个动态数组来维护的。如果一直向vector中 push数据的话,那么就会频繁出现数组扩容的情况,数组扩容的话,就是申请一块新的内存,然后将旧数组中的数据拷贝到新数组中,最后销毁旧数组。

如果对象支持移动构造的话,那么在 std::vector 扩容的时候就会使用移动构造来移动数组中的元素,而不是使用拷贝加销毁的方式来移动元素。

#include <iostream>
#include <string>
#include <vector>

class Test {
public:
    Test(int data) : data_(data) {
        std::cout << "constructor " << data_ << std::endl;
    }

    Test(const Test& o) : data_(o.data_) {
        std::cout << "copy constructor " << data_ << std::endl;
    }

    Test(Test&& o) noexcept : data_(o.data_) {
        std::cout << "move constructor " << data_ << std::endl;
    }

    void PrintData() {
      std::cout << "data = " << data_ << std::endl;
    }

private:
    int data_;
};

int main() {
  std::vector<Test> v;

  std::cout << "1, v size = " << v.size() << ", capacity = " << v.capacity() << std::endl;
  Test t1(1);
  v.push_back(t1);
  std::cout << "2, v size = " << v.size() << ", capacity = " << v.capacity() << std::endl;

  Test t2(2);
  v.push_back(t2);
  std::cout << "3, v size = " << v.size() << ", capacity = " << v.capacity() << std::endl;
  return 0;
}

运行结果如下,从运行结果可以看出来,在向数组中 push_back t2 之前,数组容量是 1,所以需要扩容,扩容的时候,对于数组中已经存在 t1,使用了移动构造的方式,而不是使用拷贝构造。

只有移动构造函数用noexcept修饰,那么标准库容器push的时候才会使用移动构造函数,否则会谨慎的使用拷贝构造,这是一种小心的做法,这是一种将语义明确交给用户的做法。

移动构造函数声明为 noexcept 的时候,编译器才会使用移动构造;如果没有 noexcept 修饰,那么仍然使用拷贝构造。

移动构造的形参是右值引用,右值引用的意思是这个值是一个临时值,函数返回之后,生命周期就结束了。std::move的意思也是,被 move 之后的对象只剩下一个空壳子了。std::move和右值引用结合起来使用,语义是一致的。

3.3 移动赋值运算符

移动赋值运算符和拷贝赋值运算符有些类似,当给一个已经创建的对象赋值的时候,才会调用赋值运算符;如果这个对象还不存在,那么调用的是拷贝构造函数或者移动构造函数。

在满足如下条件之后,编译器才会生成默认的移动赋值运算符。有一点需要注意,如果显式声明了移动构造函数,那么也不会默认生成移动赋值。

如下是官方的例子。

#include <iostream>
#include <string>
#include <utility>

struct A
{
    std::string s;

    A() : s("test") {}

    A(const A& o) : s(o.s) { std::cout << "move failed!\n"; }

    A(A&& o) : s(std::move(o.s)) {
      std::cout << "move construct\n";
    }

    A& operator=(const A& other)
    {
         s = other.s;
         std::cout << "copy assigned\n";
         return *this;
    }

    A& operator=(A&& other)
    {
         s = std::move(other.s);
         std::cout << "move assigned\n";
         return *this;
    }
};

A f(A a) { return a; }

struct B : A
{
    std::string s2;
    int n;
    // implicit move assignment operator B& B::operator=(B&&)
    // calls A's move assignment operator
    // calls s2's move assignment operator
    // and makes a bitwise copy of n
};

struct C : B
{
    ~C() {} // destructor prevents implicit move assignment
};

struct D : B
{
    D() {}
    ~D() {} // destructor would prevent implicit move assignment
    D& operator=(D&&) = default; // force a move assignment anyway
};

int main()
{
    A a1, a2;
    std::cout << "Trying to move-assign A from rvalue temporary\n";
    a1 = f(A()); // move-assignment from rvalue temporary
    std::cout << "Trying to move-assign A from xvalue\n";
    a2 = std::move(a1); // move-assignment from xvalue, A 中显式定义了移动赋值运算符

    std::cout << "\nTrying to move-assign B\n";
    B b1, b2;
    std::cout << "Before move, b1.s = \"" << b1.s << "\"\n";
    b2 = std::move(b1); // calls implicit move assignment,B 中没有显式定义移动赋值运算符,也没有定义拷贝构造,移动构造,拷贝赋值,析构函数,所以会生成默认的移动赋值运算符
    std::cout << "After move, b1.s = \"" << b1.s << "\"\n";

    std::cout << "\nTrying to move-assign C\n";
    C c1, c2;
    c2 = std::move(c1); // calls the copy assignment operator,定义了析构函数,那么移动构造和移动赋值运算符都不会默认生成

    std::cout << "\nTrying to move-assign D\n";
    D d1, d2;
    d2 = std::move(d1); // 定义了析构函数,但是使用 default 强制生成了默认移动构造,所以会调用移动构造
}

3.4 std::forward

 万能引用

如下代码中func是一个函数模板,形参是T &&arg;func1是一个普通函数,形参是int &&。函数模板的T &&arg并不是右值引用,而是万能引用,调用该函数,实参可以是任何数据类型:左值、左值引用、右值、右值引用。func1的形参是右值引用,那么不能传左值。

#include <iostream>

template<typename T>
void func(T&& arg) {
  std::cout << "arg:" << arg << std::endl;
}

void func1(int && data) {
  std::cout << "data:" << data << std::endl;
}

int main() {
  int a = 5;
  func(a);
  // 右值引用不能传左值
  // func1(a); // cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
  return 0;
}

std::forward的作用,在c++官方文档中介绍如下:

std::forward的核心作用,防止右值在传递过程中被当成左值来使用。如下图中的代码wrapper函数的形参arg永远是左值,但是当调用warapper的时候,如果传递的是右值,那么arg经过std::forward处理之后仍然是右值;如果不经std::forward处理,则是左值。

右值天然就是可以移动的,因为右值具有临时性,移动不会对其他地方造成影响。使用右值就可以使用移动构造或者移动赋值,相对于左值来说,对性能更友好。

4一次引用使用不当导致的bug

右值空间复用。

在工作中看到了如下代码,代码基于std::thread封装了一个Thread类。Thread封装了业务开发中常用的接口,比如设置调度策略,设置优先级,设置线程名。如下代码删去了不必要的代码,只保留能说明问题的代码。从代码实现上来看,我们看不出什么问题,创建一个线程,第一个形参是线程的入口函数,后边的传参是线程入口函数的参数列表。一切都很正常,看不出什么问题。

class Thread {
public:
    template <class Function, class... Args>
    Thread(Function &&f, Args &&...args) noexcept
        : internal_{[func = std::forward<Function>(f), &args...]() {
            func(args...);
        }}
    {
    }

private:
    std::thread internal_;
};

Thread类在大部分使用场景下是没问题的,比如下面的使用方式,创建了一个线程,线程中是一个死循环,每隔一秒打印一次 "thread running",可以正常工作。

#include <stdio.h>
#include <unistd.h>
#include <memory>
#include <thread>
#include <vector>

class Thread {
public:
    template <class Function, class... Args>
    Thread(Function &&f, Args &&...args) noexcept
        : internal_{[func = std::forward<Function>(f), args...]() {
            func(args...);
        }}
    {
    }

private:
    std::thread internal_;
};

void func() {
  while (1) {
    printf("thread running\n");
    sleep(1);
  }
}

int main() {
    Thread *t = new Thread(func);
    sleep(100);
    return 0;
}

4.1 问题现象

在下边这个使用场景下,就能暴露出来Thread的问题。

如下代码中连续创建了8个线程,线程的入口函数是func,func的形参是Obj对象,Obj中的成员 i_ 取值范围0~7。

#include <stdio.h>
#include <unistd.h>
#include <iostream>
#include <memory>
#include <thread>
#include <vector>

class Thread {
public:
    template <class Function, class... Args>
    Thread(Function &&f, Args &&...args) noexcept
        : internal_{[func = std::forward<Function>(f), &args...]() {
            func(args...);
        }}
    {
    }

private:
    std::thread internal_;
};

class Obj {
public:
  Obj(int i) {
    i_ = i;
    std::cout << "Obj(), i: " << i_ << std::endl;
  }

  Obj(const Obj &obj) {
    i_ = obj.i_;
    std::cout << "copy constructor, i: " << i_ << std::endl;
  }

  ~Obj() {
    std::cout << "~Obj(), i: " << i_ << std::endl;
  }

  int i_;

};

void func(Obj obj) {
    printf("                in thread, i: %d\n", obj.i_);
}

int main() {
    std::vector<Thread *> threads;
    int i = 0;
    for (i = 0; i < 8; i++) {
        printf("    out thread, i: %d\n", i);
        Obj obj(i);
        auto tmp = new Thread(func, obj);
        printf("after create thread %d\n", i);
        threads.emplace_back(tmp);
        if (i == 7) {
          sleep(10);
        }
    }
    return 0;
}

上边的代码编译之后,运行结果如下所示。我们的预期是在func中的打印分别是0~7,每个数字打印一次。但实际的打印结果是有重复的,如下图所示,2有重复的,7也有重复的。

root@wangyanlong-virtual-machine:/home/wyl/cpp# ./a.out
    out thread, i: 0
Obj(), i: 0
after create thread 0
~Obj(), i: 0
    out thread, i: 1
Obj(), i: 1
after create thread 1
~Obj(), i: 1
    out thread, i: 2
Obj(), i: 2
copy constructor, i: 2
                in thread, i: 2
~Obj(), i: 2
copy constructor, i: 2
                in thread, i: 2
~Obj(), i: 2
after create thread 2
~Obj(), i: 2
    out thread, i: 3
Obj(), i: 3
copy constructor, i: 2
                in thread, i: 2
~Obj(), i: 2
copy constructor, i: 3
                in thread, i: 3
~Obj(), i: 3
after create thread 3
~Obj(), i: 3
    out thread, i: 4
Obj(), i: 4
after create thread 4
~Obj(), i: 4
    out thread, i: 5
Obj(), i: 5
copy constructor, i: 4
after create thread 5
~Obj(), i: 5
    out thread, i: 6
Obj(), i: 6
                in thread, i: 4
~Obj(), i: 4
copy constructor, i: 5
                in thread, i: 5
~Obj(), i: 5
after create thread 6
~Obj(), i: 6
    out thread, i: 7
Obj(), i: 7
copy constructor, i: 7
                in thread, i: 7
~Obj(), i: 7
after create thread 7
~Obj(), i: 7
copy constructor, i: 7
                in thread, i: 7
~Obj(), i: 7

将 main中的Thread改成 std::thread,打印结果是符合预期的,说明这种使用方式是符合 c++ 规范的。

4.2 问题分析

导致问题的原因有以下几个方面:

(1)线程的构造函数入参是万能引用,创建线程的时候,传递的是左值,这个左值创建在栈上。在线程中又是引用捕获并且在创建8个线程的过程中,实参使用的是同一块地址,同一块栈地址。

(2)在Thread 构造函数中创建了线程,但是线程并不是立即执行的,从创建到真正执行是有一段时间的延迟。这样当线程真正运行的时候,再从args引用里边读取数据,取出来的是这块内存最新的数据,属于这个线程的数据已经被覆盖。

4.3 问题修改

引用捕获改成值捕获。

如下代码,在 Thread构造函数中的lambda表达式对args的引用捕获改成值捕获。

#include <stdio.h>
#include <unistd.h>
#include <iostream>
#include <memory>
#include <thread>
#include <vector>

class Thread {
public:
    template <class Function, class... Args>
    Thread(Function &&f, Args &&...args) noexcept
        : internal_{[func = std::forward<Function>(f), args...]() {
            func(args...);
        }}
    {
    }

private:
    std::thread internal_;
};

class Obj {
public:
  Obj(int i) {
    i_ = i;
    std::cout << "Obj(), i: " << i_ << std::endl;
  }

  Obj(const Obj &obj) {
    i_ = obj.i_;
    std::cout << "copy constructor, i: " << i_ << std::endl;
  }

  ~Obj() {
    std::cout << "~Obj(), i: " << i_ << std::endl;
  }

  int i_;

};

void func(Obj obj) {
    printf("                in thread, i: %d\n", obj.i_);
}

int main() {
    std::vector<Thread *> threads;
    int i = 0;
    for (i = 0; i < 8; i++) {
        printf("    out thread, i: %d\n", i);
        Obj obj(i);
        auto tmp = new Thread(func, obj);
        printf("after create thread %d\n", i);
        threads.emplace_back(tmp);
        // sleep(2);
    }

    sleep(100);
    return 0;
}

5 参考

std::move - cppreferenc.com

Move constructors - cppreference.com

Move assignment operator - cppreference.com

更多推荐