【C++进阶】右值引用和移动语义

文章目录

    • 1. 引言
    • 2. 不同值的辨析
    • 3. &&的特性
    • 4. 左值引用和右值引用
    • 5. 右值引用优化性能
    • 6. 引用和右值引用使用场景
    • 7. 移动语义
    • 8. forward完美转发
    • 9. emplace_back
    • 10. 无序容器
      • ① map和unordered_map的区别
      • ② set和unordered_set的区别

1. 引言

C++11中引入了右值引用和移动语义,可以避免无谓的复制,提高了程序性能

  • 提高程序性能:传统的拷贝构造函数会对对象进行深拷贝,这会导致内存的频繁分配和释放,降低程序的性能。而移动构造函数利用右值引用,可以将对象的资源所有权转移,避免了不必要的内存分配和释放,从而提高程序性能。
  • 支持移动语义:移动语义是指将一个对象的资源所有权转移给另一个对象,而不是进行拷贝。移动语义在实现容器类时特别有用,可以避免不必要的拷贝和内存分配,提高程序的效率。
  • 支持完美转发:右值引用还可以用于完美转发,即将一个函数的参数转发给另一个函数,同时保留参数的值类别(左值或右值)。这在模板编程中特别有用,可以避免不必要的拷贝和内存分配,提高程序的效率。
  • 支持移动语义的STL容器:C++11中的STL容器(例如vector、list等)都支持移动语义,可以提高程序的效率。

