C++左值与右值
首先普及一下学习多年C++没注意记忆的概念
左值:可以取地址、位于赋值符号左边的值,就记住,左值是表达式结束(不一定是赋值表达式)后依然存在的对象。左值也是一个关联了名称的内存位置,允许程序的其他部分来访问它的值。
有以下特征:
- 可通过取地址运算符获取其地址
- 可修改的左值可用来赋值
- 可以用来初始化左值引用
那些是左值?
- 变量名、函数名以及数据成员名
- 返回左值引用的函数调用
- 由赋值运算符或复合赋值运算符连接的表达式,如(a=b, a-=b等)
- 解引用表达式*ptr
- 前置自增和自减表达式(++a, ++b)
- 成员访问(点)运算符的结果
- 由指针访问成员(
->
)运算符的结果 - 下标运算符的结果(
[]
) - 字符串字面值("abc")
右值:指那些可以提供数据值的表达式(不一定可以寻址,例如存储于寄存器中的数据)。右值有可能在内存中也有可能在寄存器中。一般来说就是活不过一行就会消失的值。
那些是右值?
- 字面值(字符串字面值除外),例如1,'a', true等
- 返回值为非引用的函数调用或操作符重载,例如:str.substr(1, 2), str1 + str2, or it++
- 后置自增和自减表达式(a++, a--)
- 算术表达式(x + y;)
- 逻辑表达式
- 比较表达式
- 取地址表达式
- lambda表达式
auto f = []{return 5;};
简单讲右值不能取地址,左值能
纯右值与将亡值:右值的分类。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是C++11新增的、与右值引用相关的表达式,比如,将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型的转换函数的返回值等。
C++11中所有的值,都是左值、纯右值、将亡值其中一种
具名:顾名思义就是“有名字的”,比如变量、函数、类、名字空间等。
比较标准的解释就是:可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的(直接或间接获得的)地址
其特点如下:
- 标识符:每个具名实体都有独特的标识符,该标识符由字母、数字和下划线组成,以字母或下划线开头。
- 作用域:规定了具名实体在程序中的不同部分中的可见性和访问性。
- 类型:具名实体具有特定的类型。
- 存储位置:一旦具名,程序会为实体分配存储空间,可以是栈、堆或者静态存储区。
可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式。
临时对象:又称“无名对象”用于存储中间结果的实参,在求值结束后立即被销毁。一个最简单的例子——
int result = 2 * (3 + 4);
2*(3+4)这个表达式的结果就是用临时对象来存储的,然后临时对象再赋值给result
if(2*(3+4))
这样理解是不是更清晰一些呢,if括号内没有什么result,但还是隐式有一个什么东西储存括号内的计算结果,然后根据这个结果来判断。
临时对象在任何表达式求值类中出现(其实单个int值之类也算表达式),所以说函数返回值,传入实参等都有可能出现。
由于临时对象在发挥作用后立即被销毁,所以要避免临时对象的悬空引用
右值引用:我们都知道左值引用,int& p = q;
就是左值引用
而形如T&& k = getVar();
就是右值引用(T为类型)
右值引用一个最明显的表现,就是将右值的生命周期延长到和引用本身一样了。(这样就要避免导致前文提到的问题)。
左值引用其实也可以接受右值,不过非常量左值引用只能接受左值。
const A& a = GetA();
和右值引用一样,在C++11之前也可以用于性能优化。
对象的拷贝控制
C++11之前:拷贝构造函数、拷贝赋值运算符、析构函数
C++11之后+:移动构造函数、移动赋值运算符
拷贝构造函数:只有一个参数,就说本类的引用——最好加const,因为可以以常量对象为参数。
编译器会自动生成默认复制构造函数,这个默认复制构造函数会在启用时将被拷贝对象中的所有信息都打包(涉及浅拷贝,可见本章节对象和类部分)。
如果自行编写复制构造函数,默认复制构造函数会失效,这个时候起作用的复制构造函数可以做一些其他的事,比如将复制过去值+1,不过要谨慎对待因为自定义的复制构造函数只会进行自定义的语句。
#include<iostream>
using namespace std;
class A {
public:
int a1;
A() {};
A(const A& a) {
//a1 = a.a1;
//如果这一句被注释,那么对象b继承不了t.a1的数据,打印出来的将是一个未初始化的混乱数据,从这一点可以看出来自定义的复制构造函数只会运行自定义的语句(当然111也会被打印出来);如果这一句没被注释,那么数据a1会追寻形式参数实际参数追到对象t,于是在类外初始化的a1被复制,打印出正常的2;如果a1的初始发生在类里面,那么复制构造函数内如果没有对a1的特殊处理语句,就会直接将类内的数据默认初始化;如果这一个复制构造函数未被写出,那么编译器生成的默认复制构造函数会将对象所有数据复制,也就是会打印出2
cout << "111" << endl;
}
};
int main() {
A t;
t.a1 = 2;//a1的初始化在类外进行
A b(t);//对象b初始化的时候调用复制构造函数,用t的信息复制给b
cout << b.a1 << endl;
return 0;
}
复制构造函数的调用:1.当用一个对象去初始化同类的另一个对象时;2.作为形参的对象,是用复制构造函数初始化的;3.作为函数返回值的对象是用复制构造函数初始化 的
A b(t);
A b = t;//这两句等价
A b;
A t;
b = t;//这是直接赋值语句,和上面的不同,这一句不会调用复制构造函数,而是直接用b去给t赋值。
注意复制构造函数重点有”复制“和”构造“两个
void solve(A a){}
int main(){
A a;
solve(a);//这个实参a传入函数时会调用复制构造函数,所以说如果复制构造函数像上文提到的那样发生了自定义改变,那么这个实参a与传进solve函数内的形参a可能值就不一样了
return 0;
}
这个时候问题解决来了,如果将自定义复制构造函数的形参设置为const,那么函数内就无法对形参进行改变,任何发生改变的行为都会报错
class A {
public:
int a1 = 1;
A() {};
A(const A& a) {
a1 = Fun(a.a1);//有意思的是使用成员函数就不会报错
cout << "111" << endl;
}
int Fun(int a1) {
return a1 + 1;
}
};
const的水还是很深啊。。
第三点不给例子了,毕竟有的编译器甚至会将其优化掉,所以就不追究了
参考:C语言中文网
拷贝赋值运算符
上面提到过的用=进行初始化的操作中的=就算拷贝赋值运算符,但是注意这里所说的”拷贝赋值运算符“起作用的点不是这个,如果是用来初始化,那么=调用的是复制构造函数;如果是用来赋值,那么起作用的便是”拷贝赋值运算符“,也就是上文提到了那个”错误例子“
class A {
public:
int a1 = 1;
A() {};
A(const A& a) {
//a1 = Fun(a.a1);
cout << "111" << endl;
}
A& operator=(A& a){
//a1 = Fun(a.a1);输出为1
//如果没有这个注释,那么下面的输出就是6,道理和自定义复制构造函数是一样的,这里不做赘述
return *this;
}
int Fun(int a1) {
return a1 + 1;
}
};
int main() {
A a;
a.a1 = 5;
A b;
b = a;
cout << b.a1 << endl;
return 0;
}
说了这么多”自定义“的拷贝与赋值,那么什么情况下比较合适”自定义“呢,那就是我们要实现深拷贝的时候。
在类的拷贝构造函数中进行深拷贝:
- 分配新的内存空间,以保存要拷贝的对象的数据。
- 将原始对象的数据复制到新分配的内存空间中。
- 如果对象包含指针成员变量,还需要对指针指向的资源进行拷贝。
在类的赋值运算符(
operator=
)重载函数中进行深拷贝:- 首先释放目标对象(即赋值运算符左侧的对象)已有的资源,以避免内存泄漏。
- 分配新的内存空间,以保存要拷贝的对象的数据。
- 将原始对象的数据复制到新分配的内存空间中。
- 如果对象包含指针成员变量,还需要对指针指向的资源进行拷贝。
需要注意的是,如果类包含动态分配的资源(如堆上的内存、文件句柄等),则必须在深拷贝过程中进行适当的资源管理,以确保在对象的生命周期结束时正确释放这些资源,避免内存泄漏。
#include <cstring> // For memcpy
class MyClass {
private:
int* data;
int size;
public:
// 拷贝构造函数
MyClass(const MyClass& other) {
size = other.size;
data = new int[size];
memcpy(data, other.data, size * sizeof(int));
}
// 赋值运算符重载
MyClass& operator=(const MyClass& other) {
if (this != &other) {
delete[] data; // 释放已有资源
//为什么?想想初始化拷贝与赋值的区别
size = other.size;
data = new int[size];
memcpy(data, other.data, size * sizeof(int));
}
return *this;
}
// 析构函数
~MyClass() {
delete[] data; // 释放资源
}
};
在介绍移动构造函数之前,先浅入一下move函数
move函数
move函数简单地讲 ,就是将左值强制转换为右值引用,这个和一个强制转换是等同的——static_cast<T&&>(lvalue)。
move函数是一个“做不了实事”的函数,从结果上看它实现的”移动语义“和其他数值转移函数大差不差,只是全然用于性能优化。
上面我们聊过深拷贝的实现,可以发现其实非常复杂,而且对程序的性能影响非常大。如果在一些场合下,我们可以直接将被拷贝的对象资源直接移动到目标对象上,从而避免了资源的大量浪费。
深拷贝中,我们往往需要先建立一块内存区域,然后用这块内存区域存储数据。不过大部分情况下,我们不需要原对象的内存,这个时候使用移动语义更加合适,它直接将被拷贝对象的数据包括内存直接移动给目标对象,从而避免了新内存区域的建立
移动构造函数
上面说的move实际上并不能移动任何东西,他只是强制将一个左值变为右值引用。对于对象之间的“移动”,就是由”移动构造函数“去具体实现的,而移动构造函数本身就是由右值引用而触发的,这样就形成了一个闭环,最终满足的结果就是让”对象“有了右值的性质。
对于基本类型,move的意义并不大,使用后仍然会发生拷贝(因为没有移动构造函数)。
下面请看这个例子
别看注释,gpt胡言乱语
#include <iostream>
using namespace std;
class A
{
public:
A() :m_ptr(new int(0)) { cout << "construct" << endl; }
/A(const A& a) :m_ptr(new int(a.m_ptr)) //深拷贝的拷贝构造函数
{
cout << "copy construct" << endl;
}/
~A() { delete m_ptr; }
private:
int m_ptr;
};
A GetA()
{
return A();
}
int main() {
A a;
a = GetA();
return 0;
}
运行函数时发生了一个难绷的事
a = GetA()
GetA()本身是临时变量,在结束时自动销毁,调用了析构函数将指针内存释放,但由于是浅拷贝,在main函数结束的时候再次遭到释放,就发生了如此……好吧其实没发生,因为我的编译器自动优化了代码,使GetA返回值临时对象省略掉了,但读者还是最好默认会发生错误(得亏我试了好长时间想方设法让编译器出错)
这是一个没做深拷贝的典型例子,如果类内含栈内存数据,那很容易会发生这样的情况。但都说过了深拷贝很费计算机,而且还会影响性能,那咋办嘛。更何况堆内存很大很大的情况(这个反而经常发生),拷贝代价会异常之大,那有什么解决办法吗?
下面看这个例子
class A
{
public:
A() :m_ptr(new int(0)){}
A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷贝的拷贝构造函数
{
cout << "copy construct" << endl;
}
A(A&& a) :m_ptr(a.m_ptr)
{
a.m_ptr = nullptr;
cout << "move construct" << endl;
}
~A(){ delete m_ptr;}
private:
int* m_ptr;
};
A GetA()
{
return A();
}
int main(){
A a = GetA();
}
哎不想跑了,编译器总是把代码给优化了看不到效果
反正就是程序并没有调用拷贝构造函数,它利用移动语义直接将指针的所有者转移到了另外一个对象,避免了深拷贝的发生
TODO:模板函数的完美转发
TODO:lambda表达式