C++右值引用

阅读之前,可以先查阅 C++合成的构造函数 。

1、左值、将亡值、纯右值:

C++11的值必定属于:左值、右值(将亡值、纯右值)三者之一。不是左值就是右值。详见值类别。

  • 左值的特点:“有名字、可以取址”。没有名字或者不能取址,则必定是右值。
  • 右值的特点:即将消亡,也就是说“会被析构”。
  • 纯右值:一定没有名字。比如除去string之外字面值常量、函数返回值、运算表达式。
  • 将亡值:即将消亡的值:比如临时变量,一旦离开作用域就会被销毁;可能没有名字,例如函数的返回值(非引用(包括左值引用、右值引用))。

例如:

int main() {
    A(); // 匿名对象的作用域仅限于语句中,一旦离开当前语句,就会析构。
    getchar(); // 暂停
    return 0;
}

2、引用、右值引用

右值引用:涉及“右值”和“引用”两个概念。

  • “引用”不是对象,所以定义一个“右值引用”不会调用构造函数,避免了多余的构造过程。
  • “右值”是即将析构的值,把右值绑定到右值引用上,延长了右值的生命期,所以右值对象没有析构。

可见,将右值绑定到右值引用上,可以节约一次构造(新对象的构造)和一次析构(右值对象的析构)。

  • 可以把左值绑定到左值引用。
  • 可以把右值绑定到右值引用。
  • 不允许把左值绑定到右值引用。
  • 不允许把右值绑定到左值引用。
  • const左值引用可以接受左值或右值。
// 两次“构造”
// 实际上是先构造一个匿名的int,再用这个匿名的int来复制构造(或移动构造)新变量a1。
int a1=10; // 10是纯右值
const int& aa = 10; // 常量引用可以接受右值
int&& aaa = 10; // 右值引用接受右值


// 用自定义类型更能说明问题
class A
{
public:
    int x_;

    A()
    {
        x_ = 1;
        cout << "A()" << endl;
    }
    ~A()
    {
        x_ = 2;
        cout << "~A()" << endl;
    }

    A(const A &)
    {
        x_ = 3;
        cout << "A(const A&)" << endl;
    }

    A(A &&a)
    {
        x_ = 4;
        cout << "A(A&&)" << endl;
    }
};


// 两次构造,一次析构(当a2离开作用域时,会进行第二次析构)
// 一次是A()的匿名对象的构造
// 另一次是用匿名对象复制构造a(如果定义了移动构造函数,则调用移动构造函数而不是复制构造函数)
// 析构为匿名对象的析构
A a2=A();


// 一次构造,没有析构。
// 因为右值引用接管了匿名对象的生命期,所以匿名对象没有析构。
// 因为 a3 是右值引用(也是一种引用),不是对象,所以没有构造新对象。
A&& a3 = A();

// 一次构造,没有析构
// 同上的右值引用
const A& a4 = A();

上述测试需要关闭编译的 “返回值优化” 优化选项:

g++ -o test main.cc -fno-elide-constructors

函数返回对象:

A fun()
{
    A a; // 默认构造
    // return std::move(a); // std::move 是多余的,因为返回值是“将亡值”,是右值
    return a; // 返回时移动构造一个匿名对象,就是返回值
    // a 离开作用域,析构
}

int main()
{
    // 打印:
    // A()
    // A(A&&)
    // ~A()
    // A(A&&)
    // ~A()
    //
    // 解释:
    // func 的 a 默认构造
    // func 返回时生成一个匿名对象,移动构造
    // func 超出作用域,a 析构
    // aa 移动构造
    // func 返回的匿名对象析构
    A aa = fun(); // 构造3次,析构2次

    // 构造2次:分别是 func 中局部变量 a,和 return 时构造的匿名对象
    // 析构1次:func 中局部变量 a
    A&& ra = fun(); // 右值引用,节省一次析构、一次构造。
    getchar();
}

函数返回右值引用:

