C++ 面试核心


本章内容概述

本文用于笔者对 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 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
privateprivateprivateprivate

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,一个追求不断进步的学生,期待你的关注!

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alkaid3529

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值