初学移动语义的时候,如果不接触与之有关的工程代码,很容易被网上某些博客吹的神乎其神的“性能优化”带偏,以为只要使用了移动语义,就能 0 成本的“移动”内存中的一块区域,其实这种想法是不对的。实际上,“移动语义”是语义,是规范,不是语法,更不是技术,使用移动语义优化程序性能是一个设计模式层面讨论的问题,而不是语法汇编层面讨论的问题。

以下这些使用移动语义的方法都是错误的:

  1. 对未实现移动构造函数的类型使用移动语义,以下这个程序无论是否使用 std::move,程序的开销都完全一致:
    struct ABigType {
        char data[1000000];
    };
    
    int main() {
        ABigType a;
        ABigType b(std::move(a));
    }
    
  2. 试图通过移动语义实现一块内存“移动”到另一块内存,以下这个程序中 func函数返回的是悬垂引用(其实大于8字节的返回值对象本来就是创建在调用栈而非被调用栈里的,没有必要在代码层面进行下面这种写法的优化,按最正常的写法写就行):
    ABigType&& func() {
        ABigType a;
        return std::move(a);
    }
    

移动语义的真正用法在于解决大对象的所有权问题,试想以下这样一个场景:

void func(ABigType v) {
    // Do something
}

int main() {
    ABigType a_very_big_obj;
    func(a_very_big_obj);
    // 之后的代码不会再使用到a_very_big_obj
    return 0;
}

当调用 func时,由于 v的类型是 ABigType,既不是 ABigType*,也不是 ABigType&,所以实参会被完整的复制一份来构造一个新的 ABigType出来。如果这个 a_very_big_obj 非常大,构造形参 v的内存开销就很大,复制需要消耗的时间也很多,解决这个问题最常见的方法当然就是在形参位置使用引用或指针,也就是

void func(const ABigType &v) {
    // Do something
}

int main() {
    ABigType a_very_big_obj;
    func(a_very_big_obj);
    return 0;
}

但是这种解决方法有局限性,比如以下这些情况:

  1. func中有修改 v的需要(这意味着 func只能使用非常量左值引用),且外部调用 func时需要能传入右值。
  2. 你在设计一个 API,你也不知道调用者在调用 func之后,还会不会继续使用实参。
  3. func是一个构造函数,v将被用来初始化对象的成员。

其实归根到底,我们的需求是,我们要向 func传递一个信息——实参 a_very_big_obj在后续的代码中还会不会被用到?func能否直接“原地”操作实参的内存,而不是复制它们?如果整个项目的所有代码都是我们自己写的,那其实我们可以自己设计一种抽象来兼容能与不能原地操作实参的情况:

class ABigTypeRef {
public:
    virtual ABigType& get() = 0;
    virtual ~ABigTypeRef() = 0;
};

class ABigTypeCopyRef : public ABigTypeRef {
private:
    ABigType value;
public:
    explicit ABigTypeCopyRef(ABigType v) : value(v) {}  // 实参的复制会发生在这里
    ABigType& get() override { return value; }
    ~ABigTypeCopyRef() override = default;
};

class ABigTypeMoveRef : public ABigTypeRef {
private:
    ABigType* value;
public:
    // 这里传入的是实参的指针,之后get()返回的也是实参的引用,实参始终都没有发生复制。
    explicit ABigTypeMoveRef(ABigType* v) : value(v) {}
    ABigType& get() override { return *value; }
    ~ABigTypeMoveRef() override = default;
};

void func(ABigTypeRef* v) {
    // 在这里操作v->get()
    delete v;
}

int main() {
    ABigType a_very_big_obj;
    func(new ABigTypeCopyRef(a_very_big_obj));  // 如果实参后续还要使用,进行复制
    func(new ABigTypeMoveRef(&a_very_big_obj));  // 实参后续不再使用,func可以原地操作a_very_big_obj
    return 0;
}

细心的读者可能发现上述方案没有解决 func是构造函数的情况,但其实这是因为 a_very_big_obj分配在了栈中,如果 a_very_big_obj分配在了堆中,且 func构造的对象也用指针来操作 ABigType类型的成员,那以上方案就可以兼顾解决这个问题了。

