现代C++语言核心特性解析part3

第6章 右值引用(C++11 C++17 C++20)

6.1 左值和右值

左值和右值的概念早在C++98的时候就已经出现了,从最简单的字面理解,无非是表达式等号左边的值为左值,而表达式右边的值为右值,比如:

int x = 1;
int y = 3;
int z = x + y;

但是还是过于简单,有些情况下是无法准确区分左值和右值的,比如:

int a = 1;
int b = a;

在第一行代码中a是左值,1是右值;在第二行代码中b是左值,而a是右值。

在C++中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的
基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。以上面的代码为例,因为&a和&b都是符合语法规则的,所以a和b都是左值,而&1在GCC中会给出“lvalue required as unary ‘&’ operand”错误信息以提示程序员&运算符需要的是一个左值。

下面这些情况左值和右值的判断可能是违反直觉的,例如:

int x = 1;
int get_val()
{
	return x;
}
void set_val(int val)
{
	x = val;
}
int main()
{
	x++;
	++x;
	int y = get_val();
	set_val(6);
}

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

int *p = &x++; // 编译失败
int *q = &++x; // 编译成功

get_val函数,该函数返回了一个全局变量x,虽然很明显变量x是一个左值,但是它经过函数返回以后变成了一个右值。原因和x++类似,在函数返回的时候编译器并不会返回x本身,而是返回x的临时复制,所以int * p = &get_val();也会编译失败。
对于set_val函数,该函数接受一个参数并且将参数的值赋值到x中。在main函数中set_val(6);实参6是一个右值,但是进入函数之后形参val却变成了一个左值(参数入栈有地址):

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

最后需要强调的是,通常字面量都是一个右值,除字符串字面量以外:

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

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

6.2 左值引用

当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。
非常量左值的引用对象很单纯,它们必须是一个左值。对于这一点,常量左值引用的特性显得更加有趣,它除了能引用左值,还能够引用右值,比如:

int &x1 = 7; // 编译错误
const int &x = 11; // 编译成功

虽然在结果上const int &x = 11和const int x = 11是一样的,但是从语法上来说,前者是被引用了,所以语句结束后11的生命周期被延长,而后者当语句结束后右值11应该被销毁。
虽然常量左值引用可以引用右值的这个特性在赋值表达式中看不出什么实用价值,但是在函数形参列表中却有着巨大的作用。一个典型的例子就是复制构造函数和复制赋值运算符函数:

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();
}

如果这里将类X的复制构造函数和复制赋值函数形参类型的常量性删除,则X x3(make_x());和x3 = make_x();这两句代码会编译报错,因为非常量左值引用无法绑定到make_x()产生的右值。
常量左值引用可以绑定右值是一条非常棒的特性,但是它也存在一个很大的缺点——常量性。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用。

6.3 右值引用

右值引用是一种引用右值且只能引用右值的方法。在左值引用声明中,需要在类型后添加&,而右值引用则是在类型后添加&&

int i = 0;
int &j = i; // 左值引用
int &&k = 11; // 右值引用

右值引用的特点之一是可以延长右值的生命周期,用GCC编译以上代码需要加上命令行参数-fno-elide-constructors用于关闭函数返回值优化(RVO)。

# include <iostream>
class X {
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X&x) { std::cout << "X copy ctor" << std::endl; }
    ~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();
}

X ctor
X copy ctor
X dtor
show X
X dtor

将X &&x2 =make_x()这句代码替换为X x2 = make_x(),在没有进行任何优化的情况下应该是3次构造,首先make_x函数中x1会默认构造一次,然后return x1会使用复制构造产生临时对象,接着X x2 = make_x()会使用复制构造将临时对象复制到x2,最后临时对象被销毁。

x86-64 gcc 10.1

X ctor
X copy ctor
X dtor
X copy ctor
X dtor
show X
X dtor

6.4 右值的性能优化空间

当右值被使用之后程序会马上销毁对象并释放内存。这个过程可能会引发一个性能问题,例如:

#include <iostream>
#include <cstring>

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();
}

输出结果:
copy big memory pool.
copy big memory pool.
copy big memory pool.

1.get_pool返回的BigMemoryPool临时对象调用复制构造函数复制了pool对象。
2.make_pool返回的BigMemoryPool临时对象调用复制构造函数复制了get_pool返回的临时对象。
3.main函数中my_pool调用其复制构造函数复制make_pool返回的临时对象。
在这里每发生一次复制构造都会复制整整4KB的数据,如果数据量更大一些,比如4MB或者400MB,那么将对程序性能造成很大影响。

6.5 移动语义

在C++11标准中引入了移动语义,它可以帮助我们将临时对象的内存移动到my_pool对象中,以避免内存数据的复制。让我们简单修改一下BigMemoryPool类代码:

#include <iostream>
#include <cstring>

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_;
};

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

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

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

输出结果:
copy big memory pool.
move big memory pool.
move big memory pool.

