IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> 从C语言左值到C++11移动语义 -> 正文阅读

[C++知识库]从C语言左值到C++11移动语义

参考:

对象和变量

左值性(lvalueness)在C/C++中是表达式的一个重要属性。只有通过一个左值表达式才能来引用及更改一个对象(object)的值。(某些情况下,右值表达式也能引用(refer)到 某一个对象,并且可能间接修改该对象的值,后述)。

何谓对象?如果没有明确说明,这里说的对象,和狭义的类/对象(class/object)相比 ,更为广泛。在C/C++中,所谓的对象指的是执行环境中一块存储区域(a region of storage),该存储区域中的内容则代表(represent)了该对象的值(value)。注意到我们这 里所说的"代表",对于一个对象,如果我们需要取出(fetch)它的值,那么我们需要通过 一定的类型(type)来引用它。使用不同的类型,对同一对象中的内容的解释会导致可能得 到不同的值,或者产生某些未定义的行为。

在介绍左值之前,我们还需要引入一个概念: 变量(variable)。经常有人会把变量与 对象二者混淆。什么叫变量?所谓变量是一种声明,通过声明,我们把一个名字(name)与 一个对象对应起来,当我们使用该名字时,就表示了我们对该对象进行某种操作。但是并 不是每个对象都有名字,也并不意味着有对应的变量。比如临时对象(temporary object) 就没有一个名字与之关联(不要误称为临时变量,这是不正确的说法)。


C语言的左值和右值

C语言中的左值概念可以从一个经典错误说起:

// C语言
int a = 0;
++a = 1;

编译器:GCC 7.5.0 x86_64-linux-gnu
报错:[Error] lvalue required as left operand of assignment,说的是:赋值语句的左操作数应该是一个 lvalue

要读懂这一句报错,就要理解什么是 lvalue

显然,如果将lvalue简单理解为“left value”就会让人糊涂,就很难理解上面那句报错了,什么叫做“左边的操作数应该是左边的值???”

我所赞成的一种说法是:lvalue = locator value,参见 https://docs.microsoft.com/en-us/cpp/c-language/l-value-and-r-value-expressions?view=msvc-160

Expressions that refer to memory locations are called “l-value” expressions.
An l-value represents a storage region’s “locator” value, or a “left” value, implying that it can appear on the left of the equal sign (=).
L-values are often identifiers.

即,“左值表达式”应该是指向一段内存区域的表达式,是分配了内存的,所以是可以被赋值的,进而可以出现在等号的左侧,也可以使用&对其取地址。

但是,有两种左值表达式不能取地址,一是具有位域( bit-field )类型,因为实现中最小寻址单位是byte;另一个是具有register指定符,使用register修饰的变量编译器可能会优化到寄存器中。

char a[10];                    // ok, 一维数组
char(*p)[10] = &a;             // ok, 一维数组,每一个元素都指向一个一维数组

const char *p = "hello world"; // ok, 字符串常量存储在只读数据区,注意加 const
char(*p)[12] = &"hello world"; // ok, 字符串常量都占据一片内存,相同的字符串内存一致

struct S { int a : 2; int b : 8; };
struct S t;
int *p = &t;           // ok
int *p = &t.a;         // error, t.a is an lvalue of bitfield

register int i;
int *p = &i;           // error. i is an lvalue of register type.

int a, b;
int *p = &(a + b);     // error. a+b is not an lvalue.

因此,判断一个表达式是不是lvalue最简单的办法就是想一想它有没有被分配内存。

回过头来再看上面的报错语句:a的确被分配了内存,但是在C语言中,不论是 ++a 还是 a++都只是一条自加运算。C语言标准规定了自加和自减不能作为左值,只能是右值表达式。

右值表达式即 rvalue,意味着 “read value”,指的是那些可以提供数值的表达式(不一定可以寻址,例如存储于寄存器中的数据)。右值强调的不是表达式本身,而是该表达式运算后的结果。
这个结果往往并不引用到某一对象,可以看成计算的中间结果;当然它也可能引用到某一对象,但是通过该右值表达式我们不能直接修改该对象。

