[杂记]C++中移动语义与完美转发的一些理解


这一块比较难 初步做一个笔记 希望将来能有更深的理解


0. 引出

考虑如下代码:

std::string func(std::string str){
    
    
    return str;
}

int main(){
    
    
    
    std::string str = func("sadjals");

    system("pause");
    return 0;
}

上面这段代码会发生很多次string对象的拷贝. 首先字符串常量转换成函数里的临时变量需要一次, 返回值赋给对象str时, 如果编译器不做返回值优化(Return value optimization)的话, 则还需要创造一个temp临时变量接受函数返回值, 再将temp赋给str. 对于函数参数的拷贝, 我们可以用引用或者指针. 而对于std::string str = func("sadjals");, func("sadjals")是后文再也用不到的变量, 那我们能不能希望不要通过中间的媒介, 而是直接将func的返回值移动到被赋值的对象? 这就是移动语义的由来吧.

1. 移动语义

在说明移动语义之前, 应该有必要说明右值引用.

1.1 右值引用

右值引用就是一种特殊的引用, 它和左值引用可以说井水不犯河水. 例如, 以下代码非法:

int i = 42;
int && r = i; // Error!! 右值引用不能指向左值

当然, 常量的左值引用也可以指向右值, 例如:

const int & l = 42;  // 合法

1.2 用"偷"说明移动语义

考虑开头的那个例子. 在赋值的时候, 如果我们重载一个=运算符, 让我们实现之前说的"移动"思想, 而不是逐元素拷贝, 那么一个直接的想法是我们对当前对象, 接管右值的所有权, 然后把右值废掉, 相当于把值了过来, 这样就避免了拷贝带来的复杂度.

class Student{
    
    
private:
    std::string name;
    int* scores;
    int length;
public: 
    Student(const std::string& name_, int* scores_, int length_) : name(name_), length(length_) {
    
    
        this->scores = new int[length_];
        for (int i = 0; i < length_; ++i){
    
    
            this->scores[i] = scores[i];
        }
    }
    Student(const Student& stu) : name(stu.name), length(stu.length) {
    
    
        std::cout << "Copy constructor of " << this->name << " called\n";
        this->scores = new int[length];
        for (int i = 0; i < length; ++i){
    
    
            this->scores[i] = scores[i];
        }
    }
    Student(Student&& rStu): name(rStu.name), length(rStu.length){
    
    
        std::cout << "Move constructor of " << this->name << " called\n";
        this->scores = rStu.scores;  // 窃取
        // 销毁
        rStu.scores = nullptr;
        rStu.name = "";
        rStu.length = 0;
    }
    
    Student& operator+(const Student& stu2){
    
    
        for (int i = 0; i < this->length; ++i){
    
    
            this->scores[i] += stu2.scores[i];
        }
        return *this;
    }

    Student& operator=(Student&& stu2){
    
    
    	if (this == &stu2) return *this;
        // 执行与移动构造相似的流程
        std::cout << "overload operator = " << this->name << " called\n";
        this->scores = stu2.scores;  // 窃取
        // 销毁
        stu2.scores = nullptr;
        stu2.name = "";
        stu2.length = 0;

        return *this;
    }

    virtual ~Student() {
    
     std::cout << "Deconstructor of " << this->name << " called\n"; delete[] this->scores; }
};


void func(){
    
    
    int arr0[] = {
    
    100, 20, 59, 59, 59};
    Student s0 ("Sunxiaochuan", arr0, sizeof(arr0) / sizeof(int));

    Student s1 ("Dasima", arr0, sizeof(arr0) / sizeof(int));
    
    Student s2 = static_cast<Student&&>(s0 + s1);
    // Student s2 = s0 + s1;
    
}

在上述代码的移动构造函数和=重载函数中, 首先将右值的指针所指向的内存接管过来, 然后将右值的指针释放, 相当于窃取, 为了代码的鲁棒性, 右值对象的其余值应该都设为0.

注意: Student s2 = static_cast<Student&&>(s0 + s1);之所以用强制类型转换, 是因为如果不加的话编译器有返回值优化, 会调用拷贝构造函数. 为了方便说明, 故将其强制转换为右值. 实际上, 这一句的作用与后文提到的std::move()相同