在上面的代码中增加了一个类BigMemoryPool的构造函数BigMemoryPool (BigMemoryPool&& other),它的形参是一个右值引用类型,称为移动构造函数
复制构造函数中往往进行的是深复制,即在不能破坏实参对象的前提下复制目标对象。而移动构造函数恰恰相反,它接受的是一个右值,其核心思想是通过转移实参对象的数据以达成构造目标对象的目的,也就是说实参对象是会被修改的
以看到后面两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值(临时对象),对于右值编译器会优先选择使用移动构造函数去构造目标对象。
经测试,使用移动构造函数将性能提升了1倍多。
除移动构造函数能实现移动语义以外,移动赋值运算符函数也能完成移动操作:

#include <iostream>
#include <cstring>

class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool()
    {
        if (pool_ != nullptr) {
            delete[] 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;
    }

    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_;
};

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

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

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

输出结果:
copy big memory pool.
move big memory pool.
move(operator=) big memory pool.

编译器对于赋值源对象是右值的情况会优先调用移动赋值运算符函数,如果该函数不存在,则调用复制赋值运算符函数。
1.同复制构造函数一样,编译器在一些条件下会生成一份移动构造函数,这些条件包括:没有任何的复制函数,包括复制构造函数和复制赋值函数;没有任何的移动函数,包括移动构造函数和移动赋值函数;也没有析构函数。
2.虽然使用移动语义在性能上有很大收益,但是却也有一些风险,这些风险来自异常。试想一下,在一个移动构造函数中,如果当一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分发生了转移而另一部分没有,这就会造成源对象和目标对象都不完整的情况发生,这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常,与此同时,如果无法保证移动构造函数不会抛出异常,可以使用noexcept说明符限制该函数。这样当函数抛出异常的时候,程序不会再继续执行而是调用std::terminate中止执行以免造成其他不良影响。

6.6 值类别

值类别是C++11标准中新引入的概念,具体来说它是表达式的一种属性,该属性将表达式分为3个类别,它们分别是左值(lvalue)、纯右值(prvalue)和将亡值(xvalue)
表达式首先被分为了泛左值(glvalue)和右值(rvalue),其中泛左值被进一步划分为左值和将亡值右值又被划分为将亡值和纯右值
这里的
左值(lvalue)就是我们上文中描述的C++98的左值,而这里的纯右值(prvalue)则对应上文中描述的C++98的右值。
从本质上说产生将亡值的途径有两种,第一种是使用类型转换将泛左值转换为该类型的右值引用。比如:

static_cast<BigMemoryPool&&>(my_pool)

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

struct S {
	int a;
};
int main()
{
	int b = S().a;
}

S()是一个纯右值,访问其成员变量a却需要一个泛左值,所以这里会发生一次临时量实质化,将S()转换为将亡值,最后再访问其成员变量a。还有一点需要说明,在C++17标准之前临时变量是纯右值,只有转换为右值引用的类型才是将亡值。

6.7 将左值转换为右值

在C++11标准中可以在不创建临时值的情况下显式地将左值通过static_cast转换为将亡值,通过值类别的内容我们知道将亡值属于右值,所以可以被右值引用绑定。

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

实际上它的最大作用是让左值使用移动语义,还是以BigMemoryPool为例:

BigMemoryPool my_pool1;
BigMemoryPool my_pool2 = my_pool1;
BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1);

为了让编译器调用移动构造函数构造my_pool3,这里使用了static_cast<BigMemoryPool &&>(my_ pool1)将my_pool1强制转换为右值(也是将亡值,为了叙述思路的连贯性后面不再强调)。由于调用了移动构造函数,my_pool1失去了自己的内存数据,后面的代码也不能对my_pool1进行操作了。

正确的使用场景是在一个右值被转换为左值后需要再次转换为右值,最典型的例子是一个右值作为实参传递到函数中。

#include <iostream>
#include <cstring>

class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {
    }
    ~BigMemoryPool()
    {
        if (pool_ != nullptr) {
            delete[] 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;
    }

    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_;
};

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

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

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

输出结果:
copy big memory pool.
move big memory pool.
call move_pool
copy big memory 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));
}

在C++11的标准库中还提供了一个函数模板std::move帮助我们将左值转换为右值,这个函数内部也是用static_cast做类型转换。建议读者使用std::move将左值转换为右值而非自己使用static_cast转换,例如:

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

6.8 万能引用和引用折叠

在C++11中确实存在着一个被称为“万能”的引用,它看似是一个右值引用,但其实有着很大区别,请看下面的代码:

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为万能引用

右值引用只能绑定一个右值,但是万能引用既可以绑定左值也可以绑定右值,甚至const和volatile的值都可以绑定,例如:

int i = 42;
const int j = 11;
bar(i);
bar(j);
bar(get_val());
auto &&x = i;
auto &&y = j;
auto &&z = get_val();