左值是表达式结束后仍然存在的持久对象,右值是指表达式结束时就不存在的临时对象

  • 区分左值和右值的便捷方法是看能不能对表达式取地址,如果能则为左值,否则为右值
  • 将亡值是C++11新增的、与右值引用相关的表达式,比如:
    • 右值引用(例如:int&& x = 42;
    • std::move返回的右值引用(例如:int x = 42; int&& y = std::move(x);
    • std::forward返回的右值引用(例如:template void func(T&& t) { f(std::forward(t)); }

2. 不同值的辨析

C++11中的所有的值必将属于左值、将亡值、纯右值三者之一,将亡值和纯右值都属于右值

如何区分将亡值和纯右值:

  • 将亡值是指一个对象即将被移动,但它的生命周期仍然可以延长的值。而纯右值是指一个临时对象,它的生命周期只在当前表达式中存在,不能被延长。
  • 在 C++11 中,可以通过std::move函数将一个对象转换为将亡值。在某些情况下,编译器也会自动将一个对象识别为将亡值,例如返回一个std::vector对象时。而纯右值则通常是字面量、临时对象或者返回值表达式。
  • 区分将亡值和纯右值的关键在于它们的生命周期。如果一个对象的生命周期可以被延长,那么它就是将亡值;如果一个对象的生命周期只在当前表达式中存在,那么它就是纯右值。

区分表达式的左右值属性:如果可对表达式用&符取址,则为左值,否则为右值

左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有:

  • 变量、函数或数据成员的名字
  • 返回左值引用的表达式,如 ++x、x = 1、cout << ’ ’

纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为“临时对象”,最常见的情况有:

  • 返回非引用类型的表达式,如 x++、x + 1、std::make_shared(42)
  • 字符串字面量和除字符串字面量之外的字面量,如 42、true

3. &&的特性

右值引用允许我们将一个右值(即临时对象或表达式的结果)绑定到一个可以修改的引用上,从而避免了不必要的内存拷贝和对象构造。下面是一个右值引用的例子:

#include 
#include 

using namespace std;

void print(vector<int>&& v)
{
    for (auto i : v)
        cout << i << " ";
    cout << endl;
}

int main()
{
    vector<int> v1{1, 2, 3};
    print(move(v1)); // 将v1作为右值引用传递给print函数
    return 0;
}

右值引用就是对一个右值进行引用的类型,因为右值没有名字,所以我们只能通过引用的方式找到它。

无论声明左值引用还是右值引用都必须立即进行初始化,因为引用类型本身并不拥有所把绑定对象的内存,只是该对象的一个别名。

通过右值引用的声明,该右值又“重获新生”,其生命周期其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。

总结:

  • 左值和右值是独立于它们的类型的,右值引用类型可能是左值也可能是右值
  • auto&&或函数参数类型自动推导的T&&是一个未定的引用类型,被称为universal references,它可能是左值引用也可能是右值引用类型,取决于初始化的值类型
  • 所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用叠加都为左值引 用,当T&&为模板参数时,输入左值,它会变成左值引用,而输入右值时则变为具名的右值引用
  • 编译器会将已命名的右值引用视为左值,而将未命名的右值引用视为右值

4. 左值引用和右值引用

左值引用和右值引用的主要区别在于它们绑定到不同类型的值上。左值引用绑定到左值上,而右值引用绑定到右值上。

左值是指可以取地址的表达式,通常是已命名的变量或对象。例如:

int x = 10;
int& lref = x; // 左值引用绑定到 x 上

右值是指不能取地址的表达式,通常是临时创建的对象或字面量。例如:

int&& rref = 5; // 右值引用绑定到字面量 5 上

左值引用和右值引用的另一个区别是它们对于移动语义和完美转发的支持。右值引用通常用于实现移动语义和完美转发,而左值引用则用于传递和修改已命名的对象。如下是一个简单的示例:

#include 
#include 
using namespace std;

void printVector(vector<int>& v) // 左值引用参数
{
    for (auto i : v)
        cout << i << " ";

    cout << endl;
}

void printVector(vector<int>&& v) // 右值引用参数
{
    for (auto i : v)
        cout << i << " ";

    cout << endl;
}

int main()
{
    vector<int> v1 = {1, 2, 3, 4, 5};
    vector<int> v2 = {6, 7, 8, 9, 10};

    // 左值引用参数
    printVector(v1);

    // 右值引用参数
    printVector(move(v2));

    return 0;
}

函数printVector()接受一个右值引用参数,第一次调用该函数时,它将接受一个左值 v1,因为 v1 是已命名的对象;第二次调用该函数时,它将接受一个右值 move(v2),因为move()返回一个右值引用,将 v2 转换为右值。

5. 右值引用优化性能

第一个例子是在函数中传递一个大型对象,可以使用右值引用来避免深拷贝和提高性能。例如:

#include 
#include 

// 接受一个 std::vector 的右值引用
void process(std::vector<int>&& vec)
{
    // 对 vec 进行一些操作
    std::cout << "The vector has " << vec.size() << " elements.\n";
    // ...
}

int main()
{
    std::vector<int> v = {1, 2, 3, 4, 5};

    // 调用 process 时将 v 转换为右值引用
    process(std::move(v));

    // 此时 v 已经被移动,无法再使用
    // std::cout << v[0] << std::endl; // 会导致编译错误

    return 0;
}

process函数接受一个std::vector的右值引用,这意味着传递给函数的参数是一个临时对象或者可以被移动的对象,而不是一个具有所有权的对象。在函数内部,我们可以对这个对象进行一些操作,而无需进行深拷贝。此外,我们使用了 std::move函数将 v 转换为右值引用,这意味着 v 的所有权被移动到了process函数内部,因此在函数结束后,我们无法再使用 v。

使用右值引用可以避免不必要的深拷贝,从而提高性能。但是,需要注意的是,右值引用只适用于可以被移动的对象,如果一个对象不支持移动语义,则不能使用右值引用。

不支持移动语义的对象:

  • 内置类型,如int、float等
  • 常量对象
  • 对象中包含有指针类型的成员变量,并且这些指针指向动态分配的内存
  • 对象中包含有文件句柄、网络连接等资源
  • 对象中包含有互斥锁、条件变量等同步机制
  • 对象中包含有非平凡的析构函数
  • 对象中包含有虚函数表
  • 对象中包含有不能被移动的成员变量
  • 对象中包含有不能被移动的基类

第二个关于类的例子

假设有一个类MyString,它表示一个字符串,并且有一个成员变量char* str指向字符串的内存空间。为了避免深拷贝,我们可以使用右值引用来优化它的移动构造函数。

移动构造函数是一种特殊的构造函数,它接受一个右值引用参数,并将资源从一个对象转移到另一个对象,而不是进行深拷贝。在这个例子中,我们可以使用std::move来将指针从一个对象移动到另一个对象:

#include 

class MyString
{
public:
    MyString() : str(nullptr) {}
    MyString(const char* s)
    {
        str = new char[std::strlen(s) + 1];
        std::strcpy(str, s);
    }
    MyString(const MyString& other)
    {
        str = new char[std::strlen(other.str) + 1];
        std::strcpy(str, other.str);
    }
    MyString(MyString&& other) noexcept
    {
        str = other.str;
        other.str = nullptr;
    }
    ~MyString()
	{
        delete[] str;
    }
private:
    char* str;
};

在上面的代码中,我们定义了一个移动构造函数MyString(MyString&& other) noexcept,它接受一个右值引用参数other,并将其 str 成员变量的指针移动到当前对象的 str 成员变量中。由于我们已经将 other.str 设置为 nullptr,因此在 other 对象的析构函数中将不会释放内存。

现在,我们可以使用右值引用来避免深拷贝,例如:

MyString s1("hello");
MyString s2(std::move(s1));  // 移动构造函数被调用,避免了深拷贝

在上面的代码中,我们首先创建一个 MyString 对象 s1,然后使用std::move将其转换为一个右值引用,并将其传递给 MyString 对象 s2 的构造函数。由于我们已经定义了移动构造函数,因此在这个过程中会调用移动构造函数,而不是进行深拷贝。

6. 引用和右值引用使用场景

引用是一种别名,是一个已经存在的对象的另一个名字。而右值引用则是对临时对象(rvalue)的引用,通常用于移动语义和完美转发。

使用意义上,引用主要用于函数参数传递和返回值,可以避免复制大对象,提高性能。而右值引用则主要用于实现移动语义,可以将对象的资源所有权转移给另一个对象,避免不必要的复制和销毁,提高效率。

总的来说,引用和右值引用都可以用于传递参数和返回值,但是使用场景和意义不同。引用主要用于避免复制大对象,而右值引用则主要用于实现移动语义,提高效率。

7. 移动语义

C++11为了让普通的左值也能借组移动语义来优化性能,提供了std::move()方法来将左值转换为右值,从而方便应用移动语义。move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转义,没有内存拷贝。

【C++进阶】右值引用和移动语义_第1张图片

#include 
#include 

// 定义一个简单的类,包含一个动态分配的数组
class MyClass
{
public:
    MyClass() : data(nullptr), size(0) {}
    MyClass(int n) : data(new int[n]), size(n)
    {
        std::cout << "Constructing MyClass of size " << size << std::endl;
    }
    ~MyClass()
    {
        delete[] data;
        std::cout << "Destructing MyClass of size " << size << std::endl;
    }

    // 移动构造函数,实现移动语义
    MyClass(MyClass &&other) : data(other.data), size(other.size)
    {
        other.data = nullptr;
        other.size = 0;
        std::cout << "Moving MyClass of size " << size << std::endl;
    }

    // 移动赋值运算符,实现移动语义
    MyClass &operator=(MyClass &&other)
    {
        if (this != &other)
        {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
            std::cout << "Moving MyClass of size " << size << std::endl;
        }
        return *this;
    }

private:
    int *data;
    int size;
};

int main()
{
    std::vector<MyClass> vec;
    vec.reserve(10);

    // 向vector中添加10个MyClass对象
    for (int i = 0; i < 10; i++)
        vec.emplace_back(i + 1);

    std::cout << "Resizing vector\n";

    // 将vector的大小减半
    vec.resize(5);

    std::cout << "Done\n";

    return 0;
}

有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计右值引用的拷贝构造函数和赋值函数,以提高应用程序的效率。

8. forward完美转发

forward是一个模板函数,主要用于将参数进行完美转发,即将参数以原本的类型和引用类型传递给另一个函数,避免因为多次拷贝而引起的性能问题。

forward完美转发实现了参数在传递过程中保持其值属性的功能,即若是左值,则传递之后仍然是左值,若是右值,则传递之后仍然是右值。


具体来说,forward可以用于以下场景:

  1. 将参数转发给另一个函数,以保持参数类型和引用类型的一致性
#include 
using namespace std;

void bar(int& x)
{
    cout << "bar&" << endl;
}

void bar(int&& x)
{
    cout << "bar&&" << endl;
}

template<typename T>
void foo(T&& t)
{
    bar(forward<T>(t));
}

int main()
{
    int a = 10;
    foo(a);
    foo(10);

    return 0;
}

上述代码中,foo 函数接受一个参数T&& t,并将其转发给另一个函数bar。使用std::forward可以保持参数类型和引用类型的一致性,从而避免不必要的拷贝和类型转换。

这里的 foo 函数的参数 t 是一个“转发引用”,也称为“万能引用”。在使用这个函数模板时,t 的类型将根据函数参数的类型来确定,可能是一个左值引用或者是一个右值引用。

因此,在这个函数模板中,t 既可以是左值,也可以是右值。它的左值或右值属性取决于传递给函数的实参类型。如果传递给函数的实参是左值,则 t 也将是一个左值引用;如果传递给函数的实参是右值,则 t 将是一个右值引用。

需要注意的是,即使 t 是一个右值引用,它仍然可以绑定到左值或右值,因为它是一个“转发引用”,能够接受任何类型的参数。

  1. 在实现移动构造函数和移动赋值运算符时,使用 forward 可以将参数以右值引用的方式传递,避免不必要的拷贝和析构操作,提高性能
#include 
using namespace std;

class MyClass
{
public:
    MyClass() : m_data(nullptr) {}
    ~MyClass()
    {
        delete[] m_data;
    }
    MyClass(MyClass &&other) noexcept
    {
        cout << "Moving MyClass" << endl;
        m_data = other.m_data;
        other.m_data = nullptr;
    }

    MyClass &operator=(MyClass &&other) noexcept
    {
        cout << "Moving Operator=" << endl;
        if (this != &other)
        {
            delete m_data;
            m_data = other.m_data;
            other.m_data = nullptr;
        }
        return *this;
    }

private:
    int *m_data;
};

int main()
{
    MyClass t1;
    MyClass t2;

    MyClass t3(forward<MyClass>(t1));
    MyClass t4 = forward<MyClass>(t2);

    return 0;
}

上述代码中,移动构造函数和移动赋值运算符需要将参数以右值引用的方式传递,并将其移动到新的对象中。使用std::forward可以避免不必要的拷贝和析构操作,提高性能。

  1. 在实现通用代码时,forward 可以用于处理可变参数模板,将参数以原本的类型和引用类型传递给另一个函数
template<typename... Args>
void foo(Args&&... args)
{
    bar(std::forward<Args>(args)...);
}

上述代码中,foo函数接受可变参数模板,可以接受任意类型和数量的参数,并将其转发给另一个函数bar。使用std::forward可以将参数以原本的类型和引用类型传递给bar函数,从而实现通用代码。

例如:

#include 
#include 

template<typename... Args>
void bar(Args&&... args)
{
    std::cout << "this is bar function" << std::endl;
}

template<typename... Args>
void foo(Args&&... args)
{
    bar(std::forward<Args>(args)...);
}

int main()
{
    int x = 42;
    std::string s = "hello";
    foo(x, s);

    return 0;
}

当我们调用foo函数时,可以将任意类型和数量的参数转发给bar函数

总结:

  • move:左值变右值
  • forward:如果传递的不是T&& t类型的变量,则会将左值变为右值;是的话,则原来是什么值就转发成什么值

9. emplace_back

emplace_back是C++中vector容器的一个成员函数,用于在vector的末尾插入一个新元素。与push_back不同的是,emplace_back可以直接在vector中构造一个新元素,而不需要先创建一个对象再插入。这个新元素是通过参数列表中的参数来构造的,而不是通过复制或移动现有对象来构造的。因此,emplace_back比push_back更高效,因为它可以避免不必要的对象构造和复制。也就是说,emplace_back是就地构造,不用构造后再次复制到容器中,因此效率更高。

例如像下面的语句:

vector<string> vec;
vec.push_back(string(16, 'a'));
  • 首先,string(16, ‘a’)会创建一个string类型的临时对象,这涉及到一次string构造过程
  • 其次,vector内会创建一个新的string对象,这是第二次构造
  • 最后在push_back结束时,最开始的临时对象会被析构
  • 加在一起,这两行代码会涉及到两次string构造和一次析构

c++11可以用emplace_back代替push_back,因为emplace_back在将元素添加到容器中时,不需要进行复制或移动操作。相反,它直接在容器的末尾构造元素对象,避免了复制或移动操作的开销,可以省略一次构建和一次析构,从而达到优化的目的。这意味着emplace_back比push_back更快,特别是当元素类型较大或不可复制时,例如包含大量数据成员或具有唯一所有权的对象。

如下的例子,使用push_back和emplace_back分别向向量中添加100万个整数,并测量添加的时间:

#include 
#include 
#include 

int main()
{
    std::vector<int> vec;

    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; i++)
        vec.push_back(i);
    auto end = std::chrono::high_resolution_clock::now();

    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);

    std::cout << "push_back duration: " << duration.count() << " microseconds" << std::endl;

    vec.clear();

    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; i++)
        vec.emplace_back(i);
    end = std::chrono::high_resolution_clock::now();

    duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);

    std::cout << "emplace_back duration: " << duration.count() << " microseconds" << std::endl;

    return 0;
}