所以:

  • 左值表达式(分配内存&提供数值)一定可以作为右值表达式(提供数值);
  • 部分右值表达式只能作为右值表达式,如:
    • 整数、字符、浮点数常量:123
    • 任何函数调用表达式:func()
    • 任何转换类型表达式:(int)a
    • 作用于非左值结构体/联合体的成员访问(点)运算符,f().x、(x, s1).a、(s1=s2).m
    • 所有算术、关系、逻辑及位运算符:a+b
    • 自增和自减运算符:a++、++a
    • 赋值及复合赋值运算符:a=1
    • 条件运算符:?
    • 逗号运算符:,
    • 取址运算符:&a、*&a

也就是说,这些右值表达式通通不能出现在C语言的等号左侧,它们都没有被分配内存。


C++11 的左值和右值

  1. 与C不同的是,C++中,对于register变量,C++允许对其取址,即:

    register int i;
    &i;
    

    这种取址运算,事实上要求C++编译器忽略掉register specifier 指定的建议。无论在C/C++中,register与inline一样都只是对编译器优化的一个建议,这种建议的取舍则由编译器决定。

  2. C++中引入了引用类型(reference),引用总是引用到某一对象或者函数上,相当于对其引用的对象/函数进行操作,因而引用类型的表达式总是左值。所以说,如果函数f()的返回类型为int&,则表达式f()等价于左值表达式;如果函数g()的返回类型为int,则表达式g()为int类型的右值表达式。但是,C语言中函数调用的返回值总是右值的。

  3. C++中前缀++/–表达式、赋值表达式都返回左值,但后缀++/–还是不能作为左值。

    int a = 0;
    ++a += 1;    // ok
    a++ = 1;     // error
    
  4. 逗号表达式的第二个操作数如果是左值表达式的话,逗号表达式也是左值表达式。

    int x, y;
    y = (x = 3, x * x);     // ok, y = 9
    (x = 3, y = x * x) = 1; // ok, y = 1
    
  5. 条件表达式(? :)中,如果第2和第3个操作数具有相同类型,那么该条件表达式的结果也是左值的。

    int x = 5, y = 3;
    int a = x < y ? x : y; // ok, a = min{x,y}
    x < y ? x : y = 10;    // ok, min{x,y} = 10
    

C++11 右值引用

C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

int num = 10;
int &b = num; // ok
int &c = 10;  // error

如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。

右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。(C++标准委员会在选定右值引用符号时,既希望能选用现有 C++ 内部已有的符号,还不能与 C++ 98 /03 标准产生冲突,最终选定了 2 个 ‘&’ 表示右值引用。)

和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

int num = 10;
int && a = num;   // error 右值引用不能初始化为左值
int && a = 10;    // ok

右值引用还可以对右值进行修改:

int &&a = 10;
int &&b = 10;
a = 100;
cout << a << endl;  // 10
cout << b << endl;  // 100

C++ 语法上是支持定义常量右值引用的,例如:

const int &&c = 10;

上述这些右值引用虽然很强大,但这种并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。


C++11 移动语义

移动构造

参考:https://www.cnblogs.com/sunchaothu/p/11392116.html

在面向对象中,有的类是可以拷贝的,例如车、房等他们的属性是可以复制的,可以调用拷贝构造函数,叫做“可拷贝”;有点类的对象则是独一无二的,或者类的资源是独一无二的,比如 IO 、 std::unique_ptr等,他们不可以复制,但是可以把资源交出所有权给新的对象,称为“可移动”。

C++11最重要的一个改进之一就是引入了移动语义(move),这样在一些对象的构造时可以获取到已有的资源(如内存)而不需要通过拷贝,申请新的内存,这样移动而非拷贝将会大幅度提升性能。例如有些右值即将消亡析构,这个时候我们用移动构造函数接管他们的资源。

看一下这个例子:

#include <iostream>
#include <stdio.h>
#include <string.h>

using namespace std;

class A {
  public:
    A() {
        arr = new int[1024];
        cout << "class A construct!" << endl;
    }

    ~A() {
        delete[] arr;
        cout << "class A destruct!" << endl;
    }

    A(const A &other) {
        arr = new int[1024];
        memcpy(arr, other.arr, 1024 * sizeof(int));
        cout << "class A copy!" << endl;
    }

  private:
    int *arr;
};

A get_temp_A() { return A(); }

int main(void) {
    A a = get_temp_A();
    return 0;
}

为了看到临时对象拷贝,需要关闭了编译器省略复制构造的优化功能,命令如下:

g++ main.cpp -o main.exe -fno-elide-constructors -std=c++11
./main.exe

运行结果:

class A construct!        // 构造临时对象 A()
class A copy!             // 根据临时对象,拷贝构造一个右值
class A destruct!         // 析构临时对象 A()
class A copy!             // 根据右值,拷贝构造实例a
class A destruct!         // 析构右值
class A destruct!         // 析构实例a

发生了一次构造和两次拷贝构造!在每次拷贝构造时数组都得重新申请内存,而被拷贝后的对象很快就会析构,这无疑是一种浪费。

这就需要加上移动构造函数:

    A(A &&other) noexcept {
        arr = other.arr;
        other.arr = nullptr;
        cout << "class A move!" << endl;
    }

使用上述命令编译,运行结果:

class A construct!        // 构造临时对象 A()
class A move!             // 根据临时对象移动构造右值,不申请内存,临时对象中 arr=nullptr
class A destruct!         // 析构临时对象 A(),delete nullptr不生效
class A move!             // 根据右值移动构造实例a,不申请内存,右值对象中 arr=nullptr
class A destruct!         // 析构右值,delete nullptr不生效
class A destruct!         // 析构实例a,delete []arr 释放内存

原先的两次构造变成了两次移动!!在移动构造函数中,我们做了什么呢,我们只是获取了被移动对象的资源(这里是内存)的所有权,同时把被移动对象的成员指针置为空(以避免移动过来的内存被析构),这个过程中没有新内存的申请和分配,在大量对象的系统中,移动构造相对与拷贝构造可以显著提高性能!这里noexcept告诉编译器这里不会抛出异常,从而让编译器省一些操作(这个也是保证了STL容器在重新分配内存的时候(知道是noexpect)而使用移动构造而不是拷贝构造函数),通常移动构造都不会抛出异常的。

值得注意的是,如果不使用 -fno-elide-constructions 参数,g++编译器会优化掉函数返回时临时对象的拷贝动作,即:

class A construct!
class A destruct!

对可执行程序进行反汇编,得:

0000000000000a1a <_Z10get_temp_Av>:
 a1a:	55                   	push   %rbp
 a1b:	48 89 e5             	mov    %rsp,%rbp
 a1e:	48 83 ec 10          	sub    $0x10,%rsp
 a22:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
 a26:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
 a2a:	48 89 c7             	mov    %rax,%rdi
 a2d:	e8 b6 00 00 00       	callq  ae8 <_ZN1AC1Ev>    // 构造函数
 a32:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
 a36:	c9                   	leaveq 
 a37:	c3                   	retq   

0000000000000a38 <main>:
 a38:	55                   	push   %rbp
 a39:	48 89 e5             	mov    %rsp,%rbp
 a3c:	53                   	push   %rbx
 a3d:	48 83 ec 18          	sub    $0x18,%rsp
 a41:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
 a48:	00 00 
 a4a:	48 89 45 e8          	mov    %rax,-0x18(%rbp)
 a4e:	31 c0                	xor    %eax,%eax
 a50:	48 8d 45 e0          	lea    -0x20(%rbp),%rax
 a54:	48 89 c7             	mov    %rax,%rdi
 a57:	e8 be ff ff ff       	callq  a1a <_Z10get_temp_Av>  // 调用 _Z10get_temp_Av
 a5c:	bb 00 00 00 00       	mov    $0x0,%ebx
 a61:	48 8d 45 e0          	lea    -0x20(%rbp),%rax
 a65:	48 89 c7             	mov    %rax,%rdi
 a68:	e8 c7 00 00 00       	callq  b34 <_ZN1AD1Ev>    // 析构函数
 a6d:	89 d8                	mov    %ebx,%eax
 a6f:	48 8b 55 e8          	mov    -0x18(%rbp),%rdx
 a73:	64 48 33 14 25 28 00 	xor    %fs:0x28,%rdx
 a7a:	00 00 
 a7c:	74 05                	je     a83 <main+0x4b>
 a7e:	e8 4d fe ff ff       	callq  8d0 <__stack_chk_fail@plt>
 a83:	48 83 c4 18          	add    $0x18,%rsp
 a87:	5b                   	pop    %rbx
 a88:	5d                   	pop    %rbp
 a89:	c3                   	retq   

