读书笔记(三十二) 《C++ Primer》#5
背景:
我为什么要重学C++?第一是巩固核心,软件编程有三大核心,语言、操作系统、框架设计,语言首当其冲,核心能力大都看不见摸不着只有你自己知道的东西。第二是将分散的知识串联起来,这样就能产生新的知识和新的创意。第三是当我们在巩固时创造出了新的知识,那么这些新的知识和旧的知识将同时变成智慧融合到身体中。
本系列基于《C++ Primer》学习,由于重点放在“重学”,我略过了滚瓜烂熟的部分,挑出以前常忽略的部分,以及记忆没有那么深刻的部分,特别是那些重要的但没有上心的部分。
正文:
关联容器
关联容器包括map、set以及相应的扩展容器,包括可容纳重复关键字的multimap和multiset容器,以及用unordered为前缀的无序容器。
注意,对map使用下标操作时,如果关键字在容器中不存在,则会添加一个关键字在容器中。
无序容器大都使用哈希方式来做查找工作,它在存储上组织为一个数组,我们可以比喻为桶管理,每个数组中的元素为桶,它保存0个或多个元素,用哈希函数将元素映射到桶中,当一个桶保存多个元素时,需要顺序搜索这些元素来查找正确的那个。因此无序容器的性能通常依赖于哈希函数的质量和数组的大小。
智能指针
新的标准库中有两种智能指针,一种允许多个指针指向同一个对象shared_ptr,另一种则“独占”所指向的对象unique_ptr,标准库还提供了一个名为weak_ptr的弱引用方式,来指向shared_ptr所管理的对象。
其实智能指针也是模版,它通过模版并重载=、+/-、==运算符来实现智能指针的逻辑。 智能指针的逻辑很简单,通过计数器来记录现在有多少个shared_ptr指向相同的对象,当计数为0时它就会自动释放对象。
智能指针可以帮助我们管理内存对象的销毁时机,但智能指针不是万能的,如果你忘记销毁了智能指针,则同样会造成内存泄漏。 因此我们应该明白内存管理方式有很多种,用来防止内存泄漏的方法也不只智能指针这一种。
动态内存管理
我们常常自己来管理内存,内存泄漏也是常用的事,分配了内存但没有释放,或者没有及时释放堆积的太多等问题都是比较普遍的。
常用的new和delete运算符,在分配内存的同时,也负责调用构造函数和析构函数。
如果我们想更好的管理内存,提高内存使用效率,让内存泄漏更容易分析,那么我们就得设计内存分配器来管理内存,例如固定大小内存分配器、环形内存分配器、双端内存分配器等等,这里不详细介绍。
阻止对象拷贝
用“=delete”定义删除函数来阻止拷贝和赋值:
struct NoCopy
{
NoCopy() = default; // 默认构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator = (const NoCopy&) = delete; // 阻止赋值
}
关键字“= delete”通知了编译器,告知它我们不希望定义这些成员。 注意,析构函数不能被删除,因此不能被定义删除函数。 另外父类和子类对删除函数具有一致性,即父类定义了删除函数时子类也同样具备。
我们也常通过private来控制拷贝,即将构造函数和赋值运算符声明为private来阻止拷贝。
对象移动
在内置对象中常发生对象拷贝的情况,如果想避免拷贝又想移动对象,则可以考虑右值引用。 所谓右值引用,就是必须绑定到右值的引用,我们可以通过&&(而不是&)来获得右值引用。 右值引用有一个重要性质,即只能绑定到一个将要销毁的对象。
使用新标准库中move函数移动对象:
int &&rr1 = 42;
int &&rr3 = std::move(rr1);
std::move函数除了获取rr1右值引用外,还负责销毁,因此std::move后我们将不能再使用它了。 由于一个移后源对象具有不确定的状态,对其调用std::move是危险的,所以当我们使用move时,必须绝对确认移动后源对象没有其他用户。
为了让自定义类支持移动操作,我们可以为其定义移动构造函数和移动赋值运算符。例如:
// 移动构造函数
testClass::testClass(testClass &&s) noexcept
{
var1 = s.var1; // 移动var1
var2 = s.var2; // 移动var2
s.var1 = s.var2 = nullptr; // 移动后置空
}
// 移动赋值运算符
testClass & testClass::operator = (testClass &&s) noexcept
{
var1 = s.var1; // 移动var1
var2 = s.var2; // 移动var2
s.var1 = s.var2 = nullptr; // 移动后置空
}
与构造函数不同的是,移动构造函数不分配任何新内存,它会接管给定对象中的内存。 在接管内存后,它将给定对象中的指针都置为 nullptr 以便在后续在销毁源对象时不会销毁这部分内存。 编译器也会帮助我们合成默认的移动构造函数和移动赋值运算符,当然必须是编译器发现移动操作被用到的时候,而且只有当一个类没有定义任何自己版本的拷贝控制成员且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成默认的移动构造函数或移动赋值运算符。
可调用对象
可调用对象包括,函数、函数指针、lambda表达式、bind创建的对象、以及重载了的运算符。 从机器指令角度上来理解,函数实质是没有类型之分的,但实际调用时,仍然会需要对齐参数和返回值,因此区别在于参数数量、参数类型、返回值类型。 编译器在识别两个可调用对象是否一致时,其实并不关注它是函数指针或者其他,而关心它们的形参和返回值是否相同。
这样说来,任意两个可调用对象可以共享同一种调用形式,这使得建立函数表成为了可能,前提是它们的参数和返回类型相同。
标准库提供了这样一个模版封装,function
function<int(int, int)> f1 = add; // 加法函数指针
function<int(int, int)> f2 = divide(); // 除法函数
function<int(int, int)> f3 = [](int i, int j){ return i*j;}; // lambda表达式
count << f1(4,2) << endl; // 打印6
count << f2(4,2) << endl; // 打印2
count << f3(4,2) << endl; // 打印8
虚函数
OOP的核心是多态,多态的核心就是虚函数,我们知道通过虚函数可以在运行时动态确定要调用的函数,派生类的虚函数会直接覆盖基类。
class testClass1
{
public:
int var1;
virtual int testFunc();
}
class testClass2
{
public:
virtual int testFunc();
}
testClass1 * point = new testClass2();
point->testFunc(); // 调用派生类函数
point->testClass1::testFunc(); // 调用父类函数
虚函数原理是在虚函数调用时根据虚表来的确定,虚表中存放了当前类的虚函数,每次调用虚函数都会按固定偏移在虚表中提取函数,所以派生类覆盖了父类后,在虚表中固定偏移的函数就成了派生类的函数了。
既然派生类覆盖了父类的虚函数,派生类又怎么去调用父类的虚函数呢?其实是由于代码显式的指明了调用方向,所以在编译时编译器就已经知道要调用的哪个函数,也就不需要动态借助虚表来定位了,编译时直接静态绑定父类中函数便可。
小知识,我们还可以通过final关键字,阻止虚函数派生类覆盖。
虚函数在构造函数和析构函数中调用要特别小心,因为此时部分成员没有被初始化,例如在父类中调用虚函数,由于此时调用的是子类虚函数,而子类成员还未被初始化,调用虚函数有可能导致不可预测的错误,因此通常在构造和析构函数内执行虚函数时用显式的方式调用当前类的虚函数。
改变类成员的访问规则
用using声明可以改变基类的成员访问级别
class Base
{
private:
void testFunc();
public:
void testFunc2();
}
class Derived : private Base
{
public:
using Base::testFunc;
protected:
using Base::testFunc2;
}
上述例子中,基类中的testFunc和testFunc2在派生类中的访问级别都被改变了,testFunc从private改为了public,而testFunc2从public改为了protected。
其实成员变量的访问规则只是编译器的规则,在进程的内存和指令中没有做任何限制,在汇编里你完全可以随意访问所谓的私有变量,限制你的只是编译器。
感谢您的耐心阅读
Thanks for your reading
版权申明
本文为博主原创文章,未经允许不得转载:
Copyright attention
Please don't reprint without authorize.
微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解
QQ交流群: 777859752 (高级程序书友会)