C++的移动语义和完美转发

2023-12-13 06:00:35

参考《现代C++语言核心特性解析》

移动语义

C++11新特性的std::move()用于将一个左值转换为右值引用。它并不是实际移动或复制数据,而是通过将一个左值强制转换为一个右值引用来实现对对象的转移。这个特性在C++11中引入,用于优化对象移动操作的效率。

我们知道,右值引用只能引用右值,如果尝试绑定左值就会编译错误。

int i = 0;
int &&k = i;	// 编译错误

在C++11标准中可以在不创建临时值的情况下显式地将左值通过static_cast转换为将亡值,通过值类别的内容我们知道将亡值属于右值,所以可以被右值引用绑定。值得注意的是,由于转换的并不是右值,因此它依然有着和转换之前相同的生命周期和内存地址,例如:

int i = 0;
int &&k = static_cast<int&&>(i);

既然这个转换既不改变生命周期,也不改变内存地址,那它存在的意义是什么?实际上它最大的作用是让左值使用移动语义。

举例:

#include <iostream>

class BigMemoryPool
{
public:
    static const int PoolSize = 4096;

    BigMemoryPool() : pool_(new char[PoolSize])
    {
        std::cout << "普通构造函数" << std::endl;
    }

    ~BigMemoryPool()
    {
        if (pool_ != nullptr)
        {
            delete[] pool_;
        }
    }

    BigMemoryPool(BigMemoryPool &&other) : pool_(new char[PoolSize])
    {
        std::cout << "移动构造函数" << std::endl;
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

    BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize])
    {
        std::cout << "拷贝构造函数" << 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_pool1;
    BigMemoryPool my_pool2 = my_pool1;
    BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1);

    return 0;
}

在这段代码中,my_pool1是一个BigMemoryPool类型的对象,也是一个左值,所以用它去构造my_pool2的时候调用的是复制构造函数。为了让编译器调用移动构造函数构造my_pool3,这里使用了static_cast<BigMemoryPool &&>(my_pool1)将my_pool1强制转换为右值(也是将亡值,为了叙述思路的连贯性后面不再强调)。由于调用了移动构造函数,my_pool1失去了自己的内存数据,后面的代码也不能对my_pool1进行操作了。

结果输出:

PS C:\Users\zh'n\Desktop\新建文件夹> g++ -std=c++11 -fno-elide-constructors main.cpp -o main
PS C:\Users\zh'n\Desktop\新建文件夹> ./main
普通构造函数
拷贝构造函数
移动构造函数

但是这个示例中把my_pool1这个左值转换成my_pool3这个左值似乎没有什么意义,而且程序员如果再次去访问my_pool1还会引发未定义行为。

正确的使用场景是在一个右值被转换为左值后需要再次转换为右值,最典型的例子是一个右值作为实参传递到函数中。我们在讨论左值和右值的时候曾经提到过,无论一个函数的实参是左值还是右值,其形参都是一个左值,即使这个形参看上去是一个右值引用,例如:

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

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

结果输出:

PS C:\Users\zh'n\Desktop\新建文件夹> g++ -std=c++11 -fno-elide-constructors main.cpp -o main
PS C:\Users\zh'n\Desktop\新建文件夹> ./main
普通构造函数
拷贝构造函数
移动构造函数
call move_pool
拷贝构造函数

代码中,make_pool()返回的是一个临时对象,也是一个右值,move_pool的参数是一个右值引用,但是在使用形参pool去构造my_pool时调用的是拷贝构造函数。如果我们想调用移动构造函数的话,需要把形参pool强制转换为右值。

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

结果输出:

PS C:\Users\zh'n\Desktop\新建文件夹> g++ -std=c++11 -fno-elide-constructors main.cpp -o main
PS C:\Users\zh'n\Desktop\新建文件夹> ./main
普通构造函数
拷贝构造函数
移动构造函数
call move_pool
移动构造函数

请注意,在这个场景下强制转换为右值就没有任何问题了,因为move_pool函数的实参是make_pool返回的临时对象,当函数调用结束后临时对象就会被销毁,所以转移其内存数据不会存在任何问题。