// 注意:这个函数有问题,因为将一个局部变量绑定到了右值引用(也是一种引用)
A &&fun()
{
    A a; // 默认构造
    // return a; // 报错:无法将左值绑定到右值引用
    return std::move(a); // 返回右值引用,没有进行构造
    // 返回时,析构 a
}

int main()
{
    // 打印:
    // A()
    // ~A()
    // A(A&&)
    //
    A a1 = fun(); // 使用 func 返回的右值引用进行移动构造
    getchar();


    // 打印:
    // A()
    // ~A()
    A&& aa = fun(); // aa 是右值引用,接管了 func 返回的匿名对象的生命期。
    cout << aa.x_ << endl; // 段错误,因为 aa 引用了一个已经析构的对象 a.
    getchar();
}

函数返回 const 引用:

// `const 左值引用`和`右值引用`没有区别。
const A& fun() {
	A a; // 构造
	return a; // 返回引用,没有进行构造
    // a 析构
}
 
int main() {
    // 打印:
    // A()
    // ~A()
	const A& aa = fun(); // 定义的是引用,aa 不需要构造
    getchar();
	return 0;
}

函数返回 const 右值引用:

这种情况和“返回 const 左值引用”没有区别。

const A&& fun() {
	A a; // 构造
	return std::move(a); // 返回右值引用,没有进行构造
    // a 析构
}
 
int main() {
    // 打印:
    // A()
    // ~A()
	const A& aa = fun(); // 定义的是引用,aa 不需要构造
    getchar();

    // 打印:
    // A()
    // ~A()
    const A&& raa = fun(); // 定义的是右值引用,raa 不需要构造
    getchar();
	return 0;
}

3、引用和右值引用是左值:

也就是说,可以对右值引用取址,但是不能对右值取址(当然,左值引用也是左值,可以取址)。可以把引用理解为指针变量。

#include 
using namespace std;

void fun1(int& t) { // 接受一个左值参数,当然t本身也是左值

}

void fun2(int&& t){  // 接受一个右值参数,但是t本身是左值

}

int main() {

    int a=10;
    int && ra=move(a); // move(a)返回一个右值,ra却是一个左值
    
    // fun2(ra); // 报错:ra是一个左值,不允许绑定到右值引用

    fun1(ra); // 正确:因为ra是左值,可以绑定到左值引用

    fun2(move(a)); // 正确:move(a)返回一个右值

    // fun1(move(a)); // 报错:不允许将右值绑定到左值引用

    return 0;
}

函数调用时,同样要注意,一旦完成绑定,结果将是一个左值。

#include 
using namespace std;

void fun1(int&& t, int b){ // 接受一个右值参数;但是t本身是左值

}

void fun2(int&& t){ // t是右值引用,右值引用本身为左值
    fun1(t, 1); // 报错:t现在是一个左值,不允许将左值绑定到右值引用
}

int main(){
    int a=10;

    fun2(move(a));

}

4、复制构造函数和移动构造函数:

问:为什么接受右值引用的构造函数被视为“移动”语义?

答:因为输入参数是一个引用(右值引用也是引用),所以可以通过该引用直接访问到所引对象,我们可以借机将其持有的资源(堆指针)接管,并且将源对象的堆指针改为NULL,也就是移走了其持有的资源。其次,输入参数是对右值的引用,所以,只有通过右值进行“复制”(实际上是移动)构造时,才会调用该构造函数,所以移走一个即将析构的右值持有的资源,是安全的。

如果自定义了复制构造函数(const引用版),则移动构造函数会被编译器隐式地声明为delete的,此时如果用右值来构造对象,调用的则是const引用版的复制构造函数:

#include 
#include 
#include 
#include 
using namespace std;

