前言
明白了自己有多菜了,就应该学会不断总结所学的知识点,当然在这个过程中也学习到许多新的知识点。
此总结只记录重要部分,不追求细致入微。
大纲
内存模型
- 介绍一下C++的内存模型/说一下C++的内存分区?
C分为四个区:堆,栈,静态/全局变量区,常量区
C++分为五个区:堆,栈,静态/全局变量区,常量区,自由存储区。
根据c/c++对象生命周期不同,c/c++的内存模型有三种不同的内存区域,即自由存储区,动态区、静态区。
自由存储区:局部非静态变量的存储区域,即平常所说的栈
动态区: 用operator new ,malloc分配的内存,即平常所说的堆
静态区:全局变量 静态变量 字符串常量存在位置\
而代码虽然占内存,但不属于c/c++内存模型的一部分。
-
new和malloc的区别,delete和free的区别? 在使用的时候 new、delete 搭配使用,malloc、free 搭配使用。
-
malloc、free是库函数,支持覆盖,而new、delete是关键字,不重载。 new申请空间是,无需指定分配空间的大小,编译器会根据类型自行计算;malloc在申请空间时,需要确定所申请空间的大小。
-
new分配失败时,会抛出bad_alloc异常,malloc分配失败时返回空指针。
-
对于自定义的类型,new首先调用operator new()函数申请空间(底层通过malloc实现),然后调用构造函数进行初始化,最后返回自定义类型的指针;delete首先调用析构函数,然后调用operator delete()释放空间(底层通过free实现)。malloc、free无法进行自定义类型的对象的构造和析构。
- new:operator new() [底层通过malloc实现] --> 构造函数 --> 返回自定义类型的指针
- delete:构造函数 --> operator delete() [底层通过free实现]
-
new操作符从自由存储区上为对象动态分配内存,而malloc函数从堆上动态分配内存。(自由存储区不等于堆)
编译和调试
- 介绍一下C++的程序运行过程?
编译过程分为四个过程:编译预处理、编译及优化,汇编,链接。
-
编译预处理: 处理以
#开头的指令; -
编译及优化: 将源码
.cpp文件翻译成.s汇编代码; -
汇编: 将汇编代码
.s翻译成机器指令.o文件; -
链接: 汇编程序生成的目标文件,即
.o文件,并不会立即执行,因为可能会出现:.cpp文件中的函数引用了另一个.cpp文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序.exe文件。
链接
- 静态链接和动态链接有什什么区别?
静态链接- 静态链接是在编译链接时直接将需要的执行代码拷贝到调用处;
- 优点在于程序在发布时不需要依赖库,可以独立执行,
- 缺点在于程序的体积会相对较大,而且如果静态库更更新之后,所有可执行文件需要重新链接; 动态链接
- 动态链接是在编译时不直接拷贝执行代码,而是通过记录⼀系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定代码时,在共享执行内存中寻找已经加载的动态库可执行代码,实现运行时链接;
- 优点在于多个程序可以共享同一个动态库,节省资源;
- 缺点在于由于运行行时加载,可能影响程序的前期执行性能。
面向对象
构造函数和析构函数
- 构造函数和析构函数能否抛异常?
- 构造函数可以抛出异常。
- c++标准指明析构函数不能、也不应该抛出异常。
more effective c++关于第2点提出两点理由:
1)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
2)通常异常发生时,C++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。
- 构造函数和析构函数可以是虚函数吗?
- 如果父类的析构函数不是虚函数,则不会触发动态绑定(多态),结果就是只会调用父类的析构函数,而不会调用子类的析构函数,从而可能导致子类的内存泄漏(如果子类析构函数中存在free delete 等释放内存操作时);
- 如果父类的析构函数是虚函数,则子类的析构函数一定是虚函数(即使是子类的析构函数不加virtual,这是C++的语法规则),则会在父类指针或引用指向一个子类时,触发动态绑定(多态),析构实例化对象时,若是子类则会执行子类的析构函数,同时,编译器会在子类的析构函数中插入父类的析构函数,最终实现了先调用子类析构函数再调用父类析构函数。
- 构造函数和析构函数可以调用虚函数吗,为什么?
- 在C++中,提倡不在构造函数和析构函数中调用虚函数;
- 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
- 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
- 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数,所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候在调用子类的虚函数没有任何意义。
多态
- 什么是多态? 在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数, 如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。
执行顺序
- 构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?
构造函数顺序
- 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
- 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
- 派生类构造函数。
析构函数顺序
- 调用派生类的析构函数;
- 调用成员类对象的析构函数;
- 调用基类的析构函数。
C++11
智能指针
- 介绍一下智能指针? c++11 引入了三种智能指针:
- std::shared_ptr
- std::weak_ptr
- std::unique_ptr
shared_ptr 使用了引用计数, 每一个 shared_ptr 的拷贝都指向相同的内存, 每次拷贝都会触发引用计数+1,每次生命周期结束析构的时候引用计数-1, 在最后一个 shared_ptr 析构的时候, 内存才会释放。
weak_ptr 是用来监视 shared_ptr 的生命周期, 它不管理 shared_ptr 内部的指针, 它的拷贝的析构都不会影响引用计数, 纯粹是作为一个旁观者监视 shared_ptr 中管理的资源是否存在, 可以用来返回 this 指针和解决循环引用问题。
std::unique_ptr 是一个独占型的智能指针, 它不允许其它智能指针共享其内部指针, 也不允许 unique_ptr 的拷贝和赋值。 使用 方法和 shared_ptr 类似, 区别是不可以拷贝。
lambda表达式
编译器为我们了创建了一个类,这个类重载了(),让我们可以像调用函数一样使用。
- 讲一讲lambda表达式不同的捕获方式
与普通函数不同的是,lambda不能有默认参数。因此一个lambda调用的实参数目永远与形参数目相等。
捕获外部变量
lambda表达式可以使用其可见范围内的外部变量,但必须明确声明(明确声明哪些外部变量可以被该Lambda表达式使用)。那么,在哪里指定这些外部变量呢?lambda表达式通过在最前面的方括号[]来明确指明其内部可以访问的外部变量,这一过程也称过Lambda表达式"捕获"了外部变量。
类似参数传递方式(值传递、引入传递、指针传递),在lambda表达式中,外部变量的捕获方式也有值捕获、引用捕获、隐式捕获(隐式值捕获、隐式引用捕获)以及混合方式的捕获。
- 值捕获
值捕获和参数传递中的值传递类似,被捕获的变量的值在lambda表达式创建时通过值拷贝的方式传入,因此随后对该变量的修改不会影响影响lambda表达式中的值。如果以传值方式捕获外部变量,则在lambda表达式函数体中不能修改该外部变量的值。
int main()
{
int a = 123;
auto f = [a]{cout<<a<<endl;};
a = 321;
f(); //123
}
- 引用捕获
使用引用捕获一个外部变量,只需要在捕获列表变量前面加上一个引用说明符&。
读取最新内存中的值:
int main()
{
int a = 123;
auto f = [&a](){cout << a << endl;};
a = 321;
f(); //321
return 0;
}
更改变量的值:
int main()
{
int a = 123;
auto f = [&a]() {a = 5; };
a = 321;
f();
cout << a << endl; //5
return 0;
}
- 隐式捕获
我们还可以让编译器根据函数体中的代码来推断需要捕获哪些变量,这种方式称之为隐式捕获。隐式捕获有两种方式,分别是[=]和[&]。[=]表示以值捕获的方式捕获外部变量,[&]表示以引用捕获的方式捕获外部变量。
隐式值捕获:
int main()
{
int a = 123;
auto f = [=] { cout << a << endl; }; // 值捕获
f(); // 输出:123
}
隐式引用捕获:
int main()
{
int a = 123;
auto f = [&]{cout <<a<<endl;};//引用捕获
a = 321;
f();//输出321
}
- 混合方式
STL
内存模型
SGI 设计了双层级配置器,第⼀级配置器直接使⽤ malloc()和 free()完成内存的分配和回收。
第⼆级配置器则根据需求量的大小选择不同的策略执⾏。
对于第⼆级配置器,如果需求块大小大于 128bytes,则直接转⽽调⽤第⼀级配置器,使⽤malloc()分配内存。如果需求块大小于 128bytes,第⼆级配置器中维护了 16 个自由链表,负责 16 种小型区块的次配置能⼒。
即当有小于 128bytes 的需求块要求时,⾸先查看所需需求块大小所对应的链表中是否有空闲空间,如果有则直接返回,如果没有,则向内存池中申请所需需求块大小的内存空间,如果申\请成功,则将其加⼊到⾃由链表中。如果内存池中没有空间,则使⽤ malloc() 从堆中进⾏申\请,且申请到的大小是需求量的⼆倍(或⼆倍+ n 附加量),⼀倍放在⾃由空间中,⼀倍(或⼀倍+ n)放⼊内存池中。
如果 malloc()也失败,则会遍历自由空间链表,四处寻找“尚有未⽤区块,且区块够大”的freelist,找到⼀块就挖出⼀块交出。如果还是没有,仍交由 malloc()处理,因为 malloc() 有out-of-memory 处理机制或许有机会释放其他的内存拿来⽤,如果可以就成功,如果不⾏就报bad_alloc 异常。
常见容器
-
介绍一下vector容器? vector底层是⼀一个动态数组,包含三个迭代器器, start和finish之间是已经被使⽤用的空间范围, end_of_storage是整块连续空间包括备⽤用空间的尾部。
当空间不不够装下数据(vec.push_back(val))时,会自动申请另⼀片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间【vector内存增长机制】。
当释放或者删除vec.clear()里里⾯面的数据时,其存储空间不释放,仅仅是清空了了里面的数据。
因此,对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器器会都失效了。 -
介绍一下list? list的底层是⼀个双向链表,以结点为单位存放数据,结点的地址在内存中不⼀定连续,每次插入或删除一个元素,就配置或释放一个元素空间。 list不不⽀支持随机存取, 适合需要大量的插入和删除,而不关心随即存取的应⽤用场景。
迭代器失效
- 为什么vector的插入操作会导致迭代器失效?
vector动态增加空间时,并不是在原空间之后增加新的空间,而是以原来大小的两倍或者原空间加上实际所需的空间的大小另外配置一片较大的空间,释放原来的空间。由于操作改变了空间,所以原来的迭代器失效。
-
vector每次insert或者erase之后,以前保存的迭代器会不会失效?
- 在进行insert时,如果在p位置插入新的元素。当容器有剩余空间,不需要重新分配空间时,p之前的迭代器都有效,p之后的迭代器都失效;当容器重新分配了内存空间,那么所有的迭代器都失效;
- 进行erase时,erase的位置在p处,p之前的迭代器都有效且p指向下一个元素位置(如果p在尾元素处,p指向无效end无效),p之后的迭代器都无效。
-
deque插入和删除元素,以前保存的迭代器是否失效?
- 在中间插入或者删除元素,将使deque所有的迭代器、引用、指针失效;
- 在首部或者尾部插入元素可能会使迭代器失效(缓冲区空间已满,需重新分配内存),但不会引起指针或者引用失效;
- 在首部或者尾部删除元素,只会使指向被删除的元素迭代器失效。
-
vector,list,deque,map在erase(iter)后迭代器如何变化?
- vector和deque是序列式容器,其内存分别是连续空间和分段式连续空间,删除迭代器iter后,其后面的迭代器都失效了,此时iter指向被删除元素的下一个位置;
- list删除迭代器iter时,其后面的迭代器不会失效,将前面和后面连接起来即可;
- map删除iter时,只是当前删除的迭代器失效,其后面的迭代器依然有效。
多线程
多线程部分将根据操作系统中"进程、线程"部分和C++相关API展开讲解。
相关书籍
《c++对象内存模型》
《程序员的自我修养—链接、装载与库》
附录
记录相关网络资料地址,如有侵权,请联系我删除。