在C++11的标准库中还提供了一个函数模板std::move帮助我们将左值转换为右值,这个函数内部也是用static_cast做类型转换。只不过由于它是使用模板实现的函数,因此会根据传参类型自动推导返回类型,省去了指定转换类型的代码。另一方面从移动语义上来说,使用std::move函数的描述更加准确。所以建议读者使用std::move将左值转换为右值而非自己使用static_cast转换,例如:

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

总结:

std::move()内部是用static_cast做类型转换,只不过它是使用模板实现的函数,因此会根据传参类型自动推导返回值类型,省去了指定类型的代码。如果使用std::move()将一个左值转换为右值并赋值给其他对象后,这个对象就会被销毁,所以在函数调用过程中,创建N个对象实际上只是把第一个对象的内存不断的转移,类似层层递归。 这样做的好处就是省去了创建对象的开销,并且在对象副本庞大的情况下节省了大量时间。

完美转发

在了解完美转发之前,先了解一下什么是万能引用和引用折叠。

我们知道常量左值引用可以引用左值,也可以引用右值,是一个几乎的万能引用,但是因为它的常量性导致使用受限制。

在C++11中有一个“万能引用”,例如:

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

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

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

我们可以发现,只要是自动类型推导的引用就是万能引用。在这个推导过程中,源对象是左值,那就推导为左值引用,源对象是右值,那就推导为右值引用。

万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出最终类型。

在这里插入图片描述
举例说明:

int i = 42;
const int j = 11;
bar(i);
bar(j);
bar(get_val());

auto &&x = i;
auto &&y = j;
auto &&z = get_val();

在bar(i);中i是一个左值,所以T的推导类型结果是int&,根据引用折叠规则int& &&的最终推导类型为int&,于是bar函数的形参是一个左值引用。而在bar(get_val());中get_val返回的是一个右值,所以T的推导类型为非引用类型int,于是最终的推导类型是int&&,bar函数的形参成为一个右值引用。

完美转发的用途
看一个常规的转发函数模板

#include <iostream>
#include <string>
#include <typeinfo>

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

// 输出:Ss

normal_forwarding函数可以完成字符串的转发任务,但是它的效率很慢。首先它的参数是值传递,那么在转发过程中就会发生一次临时对象的复制。其中一个解决方法就是把void normal_forwarding(T t)换成void normal_forwarding(T& t),通过引用传递,但这是一个左值引用,如果参数是一个右值就会编译失败。

std::string get_string()
{
  return "hi world";
}

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

和移动语义的情况一样,显式使用static_cast类型转换进行转发不是一个便捷的方法。在C++11的标准库中提供了一个std::forward函数模板,在函数内部也是使用static_cast进行类型转换,只不过使用std::forward转发语义会表达得更加清晰,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会根据左值和右值的实际情况进行转发,在使用的时候需要指定模板实参。

完整示例:

#include <iostream>
#include <string>
#include <typeinfo>

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(std::forward<T>(t));
}

int main()
{
    std::string s = "hello world";
    perfect_forwarding(s);	// 实参是左值
    perfect_forwarding(1.0); // 实参是右值
}

// 输出
// Ss
// d

总结

完美转发允许将函数的参数(包括左值和右值)转发给其他函数,同时保持原始参数的值不变,这样可以实现高效的函数调用。

#include <iostream>
#include <utility>

template <typename T>
void process(T &i)
{
    std::cout << "L-value: " << i << std::endl;
}

template <typename T>
void process(T &&i)
{
    std::cout << "R-value: " << i << std::endl;
}

template <typename T>
void forwarder(T &&t)
{
    process(std::forward<T>(t));
}

int main()
{
    int a = 42;
    forwarder(a); // L-value: 42

    forwarder(7.1); // R-value: 7

    return 0;
}

// 输出
// L-value: 42
// R-value: 7.1

在上面的示例中,forwarder函数使用了完美转发,它接受一个泛型类型的参数T&& t,并将参数t转发给process函数。通过使用std::forward(t),可以将原始参数的值类别(左值或右值)传递给process函数,从而调用合适的重载函数。

通过使用完美转发,可以更好地处理函数参数的转发,避免不必要的拷贝,提高代码的性能和效率。请注意,完美转发需要注意避免悬垂引用和引用折叠等问题,在实际使用中需要谨慎处理。

文章来源:https://blog.csdn.net/m0_51415606/article/details/134962085
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。