class A {
public:
	A() {
		cout << "A()" << endl;
	}
    // 参数为常量引用,可以接受左值或右值。
    // 如果没有同时定义移动构造函数,当使用右值构造时,将调用该复制构造函数。
	A(const A&) {
		cout << "A(const A&)" << endl;
	}
	A(A&) {
		cout << "A(A&)" << endl;
	}
	//A(A&&) = delete; // 不能显式声明为delete,不然下面的std::move调用会报错;
    // 如果显式声明,那么编译器认为移动构造函数已经被定义,并且定义为delete。
    // 下面的std::move调用时,会直接调用移动构造函数版本,而不会去绑定到const引用复制构造函数版本
    // 但是,如果没有显式声明为delete,则编译器不会生成移动构造函数,运行时则寻找能够匹配的函数,
    // 显然,此时接受const引用参数的复制构造函数可以接受右值,于是被调用。
};

int main() {
	A a;
    getchar();
	A b(std::move(a)); // 输出:A(const A&)
    getchar();
	return 0;
}

5、完美转发:

指对模板参数实现完美转发:即输入什么类型(左值、右值)的参数,就是什么类型的参数。

这是C++11的引用折叠规则决定的。

引用折叠:如果有左值引用,优先折叠成左值引用(如果有左值引用,参数推导成左值引用;只有右值引用,参数推导成右值引用。)

注意:引用折叠和完美转发,只针对模板参数,非模板是不允许的,例如把一个左值绑定到右值引用是不允许的。

#include 
using namespace std;

void RunCode(int & m) {
	cout << "lvalue ref" << endl;
}

void RunCode(int && m) {
	cout << "rvalue ref" << endl;
}

void RunCode(const int & m) {
	cout << "const lvalue ref" << endl;
}

void RunCode(const int && m) {
	cout << "const rvalue ref" << endl;
}

template
void PerfectForward(T&& t) {
	// 类型转换是必须要的,以保证RunCode调用依然是完美转发
	// 否则,如果传入右值,t作为右值引用,是一个左值,所以这里的转换必不可少
	RunCode(static_cast(t)); // static_cast可以用forward/move替换
}

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

	PerfectForward(a); // lvalue ref
	PerfectForward(move(a)); //	rvalue ref
	PerfectForward(b); // const lvalue ref
	PerfectForward(move(b));  	// const rvalue ref

	return 0;
}
#include 

template
void fun(T&& t) {
	puts("fun()");
}
// 如果同时把左值引用参数的函数声明为删除的,那么就阻止了完美转发
template
void fun(T& t) = delete;

int main() {
	int a = 10;
	fun(std::move(a)); // 正确
	fun(a); // 编译不过,不能进行完美转发

	return 0;
}
// unique_ptr deleter with state
#include 

// 非模板函数,不能进行完美转发
void fun(int&& t) {
	puts("fun()");
}

int main() {
	int a = 10;
	fun(a); // 编译不过,不允许把左值绑定到右值引用

	return 0;
}
// unique_ptr deleter with state
#include 
 
template
class A{
public:
    // 非模板函数,不能进行完美转发,即使是在类模板中也不行
    void fun(int&& t) {
	puts("fun()");
    }
}; 

int main() {
    A a;
    int n = 10;
    a.fun(n); // 编译不过,不允许把左值绑定到右值引用
 
    return 0;
}

6、移动构造函数、移动赋值函数、复制构造函数、复制赋值函数:

// TODO

移动构造函数不允许抛出异常。否则将带来危险。

应该为移动构造函数添加noexcept关键字,这样,一旦移动构造函数抛出异常,就会调用terminate终止程序运行(反过来说,如果没有noexcept关键字,抛出异常之后,程序可以捕获这个异常,处理之后,继续运行)。

可以使用std::move_if_noexcept模板函数替代std::move函数:

当移动构造函数没有noexcept关键字时,该函数返回一个左值引用,可以绑定到复制构造函数(复制语义);

当移动构造函数有noexcept关键字时,该函数返回一个右值引用,可以绑定到移动构造函数(移动语义)。

move_if_noexcept牺牲了性能;并且,只有使用noexcept关键字,才能使用移动语义,否则将使用复制语义。

