右值和移动

发布于:2025-05-13 ⋅ 阅读:(11) ⋅ 点赞:(0)

值类别(value categories)

在这里插入图片描述

lvalue

通常可以放在等号左边的表达式, 左值

例子

  • 变量,函数或数据成员的名字
  • 返回左值引用的表达式,如++x, x = 1, cout << ’ '. x = 1 和 ++x返回的都是对x的int&. x++则返回的是int
  • 字符串字面量如 “hello world”

rvalue

通常只能放在等号右边的表达式, 右值

glvalue

generalized lvalue, 广义左值

xvalue

expiring lvalue, 将亡值

prvalue

pure rvalue, 纯右值

值类型(value type)

是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另一个数值

在C++里,所有的原生类型,枚举,结构,联合,类都代表值类型。只有引用(&)和指针( * ) 才是引用类型

在Java里,数字等原生类型是值类型,类则属于引用类型

在Python里,一切类型都是引用类型

引用类型(reference value)

非常左值引用

可以绑定非 const lvalue

常左值引用

可以绑定 lvalue, xvalue, prvalue

非常右值引用

可以绑定非 const xvalue, prvalue

常右值引用

可以绑定 xvalue, prvalue

生命周期

C++的规则是:一个临时对象(prvalue)会在包含这个临时对象的完整表达式估值完成后,按生成顺序的逆序被销毁,除非有生命周期延长发生

C++对临时对象有特殊的生命周期延长规则

  • 如果一个prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长
    result&& r = process_shape( circle(), triangle());
    
  • 这条生命周期延长规则只对prvalue有效,而对xvalue无效

移动的意义

右值引用的目的是实现移动,而实现移动的意义是减少运行的开销

移动案例

string result =
  string("Hello, ") + name + ".";

C++11 以前的执行流程

  1. 调用构造函数 string(const char*),生成临时对象 1;"Hello, " 复制 1 次
  2. 调用 operator+(const string&, const string&),生成临时对象 2;"Hello, " 复制 2 次,name 复制 1 次
  3. 调用 operator+(const string&, const char*),生成对象 3;“Hello, " 复制 3 次,name 复制 2 次,”." 复制 1 次
  4. 假设返回值优化能够生效(最佳情况),对象 3 可以直接在 result 里构造完成
  5. 临时对象 2 析构,释放指向 string("Hello, ") + name 的内存
  6. 临时对象 1 析构,释放指向 string("Hello, ") 的内存

C++11 以后的执行流程

  1. 调用构造函数 string(const char*),生成临时对象 1;"Hello, " 复制 1 次
  2. 调用 operator+(string&&, const string&),直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2;name 复制 1 次
  3. 调用 operator+(string&&, const char*),直接在临时对象 2 上面执行追加操作,并把结果移动到 result;“.” 复制 1 次
  4. 临时对象 2 析构,内容已经为空,不需要释放任何内存
  5. 临时对象 1 析构,内容已经为空,不需要释放任何内存

如何实现移动

除了拷贝构造函数外,增加移动构造函数

增加swap成员函数,实现与另一个对象的快速交换

在你的对象的名空间下,应当有一个全局的 swap 函数,调用成员函数 swap 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 swap 函数

实现通用的 operator=

上面各个函数如果不抛异常的话,应当标为 noexcept。这对移动构造函数尤为重要

  • noexcept告诉编译器,函数不会抛异常,有利于编译器对程序做更多的优化
  • 以下情形鼓励用noexcept: 移动构造函数(move constructor)、移动分配函数(move assignment)、析构函数(destructor)

不要返回本地变量的引用

在C++编程中,如果在函数内部返回一个指向本地对象的引用,这是一个常见的错误

int& getLocalVariable() {
    int num = 42;
    return num; // 错误:返回了一个指向本地变量的引用
}

int main() {
    int& ref = getLocalVariable(); // 错误:引用指向已被销毁的本地变量
    cout << ref << endl; // 可能输出任意值,因为引用指向已被销毁的内存
}

引用坍缩(又称“引用折叠”)

