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



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


    0. 引出

    考虑如下代码:

    std::string func(std::string str){
        return str;
    }
    
    int main(){
        
        std::string str = func("sadjals");
    
        system("pause");
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    上面这段代码会发生很多次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!! 右值引用不能指向左值
    
    • 1
    • 2

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

    const int & l = 42;  // 合法
    
    • 1

    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;
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62

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

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

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

    Move constructor of Sunxiaochuan called
    Deconstructor of Sunxiaochuan called
    Deconstructor of Dasima called
    Deconstructor of  called
    
    • 1
    • 2
    • 3
    • 4

    解释:

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

    1.3 std::move()

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

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

    则在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;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    点进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);
    }
    
    • 1
    • 2
    • 3
    • 4

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

    2.完美转发

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

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

    输入:

    list(a)
    
    • 1

    输出:

    [2, 1, 0]
    
    • 1

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

    2.1 万能引用(perfect reference)

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

    template<typename T>
    void function(T t) {
        func(t);
    }
    
    • 1
    • 2
    • 3
    • 4

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

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

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

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

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

    template<typename T>
    void function(T&& t) {
        func(t);
    }
    
    • 1
    • 2
    • 3
    • 4

    这时, 编译器会自动推断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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    输出:

    101
    
    • 1
  • 相关阅读:
    mysql 随笔
    笔试强训(三十九)
    计算机视觉五大核心研究任务全解:分类识别、检测分割、人体分析、三维视觉、视频分析
    汽车级瞬态抑制TVS二极管优势特性及型号大全
    渗透测试 | 端口扫描
    Docker | 将本地项目发布到阿里云的实现流程
    微信小程序汇总02
    【Java】对象的实例化
    使用Net将HTML简历导出为PDF格式
    处理Java异常的10个最佳实践
  • 原文地址:https://blog.csdn.net/wjpwjpwjp0831/article/details/126912639