一段适合初学者的引入
-
面向对象和面向过程是一个相对的概念
-
面向过程是按照计算机的工作逻辑来编码的方式,最典型的面向过程的语言就是c语言了,c语言直接对应汇编,汇编又对应电路
因为严格按照计算机的工作逻辑,就必须服从内存层面的限制。而变量存放在栈或堆区,函数存放在代码区,导致体现数据封装的结构体和逻辑封装的函数,必须分开声明,所以无法直接实现类。这是面向过程的本质
-
面向对象则是按照人类的思维来编码的一种方式,C++就完全支持面向对象功能,可以按照人类的思维来处理问题
-
举个例子,要把大象装冰箱,按照人类的思路自然是分三步,打开冰箱,将大象装进去,关上冰箱
要实现这三步,我们就要首先有人,冰箱这两个对象。人有给冰箱发指令的能力,冰箱有能够接受指令并打开或关闭门的能力
但是从计算机的角度讲,计算机只能定义一个叫做人和冰箱的结构体。人有手这个部位,冰箱有门这个部位。然后从天而降一个函数,是这个函数让手打开了冰箱,又是另一个函数让大象进去,再是另一个函数让冰箱门关上。
面向过程每一步都是上帝在操作数据,而面向对象把动作下放到了数据上
从维护角度,你更愿意开发一个全知全能的上(shi)帝(shan),还是开发一群独立个体呢
从开发者的角度讲,面向对象显然更利于程序设计。用面向过程的开发方式,程序一旦大了,要从天而降引入一个函数,想想都头大,一些纯c写的大型程序,也是模拟了面向对象的模式
那么,如何用面向过程的c语言模拟出面向对象的能力呢?类就诞生了,在类中可以定义专属于类的函数,让类有了自己的动作。回到那个例子,人的类有了让冰箱开门的能力,冰箱有了让人打开的能力,不再需要天降神秘力量了
构造函数和析构函数
我们从如何将结构体变成类出发,毕竟高级的东西也得从机器本身出发
结构体本身只是划定一块连续内存给若干变量,如何将这一块内存组织成一个整体而非单纯的一块内存呢?
构造函数
对象生成时就会调用来初始化,可设定参数
即使没有定义,类也至少有
默认构造函数
和默认复制构造函数
(还有默认赋值运算符
)
-
默认构造函数(无参,当无构造函数时,会生成一个,且不执行内存初始化)
Test() {} // 按理说就长这样,但如果你自己写出来了,那也不能算默认了,归为自定义的普通构造函数 (文字游戏)
-
普通构造函数(人为定义的且不为特定参数)
Test(int i1_, int i2_): i1(i1_), i2(i2_) {} // 初值列,默认学过
-
复制构造函数(参数为同类引用常量,不定义时也会有一个,默认执行变量浅拷贝逻辑)
Test(const Test& test): i1(test.i1), i2(test.i2) {}
- 如果参数没有修改,建议加上
const
- 复制构造必须传引用,否则仍然需要通过复制来传值,又要调用复制构造,就是死循环
- 如果参数没有修改,建议加上
-
移动构造函数(后面会详解,简单说就是:不是复制,而是剪切,类似转交内存,更加高效)
析构函数
对象销毁时就会调用,无参无返回值不可 const,故不可重载(但可以多态)
-
当需要手动善后时,就需要定义析构函数。如果不定义,也会生成一个空析构,里面啥也不干
释放指针只是一个简单的例子,到了后期,网络和 IO 流等,必须充分利用好析构的机制
// 例: 必要的手动析构 class Test{ public: int* pi; Test(int i) : pi(new int(i)) {} Test(const Test& test): pi(new int(*test.pi)) {} ~Test(){ delete pi; } };
this 关键字,常成员函数和常对象
this 是一个很重要的关键字,而后两个概念也与之相关,所以特地放在一起,看完就懂了
this 关键字
this 关键字并不陌生,在类方法中表现为指向当前对象的指针
类函数访问属性,实际上都是隐式使用了
this -> 属性
,不过绝大多数情况下可以省略少数情况比如变量同名时就不行,this 可以告诉函数,调用函数的是某一个对象
如果 this 真是指针,那不就揭示了用 C 语言实现结构体和函数绑定的奥秘?
如果你这么想,那还为时尚早
写到这里有感而发,建议读的时候略过:
说到 this 就有 this 底层是指针啥的以及衍生的说法
但是,学习 C++ 最重要的一点是,作为 C++ 的开发者,如果干的不是底层的活,不需要底层的优化和设计思路,就请专注于已搭建好的上层特性
别把编译器奉为圣经,不断地把底层和 C++ 特性直接挂钩,请问这个过程中你得到了什么?就像用物理解释化学(不一定恰当)一样,走进失去 C++ 本身意义的死胡同。以此类推,任何语言都会失去意义,因为最后都是 C 语言
学习 C++ 乃至整个编程的体系,都有一个重要的东西叫做透明,它的直接目的就是防止底层和上层的设计思路用串了、产生不必要的等效替代。于是开发者才能专注于自己正在做的东西
当然不是说不要看底层实现,只不过这和学习语言本身并不是一回事,类似先射箭后画靶的感觉。拿个底层实现说自己学会了、看透了 C++ 是不明智的,也不能说是错的,只能说这东西不存在绝对的定律
还记得一开始说的吗?this
是关键字
既然是关键字,便不能等同于指针变量,而应该说是指针特性的超集
当然,代码在编译期间,
this
的指针职能多半还是通过一个指针实体实现但,这时候的代码已经不是 c++ 了,与 c++ 语法又有什么关系呢
常成员函数
C++ 类成员属性也是结构体,成员函数能通过 this 或指针操作属性,有时是不安全的
对于类指针,我们可以直接定义
const Test* const
保证属性的安全,那么我拿出this
阁下又该如何应对?于是便有了常成员函数
Test::func() const {}
。虽然不严谨,但中间的 const 可以理解为对this
的限制很喜欢 C++ 的一点,学了一段时间就会发现,有些语法是针对另一个个语法的漏洞打的补丁(笑,无恶意)
除了限制 this
外,为了防止打破常函数的安全性,常函数内不能调用非常函数,反之则可以
- 不论是 IDE 还是前辈,都会建议你:不修改属性的成员函数一律设为常函数(属性同理,无所谓,波浪线会出手)
从常函数的角度看,构造和析构修改了属性,静态成员函数没有
this
,因此都不能设为常函数
-
PS:同名同参数单纯只加个
const
也算重载,且,普通对象会优先调用普通重载,常量对象优先调用常重载关于重载,个人认为比较确切的定义是:保持名字一致情况下,新的函数会对外界产生不同的影响
根据这条定义,符合重载的形式有:
- 指针 ↔ 指针常量
- 函数 ↔ 常函数(可以归入 1)
- 参数不同(在参数变量前加 const 其实和换个参数名一样,没意义,因为不会向外界反馈不同结果)
常对象
如果整个对象都没有修改需求,按照常变量原则,可以直接创建常对象,也没啥可以多说的
常对象是完全不变的,为了安全,常对象不能调用非常函数
-
常对象作为一个不变的整体,少了很多要考虑的逻辑,会被编译器显著优化,如果可以的话,多用常对象
回顾过去的代码,其实很多对象都是当成打包的常量+配套函数使用的,所以这是强烈建议养成的好习惯
几个关键字
inline 内联
在早期 C++ 程序中,执行函数需要新开栈,造成一定开销,而
inline
关键字可以在编译时将函数内部逻辑直接放到外部(如果可以实现的话),减少一层栈但如今,inline 导致的外部程序过大,带来的缺点可能大于减少栈的优点,所以是可选项(现代编译器,即使加了
inline
也会自动判断是否合适)以下代码展示了
inline
的几个特性
class Test{
public:
void fun(){
cout << "fun()" << endl;
}; // 直接在类中声明且定义,自动视为inline (因为这样的函数一般都很短,适合内联)
void gun(); // 声明处不必加inline,但可以增加可读性
}
inline Test::gun(){ // 定义处必须加inline
cout << "gun()" << endl;
}
mutable 可变
mutable
修饰的变量可以无视函数的const
限制被修改,如常成员函数但
mutable
并不是类型的一部分,是让非常成员变量,在对象视为const
时不受影响这关键字意义不明,其唯一指定用途,就是统计常函数的调用次数,此外都是野蛮的,都是在背刺前面写的内容
- 注意:
mutable
是针对某一个非常对象的,因此不能修饰常成员变量和不属于对象的静态成员变量
default 默认
default
用赋值语法为某些成员函数指定默认函数体(包括默认构造,复制构造,operator=
和析构函数)用在其他无默认形式的函数会报错
default
的意义是:既能显式定义默认函数,又十分简洁,从两重意义上提高了代码可读性
class Test{
public:
Test() = default;
Test(Test& test_) = default;
Test& operator=(Test& test_) = default;
~Test() = default;
}
delete 取消
delete
用于取消函数的定义,从而不被编译,可以用于各种成员函数
delete
可以取消编译器自动生成的函数,如默认成员函数老四样,以及取消数值型参数的隐式重载例如:通过取消复制构造实现单例模式
- 注意:析构函数不要
delete
,虽然语法不会阻止这样做
class Test{
public:
Test(Test& test_) = delete; // 禁止复制构造
void fun(int i){}
void fun(double i) = delete; // 可以阻止参数从double到int的隐式转换,但用处不是很大
}
友元
在类中声明 友元类 / 友元函数,被声明者可以直接访问该类的私有成员
- 如果搞不清关系,很简单,我对某个目标声明友好,所以我对他开放我的私有成员
问题是,只是为了访问私有成员,友元还会破坏封装性,完全可以用
getter
和setter
封装实现所以
friend
除了某些特定场合(流运算符<<
>>
重载)都没用友元没有公有私有概念,可以声明在类中任何位置,语法展示如下 ↓
class Test{
friend class Tool; // 友元类(class也可以隐式)
friend void fun(int); // 友元函数(外部函数)
friend void Tool::fun1();
friend Tool& Tool::operator=(const Tool&); // 这两行表明(应该)什么函数都能声明成友元
}
冷知识:一个类对自己可以视作友元(便于理解),总之可以随便访问同类对象的私有成员
运算符重载
C++ 为类提供了 “自定义运算符行为” 这一高度灵活的功能,统一以
operator运算符
为函数名的形式实现重载运算符在后期是每个类的标配
注意:
-
不能重载 C++ 不支持的运算符,比如 python的 **
-
要了解运算符的元数。或者说,运算符用法决定了参数和返回值的定义
-
比如返回值和调用者同类型,才可以支持连续使用运算符(很多运算符都有)
-
比如数值意义的运算符,一般更适合返回临时对象值,而非引用(并非性能原因,更多是安全原因)
-
唯一的三元运算符
?:
无法重载,所以都是一元和二元
-
-
=
有默认重载 -
运算符重载不一定是成员函数,也可以是外部函数,首个参数是指定调用的主体,来取代成员函数的隐式
this
,在外部重载后,其他类一般可以将其声明为友元函数,并使用 -
除了运算符本身的语法,也可以当作函数,直接
.operator运算符(参数)
来使用(我只能说一般很少这样干)
一元运算符
++
--
[]
()
<<
>>
其中
<<
>>
特殊,因为流运算符的调用主体必须是流对象,而成员函数的调用主体都是this
所以重载不能成为当前类的成员函数
又不好直接修改标准库的流类,所以就作为当前类的友元在外部重载了,没有
this
,第一个参数为流引用二元运算符
+
-
*
/
=
>
<
==
类型转换运算符
operator 类型
特殊运算符
new
delete
new[]
delete[]
class Test{
// 流对象会改变,不能为常量
friend std::ostream& operator<< (std::ostream& os, const Test& test);
friend std::istream& operator>> (std::istream& is, Test& test);
public:
std::string name;
void operator++(){}
void operator()(const std::string& str) const {
std::cout << str << std::endl;
} // 参数作仿函数参数
int operator[](unsigned i){}
Test& operator= (Test& test){
if(this == &test){
return *this; // 根据地址判断自赋值
}
name = test.name;
return *this;
}
}
std::ostream& operator<< (std::ostream& os, const Test& test){
os << test.name << std::endl;
return os;
} // 定义该类的输出规则
普通继承
继承,面向对象三大特性之一,一方面可以抽象共性便于编程,一方面可以在类之间建立联系,便于边扩展边维护
- 有过简易游戏引擎的经历,所以对此深有体会
关于继承的种类,简单说就是限定了子类继承成员时的权限上限,高于此关键字的都会降权限
- 补充一句:
protected
同样不可被外界访问,但权限略高一级,相当于可以被继承的private
public
的结果是不改变任何权限,也是一般(99%)情况使用的
protected
private
在业务代码里因为加了多余的访问限制,基本是不考虑的,但在标准库中利用访问权限的特性设计模板类和函数,使用率就显著上升
class Spear{
public:
std::string name;
}
class FireSpear: public Spear{
public:
void attack();
}
- 冷知识:继承在内存中的体现,子类的内存块头部是父类的结构,紧接着是子类的结构,因此解释了以下特性:
- 子类的构造,会先进行父类的构造,再构造自身
- 如果子类构造中没有调用父类构造,则使用父类默认构造。此时如果父类默认构造被取消了,会无法创建子类对象
- 子类继承了父类的构造,所以子类的默认构造要手动补上,不会自动生成
- 子类的析构,会先析构自身,再调用父类的析构
- 父类的指针,可以指向子类的对象,且因为指针的类型是父类,只能控制属于父类的那部分
多态,虚函数,override 关键字
多态
继承解决了变量的抽象,但父类的抽象与子类对函数的的扩展产生了矛盾。当使用父类指针指向子类对象时,可以使用子类的属性,但不能使用子类重写的函数
C++ 提供了虚函数与
override
(重写),实现了从父类指针接受不同子类对象调用不同逻辑
// 示例: Spear的子类可以重写attack()方法, 调用自己的方法后释放内存(或使用智能指针)
void attack(Spear* p_spear){
p_spear -> attack();
delete p_spear;
}
虚函数
形式:
virtual 函数声明
编译时,父类指针调用函数的语句,会找到父类中的声明,根据其是否为
virtual
绑定到指定函数体
子类重写普通函数,调用的函数体由指针类型决定(父类函数)
子类重写虚函数,调用的函数体由指针实际指向的对象决定(子类函数)
注意:
-
重写务必保证函数名,类型,
const
等完全一致。但因为虚函数检查在父类,子类可以选择省略virtual
-
父类的析构函数必须为虚函数,因为要保证子类销毁时一定调用子类的析构,不能被父类指针影响而泄露了子类部分的内存(而且排查起来也麻烦,血泪教训)
代码中的析构和
virtual
经常是成对出现,毕竟自己写析构经常意味着手动管理内存
虚函数原理
静态绑定 & 动态绑定
编译时,每个函数调用要绑定到一个跳转地址
静态绑定:没有多态的普通函数,始终绑定在固定地址
动态绑定:对于重写虚函数,如何实现调用时绑定到具体的不同地址?C++ 实现了一种类似类型检查的功能
编译期,使函数调用绑定到对象虚函数表的内容上,由对象的虚函数表来决定函数地址
虚函数表
涉及动态绑定的类,每个类有一个虚函数表,存放了它的虚函数的实际地址,可以说是一个指针数组
虚函数表于编译器生成,位于进程内存的数据区
该类每个对象有一个虚函数表指针,位于其内存块头部,所有成员变量之前,指向该类的虚函数表
因此,父类指针指向的子类对象,其虚函数表为子类的虚函数表,调用的函数地址为表中的实际函数地址
override
C++11 提供的类似注解的关键字,①辅助程序员检查函数重写是否正确,②提高代码可读性
virtual void attack() const override {}
静态成员变量和静态函数
静态成员变量
静态成员变量与静态外部变量同属于静态变量区,具有静态变量的内存特性,但须体现封装性
因为编译期间就要初始化,静态成员变量必须在类外初始化,通过类名进行
静态成员变量有超长的生命周期,只要权限够都能访问,即使没有对象。便有
静态成员变量属于该类
的说法或者说
属于该类和所有对象公有
,对象和非静态成员函数也都能访问(只要权限够)
静态成员函数
静态变量诞生的意义是为类和所有对象公有的变量
所以静态函数诞生的意义是为类和所有对象公有的封装性(熟悉一下特殊语法,反正也不是总用到)
由于每个对象对静态成员函数,仅有使用权但没有所有权,也可以说
静态成员函数也属于该类
而且静态成员函数无法具有
this
以至于其无法隐式访问所有非静态成员变量,只能访问静态成员变量;也不能定义为常函数
class Test{
public:
static unsigned i;
static int get_pi(){
return pi;
}
private:
static int pi;
};
unsigned Test::i = 20; // 类外初始化
int Test::pi = 20;
int main(){
std::cout << Test::i << std::endl; // 公有静态变量,不利于封装性
std::cout << Test::get_pi() << std::endl; // 通过函数访问私有静态变量值
}
纯虚函数
如果一个类只有虚函数,可以直接声明
函数声明 = 0
成为纯虚函数,此类称为纯虚类纯虚函数无需在基类中实现函数体,全在子类中实现。纯虚类也被规定不能实例化,只能使用指针
纯虚函数的使用频率还是很高的。这种类在 Java 里被单独拎了出来,叫做接口
class Test{
public:
virtual void action() const = 0; // 纯虚函数的声明(可以不实现)
}
RTTI 运行时类型识别
输入一个对象,输出对象类型相关信息
可以用于指针或引用目标不确定的场合,是 C++ 判断当前父类指针指向实际类型的唯一方法
使用场景:①异常处理;②IO 操作
方法:
-
typeid(对象)
返回一个type_info
结构体,包含类型相关的各种信息,以及name()
方法返回类型字符串 -
使用
dynamic_cast<子类指针类型>(指针)
可以将父类指针转换为子类指针,仅当父类指针指向子类对象时可行,转换失败返回nullptr
冷知识:C++11引入
nullptr
的原因:-
C++中
NULL
的定义局限性是 C 的历史遗留问题:宏定义以及传统类型 -
C 语言中的宏定义
NULL
为(void *)0
,由于 C 的精髓是接近硬件,指针给的限制少,也没有重载语法,因此NULL
可以正常用于各种指针的空数据 -
随着 C++ 的指针限制,类型的问题体现出来:
C++ 不接受不安全的
void
向任何类型的隐式转换,NULL
值不能传给其他类型指针,更不能传给普通变量,所以就废了 -
于是,C++ 选择了直接定义
NULL=0
这一权宜之计,但 C++ 支持重载,带来第二个隐患:- 参数类型歧义(当参数仅符合数量但无匹配类型时,依然会尝试转换类型),而此时
NULL
默认为整数
- 参数类型歧义(当参数仅符合数量但无匹配类型时,依然会尝试转换类型),而此时
-
最终 C++ 用自己的方法解决问题,保留了 NULL 为 0,定义了
nullptr
专用于不会被转换的空指针- (猜猜怎么实现类型转换?我觉得模板可以一战,不过
nullptr
本身是个关键字。这里放一段后来找到的模拟nullptr功能的代码)
struct nullptr_t { // 定义一个函数指针转换操作符,可以将 nullptr_t 转换为任何类型的空指针 template
operator T*() const noexcept { return 0; } // 这是一个辅助的成员模板,用于解决nullptr_t到任意类型的转换问题 template operator T C::*() const noexcept { return 0; } }; nullptr
主要是替代宏定义,内部逻辑说到底还是解决 0 的转换(偶尔用打补丁的思路看语法,就不难
- (猜猜怎么实现类型转换?我觉得模板可以一战,不过
-
注:
-
typeid
支持对基本类型的判断,这个一般编译时就被优化了只有多态的指针,才会在运行时实时判断类型。传入
nullptr
时会出现 bad_typeid 异常 -
typeid().name()
输出形式可能因编译器而异,如 msvc 输出就是声明类型,如int
struct A
class B
-
dynamic_cast
仅可用于继承关系转换,非强制转换
int main(){
Spear* p_spear = new Spear();
// 转成标准 string 来比较
if(std::string(typeid(*p_spear).name()) == "class FireSpear"){ // 获取结果: class 类名
FireSpear* p_fire_spear = dynamic_cast<FireSpear*>(p_spear); // 向下转换为该类型指针
if(p_fire_spear){ // 非nullptr
// 使用指针, 略
}
}
}
基本操作很简单,后面看看实际应用
多继承
多继承的类,与继承同理,其内存头部依次排列各个基类的成员变量
执行构造也是按继承顺序最后到自己,析构也是从自己开始反过来逐个析构
class Base1{
public:
Base1(int i1_): i1(i1_) {}
int i1;
}
class Base2{
public:
Base1(int i2_): i1(i2_) {}
int i2;
}
class Test: public Base1, public Base2{
public:
Test(int i1_, int i2_, int t_): Base1(i1_), Base2(i2_), t(t_) {} // 在初始化列表中构造两个父类
int t;
}
注意:
- 多继承可能从不同父类中重复继承了相同的变量,调用时产生二义性
- 因为是直接继承多个类,分析起来较为复杂(实际上多继承主要应用就是接口模式,反观 Java 取消多继承而定义了接口,与类区分开来,使得运行时类型检查也更方便)
虚继承
菱形多继承会产生相同变量二义性问题,子类中同时存在来自两个继承路径,最终源自同一个基类的变量
如下图,
Base::i
在内存中有两份,访问就会歧义graph TD Base Base --> Base1 Base --> Base2 Base1 --> Test Base2 --> Test
这里的歧义,特指
Base1
Base2
中同继承于Base
的变量如果只是各自定义的同名变量,并不会歧义,因为可以用
t.Base1::i
形式区分来源
- 这种情况在游戏开发中出现较多,例如一个实体类可能继承自多个标签,而不同标签可能继承了相同的纯虚类
原理:
-
当一个类使用虚继承,会用一个虚继承表存放父类成员变量的偏移地址(?存疑)
虚继承的类被继承时,会逐一比对偏移地址,从而保证同源的变量在目标子类中只继承一份
-
注意,虚继承应发生在变量被歧义继承时,需要定位歧义的变量,在子类声明中使用
virtual
继承,如下:
class Base0{
public:
int i = 0;
};
class Base1: virtual public Base {
public:
Base1(const int i) : i(i){}
int i = 10;
};
class Base2: virtual public Base {
public:
Base2(const int i) : i(i) {}
int i = 20;
};
// 先保证根源类的子类都是虚继承,他们才能为子类提供不重复的成员变量
class Common: public Base1, public Base2 {
public:
Common(const int i): Base1(10), Base2(20), i(i) {};
int i = 30;
};
Common common(0);
std::cout << common.Base0::i << std::endl; // 0
移动构造函数和移动赋值运算符
移动是一种类似剪切的操作
移动赋值和拷贝不另外构造对象,而是直接将对象内存的访问权限转交(在 Rust 中也体现的淋漓尽致)
适用于没有保留需求的数据转移,将被复制的对象作为右值,对于大型类,可以巨大提高性能
当对象被移动给另一个对象后,原有的对象就不能再使用了
class Test
{
public:
Test() = default;
Test(const Test& test){
if(test.str) {
str = new char[strlen(test.str) + 1]();
strcpy_s(str, strlen(test.str) + 1, test.str);
}else {
str = nullptr;
}
}
Test(Test&& test) noexcept{
if(test.str) {
str = test.str;
test.str = nullptr;
}else {
str = nullptr;
}
}
Test& operator=(const Test& test){
if(this == &test) {
return *this;
}
if(str) {
delete[] str;
str = nullptr;
}
if(test.str) {
str = new char[strlen(test.str) + 1]();
strcpy_s(str, strlen(test.str) + 1, test.str);
}else {
str = nullptr;
}
return *this;
}
Test& operator=(Test&& test) noexcept{
if(this == &test) {
return *this;
}
if(str) { // 赋值非构造,要先清理现有字符串
delete[] str;
str = nullptr;
}
if(test.str) {
str = test.str;
test.str = nullptr;
}else {
str = nullptr;
}
return *this;
}
private:
char* str = nullptr;
};
补充:
strlen( )
返回的字符串长度不包括末尾字符,所以要 + 1
strcpy_s(Dest, length, Src)
是strcpy
的安全版本,相比后者增加了长度,保证复制时,有足够大缓冲空间存放被复制的内容
- 以上两个函数都不属于
namespace std
暂时不用的指针要赋
nullptr
防止悬空,指针转移时要及时释放旧内存(这个需要多写熟练)因为要将目标指针置 null,所以参数不能为 const
默认移动构造 / 默认移动赋值
当一个类没有任何自定义拷贝构造和赋值,且非静态成员都可移动,系统会生成默认移动拷贝
- 注:一定是没有任何,定义了赋值也会影响生成默认移动构造(直接隐式 delete)
可移动
可移动即支持移动语义
基础类型都可移动,有移动语义的类也可移动
关于移动语义的补充
移动语义是 C++11 的重要革新,同时将右值的定义与移动语义结合起来
-
移动语义和所有权是干嘛的
引用知乎上的话:移动语义的诞生,就是方便区分这个到底是转移所有权,还是进行复制
在 C++11 以前,转移数据的方式还很原始,没有一步到位的所有权转移,只有复制+销毁的原始方法
因此产生的性能浪费有几个典型的例子:
-
函数的返回值对象被赋值,其中的所有权转移,需要调用复制构造
-
STL 容器的转移,除了本体外,每个元素都要调用复制构造
显然这些复制都是虚假的复制,都是复制+销毁,本质分明是所有权的转移,完全可以在底层改为指针的移动
这就是移动语义的初衷,也是为何右值是“抛弃了”地址属性的值,因为不需要
-
-
移动语义真的“物理”移动了对象吗
事实上,我们一直强调的是,不允许再使用被移动的对象,以及在实现移动构造时,都会手动将旧对象置 null
实践证明,所有权显然不等于数据本身,被移动的对象确实仍然能用(很符合我对 C++ 的想象)
STL 容器以及智能指针等类,也都是实现好了移动的逻辑,比如智能指针会置空指针,而且也仍可以调用函数
所以这是一个君子协议。如果自定义类的移动构造没有置 null,或者干脆是移动基础类型,就更能体现这一点了