stdout:

push_back duration: 5001 microseconds
emplace_back duration: 4000 microseconds

10. 无序容器

C++11增加了无序容器 unordered_map/unordered_multimap 和unordered_set/unordered_multiset,由于这些容器中的元素是不排序的,因此,比有序容器map/multimap 和 set/multiset 效率更高。 map 和 set 内部是红黑树,在插入元素时会自动排序,而无序容器内部是散列表(Hash Table),通过哈希(Hash),而不是排序来快速操作元素,使得效率更高。由于无序容器内部是散列表,因此无序容器的 key 需要提供 hash_value 函数,其他用法和map/set 的用法是一样的。不过对于自定义的 key,需要提供 Hash 函数和比较函数。

① map和unordered_map的区别

  1. 内部实现机理

map和unordered_map都是关联容器,存储的元素都是键值对(key-value)。

map是基于红黑树实现的,它能够自动根据键值对的键排序,因此可以快速地查找、插入和删除元素,时间复杂度为O(log n)。但是,红黑树的构建和维护需要耗费一定的时间和空间,因此在元素数量较少时,使用map可能会比较浪费资源。

unordered_map是基于哈希表实现的,它不会对键进行排序,而是根据哈希函数将键映射到哈希表的某个位置上,因此能够快速地查找、插入和删除元素,时间复杂度为O(1)。但是,哈希表的构建和维护需要耗费一定的时间和空间,而且哈希函数的质量和哈希表的装载因子会影响其性能。

  1. 优缺点

