参考:
对象和变量
左值性(lvalueness)在C/C++中是表达式的一个重要属性。只有通过一个左值表达式才能来引用及更改一个对象(object)的值。(某些情况下,右值表达式也能引用(refer)到 某一个对象,并且可能间接修改该对象的值,后述)。
何谓对象?如果没有明确说明,这里说的对象,和狭义的类/对象(class/object)相比 ,更为广泛。在C/C++中,所谓的对象指的是执行环境中一块存储区域(a region of storage),该存储区域中的内容则代表(represent)了该对象的值(value)。注意到我们这 里所说的"代表",对于一个对象,如果我们需要取出(fetch)它的值,那么我们需要通过 一定的类型(type)来引用它。使用不同的类型,对同一对象中的内容的解释会导致可能得 到不同的值,或者产生某些未定义的行为。
在介绍左值之前,我们还需要引入一个概念: 变量(variable)。经常有人会把变量与 对象二者混淆。什么叫变量?所谓变量是一种声明,通过声明,我们把一个名字(name)与 一个对象对应起来,当我们使用该名字时,就表示了我们对该对象进行某种操作。但是并 不是每个对象都有名字,也并不意味着有对应的变量。比如临时对象(temporary object) 就没有一个名字与之关联(不要误称为临时变量,这是不正确的说法)。
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];
char(*p)[10] = &a;
const char *p = "hello world";
char(*p)[12] = &"hello world";
struct S { int a : 2; int b : 8; };
struct S t;
int *p = &t;
int *p = &t.a;
register int i;
int *p = &i;
int a, b;
int *p = &(a + b);
因此,判断一个表达式是不是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 的左值和右值
-
与C不同的是,C++中,对于register变量,C++允许对其取址,即: register int i;
&i;
这种取址运算,事实上要求C++编译器忽略掉register specifier 指定的建议。无论在C/C++中,register与inline一样都只是对编译器优化的一个建议,这种建议的取舍则由编译器决定。 -
C++中引入了引用类型(reference),引用总是引用到某一对象或者函数上,相当于对其引用的对象/函数进行操作,因而引用类型的表达式总是左值。所以说,如果函数f()的返回类型为int&,则表达式f()等价于左值表达式;如果函数g()的返回类型为int,则表达式g()为int类型的右值表达式。但是,C语言中函数调用的返回值总是右值的。 -
C++中前缀++/–表达式、赋值表达式都返回左值,但后缀++/–还是不能作为左值。 int a = 0;
++a += 1;
a++ = 1;
-
逗号表达式的第二个操作数如果是左值表达式的话,逗号表达式也是左值表达式。 int x, y;
y = (x = 3, x * x);
(x = 3, y = x * x) = 1;
-
条件表达式(? :)中,如果第2和第3个操作数具有相同类型,那么该条件表达式的结果也是左值的。 int x = 5, y = 3;
int a = x < y ? x : y;
x < y ? x : y = 10;
C++11 右值引用
C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:
int num = 10;
int &b = num;
int &c = 10;
如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。
…
右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。(C++标准委员会在选定右值引用符号时,既希望能选用现有 C++ 内部已有的符号,还不能与 C++ 98 /03 标准产生冲突,最终选定了 2 个 ‘&’ 表示右值引用。)
和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:
int num = 10;
int && a = num;
int && a = 10;
右值引用还可以对右值进行修改:
int &&a = 10;
int &&b = 10;
a = 100;
cout << a << endl;
cout << b << endl;
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>
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 {
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) {
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;
}
小结一下移动构造函数和移动赋值函数的书写要诀:
- 偷梁换柱直接“浅拷贝”右值引用的对象的成员;
- 需要把原先右值引用的指针成员置为 nullptr,以避免右值在析构的时候把我们浅拷贝的资源给释放了;
- 移动构造函数需要先检查一下是否是自赋值,然后才能先delet自己的成员内存再浅拷贝右值的成员,始终记住第2条。
关于构造函数这部分有很多best practice :搜索“三五法则”、 “copy and swap”、 “move and swap” 了解详情
std::move()
std::move(lvalue) 的作用就是把一个左值转换为右值。
int lv = 4;
int &lr = lv;
int &&rr = lv;
如果使用std::move 函数就可以把左值转换为右值,就能进行右值引用了:
int &&rr = std::move(lv);
看一看 std::move 的源码实现:
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
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 = std::move(a2);
a2 = std::move(tmp);
}
可以看到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; }
unique_ptr(unique_ptr &&source)
{
ptr = source.ptr;
source.ptr = nullptr;
}
unique_ptr &operator=(unique_ptr rhs)
{
std::swap(ptr, rhs.ptr);
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) {
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 {
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(A &a1, A &a2) {
A tmp(std::move(a1));
a1 = std::move(a2);
a2 = std::move(tmp);
}
int main(void) {
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);
}
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));
}
这样当从f1调用f2的时候,调用的就是移动构造函数而不是拷贝构造函数,实现了完美转发,减少了资源浪费。
|