当一个泛型函数模板中的类型参数与实际参数类型相同时,编译器会进行类型折叠,即将模板中的类型参数替换为实际参数类型,从而简化代码。

template  <typename  T>
void  process_shape(T&&  shape)  {
     puts("Processing  shape");
}

void  foo()  {
     process_shape(circle());   //  调用  process_shape(circle())
     process_shape(triangle());  //  调用  process_shape(triangle())
}
        

当我们调用 process_shape(circle()) 和 process_shape(triangle()) 时,编译器会根据实际参数类型进行类型折叠,即 T 的推导结果分别是 circle 和 triangle

在有模板的代码中,当类型推导结果相同时,编译器会将模板中的类型参数替换为实际参数类型,从而简化代码。这个现象在实际编程中经常会遇到

完美转发

定义一个函数模板,该函数模板可以接收任意类型参数,然后将参数转发发给其他目标函数,且保证目标函数接受的参数其类型与传递给模板函数的类型相同

对于一个函数,如果形参是右值引用,但在函数体内,这个“右值引用”实际上是一个左值变量,然后函数内再有一个函数传入这个参数,那么就会调用对应的左值引用版本,而完美转发的意义就相当于做一次类型转换,让这个参数保持一开始传入时的左值右值类别

移动案例

#include <string>
#include <ctime>
#include <chrono>
#include <iostream>

void func1(std::string s)
{
}

void func2(const std::string &s)
{
}

void test2()
{
    std::string a = "hello";
    auto start = std::chrono::system_clock::now();
    for (size_t i = 0; i < 20000000; i++)
    {
        func1(std::string("hello"));
    }
    auto end = std::chrono::system_clock::now();
    std::chrono::duration<double> elapsed_seconds = end - start;
    std::cout << "elapsed time: " << elapsed_seconds.count() << "s\n";

    start = std::chrono::system_clock::now();
    for (size_t i = 0; i < 20000000; i++)
    {
        func2(std::string("hello"));
    }
    end = std::chrono::system_clock::now();
    elapsed_seconds = end - start;
    std::cout << "elapsed time: " << elapsed_seconds.count() << "s\n";
}

// 改进版本
void test3() {
    std::string a = "hello";
    auto start = std::chrono::system_clock::now();
    for (size_t i = 0; i < 20000000; i++)
    {
        func1(a);
    }
    auto end = std::chrono::system_clock::now();
    std::chrono::duration<double> elapsed_seconds = end - start;
    std::cout << "elapsed time: " << elapsed_seconds.count() << "s\n";

    start = std::chrono::system_clock::now();
    for (size_t i = 0; i < 20000000; i++)
    {
        func2(a);
    }
    end = std::chrono::system_clock::now();
    elapsed_seconds = end - start;
    std::cout << "elapsed time: " << elapsed_seconds.count() << "s\n";
}

int main()
{
    std::cout << "---- test2 测试时间 ----" << std::endl;
    test2();

    std::cout << "---- test3 测试时间 ----" << std::endl;
    test3();
}

结果:

---- test2 测试时间 ----
elapsed time: 1.8719s
elapsed time: 1.90104s
---- test3 测试时间 ----
elapsed time: 0.424229s
elapsed time: 0.0873127s

问题: 在test2 case 中为什么func1比func2快?

func1(string(“func1”))时,传入的参数是一个右值.同时在函数调用中,临时对象的所有权会被转移给函数的参数s,因此会发生一次移动构造操作

func2(string(“func2”))时,传入的参数也是一个右值,虽然函数参数s是通过常量引用传递的,但临时对象string(“func2”)可以绑定到常量引用,因此不会发生拷贝操作。

为什么test3整体时间比test2快?

例如func1(a)和func2(a),传入的参数是一个左值

因为a是一个已命名的变量,它具有持久性,并且可以被多次使用. 原因在于使用已命名的变量作为参数时,可以直接进行拷贝构造,而不需要进行额外的临时对象的创建和销毁

参考资料


网站公告

今日签到

点亮在社区的每一天
去签到