map的优点是能够自动排序,支持快速查找、插入和删除元素,并且不受哈希函数的影响。缺点是构建和维护红黑树需要耗费较多的时间和空间,而且在元素数量较少时可能会比较浪费资源。

unordered_map的优点是能够快速查找、插入和删除元素,时间复杂度为O(1),并且不受元素顺序的影响。缺点是构建和维护哈希表需要耗费较多的时间和空间,而且哈希函数的质量和哈希表的装载因子会影响其性能。

  1. 适用处

map适用于需要对键进行排序的场景,例如需要按照字典序或者数字大小对元素进行排序。同时,当元素数量较少时,使用map可以避免哈希函数的影响,提高性能。

unordered_map适用于需要快速查找、插入和删除元素的场景,例如需要进行散列表操作。同时,当元素数量较多时,使用unordered_map可以避免红黑树的影响,提高性能。但是,需要注意哈希函数的质量和哈希表的装载因子,以避免影响性能。

  1. 例子

假设有一个字符串数组,需要统计每个单词出现的次数。可以使用map和unordered_map来实现。

map:

#include 
#include 

using namespace std;

int main()
{
    string words[] = {"hello", "world", "map", "world", "example", "map", "example", "hello"};
    map<string, int> wordCount;

    for (string word : words)
        wordCount[word]++;

    for (auto it : wordCount)
        cout << it.first << " : " << it.second << endl;

    return 0;
}

