掘金 后端 ( ) • 2024-04-22 15:07

引例

普通构造与移动语义的时间对比

// 创建一个大数据量的字符串
    const int dataSize = 1000000; // 1,000,000 characters
    char* largeString = new char[dataSize + 1];
    for (int i = 0; i < dataSize; ++i) {
        largeString[i] = 'A'; // 填充字符 'A'
    }
    largeString[dataSize] = '\0'; // 添加字符串结束符

    // 测量使用移动构造的执行时间
    auto startMoveConstructor = high_resolution_clock::now();
    MyString temp(largeString);
    MyString moved = std::move(temp);
    auto endMoveConstructor = high_resolution_clock::now();
    auto durationMoveConstructor = duration_cast<microseconds>(endMoveConstructor - startMoveConstructor);
    cout << "Time taken by move constructor: " << durationMoveConstructor.count() << " microseconds" << endl;

    // 测量使用拷贝构造的执行时间
    auto startCopyConstructor = high_resolution_clock::now();
    MyString tempCopy(largeString);
    MyString copied(tempCopy);
    auto endCopyConstructor = high_resolution_clock::now();
    auto durationCopyConstructor = duration_cast<microseconds>(endCopyConstructor - startCopyConstructor);
    cout << "Time taken by copy constructor: " << durationCopyConstructor.count() << " microseconds" << endl;

    // 测量使用移动赋值的执行时间
    auto startMoveAssignment = high_resolution_clock::now();
    MyString anotherTemp(largeString);
    MyString assigned;
    assigned = std::move(anotherTemp);
    auto endMoveAssignment = high_resolution_clock::now();
    auto durationMoveAssignment = duration_cast<microseconds>(endMoveAssignment - startMoveAssignment);
    cout << "Time taken by move assignment: " << durationMoveAssignment.count() << " microseconds" << endl;

    // 测量使用拷贝赋值的执行时间
    auto startCopyAssignment = high_resolution_clock::now();
    MyString anotherTempCopy(largeString);
    MyString assignedCopy;
    assignedCopy = anotherTempCopy;
    auto endCopyAssignment = high_resolution_clock::now();
    auto durationCopyAssignment = duration_cast<microseconds>(endCopyAssignment - startCopyAssignment);
    cout << "Time taken by copy assignment: " << durationCopyAssignment.count() << " microseconds" << endl;

    delete[] largeString; // 释放动态分配的内存

    // 测量返回值优化的执行时间(使用移动语义)
    auto startReturnFromFunctionWithMove = high_resolution_clock::now();
    MyString returnedFromFunction = getMyString(); // 假设有一个函数 getMyString() 返回 MyString
    auto endReturnFromFunctionWithMove = high_resolution_clock::now();
    auto durationReturnFromFunctionWithMove = duration_cast<microseconds>(endReturnFromFunctionWithMove - startReturnFromFunctionWithMove);
    cout << "Time taken by return from function with move: " << durationReturnFromFunctionWithMove.count() << " microseconds" << endl;

    // 测量返回值优化的执行时间(不使用移动语义)
    auto startReturnFromFunctionWithoutMove = high_resolution_clock::now();
    MyString returnedFromFunctionWithoutMove = getMyStringWithoutMove(); // 使用没有移动语义的返回
    auto endReturnFromFunctionWithoutMove = high_resolution_clock::now();
    auto durationReturnFromFunctionWithoutMove = duration_cast<microseconds>(endReturnFromFunctionWithoutMove - startReturnFromFunctionWithoutMove);
    cout << "Time taken by return from function without move: " << durationReturnFromFunctionWithoutMove.count() << " microseconds" << endl;

得出的结果如下: 可以看出,使用移动语义后能提高我们程序的效率,这就是为什么C++11以后会有语义的概念,其实主要都是为了提升效率而不断引入的。接下来我们一步一步探究其中的奥秘,首先来看一些概念

一、左值与右值

C++中的表达式,要么是左值,要么是右值。左值是可寻址的变量,有持久性;而右值一般是不可寻址的常量,或在表达式求值过程中创建的无名临时对象,短暂性的。 通常情况讲,左值就是能放在等号左边的表达式,如

int i = 1; i = 2;