#include 
#include 
using namespace std;

struct A {
	A() {
		cout << "A()" << '\n';
	}
	A(const A&) {
		cout << "A(const A&)" << '\n';
	}

	A(A&&){
		cout << "A(&&)" << '\n';
	}

	~A() {
		cout << "~A()" << '\n';
	}
};

struct Queue {
	queue Q;

	void push1(A a) { // 复制构造,如果是以push1(std::move(a))调用,则是移动构造;否则是复制构造,不论是直接用对象还是std::ref(a)作为参数,都是一样的
		Q.push(a); // 再次复制构造
		Q.push(std::ref(a)); // 复制构造,与上一行没有区别
		Q.push(std::move(a)); // 移动构造
	} // 离开时,析构a

	void push2(A& a) { // 刚刚进入函数时,因为引用不是对象,所以没有发生构造
		Q.push(a); // 调用复制构造
		Q.push(std::move(a)); // 调用移动构造
	}

	void push3(A&& a) { // 右值引用,没有构造
		Q.push(a); // 调用复制构造,因为右值引用a是左值
		Q.push(std::move(a)); // 调用移动构造
	}
};

int main() {
	Queue Q;

	A a1;
	Q.push1(std::move(a1));

	A a2;
	Q.push1(a2);
	Q.push2(std::ref(a2)); // 与上一行没有区别

	A a3;
	// Q.push2(std::move(a3)); // 编译不过
	Q.push2(a3);
	Q.push2(std::ref(a3)); // 与上一行没有区别

	A a4;
	// Q.push3(a4); // 编译不过
	Q.push3(std::move(a4));

	return 0;
}

7、编译器优化:

编译器默认会采用“返回值优化”(称为RVO或者NRVO, 即 Return Value Optimization 或 Named Return Value Optimization)。

要想通过调试来观察移动语义与复制语义的不同,应该关闭编译器优化。否则,有些编译器优化之后,本来逻辑上的复制语义可能被优化成移动语义,学习者将观察不到。

g++/clang++使用-fno-elide-constructors选项关闭编译器优化。

8、合成的移动操作:

如果我们没有显示定义复制构造/赋值函数,那么编译器会为我们合成(浅复制)。

移动操作的默认合成规则不同:特别是,如果自定义了复制构造函数、复制赋值运算符或者析构函数,那么除非我们自定义,否则编译器将不会合成移动构造/赋值函数(会被编译器隐含声明称为deleted)。

三五法则:对于五个拷贝控制函数,即复制构造函数、复制赋值函数、析构函数、移动构造函数、移动赋值函数(前三个是基本拷贝操作),应该看成一个整体,要么都不自定义,要么都自定义。

如果没有移动操作(包括合成的),那么会调用复制操作。

与复制构造函数不同,移动构造函数永远不会被隐式被声明为deleted,除非我们显示地要求编译器生成=default的移动操作,并且编译器不能移动所有成员,那么此时会被编译器隐含地声明成deleted.

只有当一个类没有定义任何自己版本的复制控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或者移动赋值函数。

同时从这里我们也可以隐隐推断,为甚我们定义了自己的拷贝构造,拷贝赋值运算符,析构函数后,编译器不会帮我们合成移动构造函数,因为,如果我们定义了这些操作往往表示类内含有指针成员需要动态分配内存,如果需要为类定义移动操作,那么应该确保移动后源对象是安全的,但是默认的移动构造函数不会帮我们把指针成员置空,移后源不是可析构的安全状态,如果这样,当离开移动构造后,源对象被析构,对象内的指针成员空间被回收,转移之后对象内的指针成员出现悬垂现象,程序将引起致命的错误。所以当我们定义了自己的拷贝操作和析构函数时,编译器是不会帮我们合成默认移动构造函数的。

#include 
using namespace std;