stdout:

example : 2
hello : 2
map : 2
world : 2

unordered_map:

#include 
#include 

using namespace std;

int main()
{
    string words[] = {"hello", "world", "map", "world", "example", "map", "example", "hello"};
    unordered_map<string, int> wordCount;

    for (string word : words)
        wordCount[word]++;

    for (auto it : wordCount)
        cout << it.first << " : " << it.second << endl;

    return 0;
}

stdout:

map : 2
world : 2
hello : 2
example : 2

可以看到,使用map和unordered_map实现的结果是一样的,但是unordered_map的输出结果没有按照字典序排列。这是因为unordered_map使用哈希表实现,不保证元素的顺序。而map使用红黑树实现,可以保证元素按照字典序排列。因此,在需要按照顺序遍历元素的情况下,应该使用map;在不需要保证顺序的情况下,可以使用unordered_map,因为它的查找速度更快。

② set和unordered_set的区别

  1. 内部实现机理

set是一种基于红黑树实现的关联式容器,它的元素按照一定的顺序排列,每个元素都唯一且不可修改。红黑树是一种自平衡的二叉查找树,在插入和删除元素时能够保持树的平衡性,使得查找、插入、删除等操作的时间复杂度都为O(log n)。

unordered_set是一种基于哈希表实现的关联式容器,它的元素不按照任何顺序排列,每个元素都唯一且不可修改。哈希表是一种通过哈希函数将元素映射到桶中的数据结构,具有查找速度快的优点,但是在插入和删除元素时需要处理哈希冲突,这会增加一定的开销。

  1. 优缺点