这里的变量i就是左值,他是可修改的,但是加上const之后,他就具有常量属性,不可修改

const int i = 1; i = 2;//错误。因为i具有常量属性,不可修改

能用到左值的运算符通常有:

  • 赋值运算符

int a; a = 4;//整个赋值语句的结果仍然是左值

  • 取地址 &

int a = 4;//变量就是左值 &a;

  • 下标,如string, vector下标[]都需要左值

string s = "I'm KK"; s[0]; vector::iterator iter; iter++; iter--

  • 通过看运算符在字面量上的操作判断

i++;//正确 9++;//错误

而不是左值的,就是右值,右值也会被称为临时值

二、引用的分类

左值引用(绑定到左值上)带一个“&”

我们希望引用的对象可改变值,就会用到左值引用。左值引用只能绑定到左值上

int a = 1;
int& b{a}; //正确,a是左值,b可以绑定
int& c;//错误,引用必须要初始化
int& d = 1;//错误,左值引用不能绑右值

cosnt引用(常量引用)

常量引用也是左值引用,但是我们希望引用的对象是不改变的,const引用可以绑左值,右值

int t = 1;
const int& a = t;
a = 2;//错误,a具有const属性,不是可修改的左值
const int& b = 2;//正确,const引用可以绑定到右值上,这里就区别于普通左值引用了
/*
    其实“const int& b = 10;”这句发生了这个事情:
    int tmp = 2;//这里的tmp是一个临时变量
    const int& b = tmp;
/*

右值引用(绑定到右值上)带"&&"

右值引用主要是来绑定到那些临时的或者即将销毁的对象,右值引用只能绑右值

int&& a = 1;//正确
int i = 2;
int&& b = i;//错误,右值引用不能绑左值
int&& c = i * 100//正确,i * 100 结果是右值

小结几点

  • (1)前置递增减运算符与后置递增减运算符的区别 前置递增减运算法是左值表达式,因为++i是直接将i变量+1然后再返回i本身,而后置递增运算符是右值表达式,因为i++是先产生一个临时变量来保存i的值用于使用目的,再给i+1,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值;
int i = 7;
(++i) = 20;//正确,i被赋值成20
(i++) = 10;//错误,表达式必须是可修改的左值
int j = 1;
int&& a = j++;//可以,成功绑定右值,但此后a的值和j没关系
int& b = j++//不可以,左值引用不能绑右值表达式
  • (2) &&r1绑定到了右值,但r1是本身是左值(看成一个变量)
  • (3) 所有变量都要看成左值,因为他们是有地址的
  • (3)临时对象都是右值

三、探究临时对象

前面的例子中我们提到临时对象,临时对象的产生往往容易被我们忽略,而产生临时对象会消耗资源和空间,这对于我们的程序,应该是尽量去避免产生临时对象以达到提高、优化性能的目的。 以下是一些常见的会产生临时值的地方:

  • 函数传参
func("some temporary string");//这里虽然传的是常量,但是C++中大概率还是产生一个临时变量来复制
  • 初始化
v.push_back(x());//这里会初始化一个临时的x,然后被复制进vector
  • 类型转换产生
TValue sum;
sum = 100;//这里会产生一个临时的TValue的对象来进行调用一个拷贝赋值
  • 函数返回对象时
TValue doubled(TValue& t)
{
    TValue tmp;
    tmp.x = t.x * 2;
    tmp.y = t.y * 2;
    return tmp;//这里会产生一个临时对象用于返回,tmp是左值,但优先移动,不支持移动时仍可复制。但要注意,现在的大多编译器会进行优化
}
  • 表达式赋值
a = b + c; // b+c是一个临时值, 然后被赋值给了a  
a = b + c + d; //c+d是一个临时变量, b+(c+d)是另一个临时变量
  • 后置递增减运算符
x++; // 前面提到的,先产生一个临时变量来保存i的值用于使用目的,再给i+1,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值;

四、对象移动与move()的作用

对象移动

什么是对象移动?对象移动其实就是把一个不想用了的对象A(临时值那些)中的一些有用的数据提取出来,在构建新对象B时就不需要重新构建对象中的所有数据————而是直接从A中提取出来,这样就避免了拷贝复制浪费资源与效率

move()函数

move()函数的作用就是将一个左值强制转换成右值,这样就能使得一个右值引用能绑定到这个转换成的右值对象了。请注意:C++中的move函数只是做了类型转换,并不会真正的实现值的移动!!! 要实现真正的移动,得自己手动重载移动构造函数和移动复制函数。我们需要在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。 不过实际上,通常情况下C++编译器会默认在用户自定义的class和struct中生成移动语义函数。这样的前提是我们自己没有主动定义该类的拷贝构造等函数。

需要注意的点是:

  • 对象在被move后,并没有被立即析构,而是在其离开作用域后才会被析构,如果此时继续使用被析构的对象的一些变量,会发生一些意想不到的错误。因此一般需要手动将源对象的值置空,以防止同一片内存区域被多次释放!
  • 如果我们没有提供移动构造函数,只提供了拷贝构造函数,std::move()会失效但是不会发生错误,因为编译器找不到移动构造函数就去寻找拷贝构造函数,这也是拷贝构造函数的参数是const T&常量左值引用的原因!
  • c++11中的所有容器都实现了move语义
  • 一些基本类型使用move还是会被复制,因为它们没有对象的移动构造函数,所以move对于含有内存,文件句柄等资源对象更有意义

五、移动构造函数与移动赋值运算符

下面给出一个使用移动构造和移动赋值运算符的地址:

#include <iostream>
#include <utility>
#include <vector>

class MyString {
public:
	//constructor
    explicit MyString(const char* data) {
        if (data != nullptr) {
            _data = new char[strlen(data) + 1];
            strcpy(_data, data);
        }
        else {
            _data = new char[1];
            *_data = '\0';
        }

        std::cout << "built this object, address: " << this << std::endl;
    }

    //Destructor
    virtual ~MyString() {
        std::cout << "destruct this object, address: " << this << std::endl;
        delete[] _data;
    }

    //Move constructor
    MyString(MyString&& str) noexcept
        : _data(str._data) {
        std::cout << "move this object" << std::endl;
        str._data = nullptr;//这一步很重要
    }


    //copy assignment
    MyString& operator=(const MyString& str) {
        if (this == &str)//避免自我赋值
            return *this;

        delete[] _data;
        _data = new char[strlen(str._data) + 1];
        strcpy(_data, str._data);
        return *this;
    }

    //Move assignment
    MyString& operator = (MyString&& str) noexcept {
        if (this == &str)//避免自我赋值
            return *this;

        delete[] _data;
        _data = str._data;
        str._data = nullptr;//不再指向之前的资源
        return *this;
    }
public:
    char* _data;
};

void f_move(MyString&& obj) {
	MyString a_obj(std::move(obj));
	std::cout << "move function, address: " << &a_obj << std::endl;
}

int main()
{
    MyString obj{ "abc" };

    f_move(std::move(obj));
    std::cout << "==================== end ==================" << std::endl;
    return 0;
}

输出结果如下:

观察输出结果,可以验证我们上诉所说的 这里我们需要注意:在移动构造函数和移动赋值函数中,我们将当前待移动对象的资源赋值为了空(str._data=nullptr),这里就是我们手动实现了资源的移动! 假如尝试修改两个地方,将导致报错:

  • 使用资源被move后的对象 在main函数中添加如下:
int main()
{
    MyString obj{ "abc" };

    f_move(std::move(obj));
    std::cout << obj._data << std::endl; // danger!
    std::cout << "==================== end ==================" << std::endl;
    return 0;
}

会导致报错:

image-13.png

因为此时obj中的内容已经为空了!

  • 在实现移动构造函数时不赋值为nullptr 将这里注释掉:
MyString(MyString&& str) noexcept
    : _data(str._data) {
    std::cout << "move this object" << std::endl;
    //str._data = nullptr;//这一步很重要
}

程序崩溃:

image-14.png

因为我们没将源对象指针置空,两个指针指向同一块资源,当他们生命周期结束后,都会释放同一块资源,导致两次释放!

参考资料:

  • 《C++新经典》——王建伟
  • 深入理解C++中的move和forward ——腾讯云开发者 张凯