class A {
public:
	A() :m_pi(new int(3)) {
		cout << "A()" << endl;
	}
	A(const A& other) :m_pi(other.m_pi) { // 如果将复制构造函数改成A(A& other)形式,则非const左值引用不允许绑定右值
		cout << "A(const A&)" << endl;
	}
	A(A& other) :m_pi(other.m_pi) { // 顺便提及:const与非const属于不同的类型,可以重载;所以这里不会出现重定义问题
		cout << "A(A&)" << endl;
	}
	//A(A&& other) :m_pi(other.m_pi) {
	//	other.m_pi = nullptr;
	//	cout << "A(&&)" << endl;
	//}
	~A() {
		cout << "~A()" << endl;
	}

	int *m_pi;
};


int main() {
	A a;
	auto aa = std::move(a); // 这里调用的是复制构造函数(const版)
	// 由于自定义了复制构造函数,编译器将不会再生成默认移动构造函数
	// 由于const引用可以绑定到右值上,所以这里运行成功
	// 如果将复制构造函数改成A(A& other)形式,这里会报错,因为没有移动构造函数,而非const左值引用不允许绑定右值

	return 0;
}

9、std::move的实现:

以下代码参考:API。

// 定义于头文件 
template< class T >
typename std::remove_reference::type&& move( T&& t ) noexcept; // C++11起 C++14前

template< class T >
constexpr std::remove_reference_t&& move( T&& t ) noexcept; // C++14起

// 返回值 static_cast::type&&>(t)

// 定义于头文件 
template< class T >
struct remove_reference; 

// 辅助类型
template< class T >
using remove_reference_t = typename remove_reference::type;

// 可能的实现
template< class T > struct remove_reference      {typedef T type;};
// 以下两行似乎是模板特例化,将模板参数特例化为引用和右值引用类型
template< class T > struct remove_reference  {typedef T type;};
template< class T > struct remove_reference {typedef T type;};

std::move 是一个类型转换, 并没有完成其他工作:

std::move 是一个模板函数,输入参数为右值引用类型,这是为了模板类型的完美转发;

std::remove_reference::type 将本来的引用或右值引用类型的“引用”属性去掉,变成值类型,所以 typename std::remove_reference::type&& 是T的右值类型;

static_cast::type&&>(t) 将输入参数 t 强制转化为右值引用。

由于 std::move 函数输入参数和输出参数都是右值引用,所以整个过程都没有构造对象,故不会发生额外的构造和析构过程。

测试代码如下:

#include 
using namespace std;

class A {
public:
    A():m_pi(new int(3)) {
        cout << "A()" << endl;
    }
    A(const A& other) :m_pi(other.m_pi) {
        cout << "A(A&)" << endl;
    }
    A(int a):m_a(a){
        cout<<"A(int)"<

10、unique_ptr与std::move:

#include 
using namespace std;

int main(){
    unique_ptr up(new int(10));
    int *p=std::move(up); // 现在up为空

    return 0;
}

解释:std::move的参数是右值引用类型(参见7、(3),注意到std::move是模板函数,可以进行引用折叠,非模板函数不允许直接将左值赋给右值参数),在std::move的return时,调用的是unique_ptr的移动构造函数,移动构造函数会转移up所拥有的资源,并且把up置为空。

11、右值与 sizeof:

struct A {
    int x, y;
};

int f() {
    return 1;
}

int main() {
   // gcc 上测试,sizeof(int()) 返回 1
   // Visual Studio 上测试,报错:sizeof 的操作数不能是函数
   cout << sizeof(int()) << endl; // 1 为什么?
   cout << sizeof(10) << endl; // 4
   cout << sizeof(A) << endl; // 8
   cout << sizeof(A()) << endl; // 1 为什么?
   // 在 gcc 和 Visual Studio 都能测试通过。
   cout << sizeof(f()) << endl; // 4
}

疑惑:sizeof(int()) 为什么等于 1?

参考:

《深入理解C++11: C++新特性解析与应用》

《C++ Primer 第五版》

C++中文 - API参考文档

你可能感兴趣的:(C++,c++,开发语言)