确实,优化后的代码只进行了一次构造和析构,并没调用拷贝构造或移动构造。

虽然编译器很多时候可以进行优化,但编译器优化不了的时候还是需要了解和运用移动语义才能写出更加高效的代码。

除了移动构造函数,移动赋值运算符应该一并给写出来。

    A &operator=(A &&rhs) noexcept {
        // check self assignment
        if (this != &rhs) {
            delete[] arr;
            arr = rhs.arr;
            rhs.arr = nullptr;
        }
        cout << "class A move and assignment" << std::endl;
        return *this;
    }

还有拷贝赋值构造函数:

    A &operator=(const A &rhs) {
        // check self assignment
        if (this != &rhs) {
            delete[] arr;
            arr = new int[1024];
            memcpy(arr, rhs.arr, 1024 * sizeof(int));
        }
        cout << "class A copy and assignment" << std::endl;
        return *this;
    }

小结一下移动构造函数和移动赋值函数的书写要诀:

  1. 偷梁换柱直接“浅拷贝”右值引用的对象的成员;
  2. 需要把原先右值引用的指针成员置为 nullptr,以避免右值在析构的时候把我们浅拷贝的资源给释放了;
  3. 移动构造函数需要先检查一下是否是自赋值,然后才能先delet自己的成员内存再浅拷贝右值的成员,始终记住第2条。

关于构造函数这部分有很多best practice :搜索“三五法则”、 “copy and swap”、 “move and swap” 了解详情


std::move()

std::move(lvalue) 的作用就是把一个左值转换为右值。

int lv = 4;      // lv 是个左值
int &lr = lv;    // 正确,lr是l的左值引用
int &&rr = lv;   // 错误,不可以把右值引用绑定到一个左值

如果使用std::move函数就可以把左值转换为右值,就能进行右值引用了:

int &&rr = std::move(lv);  // 正确,把左值转换为右值 

看一看 std::move 的源码实现:

// FUNCTION TEMPLATE 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);
}

可以看到std::move 是一个模板函数,通过remove_\reference_t获得模板参数的原本类型,然后把值转换为该类型的右值。用C++大师 Scott Meyers 的在《Effective Modern C++》中的话说, std::move 是个cast ,not a move.

值得注意的是: 使用move意味着,把一个左值转换为右值,原先的值不应该继续再使用(承诺即将废弃)


以 std::move 实现高效的 swap 函数

我们可以使用 move语义实现一个 交换操作,swap;
在不使用 Move 语义的情况下

swap(A &a1, A &a2){
    A tmp(a1); // 拷贝构造函数一次,涉及大量数据的拷贝
    a1 = a2;   // 拷贝赋值函数调用,涉及大量数据的拷贝
    a2 = tmp;  // 拷贝赋值函数调用,涉及大量数据的拷贝
}

如果使用 Move语义,即加上移动构造函数和移动赋值函数:

void swap_A(A &a1, A &a2){
    A tmp(std::move(a1)); // a1 转为右值,移动构造函数调用,低成本
    a1 = std::move(a2);   // a2 转为右值,移动赋值函数调用,低成本
    a2 = std::move(tmp);  // tmp 转为右值移动给a2
}

可以看到move语义确实可以提高性能,事实上, move语义广泛地用于标准库的容器中。C++11标准库里的std::swap 也是基于移动语义实现的。

说到了 swap, 那就不得不说一下啊 move-and-swap 技术了


Move & Swap 技巧

看下面一段代码,实现了一个 unique_ptr ,和标准的std::unqiue_ptr的含义一致,智能指针的一种

template <typename T> class unique_ptr {
    T *ptr;

  public:
    explicit unique_ptr(T *p = nullptr) { ptr = p; }

    ~unique_ptr() { delete ptr; }

    // move constructor
    unique_ptr(unique_ptr &&source) // note the rvalue reference
    {
        ptr = source.ptr;
        source.ptr = nullptr;
    }

    /*    unique_ptr& operator=(unique_ptr&& source)   // 这里使用右值引用
       {
           if (this != &source)    // beware of self-assignment
           {
               delete ptr;         // release the old resource

               ptr = source.ptr;   // acquire the new resource
               source.ptr = nullptr;
           }
           return *this;
       } */

    // move and swap  idiom replace the move assignment operator
    unique_ptr &operator=(unique_ptr rhs) // 这里不用引用,会调用移动构造函数
    {
        std::swap(ptr, rhs.ptr);
        // std::swap(*this,rhs)  // is also ok
        return *this;
    }

    T *operator->() const { return ptr; }

    T &operator*() const { return *ptr; }
};