调用func()函数, 输出如下:

Move constructor of Sunxiaochuan called
Deconstructor of Sunxiaochuan called
Deconstructor of Dasima called
Deconstructor of  called

解释:

  1. 第一行: 在函数func中, 首先按照常规方法创建了s0, s1对象. 之后, 计算s0+s1, 由于重载的+运算符参数和返回都是引用, 所以这个过程不会发生构造函数的调用. 随后将返回值(右值)赋给s2, 这时直接调用移动构造函数, 把s0的对象清空. 注意, 此时不会调用=重载, 除非这么写:
Student s2;
s2 = static_cast<Student&&>(s0 + s1);
  1. 第二到四行: func函数退出时, 要清除局部变量. 后产生的先清除, 因此s2清除, s1清除, 最后s0. 注意s0已经被清空, 因此输出空字符串.

1.3 std::move()

观察上面的student类, 里面包含了string类作为对象. 在student类的移动构造函数中, 按照上面的写法:

Student(Student&& rStu): name(rStu.name), length(rStu.length){
    
    }

则在string的类还是会调用拷贝构造, 这时比较低效的. 因此, 我们可以利用std::move()强制转换:

    Student(Student&& rStu): name(std::move(rStu.name)), length(rStu.length){
    
    
        std::cout << "Move constructor of " << this->name << " called\n";
        this->scores = rStu.scores;  // 窃取
        // 销毁
        rStu.scores = nullptr;
        rStu.name = "";
        rStu.length = 0;
    }

点进move的源码:

template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
    
     // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

我们发现, 正如前文所说, 其就相当于static_cast强制转换为右值.

2.完美转发

python中有一个map函数, 可以将一个函数作用于任意参数, 例如:

a = map(int, [2.3, 1.5, 0.6])

输入:

list(a)

输出:

[2, 1, 0]

那我们在C++中能实现类似的功能吗?

2.1 万能引用(perfect reference)

要实现这样的功能, 就一定要利用模板. 那如果采用这样的方式:

template<typename T>
void function(T t) {
    
    
    func(t);
}

其中func是另一个函数. 这样看似可以, 但是有一个问题, 那就是不论调用function时传入的t是左值还是右值, 到了函数里都成为了左值, 会进行额外的拷贝操作.

为什么要保持左值右值的一致性? 因为左值或右值的传递, 直接决定了该参数的传递过程使用的是拷贝语义(调用拷贝构造函数)还是移动语义(调用移动构造函数)。

能够保持左值右值一致性, 就叫做完美转发.

如果我们想保持左值右值不变, 就要利用到万能引用.

万能引用很简单, 只需要两个&&号声明即可, 修改为:

template<typename T>
void function(T&& t) {
    
    
    func(t);
}

这时, 编译器会自动推断t为左值还是右值, 推断的原则叫做引用折叠规则:

但凡有左值引用参与的, 就推断成左值引用. 也即只有当t是右值引用时, 才推断为右值引用.
即:
t不是引用时, 推断为右值引用
t为左值引用时, 推断为左值引用
t为右值引用时, 推断为右值引用

2.2 完美转发(perfect forwarding)

但除此之外,还需要解决一个问题,即无论传入的形参是左值还是右值,对于函数模板内部来说,形参既有名称又能寻址,因此它都是左值。那么如何才能将函数模板接收到的形参连同其左、右值属性,一起传递给被调用的函数呢(真正的"完美"转发?)?

只需使用std::forward即可. 其作用是将原本的引用属性保持不变.

因此, 我们可以写出如下程序:

template <typename Func, typename... Args>
auto myMap(Func&& f, Args&& ... args){
    
      // 万能引用
    /*
    Func: 函数指针等可调用的对象
    args: 参数  ...表示可变参数
    */
   return (std::forward<Func>(f)) (std::forward<Args>(args)...);
}

int f(int a, char b){
    
    
    return a + b;
}


int main(){
    
    
    std::cout << myMap(f, 2, 'c');
    system("pause");
    return 0;
}

输出:

101

猜你喜欢

转载自blog.csdn.net/wjpwjpwjp0831/article/details/126912639