C++11 标准为以上做法提供了一种简化的写法,这种写法就叫“移动语义”,上述代码使用移动语义可以写成

// 这种设计一个只会分配在栈中,与另一块堆内存的生命周期完全相同的设计模式叫RAII
// 这种设计模式能有效避免内存泄漏,RAII在标准库中最经典的实践是std::unique_ptr
// 感兴趣的人可以进一步学习
class ABigTypePtr {
private:
    ABigType* value;
public:
    ABigTypePtr() : value(new ABigType()) {}
    ABigTypePtr(const ABigTypePtr& other) {  // 复制构造函数,实参后续还需要使用
        value = new ABigType(*other.value);  // 执行复制操作
    }
    ABigTypePtr(ABigTypePtr&& other) noexcept {  // 移动构造函数,调用者保证实参后续不再使用
        value = other.value;  // 这就是移动语言之所以“移动”的本质,最原始最直白的写法
        other.value = nullptr;  // 必须,这样能保证实参在回收时,不会错误的把this.value回收掉
    }
    ABigType& operator*() { return *value; }
    ABigType* operator->() { return value; }
    ~ABigTypePtr() { delete value; }  // 删除一个nullptr不会发生任何事
};

void func(ABigTypePtr v) {
    // 操作ABigTypePtr和操作ABigType*的方法完全一样,这是因为ABigTypePtr实现了operator*和operator->
}

int main() {
    ABigTypePtr a_very_big_obj;
    func(a_very_big_obj);  // 如果实参后续还要使用,形参会调用复制构造函数执行复制
    // std::move的本质是将一个T类型的变量强制类型转换为T&&(右值引用)类型
    // 右值引用在语义(规范)上的含义是后续的代码将不再继续使用被引用的内存空间
    // 实参后续不再使用,形参构造时会粗暴地让v.value指向a_very_big_obj.value,从而实现0拷贝
    func(std::move(a_very_big_obj));
    return 0;
}

如果 ABigType本身就实现了移动构造函数,写法会更舒适:

struct ABigType {
    char *data;
    ABigType() : data((char*) malloc(100000)) {}
    ABigType(const ABigType& other) {  // 复制构造函数
        data = (char*) malloc(100000);
        memcpy(data, other.data, 100000);
    }
    ABigType(ABigType&& other) noexcept {  // 移动构造函数
        data = other.data;
        other.data = nullptr;
    }
    ~ABigType() { if (data) free(data); }
};

void func(ABigType v) {
    // Do something
}

int main() {
    ABigType a_very_big_obj;
    func(a_very_big_obj);  // a_very_big_obj后续还要使用,执行复制操作
    func(std::move(a_very_big_obj));  // a_very_big_obj后续不再使用,使用移动语义
    return 0;
}

不难看出,即使不使用移动语义,凭借 C++11 之前的 C++ 语法特性,也能实现“移动”的效果,因此才说移动语义是一种函数传参时传递信息的协议,是一种代码规范,而不是一项技术。而在 C++ 标准库中,各种在堆中分配内存的容器(如 std::vectorstd::list等)、智能指针(主要指 std::unique_ptr)都实现了移动构造函数,因此合理使用移动语义来传递这些类型的容器时,就能做到降低程序开销的效果,而在设计 C++11 及以后的库时,为自己将会在堆中分配内存的类型添加移动构造函数,会更有利于库的使用者编写高性能程序。

另外,在设计 C++11 及以后的库时,对于占用内存特别大的对象,建议直接删除复制构造函数,自己实现一个 copy方法用于对象拷贝,这样能避免调用库函数的小白程序员在无意中进行大对象复制:

class CareForCopyObject {
public:
    CareForCopyObject(const CareForCopyObject&) = delete;
    CareForCopyObject copy();
}

封面:https://upyun.kircute.top/cover/6_72908383_p0.png

文章作者: 卡比三卖萌KirCute
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 卡比三卖萌KirCute的博客
语言特性杂谈
喜欢就支持一下吧