set的优点在于能够保持元素的有序性,可以方便地进行范围查找和二分查找等操作。同时,红黑树的自平衡性能够保证操作的时间复杂度比较稳定,不会受到数据分布的影响。缺点在于插入和删除元素时需要维护红黑树的平衡性,这会增加一定的开销。

unordered_set的优点在于查找元素的速度比set快,因为它是通过哈希函数进行查找的。同时,在插入和删除元素时不需要维护任何平衡性,因此操作的开销比较小。缺点在于元素的顺序是不确定的,不支持范围查找和二分查找等操作。同时,哈希函数的设计和哈希冲突的处理会影响到查找的性能。

  1. 适用处

set适用于需要保持元素有序的情况,比如需要进行范围查找和二分查找等操作的场景。同时,由于红黑树的平衡性能够保证操作的时间复杂度比较稳定,因此在数据量比较大,对时间复杂度有要求的场景下也比较适用。

unordered_set适用于对查找速度有要求的场景,比如需要进行大量的查找操作但不需要保持元素有序的情况。同时,由于哈希表不需要维护任何平衡性,因此在插入和删除元素频繁的场景下也比较适用。但是,由于哈希函数的设计和哈希冲突的处理会影响到查找的性能,因此在数据量比较小或者哈希函数的设计不合理的情况下可能会出现性能问题。

  1. 例子

假设我们有一个字符串列表,我们想要找到其中的重复项。

set:

#include 
#include 
#include 
using namespace std;

int main()
{
    vector<string> strs = {"hello", "world", "hello", "world", "set", "unordered_set"};
    set<string> s;
    for (auto &str : strs)
    {
        if (s.count(str))
            cout << "Found duplicate: " << str << endl;
        else
            s.insert(str);
    }
    return 0;
}

stdout:

Found duplicate: hello
Found duplicate: world

unordered_set:

#include 
#include 
#include 
using namespace std;

int main()
{
    vector<string> strs = {"hello", "world", "hello", "world", "set", "unordered_set"};
    unordered_set<string> s;
    for (auto &str : strs)
    {
        if (s.count(str))
            cout << "Found duplicate: " << str << endl;
        else
            s.insert(str);
    }
    return 0;
}

stdout:

Found duplicate: world
Found duplicate: hello

可以看到,set 和 unordered_set 都能找到重复项,但是它们输出的顺序不同。set 输出的顺序是按照字典序排列的,而 unordered_set 没有任何顺序保证。

你可能感兴趣的:(C++,c++,算法)