本章内容概述
本文用于笔者对 C++ 面试过程中常见的问题进行分析,同时加入笔者自己的理解,对相关问题进行阐述。
C++ 博大精深,碍于笔者业水平有限,因此本文仅在笔者范围内对相关知识进行分析,如有不足之处,请读者见谅。
本文计划从四个角度对 C++ 进行分门别类的探讨,分别是基础知识
、面向对象
、标准模板库
和内存管理
,此外对 C++ 11 新特性
也做出了一些补充,希望对读者有所帮助。
一、C++ 基础知识
1. C & C++
C 是面向过程的语言,C++ 是面向对象的语言。
C++ 引入了面向对象的思想,如封装、继承和多态,以及函数重载、类型转换限制、泛型编程等。
2. static
修饰局部变量
,作用域为局部作用域,仅能在首次函数调用中被初始化,之后不再初始化,保存在全局区但不能全局访问。
修饰全局变量
,作用域为整个文件,全局可访问。
修饰函数
,限制函数作用域为本文件,在文件外不可见。
修饰成员变量
,全部对象共用一个静态成员变量,类内定义,类外初始化,属于类,不对对象负责。
修饰成员函数
,仅能访问静态成员变量,不接受 this 指针。
3. #define & const
define 属于预处理指令,在预处理阶段处理,直接拷贝展开,无定义域,也无类型检查,保存在代码段。
const 属于关键字,在编译阶段处理,有类型检查,受作用域限制,保存在数据段。
4. 静态链接和动态链接
静态链接,直接拷贝到指定处,不依赖,但会导致存在大量重复代码。
动态链接,记录符号参数,在运行过程中查询指定代码,节约资源,但需要运行时加载,影响前期性能。
5. 声明 定义 赋值 初始化
声明
,表示存在某一变量,不分配地址,也不能使用,使用 extern 修饰,表示随后定义。
定义
,分配地址,可以开始使用,但需要先初始化。
初始化
,通过赋值,进行初始化。
6. 各数据类型与“零值”比较
int i = 10;
if (i == 0) { cout << "i == 0" << endl; }
bool b = 1;
if (b == 0) { cout << "b == 0" << endl; }
double d = 0.1;
if (d == 0) { cout << "d == 0" << endl; }
void* p = nullptr;
if (p == nullptr) { cout << "p == 0" << endl; }
7. volatile
volatile
,使得对被修饰变量的访问应当从内存中取出,表示可以被编译器未知的因素修改。
8. 全局变量和局部变量
全局变量,定义、保存在全局区,全局可见,局部变量保存在堆栈,仅作用域可访问。操作系统和变异其可以通过存储位置判断。
9. memcpy & strcpy & sprintf
memcpy
,直接操作内存,将指定内存的内容拷贝到目的内存,效率较高。
strcpy
,操作字符串,将指定字符串拷贝到目的字符串。
sprintf
,主要完成多种数据类型到字符串的转变。
10. inline
内联函数,执行效率高,直接在调用处展开代码,比宏定义更加安全,但会导致代码膨胀,开销增大。
11. 悬空指针和野指针
悬空指针,指针指向的内存已经被释放,但是指针未置空,即指向已经被回收的内存的指针。
野指针,未初始化的指针,指向未知内存。为避免野指针,应当尽量在指针定义时便初始化,默认初始化为 nullptr,释放内存后便立刻置空,指针操作时注意不要越界。
12. 指针 & 引用
指针有独立的内存空间,大小为 4 字节,解引用后才能获得实际对象,可以改变指针指向的对象,可以有多级指针。
引用没有独立的内存空间,大小与具体的对象保持一致,引用本身便可以直接访问对象,引用必须定义时初始化,且不能改变指向,不能多级引用。
13. 常量指针 & 指针常量
常量指针,即常量的指针,指针指向的内存的值不能改变。
int a=10;
const int * pi=&a;
指针常量,即指针型常量,本质是一个指针,指针的指向不能改变。
int a=10;
int * const pi=&a;
14. 句柄 & 指针
句柄,系统提供的文件描述符,由操作系统管理,存储系统信息,调用即可,本质是一个 32 bit 的 uint。
指针,指向逻辑地址,与句柄有所类似但完全不同。
15. extern C
为了在 C++ 中也能正常支持原有的 C 的代码,用此作为声明,一般的使用策略为:
- C++ 中调用 C 代码
- 在 C++ 头文件中使用
二、C++ 面向对象
1. 面向对象三大特征
封装
,对解决问题的方法和需要用到的数据进行打包,可以处理某一类问题。
继承
,无需重新编写类,便可获得原有的全部功能,并可以进行拓展。
多态
,相同的接口可能实现不同的功能。
2. 成员访问权限
private
,私有属性,仅能在类内访问。
protected
,保护属性,仅能在类内和子类内访问。
public
,公有属性,可以随意访问。
三种属性在不同的继承方式下访问属性也有所不同,如下所示:
基类成员权限 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
public | public | protected | private |
protected | protected | protected | private |
private | private | private | private |
3. 多态
静态多态
,函数重载和模板,编译期间确定函数。
动态多态
,虚函数和继承关系,运行期间确定函数。通过一个指向虚函数表的虚指针成员,当子类重写虚函数后,会修改虚函数表中的虚函数指针,直接访问虚函数表中对应的虚函数地址,便可调用对应函数。
虚指针在每个对象中都存在,但虚函数表对类负责。虚指针在构造函数中被赋值,因此构造函数不能被声明为虚函数。
动态多态可以隐藏实现细节,提高代码可复用性,但要求必须父类指针指向子类的对象。
纯虚函数,必须要求子类重写该函数,否则无法实例化对象。
4. 构造函数和析构函数
构造函数不能被声明为虚函数,析构函数尽量声明为虚函数。
构造函数尽量不要抛出异常,容易造成内存泄露;析构函数不可以抛出异常,会导致内存泄漏甚至程序崩溃。
5. 覆盖和重载
覆盖
,子类中存在与父类相同函数签名的函数,但函数体不同,发生在类内。
重载
,同一作用域下,两函数名相同但函数签名不同,可以根据调用条件自动选择合适函数。
特别地,父类中 b 函数调用 a 函数,在子类中重写 a 函数后,调用 b 函数,需要考虑 a 函数的声明情况,调用结果也不同:当 a 函数声明为虚函数时,则调用子类中的 a 函数,否则调用父类的 a 函数。
6. 重写 重载 隐藏
重写
,父类声明虚函数,子类便可以重写同名函数,函数签名也必须相同。
重载
,同一作用域下,函数名相同但函数签名不同的多个函数。
隐藏
,父类未声明虚函数,子类定义同名函数,父类函数被隐藏。
7. RTTI
运行时类型识别,通过两个运算符实现:
typeid
,返回表达式类型,可以通过基类指针得到派生类数据类型。
dynamic_cast
,类型检查,可以实现安全的下行转换。
8. 默认成员函数
即便是一个空类,也存在默认的成员函数:
默认构造函数、默认拷贝构造函数、默认析构函数、默认重载赋值运算符、默认取地址运算符和默认取地址运算符(常量)。
仅当这些函数被使用时,编译器才会定义。
三、C++ STL 标准模板库
1. STL
标准模板库,一般包括:算法、容器、迭代器。
算法包括常用的排序、复制等,以及特殊容器的相关算法。
容易,即存储数据的各类数据结构,分为序列式容器和关联式容器,序列式容器如 list、vector、queue、stack 等,关联式容器如 map、set 等。
迭代器,可以在不暴露容器内部结构的情况下,访问容器内的元素。
2. map & hash_map
hash_map 查找速度更快,底层是哈希表,是无序的。
map 查找速度略慢,底层是红黑树,是有序的。
3. Vector
底层原理
,本事上是一个动态数组,begin() & end() 表示被使用的空间范围,end_of_storage 是整块连续空间的尾部。
当空间不在足够保存新插入的数据时,会自动申请更大的内存空间,并拷贝当前数据到新的内存空间,释放原有的内存空间。当释放或删除容器数据时,不会释放存储空间,仅仅清空数据。
因此,当对 vector 的操作引起空间重新配置时,原有的迭代器便会失效。
reserve
& resize
,reserve 直接扩充到已经确定的大小,减少内存修改次数,提高效率,resize 改变有效大小空间。
size
& capacity
,size 表示容器中当前元素个数,capacity 表示容易可存储的元素数量。
vector 容器底层实现要求连续的对象排列,但是特殊的,引用并非对象,没有实际地址,因此 vector 不可以存储引用。
迭代器失效
,当插入元素引起内存分配时,原有的迭代器便会失效。
调整内存
,常用函数如下:
// 清空内存
cac.clear();
// 清空且释放内存
vector().swap(cac);
// 调整容器内存匹配容量和大小
cac.shrink_to_fit();
4. List
底层原理
,是一个双向链表,以节点为单位存放数据,节点地址在内存中不一定连续,每次插入删除,仅操作一个节点即可。
不支持随机存取,适合需要大量插入、删除的应用场景。
5. deque
底层原理
,双向开口的连续空间,可在头尾进行插入删除,双向队列。
综合比较三种常用的序列式容器,vector 适合随机访问频繁,对象数量变化不大的情况,vector 迭代器比 deque 简单的多。
list 不支持随机存储,适用于对象数量变化频繁,频繁插入删除的场景。
需要在首尾进行操作的情况需要使用 deque。
6. map & set & multiset & multimap
底层实现都是基于红黑树
,set / multiset 会自动排序,但 set 不允许重复,multiset 允许重复。同理,map / multimap 也都会排序,但是否允许重复?
基于存储结构,两者插入删除更高效,切内存不会改变,因此迭代器不会失效。
7. unordered_map & unordered_set
unordered_map
,底层是一个哈希表,可以降低存储和查找的时间复杂度,但会消耗更多内存。
8. 常用容器
顺序容器
,主要包括以下 5 种:
// 固定大小的数组,支持随机访问,但不能插入、删除元素
array<T, N>
// 动态数组,支持随机访问,可在首尾插入、删除元素,尾部操作更快
vector<T>
// 双向队列,支持随机访问,可在首尾插入、删除元素,首尾操作都快
deque<T>
// 双向链表,支持双向顺序访问,可随意插入删除,任意处插入删除都很快
list<T>
// 单向链表,支持单向顺序访问,可随意插入删除,任意处插入删除都很快
forward_list<T>
关联容器
,主要分为 map 和 set 两类。
map 类容器,包含下列若干:
// 关联数组,可以保存键值对,形如: 关键字 - 值,底层为 红黑树
map<K,T>
// 关键字可重复的 map
multimap<K,T>
// 底层为 哈希表 的 map
unordered_map<K,T>
// 缝合怪
unordered_multimap<K,T>
set 类容器,包含下列若干:
// 关键字数组,底层为 红黑树
set<T>
// 关键字可重复的 set
multiset<T>
// 底层为哈希表的 set
unordered_set<T>
// 缝合怪
unordered_multiset<T>
容器适配器
,包括以下三种类型:
// 栈
stack<T>
// 队列
queue<T>
// 优先队列
priority_queue<T>
9. 迭代器
迭代器是连接容器和算法的重要桥梁,迭代器可以在不暴露容易内部结构的情况下,访问容易内元素,从三个角度对迭代器进行分析。
迭代器底层原理
,迭代器的底层原理,主要包括两个部分:萃取技术和模板偏特化。
萃取技术
,即 traits,可以进行类型推导,具体功能体现为:可以个根据不同类型的容器,执行不同的处理流程。在萃取进行类型推导的过程中,需要使用到模板偏特化。模板偏特化可以辅助进行推导参数,但仅能判断内置类型。
迭代器种类
,分为很多种,大致如下:
种类 | 功能 |
---|---|
输入迭代器 | 只读,在每个遍历位置上仅能读取一次 |
输出迭代器 | 只写,在每个遍历位置上仅能写一次 |
前向迭代器 | 可重复读写,但只能前向顺序移动,无 operator – |
双向迭代器 | 可重复读写,可双向顺序移动 |
随机访问迭代器 | 可重复读写,且可双向随机移动,支持跳跃式移动 |
迭代器失效
,插入和删除操作极易改变容器的内存大小,因此导致内存中的容器位置发生变化,迭代器表现出失效。
插入操作
,不同容器进行插入操作后,失效情况不尽相同,大致如下:
容器类型 | 插入操作失效情况 |
---|---|
vector & string | 如果内存重新分配,则全部失效,否则插入点之前的迭代器有效,插入点之后的迭代器失效 |
deque | 插入位置在头尾,则引用和指针依然有效,否则全部失效 |
list & forward_list | 全部有效 |
删除操作
,不同容器进行插入操作后,失效情况不尽相同,大致如下:
容器类型 | 删除操作失效情况 |
---|---|
vector & string | 删除点之前的全部有效,off_the_end 总失效 |
deque | 删除位置在头尾,则全部有效,否则全部失效,off_the_end 总失效 |
list & forward_list | 全部有效 |
关联性容器 map、set | 对应迭代器失效,其余有效 |
10. STL 内存优化
STL 在内存管理中,使用的是二级内存配置器的方式。
第一级配置器
,对 malloc & free 进行简单的封装,在 allocate 中调用 malloc 函数,在 deallocate 中调用 free 函数,同时使用 oom_malloc 处理 malloc 失败情况。
第二级配置器
,解决第一级配置器的一些不足之处:内存分配和释放的效率低下,容易产生内存碎片,配置内存需要额外的内存负担。
具体解决方式如下:
当分配的内存小于 128 bytes 时,通过内存池管理:第二级配置器维护了一个自由链表数组,直接从相应节点上取出一个内存节点即可完成工作。
自由链表数组
,一个指针数组,每个指针指向一个链表的头节点,数组大小为 16,即维护了 16 个链表,每个链表中的节点保存相同大小的内存块,16 个链表完成了从 8 到 128 的各个大小的内存块链表管理。
内存分配
,allocate 首先判断待分配内存大小,若超出 128 byte,则直接通过第一级配置器,否则从自由链表数组中取出相应大小内存。
填充链表
,如果出现链表节点为空的情况,即链表节点中的待分配内存使用耗尽,则需要及时填充链表,通过 refill 函数完成。
refill
,首先调用 chunk_alloc 函数从内存池中分配一大块内存(默认为 20 个链表节点,内存池中也不足时则少于 20 个),并将其划分为 20 块大小相同的内存块,串联成一个链表。
chunk_alloc
,首先判断内存池内存是否充足,充足则直接分配 20 个节点后返回;不充足但足够一个节点则直接返回一个节点;若一个节点都不足则现将剩余内存分配给足够一个节点的链表,然后调用 malloc 获取所需内存大小的两倍的内存大小,成功则返回相应内存,失败则搜寻其余链表的可用空间分配,若其余链表也不足,则只能调用第一级配置器。
四、C++ 内存管理
本节,主要探讨内存管理相关的一些问题,因为指针的存在,必须对内存管理有着格外的注意。
1. new/delete & malloc/free
从关键字本身来说,new/delete 是操作符,而 malloc/free 是函数,两者并不完全相同。
在使用方式上,new/delete 需要指明指针类型,但 malloc/free 直接返回 void 类型指针;new/delete 使用时会检验指针安全性,但 malloc/free 不会;new 可以重载,可以调用构造函数,但 malloc/free 不可以。
本质上,new/delete 是在堆区申请为某一类型的变量申请一块可以储存一个变量的空间,而 malloc/free 是申请一块指定大小的空间。
需要注意,delete/free 后,内存不会立刻回收,仅是表示此处内存不再被使用,可以再分配。
需要注意的是,两者不能混搭使用,malloc/free 必须指定空间大小,new/delete 会自行类型检查和,无需指定内存大小,混用很容易出错。
2. delete/delete[]
delete,仅调用一次对应的析构函数;delete[],会多次调用析构函数。因此,new[] 申请的空间,如果用 delete 释放的话,会导致后续的对象在未调用析构函数的情况下被直接释放,如果对象中恰好存在堆区内存的话,就会导致内存泄漏。
3. mallpc/new 失败返回空指针
malloc 失败会默认返回空指针,因此对 malloc 的空间需要判断指针是否为空;new 则会默认抛出异常,可以检测。
4. 内存泄漏
内存泄漏有很多种情况,常见的情况如下:
malloc/new 和 free/delete 未成对出现,或者new[] 和 delete[] 未成对出现,即在堆区申请的内存未释放,就会导致内存泄漏。
最常见的一种,就是类中成员变量包含指向堆区内存的指针,但是未重载赋值运算符或未定义拷贝构造函数,导致浅拷贝的问题,在析构函数中释放堆区内存,就会导致非法访问,必定出现内存泄露。
5. 内存分配
栈区
,执行函数时,函数内的局部变量都会在栈区分配内存,函数返回后立刻释放,高效但容量有限。
堆区
,手动向堆区申请,自行释放,否则会在程序结束后由操作系统回收。
全局区
,存放全局变量或静态变量。
常量区
,存放常量。
6. 堆和栈
两者内存空间有诸多不同:
分配管理方式不同
,堆区内存自行申请释放,栈区内存由编译器管理。
产生碎片不同
,频繁 new/delete 或 malloc/free 会产生大量内存碎片,导致连续的内存空间减少,程序效率降低;栈遵循先进后出的特性,因此不会产生内存碎片。
生长方向不同
,堆区从低地址向高地址生长,栈向内存地址减小的方向增长。
大小不同
,栈有规定大小,堆区大小可以调整。
7. 静态内存分配和动态分配内存
静态分配内存,在编译期间完成,不占用 CPU 资源,在栈上直接分配,不需要引用或指针,效率较高。
动态分配内存,在程序运行期间完成,占用 CU 资源,需要指针或引用类型支持,按需分配,可能造成内存泄漏。
若使得某个类只能在堆上分配内存,则将析构函数声明为 private;仅能在栈上,则将 new/delete 重载为 private。
8. 字节对齐
从偏移为 0 的位置开始存储,以 n 字节对齐的情况下,满足下列条件:
sizeof 的结果为 n 的整数倍,各成员首地址必然是 min(n, 自身大小) 的整数倍。
五、C++ 11 新特性
1. 强制类型转换
static_cast
,意为“静态转换”,即在编译期间转换,如果转换失败则会抛出错误,适用情况有:
基本数据类型的转换和数据强制类型转换
:将一种数据类型转换为另一种数据类型,但是指针不可以,代码如下:
int a = static_cast<int>(10.7);
//int* p;
//double* pd = static_cast<double*>(p); 不可以进行基本数据类型之间的转换
类层次之间的上行转换
:将子类(引用或指针)转换为父类(引用或指针),但是需要注意的是,只能做类之间的上行转换,不能进行下行转换,因为没有动态类型检查,是不安全的,代码如下:
base b = static_cast<base>(derive());
base* pb = static_cast<base*>(&derive());
//derive d = static_cast<derive>(base()); 不可以下行转换
指针与空指针转换
:可以将空指针转换为目标类型的空指针,代码如下:
void* pN;
base* b = static_cast<base*>(pN);
表达式类型转换
:可以将任何类型的表达式转换为 void 类型。
const_cast
,常量转换,主要适用于 const 和非 const、volatile 和非 volatile 之间的转换,可以强制去除常量属性,但是只能用于去除常量指针和常量引用的常量属性
,不可以去除常变量的常量属性。或许会产生疑惑,既然将它声明为常量引用,就是不希望修改它,为什么又要去除引用的常量属性呢?这是因为在某些情况下,必须将常量指针传入参数列表中声明为普通指针的函数中,可以保证在函数中不会对其进行修改,从而人为保证安全性,以通过编译。代码如下:
const int ci = 10;
const int* pci = &ci;
int* fpci = const_cast<int*>(pci);
cout << ci << endl; //10
cout << *pci << endl; //10
cout << *fpci << endl; //10
cout << pci << endl; //009CF744
cout << &ci << endl; //009CF744
cout << fpci << endl; //009CF744
既然常量指针被去除了常量属性,那么对其进行修改会如何呢?代码如下:
const int ci = 10;
const int* pci = &ci;
int* fpci = const_cast<int*>(pci);
*fpci = 20;
cout << ci << endl; //10
cout << *pci << endl; //20
cout << *fpci << endl; //20
cout << pci << endl; //009CF744
cout << &ci << endl; //009CF744
cout << fpci << endl; //009CF744
可以观察到很有趣的现象,指针指向的值发生了改变,但原变量的值却并未改变,而且它们三者的地址竟然仍保持一致。事实上,这种赋值行为属于未定义行为,是十分不建议的,去除常量属性的初衷是在人为保证安全性的情况下通过编译,绝不是为了修改内容,因此对去除常量属性后的指针做修改已经损害了安全性,是不被建议的行为。
reinterpret_cast
,重解释转换,可以用来处理无关类型之间的转换:产生一个新的值,这个值会有与原始参数有完全相同的比特位,执行时按照逐个比特复制,从而完成指针类型、指针到整型、整型到指针的转换,一般不建议使用。
dynamic_cast
,动态类型转换,可以动态实现父类与子类指针之间的转换,会检查指针指向的对象类型和转化后的类型,相同时才会安全,否则置空,只能用于父类含有虚函数,因此更为安全。
2. 智能指针
,智能指针,对普通指针进行封装,避免人为错误导致的内存泄漏,可以动态分配内存,解决空悬指针或内存泄漏等问题。
auto_ptr
,独占式指针,同一时间仅能有一个指针指向该对象。但是,因为其在函数传参过程中不会归还对象所有权,容易导致程序崩溃,并且不能作为模板参数,便被逐渐淘汰。
unique_ptr
,独占式拥有概念,同一时刻仅能有一个智能指针可以指向该对象,无法拷贝构造和拷贝赋值,但是可以移动构造和移动赋值。
shared_ptr
,共享式拥有概念,多个智能指针可以指向同一个对象。
weak_ptr
,解决 shared_ptr 相互引用导致的死锁问题,可以在不增加引用计数的情况下绑定到 shared_ptr。
本章总结
本文对 C++ 基础语法进行了相对全面的总结回顾,希望读者能够深入理解体会,对读者有所帮助。
最后,我是Alkaid#3529,一个追求不断进步的学生,期待你的关注!