在这里如果要按照常规办法写移动赋值函数,函数体内需要写一堆检查自赋值等冗长的代码。使用 move-and-swap语义,只用简短的两行就可以写出来。 在移动赋值函数中 source 是个局部对象,这样在形参传递过来的时候必须要调用拷贝构造函数(这里没有实现则不可调用)或者移动构造函数
,(事实上仅限右值可以传进来了)。然后 std::swap 负责把原先的资源和source 进行交换,完成了移动赋值。这样写节省了很多代码,很优雅。


完整代码

#include <iostream>
#include <stdio.h>
#include <string.h>

using namespace std;

class A {
  public:
    A() {
        arr = new int[1024];
        cout << "class A construct!" << endl;
    }

    ~A() {
        delete[] arr;
        cout << "class A destruct!" << endl;
    }

    A(const A &other) {
        arr = new int[1024];
        memcpy(arr, other.arr, 1024 * sizeof(int));
        cout << "class A copy!" << endl;
    }

    A(A &&other) noexcept {
        arr = other.arr;
        other.arr = nullptr;
        cout << "class A move!" << endl;
    }

    A &operator=(const A &rhs) {
        // check self assignment
        if (this != &rhs) {
            delete[] arr;
            arr = new int[1024];
            memcpy(arr, rhs.arr, 1024 * sizeof(int));
        }
        cout << "class A copy and assignment" << std::endl;
        return *this;
    }

    A &operator=(A &&rhs) noexcept {
        // check self assignment
        if (this != &rhs) {
            delete[] arr;
            arr = rhs.arr;
            rhs.arr = nullptr;
        }
        cout << "class A move and assignment" << std::endl;
        return *this;
    }

  private:
    int *arr;
};

A get_temp_A() { return A(); }

// void swap(A &a1, A &a2) {
//     A tmp(a1); // 拷贝构造函数一次,涉及大量数据的拷贝
//     a1 = a2;   // 拷贝赋值函数调用,涉及大量数据的拷贝
//     a2 = tmp;  // 拷贝赋值函数调用,涉及大量数据的拷贝
// }

void swap_A(A &a1, A &a2) {
    A tmp(std::move(a1)); // a1 转为右值,移动构造函数调用,低成本
    a1 = std::move(a2);   // a2 转为右值,移动赋值函数调用,低成本
    a2 = std::move(tmp);  // tmp 转为右值移动给a2
}

int main(void) {
    // A a, b;
    // swap(a, b);

    // A a = get_temp_A();

    A a, b;
    std::swap(a, b);

    return 0;
}

C++11 完美转发

讲了这么多左值右值和move语义,C++11正是利用它们解决了C++98解决不了的完美转发(perfect forwarding)问题,即实参被传入到函数中,当它被再传到另一个函数中,它依然是一个左值或右值,我们来看一个例子:

template <class T>
void f2(T t){
  cout<<"f2"<<endl;
}

template <class T>
void f1(T t){
  cout<<"f1"<<endl;
  f2(t);  //如果t是右值,我们希望传入f2也是右值
}         //如果t是左值,我们希望传入f2也是左值

//在main函数里:
int a = 2;
f1(3); //传入右值
f1(a); //传入左值

C++11之前的情况是怎么样的呢?当我们从f1调用f2的时候,不管传入f1的是右值还是左值,因为t是一个变量名,传入f2的时候都变成了左值,这就会造成因为调用T的拷贝构造函数而生成不必要的拷贝浪费大量资源,我们来看C++11如何解决这个问题:

template <class T>
void f2(T t){
  cout<<"f2"<<endl;
}

template <class T>
void f1(T&& t){    //这是通用引用,而不是右值引用
  cout<<"f1"<<endl;
  f2(std::forward<T>(t));  //std::forward<T>(t)用来把t转发为左值或右值,决定于T
}

这样当从f1调用f2的时候,调用的就是移动构造函数而不是拷贝构造函数,实现了完美转发,减少了资源浪费。

  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2021-10-22 10:47:24  更:2021-10-22 10:47:53 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 2:44:31-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码