所谓的万能引用是因为发生了类型推导,在T&&和auto&&的初始化过程中都会发生类型的推导,如果已经有一个确定的类型,比如int &&,则是右值引用。在这个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出左值引用;反之如果源对象是一个右值,则会推导出右值引用,不过无论如何都会是一个引用类型
万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了一套引用叠加推导的规则——引用折叠。在整个推导过程中,只要有左值引用参与进来,最后推导的结果就是一个左值引用。只有实际类型是一个非引用类型或者右值引用类型时,最后推导出来的才是一个右值引用
万能引用的形式必须是T&&或者auto&&,也就是说它们必须在初始化的时候被直接推导出来,如果在推导中出现中间过程,则不是一个万能引用,例如:

#include <vector>
template<class T>
void foo(std::vector<T> &&t) {}
int main()
{
	std::vector<int> v{ 1,2,3 };
	foo(v); // 编译错误
}

foo的形参类型是std::vector&&而不是T&&,所以编译器无法将其看作一个万能引用处理。

6.9 完美转发

万能引用最典型的用途被称为完美转发。在介绍完美转发之前,我们先看一个常规的转发函数模板:

#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按值转发,也就是说std::string在转发过程中会额外发生一次临时对象的复制。
使用万能引用实现转发函数模板:

#include <iostream>
#include <string>
template<class T>
void show_type(T t)
{
	std::cout << typeid(t).name() << std::endl;
}
template<class T>
void perfect_forwarding(T &&t)
{
	show_type(static_cast<T&&>(t));
}
std::string get_string()
{
	return "hi world";
}
int main()
{
	std::string s = "hello world";
	perfect_forwarding(s);
	perfect_forwarding(get_string());
}

唯一可能需要注意的是show_type(static_cast<T&&>(t));中的类型转换,之所以这里需要用到类型转换,是因为作为形参的t是左值。
当实参是一个左值时,T被推导为std::string&,于是static_cast<T&&>被推导为static_cast<std:: string&>,传递到show_type函数时继续保持着左值引用的属性;当实参是一个右值时,T被推导为std::string,于是static_cast <T&&>被推导为static_cast< std::string&&>,所以传递到show_type函数时保持了右值引用的属性。在C++11的标准库中提供了一个std::forward函数模板:

template<class T>
void perfect_forwarding(T &&t)
{
	show_type(std::forward<T>(t));
}

请注意std::move和std::forward的区别,其中std::move一定会将实参转换为一个右值引用,并且使用std::move不需要指定模板实参,模板实参是由函数调用推导出来的。而std::forward会根据左值和右值的实际情况进行转发,在使用的时候需要指定模板实参。

6.10 针对局部变量和右值引用的隐式移动操作

在对旧程序代码升级新编译环境之后,我们可能会发现程序运行的效率提高了,这里的原因一定少不了新标准的编译器在某些情况下将隐式复制修改为隐式移动

#include <iostream>
struct X {
    X() = default;
    X(const X&) = default;
    X(X&&) {
    std::cout << "move ctor" << std::endl;
}
};
X f(X x) {
    return x;
}
int main() {
    X r = f(X{});
}

x86-64 gcc 10.1:
move ctor
move ctor
move ctor

x86-64 gcc 11.1:
move ctor

对于支持移动语义的新标准,这个地方会隐式地采用移动构造函数来完成数据的交换。
对于局部变量也有相似的规则,只不过大多数时候编译器会采用更加高效的返回值优化代替移动操作:

#include <iostream>
struct X {
    X() = default;
    X(const X&) = default;
    X(X&&) {
    std::cout << "move ctor" << std::endl;
}
};
X f() {
    X x;
    return x;
}
int main() {
    X r = f();
}

move ctor
move ctor

在C++20标准中,隐式移动操作针对右值引用和throw的情况进行了扩展,例如:

#include <iostream>
#include <string>
struct X {
    X() = default;
    X(const X&) = default;
    X(X&&) {
    std::cout << "move" << std::endl;
}
};
X f(X &&x) {
    return x;
}
int main() {
    X r = f(X{});
}

以上代码使用C++20之前的标准编译是不会调用任何移动构造函数的。原因前面也解释过,因为函数f的形参x是一个左值,对于左值要调用复制构造函数。要实现移动语义,需要将return x;修改为return std::move(x);。显然这里是有优化空间的,C++20标准规定在这种情况下可以隐式采用移动语义完成赋值。具体规则如下。
可隐式移动的对象必须是一个非易失或一个右值引用的非易失自动存储对象,在以下情况下可以使用移动代替复制。
1.return或者co_return语句中的返回对象是函数或者lambda表达式中的对象或形参。
2.throw语句中抛出的对象是函数或try代码块中的对象。

实际上throw调用移动构造的情况和return差不多,我们只需要将上面的代码稍作修改即可:

void f() {
	X x;
	throw x;
}
int main() {
	try {
		f();
	}
	catch () {
	 }
}

可以看到函数f不再有返回值,它通过throw抛出x,main函数用try-catch捕获f抛出的x。这个捕获调用的就是移动构造函数。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值