01 C++ 基础
概念
1. C/C++ 的特点/和其他语言的区别
# C/C++
- 静态/强类型语言, 通过编译器直接编译成本地机器码, 并由操作系统直接执行, 效率非常高;
- 兼容C语言所有语法, 除了C语言面向过程, 还具备面向对象, 泛型编程, STL的特点;
# JAVA
- 静态/强类型语言, 生成中间代码, 再由虚拟机执行, 效率较低, 但移植性较好;
# Python
- 动态/弱类型语言, 变量使用前不需要指定类型, 在运行时确定类型;
- 解释性/脚本语言, 边解释边执行, 不需要写完整代码, 然后整体编译后执行;
- 关注业务逻辑实现, 效率低;
2. 面向对象三大特性
# 三大特性
封装, 继承, 多态
# 封装
- 将一个对象的属性和功能用一个类包裹起来, 掩饰内部的具体实现细节, 只对外提供方法接口;
- 项目中, 一个类往往会分写成 .h 和 .cpp 两个文件
* .h 文件: 类成员和类方法的声明;
* .cpp 文件: 类方法的具体实现;
- 阅读 .h 文件可以快速知道这个对象具有的属性和功能, 阅读 .cpp 文件则可以了解具体实现细节;
# 继承
- 子类通过继承父类, 重用父类中的成员变量和方法, 提高代码复用性;
- 子类也可以添加新的成员变量和方法, 来实现自己特有的功能;
- 但是继承具有侵入性, 子类必须继承父类的所有东西, 可以大致分三个方面:
* 子类需要且与父类完全相同的东西: 继承下来完全没有问题;
* 子类需要但与父类稍有不同的方法: 通过多态重写方法;
* 子类不需要的东西: 带来的冗余, 在运行中浪费内存;
# 多态
- 一个对象在不同情形下表现出不同的行为特征, 分为静态多态和动态多态;
- 静态多态
* 重载: 一个类中定义多个相同名称的方法, 但形参数量或类型不同, 在实际调用时程序根据实际传入参数调用不同的方法;
(不能根据返回值来重载, 因为函数调用时可以忽略返回值)
* 静态多态是在编译时确定;
- 动态多态
* 重写: 在基类中将成员方法定义为虚函数, 在子类中重新实现它;
在编写代码时, 可以用基类指针指向子类对象, 程序运行时会通过判断实际对象的类型, 正确调用子类的方法;
* 动态多态是在运行时确定;
3. 模板
# 定义
- 用于实现泛型编程的机制, 分为函数模板和类模板;
- 函数模板, 可以将函数返回值类型和形参用一个通用数据类型代替;
- 类模板, 可以将成员变量的数据类型用通用数据类型代替;
- 函数模板能够推导出一致的数据类型时, 可以省略类型的指定, 编译器会自动类型推导;
- 类模板必须显式指定类型, 并且模板参数列表可以指定默认类型;
多态
1. 虚函数和纯虚函数
# 虚函数
- 用 virtual 修饰的函数;
- 一般对基类成员函数使用, 允许这个函数被子类重写覆盖, 实现动态多态;
- 编写代码时, 用父类指针指向子类对象, 程序运行时通过判断实际对象的类型, 调用正确的子类方法;
# 纯虚函数
- 直接在虚函数声明后面加上 = 0;
- 拥有纯虚函数的类被称为抽象类, 不能实例化对象;
- 引入纯虚函数的目的是为了构建接口, 它的具体实现必须依靠子类来完成, 所以继承它的类必须重写纯虚函数的方法。
2. 静态函数和虚函数
# 静态函数
- 静态函数在一个文件中只有一个, 不能重写, 并且在编译时就确定了函数的入口地址;
- 静态函数链接属性是文件内部, 它对外部文件不可见, 外部文件可以重复命名该函数而不引起冲突;
# 虚函数
- 虚函数在基类和子类中有多个, 子类需要重写基类虚函数, 并且在运行时动态绑定;
- 虚函数使用了虚函数表, 在运行时需要查表获取当前对象的虚函数入口地址, 增加了内存开销;
3. 动态多态的具体实现
# 虚函数表指针和虚函数表
- 虚函数表指针和虚函数表是实现动态多态的机制;
- 当一个类中定义了虚函数, 其对象在内存开始位置会额外创建一个隐藏成员, 它是虚函数表指针, 指向虚函数表;
- 虚函数表中存储了该对象的虚函数地址, 这些地址指向代码区的函数入口;
- 诺对象为基类, 虚函数列表存储基类的虚函数; 诺对象为子类, 并且重写了某些成员函数, 虚函数列表将修改这些重写了的虚函数入口地址;
- 这保证了对象运行时, 它的虚函数列表保存的都是与对象类型匹配的虚函数入口;
- 虚函数表机制增加了内存开销, 以及查表匹配的开销, 但实现了动态多态的机制;
# 多重继承时虚函数列表
- 多重继承时, 子类虚函数列表将变为一个二维数组, 每虚函数都会存储多个父类的函数入口地址;
- 默认情况下, 对象在运行时只会访问第一个函数入口地址, 但是在代码中可以通过父类类名指定需要访问哪一个父类的虚函数;
# 子类定义虚函数的情况
- 子类重写基类虚函数, 可以不加 virtual 关键字, 编译器依然默认是虚函数;
- 子类重写基类虚函数, 子类对象虚函数列表中对应的函数地址会被重写的函数地址替换; 诺为多重继承, 所有虚函数列表的函数地址都会被替换;
- 子类新增虚函数, 虚函数会被添加到虚函数列表的末尾; 诺为多重继承, 只添加到第一个虚函数数组后面;
- 子类新增的虚函数无法通过父类指针来调用, 须添加对应的基类虚函数或者将父类指针动态转换到子类指针来调用(通过虚函数表指针的地址偏移也可以取到, 但不推荐);
4. 重载和重写/覆盖
# 重载
- 在同一作用域中, 两个函数名相同,但是参数列表不同(个数,类型),返回值类型没有要求;
# 重写/覆盖
- 子类继承了父类,父类中的函数是虚函数,在子类中重新定义了这个虚函数
5. 析构函数
# 析构函数的作用
- 析构函数会在对象所在函数调用完毕时被自动的调用, 用以销毁对象创建和使用过程中在堆区动态申请的内存空间, 避免内存泄露;
# 类析构顺序
- 1)子类析构函数; 2)对象成员析构函数; 3)基类析构函数;
6.基类为什么需要虚析构函数?默认析构函数
# 防止内存泄露
- 因为, 运行时创建子类对象, 会用父类指针指向它;
- 如果基类没有虚析构函数, 那么子类对象销毁时只会调用父类的析构函数, 无法调用子类的析构函数, 造成子类额外申请的内存空间无法释放;
- 所以, 要让父类析构函数成为虚析构函数, 从而在销毁对象时先调用子类析构, 再调用父类析构, 从而彻底释放内存;
# 默认虚构函数
- 默认析构函数不是虚析构;
- 因为, 虚函数需要额外的虚函数列表和虚表指针, 它们会占用额外的内存;
- 当一个类没有子类时, 虚析构是没有意义的, 所以 C++ 默认没有虚析构;
- 只有当程序员需要设计子类时, 才需要手动设置基类的虚函数;
7. RTTI 机制
# RTTI
- Run-time Type Information, 代表运行时类型信息, 是一种在运行时获得对象类型的方法;
- 具有虚函数的类可以用父类指针指向子类对象, 指针的类型不一定与所指对象的类型相同;
- C++ 中用一个 type_info 类型的变量指示对象的运行时类型信息, 该变量的地址被保存在虚函数表 -1 位置;
- 利用 typeid() 函数可以获取对象的 type_info 变量;
- dynamic_cast 在检查转换是否成功时, 也需要查询 type_info 变量, 并判断它是否与要转换的类型一致;
8. 继承的优缺点
# 优点:
- 代码重用,减少创建类的成本,每个子类都拥有父类的方法和属性;
- 子类与父类基本相似,但又与父类有所区别;
- 提高代码的可扩展性。
# 缺点:
- 继承时侵入性的,只要继承就必须拥有父类的所有属性和方法;
- 可能造成子类代码冗余、灵活性降低,因为子类必须拥有父类的属性和方法。
9. 虚继承
# 菱形继承
- 定义一个基类, 它有两个子类;
- 然后又创建一个孙类, 继承原来的两个子类, 它们的继承关系图表示为一个菱形;
# 问题
- 基类中的成员变量在两个子类中都有继承, 孙类再继承两个子类后, 它的对象就会有两个重复的成员变量;
- 孙类对象可以通过加作用域的方式区别这两个同名成员变量, 但是它本身就是冗余的, 浪费内存空间;
# 虚继承
- 通过在继承时加 virtual 关键字的方式实现;
- 其底层原理是将继承自两个子类的成员变量替换为指针, 它们都指向同一个地址, 用这个地址来保存唯一一份成员变量;
语法
1. i++ 和 ++i 区别
# 区别
- i++ : 创建临时变量存储 i 的值; 然后变量 i 本身再加 1; 最后返回临时变量的值. 所以不能作为左值;
- ++i : 变量 i 先加 1, 然后返回 i 的引用. 所以 ++i 是一个可以修改的左值;
2. new/delete 和 malloc/free 区别
# 相同点
- 程序员手动在堆区开辟的一块内存空间;
- 用指针接收这块内存空间地址的返回;
- 这块内存空间的释放也需要程序员在代码中手动实现;
# 不同点
- malloc/free
* C 语言标准库里的函数;
* 只能申请一块空白的空间, 不能指定这块空间的数据类型, 也不能调用构造/析构函数初始化/销毁对象;
* 需要程序员手动计算需要申请的内存空间大小, 并将申请到的空间地址强制转换为需要的类型返回给指针;
- new/delete
* C++ 中的关键字;
* 在申请内存空间时必须确定数据类型, 并且可以调用构造函数完成数据初始化, 在内存释放前也会调用析构函数先销毁对象再释放内存;
* 可以根据数据类型的大小和数量自动计算需要申请的内存空间大小, 并且自动返回对应类型的空间地址;
----------------------------------------------------------------------------------------------------------
# malloc/free
char* p = (char*)malloc(sizeof(T) * LEN);
free(p);
# new/delete
string* p = new string("new");
delete p;
------------------------------------------------------------------------------------------
3. static 和 const 的作用
# static
- 可以修饰: 局部变量, 全局变量, 普通函数, 成员变量, 成员函数;
- 主要作用: 1)修改链接属性(全局变量/普通函数), 使它们对外部文件不可见, 从而让不同文件可以定义相同名称的静态变量和函数;
2)修改存储方式(局部/全局变量), 使它们保留在内存全局区, 在程序执行期间, 一直不释放, 直到程序结束运行;
3)修改归属(成员变量/函数), 属于类, 被该类所有对象共享, 并且可以在没有生成类对象的情况下,通过类名直接访问到;
# const
- 可以修饰: 局部变量, 全局变量, 指针, 函数(返回值, 形参, 成员函数后面), 对象
- 局部/全局常量: 修改存储方式, 在定义时就被初始化,并且以后不能被修改; 其中局部常量存放在栈区,全局常量存放在全局区;
- 指针: 定义常量指针和指针常量.前者表示指针指向的值不能变;后者表示指正的指向地址不能改变;
- 函数的返回值类型: 使它的返回值是常量, 不能成为'左值';
- 形参: 规定该形参在函数体内不能被改变;
- 成员函数后面: 常函数,在函数的()后面加上const, 该函数对成员变量只有只读的权限, 防止修改成员变量;
- 对象: 常对象, 只能调用常函数和常成员变量;
# const 和 static 成员变量初始化过程
- static 成员变量
* 声明可以放在类中;
* 初始化在类外实现, 一般都写在这个类的文件中;
* 它的存在只与类有关, 即使没有生成类的对象, 它也依然存在, 可以通过类名直接访问到;
* 所以它的初始化不能放在构造函数中, 因为构造函数的执行时机是在生成对象时;
_ const 成员变量
* 声明也放在类中;
* 初始化通过构造函数来实现(一般通过初始化列表);
* 它的存在与对象有关, 必须生成具体的对象后, 其常成员变量才会被创建, 不同的对象常成员变量的值可以不同;
* 又因为它是常量, 所以必须在对象初始化时就被确定, 且其后不能被修改, 所以它的初始化过程必须通过类的构造函数来实现;
# static 和const不能同时修饰成员函数
- 两者的语意是矛盾的;
* static 作用于类,与类的对象没有关系, 因此没有 this 指针;
* const 作用于对象, 每个对象可以有不同的常成员变量, 并且在创建对象的时候初始化,因此必需要用到 this 指针来指向具体的对象;
4. 指针和引用
# 指针和引用
- 指针
* 指针是一个变量,这个变量存储的是一个匿名对象的地址;
* 可以通过 '*' 或 '->' 来间接访问对象和对象的成员;
* 指针固定4个字节大小, 创建时可以为空, 也可以有多级引用;
- 引用
* 引用是另一个对象的别名,还是代表这个对象本身;
* 对引用进行的任何操作就是对对象本身进行操作,可以直接用引用名访问这个对象;
* 引用本质上类似指针常量, 创建时必须初始化, 且指向一个已存在的对象, 不允许修改指向, 也不允许多级引用;
5. 数组与指针的区别
# 相同点
- 它们都指向一个内存地址, 数组是数组元素的首地址, 指针是对应匿名对象的地址;
# 数组
- 用于保存一组相同数据类型的元素;
- 元素的数量在初始化时确定, 在创建时申请一块连续的内存空间;
- 可以用 '[]' 直接访问指定位置的元素;
- 数组内存空间的申请和销毁都是自动的;
# 指针
- 用于保存指定匿名数据对象的地址;
- 间接访问: 需要用 '*' 来解引用指向的对象, 用 '->' 来访问对象的成员;
- 指向对象的内存空间需要程序员手动在堆区开辟和销毁;
6. 空指针和野指针
# 空指针
- 值为 null 的指针;
# 野指针
- 指向一个未知内存地址的指针;
* 如在声明时未初始化的指针变量, 它指向了一个随机的地址;
* 如所指对象已经被销毁了的指针, 它的值没有被及时置空, 依然指向原来的地址;
# 野指针定位方法(IOS)
- Xcode 上的相应设置;
* 内存涂鸦: 当对象释放时, 将其内存填上不可访问的数据;
* 僵尸对象: 当对象释放时, 将其标记为僵尸对象, 底层并未将其内存作释放;
- 用智能指针, 当对象超出作用范围时, 调用析构函数自动释放对象的内存;
7. 函数指针
# 什么是函数指针?
- 指向一个函数入口地址的指针, 通过函数指针可以实现函数的调用;
# 作用1 - 利用函数指针调用函数
- 定义具有相同参数列表的函数指针;
- 将函数名赋值给函数指针;
- 函数指针就可以像函数名一样调用函数了;
-------------------------------------------------------------------------------------------
int max(int x, int y); # 定义函数
int (*p)(int a, int b); # 定义函数指针
p = max; # 函数指针赋值
p(a,b); # 函数指针调用
-------------------------------------------------------------------------------------------
# 用法2 - 回调函数
- 将函数名/函数指针作为参数传递给其他函数, 让其他函数在执行过程中可以回调该函数;
* 调用函数 A 时, 可以将另一个函数 B 的名称作为实参传入;
* 然后, 在函数 A 的实现代码中就可以直接利用函数指针来调用函数 B 了;
- 这种方式增加了代码的灵活性, 函数 A 可以传入不同的函数名, 从而用同一套代码回调不同的函数;
* 通用的排序函数 sort(), 它接受待排序的数组和具体的排序函数, 然后返回排完序的数组;
* 在调用 sort() 函数时, 我们可以传入(冒泡排序, 选择排序, 快速排序...)不同的排序函数;
* 让 sort() 在内部回调不同的排序函数完成排序;
-------------------------------------------------------------------------------------------
void process(int i, int j, int (*p)(int a, int b)){ # 其他函数
cout << p(i,j);
}
process(1,2,max); # 将函数名作为实参传入
-------------------------------------------------------------------------------------------
8. 字符串存放的两种方式
# char* p = "123";
- "123" 是字符串常量, 保存在全局区;
- p 是指针, 保存在栈区, 它的值是 "123" 的地址;
- "123" 无法修改, p 指向可以修改;
# char a[] = "123";
- "123" 被保存到栈区;
- a 是数组, 代表数组首元素的地址, 它的指向不能修改, 但元素的值可以修改;
9. 隐式类型转换
# 是什么?
- 在对象赋值时, 传入对象和接收对象的类型不一致;
- 这个时候编译器会自动调用接收对象的构造函数将传入对象转换成希望的类型, 然后赋值给接收对象;
# 使用场景
- 内置类型, 低精度向高精度赋值时会触发隐式类型转换, 如 int 向 double 赋值;
- 函数调用时, 传入的实参与函数期待的类型不匹配, 编译器会调用构造函数对其转换;
# 显示/隐式类型转换
- 隐式类型转换可能会带来隐患,(可读性,错误排查);
- 推荐使用显示类型转换, 即在对象赋值或传入函数实参时, 先将对象转换为匹配的类型, 然后再传入;
-----------------------------------------------------------------------------------------------------
class A{
A(string str); # 类型 A 的构造函数
};
fun(A a); # fun 函数期待传入的形参类型是 A
fun(A("123")); # 显式类型转换
fun("123"); # 隐式类型转换
-----------------------------------------------------------------------------------------------------
10. 函数调用过程(栈变化过程)
# 栈空间
- 主要依靠三个寄存器实现;
- EIP: 保存当前指令地址;
- EBP: 保存当前函数的栈底地址;
- ESP: 保存当前栈顶地址;
- EBP: 被称为基地址, 通过地址偏移, 基地址 + 偏移量可以取到被调函数的形参; 基地址 - 偏移量可以取到背调函数的实参;
# 栈空间压栈过程
- CPU 依次执行 EIP 所指位置的指令, 并将其下移;
- 当 EIP 指向被调函数时, 先将需要传入的参数由右到左依次压入栈中;
- 然后调用 call(), 将当前位置的下一条指令地址作为函数返回地址压入栈中;
- 然后压入 EBP 寄存器中保存的上一个栈底地址, 再将当前 ESP 地址保存到 EBP 寄存器中, 作为新被调函数的基地址;
- 此时, EIP 进入被调函数的指令空间, 依次执行指令, 并压入被调函数的实参;
- 如此往复, 直到遇到 return;
- 返回时, 需要先把返回的值作存储, 诺返回值 4或8 字节, 直接保存在单个寄存器 EAX 或 EAX,EDX 两个寄存器中, 诺超过 8 字节, 则在栈中申请一块临时内存作存储, 将临时内存首地址保存在寄存器中;
- 栈顶指针指向 EBP 中的地址, EBP 解引用所指内存单元上的值, 指向上一函数的栈顶地址;
- 栈顶指针向上偏移一个地址单位, 解引用内存单元获得函数返回地址, 赋值给 EIP 实现函数返回;
- 此时, 在原来的函数空间上继续执行后续指令;
11. 拷贝构造函数的形参不能值传递
# 拷贝构造函数
- A(const A& a);
- 传入一个已经存在的同类型对象的引用, 然后复制该引用对象所有成员变量给新的对象;
# 不允许值传递
- 拷贝构造函数形参值传递时, 会再次调用该拷贝构造函数生成一个相同的对象;
- 第二次调用的拷贝构造函数, 又会再次值传递, 所有又要再调用拷贝构造函数;
- 循环往复, 会造成栈空间满, 然后程序出错;
# 拷贝函数调用时机
- 均涉及到新对象的创建;
- 调用函数的形参进行值传递时, 形参会调用构造函数;
- 函数返回一个对象而非引用的时候, 返回对象会调用构造函数;
- 用已存在的对象在声明中初始化对象时, 会调用构造函数;
12. 深拷贝和浅拷贝
# 浅拷贝
- 在一个类对象中, 它的成员变量可以是变量也可以是指针;
- C++ 默认情况下的拷贝构造函数, 其功能是将传入对象的所有成员赋值给新的对象;
- 这种情况下, 新对象的指针成员的值也等于原对象指针成员的值, 即两个对象的成员指针指向同一内存地址;
# 浅拷贝问题产生
- 浅拷贝时, 一个对象改变了这块内存, 另一个也改变了;
- 一个对象销毁, 会调用析构函数直接释放这片内存, 诺其他对象占用了这片内存, 另一个对象调用时就会出错;
- 第二个对象销毁时, 会再次调用析构函数直接释放这片内存, 诺其他对象占用了这片内存, 会导致这片内存存在重分配的风险;
# 深拷贝
- 深拷贝指的是在创建新对象时, 正常变量保持赋值不变, 但指针变量需要重新申请一块新的内存空间;
---------------------------------------------------------------------------------
class A{
private:
int a;
int* b;
public:
A(){ # 构造函数
a = 10;
b = new int(10);
}
A(const A& a){ # 默认拷贝构造函数
this.a = a.a;
this.b = a.b;
}
A(const A& a){ # 深拷贝构造函数
this.a = a.a;
this.b = new int(10);
}
};
---------------------------------------------------------------------------------
13. 头文件的顺序
# xxx.h 文件
- 一般到特殊
- 1) 系统头文件; 2) 第三方库头文件; 3) 本项目头文件;
- 避免后续头文件漏掉包含, 减少报错
- 特殊到一般
- 1) 本项目头文件; 2) 第三方库头文件; 3) 系统头文件;
- 可以检测出项目头文件是否漏掉包含
# xxx.cpp 文件
- 1) xxx.h; 2) 上述顺序;
- 检测 xxx.h 文件是否漏包含, 保证项目每一个头文件都可以独立编译
14. 双引号””和尖括号<>的区别
# 双引号””
- 先在当前目录查找有无该头文件,
- 没有则到系统指定的目录下找该头文件
# 尖括号<>
- 直接到系统指定的目录下查找该文件
15. C++头文件重复包含问题及解决
# 是什么?
- 一个头文件被多次包含.
- 例如: 有 A 和 B 两个 C++ 头文件, 并且 B 文件开头已经包含了 A 头文件;
此时, 我们编写 C 文件(h/cpp), 依次包含 A 和 B 两个文件;
这种情况下, B 被包含了两次;
# 解决方法
- 有两种: #ifndef 和 #pragma once
- #ifndef
- 在文件开头,使用一个宏变量来标记文件是否被包含过.诺未包含过,执行包含代码,且将宏变量置1;否则跳过包含代码
- 优点: 1)兼容性好,所有C++编译器都支持; 2)可以对多个头文件的包含代码进行标记;
- 缺点; 1)可能发生宏变量名重复,造成头文件漏包含; 2)编译器在预处理阶段需要进行判定,在编译大型项目时会花费较多的时间;
- #pragma once
- 要求写在文件开头第一行, 它在物理上保证了同一个文件不会被重复包含
- 优点: 1)避免 ifndef 带来的宏变量名重复问题; 2)编译器预处理阶段不需要进行判定,在编译大型项目时更快;
- 缺点: 1)兼容性差,不受一些编译器支持; 2)只能针对物理上的整个文件,不能针对文件中的代码段,也不能避免某个文件的拷贝;
- 混合使用
- 不能避免宏变量重复命名和编译器不支持的问题;
16. 类成员访问权限
17. struct 和 class 区别
18. 类内定义引用成员变量的方法
19. 左值和右值
# 左值
- 左值在内存中有固定的存放位置, 在代码中用一个专门的变量名代表它, 可以用取址符取址;
- 如通过声明语句声明的变量, 对象, 指针等;
# 右值
- 右值一般是内存中临时创建的变量, 在代码中并没有专门的变量名名代表它, 也不能用取址符取址;
- 如函数值传递或数学计算(1+2)时临时创建的变量;
20. 类大小的计算
# 一个对象需要占用多大的内存空间:
- 非静态成员变量总合。
- 加上编译器为了CPU计算,作出的数据对齐处理;
- 加上为了支持虚函数,产生的额外负担;
# 空的class在内存中多少字节
- 1 字节, 分配一个字节的空间用于占位;
C++11 新特性
1. C++ 四种强制类型转换
# C++ 有四种类型转换
静态转换(static_cast), 动态转换(dynamic_cast), 常量转换(const_cast), 重新解释(reinterpret_cast)
- 静态转换: 类似于C语言的强制类型转换, 是无条件的不安全的转换; 主要用于基本数据类型; 多态类向上转换安全, 向下转换不安全;
- 动态转换: 安全的转换, 只用于基类有虚函数的类, 运行时检查转换是否合法(运行时的类型和要转换的类型是否相同), 非法情况返回 NULL;
- 常量转换: 将常量转换为非常量;
- 重新解释: 无视类型的转换, 可以在整型(足够大)和指针, 指针和指针间无视类型地转换;
会造成程序的不安全, 如可以将任意整数地址转换为函数指针, 但该指针并不是函数入口, 无法使用;
2. 智能指针
# 分类: auto_ptr, unique_ptr, shared_ptr, weak_ptr
- auto_ptr 是在 c++11 以前使用的, 现在被后三者替换了
# 为什么使用智能指针?
- C++ 中, 指针指向的堆区对象在函数调用后需要手动释放, 否则会造成内存泄露;
- 智能指针可以帮助我们在函数结束后自动释放内存;
- 智能指针本质上是一个类对象, 有作用域限制, 当超过作用域时会调用其析构函数, 实现自动释放资源;
# 唯一指针 unique_ptr
------------------------------------------------------------------------------------------
unique_ptr<string> p1 (new string ("unique"));
unique_ptr<string> p2;
p1 = p2; // 错误
p1 = move(p2); // 正确
p1 = unique_ptr<string>(new string ("You")); // 正确
------------------------------------------------------------------------------------------
- 唯一指针, 对象的所有权只被该指针唯一拥有, 不能像普通指针一样用 '=' 赋值;
- 当需要传值时, 需要用 move() 函数把对象的所有权从原指针转移到新指针上, 转移后对象的所有权归新指针所有, 原指针置空;
- 但如果右值是一个临时的变量地址, 它是可以直接用 '=' 给智能指针赋值的;
- unique_ptr 指针一般用于函数嵌套调用时的参数传递, 上层函数将对象的所有权转移给下层函数, 当最后一个函数执行完毕后立即销毁对象, 释放空间;
# shared_ptr
------------------------------------------------------------------------------------------
shared_ptr<string> p1 (new string ("shared")); // OK
shared_ptr<string> p1 = make_shared<string>("shared"); // 推荐
shared_ptr<string> p2 = p1; // '=' 赋值, count + 1
shared_ptr<string> p2 = move(p1); // move(), count 不变
p1.use_count(); // 查看指针数量
p1.release(); // 释放p1的对象
------------------------------------------------------------------------------------------
- 共享指针, 对象可以被多个指针共享, 内部拥有一个 count 计数器, 用于记录当前指向该对象的指针数量, 当指针数量归零(即没有指针指向它)时, 对象销毁;
- 可以用 '=' 给新指针赋值, 新指针共享原来的对象, 并且计数器加一;
(当用 move() 给指针赋值时, 原指针的对象所有权转移到新指针, 计数器的值不变)
- shared_ptr 一般用于多线程共享一个对象的情况, 每个线程结束时共享对象的计数器都会减一, 当最后一个线程结束时即销毁对象;
# weak_ptr
------------------------------------------------------------------------------------------
shared_ptr<string> p1 (new string ("shared")); // shared_ptr
weak_ptr<string> p2 = p1; // weak_ptr 赋值
if(shared_ptr<string> p3 = p2.lock()) // weak_ptr 使用时需要转换
p3.fun();
else
cout << '对象已释放' ;
------------------------------------------------------------------------------------------
- 弱指针, 配合 shared_ptr 而存在, 提供一种访问shared_ptr的方法, 当 shared_ptr 赋值给弱指针时, 对象引用的计数器不加一;
- 可以用来解决共享指针相互引用带来的对象内存无法释放问题(内存泄露):
* 用 p1 指向 a 对象, a 对象内部包含 b 对象指针, p2 指向 b 对象, b 对象内部包含 a 对象指针;
* 此时 a,b 对象计数器都为 2, 释放 p1,p2 指针时, a, b 对象的计数器都只能减一, 永远不能归零, 然后销毁对象;
* 这种情况下, 把对象内部指针换成弱指针, 就能保证对象计数器是 1, 释放 p1,p2 指针后计数器正常归零, 销毁对象;
- 弱指针在使用时须使用 lock() 将其转换为共享指针, 才能进行对象访问, 转换不保证一定能成功;
(转换时, 诺对象还存在, 则转换成功; 诺对象被销毁, 则转换失败);
3. C++11 新特性
# auto 关键字
- 编译器可以根据变量值自动推导出类型;
- 但是不能用于函数传参以及数组类型的推导;
# nullptr 关键字
- C++ 是强类型的, void 类型的空指针不能被隐式转换成其他类型的空指针;
- 所以只能将 NULL 宏定义为 0, 在遇到函数重载时会产生二义性, 即分不清是整数 0, 还是空指针;
- nullptr 是一种特殊类型的字面值,它可以被隐式转换成任意其它的指针类型;
# 智能指针
- C++11 用 unique_ptr, shared_ptr 和 weak_ptr 智能指针代替 auto_ptr,用于更好地解决内存管理问题;
# 初始化列表
- 在构造函数后面用 ': + 成员变量()' 的方式, 更方便地对类进行初始化;
# 右值引用
- 可以避免函数值传递时临时对象调用两次构造函数;
- 当函数要传入一个一次性的实参对象时, 需要调用其构造函数创建它的临时对象;
- 在进行函数值传递时, 又要调用一次拷贝构造函数再创建一个对象, 非常浪费资源;
- 右值引用可以第一次创建的临时对象保留下来, 直接将其作为函数的形参, 避免调用拷贝构造;
# atomic 操作
- 原子数据类型, 不会发生数据竞争, 能够直接用在多线程中, 程序员不必为它们额外添加互斥资源锁;
- 实现上,可以理解为这些原子类型内部自己加了锁
# 新增 array 和 tuple 容器
- array 数组容器, 固定大小, 与数字类似但支持很多 STL 容器的特性;
- tuple 元组容器, 可用于函数返回多个值;
# 可变参数模板
- 其语法为:在class或typename后面带上省略号;
- 对模板参数列表进行了高度泛化,可以表示任意数目、任意类型的参数(原来的模板参数数量是固定的, 这里是可变的);
- 另外, 可变参数列表也可以用于递归函数, 实现每层递归使用一个参数, 然后将剩下参数的传入下一层递归;
----------------------------------------------------------------------------------------
Template<class ... T>
void func(T ... args){
cout<<”num is”<<sizeof(args...)<<endl;
}
func(); # 0 个形参
func(1); # 1 个 int 形参
func(1,2.o); # 1 个 int 形参 + 1 个 double 形参
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
void print(){ # 递归结束函数
cout << "empty" << endl;
}
template<> void print(T head, Args... args){ # 模板重载
cout << head << ","; # 使用一个参数
print(args...); # 剩余参数传入下层递归
}
----------------------------------------------------------------------------------------
# Lambda 表达式
- 在当前代码位置快速地实现一个一次性使用的匿名函数, 避免了定义函数指针, 仿函数的麻烦;
- 组成: [外部参数](参数列表)->返回值{函数体};
* [] 获取外部参数
= 外部参数值传递
& 外部参数引用传递
this 类中使用, 获取当前类对象的成员变量
- 例子: 在 sort 函数中快速实现一个自定义的比较函数, 并将其作为参数传递给 sort 函数内部, 实现自定义排序
----------------------------------------------------------------------------------------
sort(v.begin(), v.end(), [](const string& lhs, const string& rhs)
{ return lhs.size() > rhs.size(); });
----------------------------------------------------------------------------------------
Linux
1. fork 函数
# fork()
- Linux 通过系统调用, 从 fork() 函数执行处, 创建一个与当前进程一模一样的子进程;
- 子进程完成创建时, 其代码区, 全局区, 栈区, 堆区数据与父进程完全相同, 但是之后的数据可能会出现区别;
- fork() 具有三种返回值, fork() 调用失败返回负值; 父进程返回子进程 pid; 子进程返回 0;
---------------------------------------------------------------------------------------------
pid_t fpid; # fpid 接收 fork() 返回的值
fpid = fork();
if (fpid < 0)
printf("fork 失败");
else if (fpid == 0) {
printf("子进程, pid = %d ",getpid());
}
else {
printf("父进程, pid = %d ",getpid());
}
---------------------------------------------------------------------------------------------
# 写时拷贝
- fork() 创建子进程时需要提供与父进程相同的代码区, 全局区, 栈区和堆区数据;
- 最初的方式是直接拷贝所有数据到新的内存空间; 但是这种方法资源消耗较大, 并且效果不理想:
- 诺父子进程后续运行情况相似, 则两份内存数据冗余, 重复性高;
- 诺父子进程后续运行情况完全不同(调用exec), 则复制过来的数据马上就会被抹除, 没有复制的必要;
- 写时拷贝技术就是在子进程没有运行 exec() 时, 与父进程共享同一块物理内存, 只有当两者对某一段内存进行操作时, 才把那一段的内容拷贝到子进程的独立内存空间中;
2. fork 函数和 exec 函数
# exec()
- Linux 通过系统调用, 让进程从 exec() 函数执行处, 根据传入的文件路径找到一个可执行脚本或者二进制代码, 并用它来取代该进程的后续执行内容;
- 通过与 fork() 函数配合, 可以让父进程产生的子进程执行另外一段代码;
- 在 exec() 调用后, 子进程除了 pid 以外, 代码区, 全局区, 栈区, 堆区数据与父进程完全不同;
3. fork 函数和 wait 函数
# wait()
- Linux 通过系统调用, 让进程从 wait() 函数执行处阻塞, 等待子进程执行结束后, 回收子进程资源, 然后继续后续程序的运行;
- 需要和 fork() 函数配合使用, 父进程产生子进程后, 父进程立即阻塞, 子进程继续执行;
- 诺父进程没有子进程, wait() 返回 -1; 诺有子进程正常结束(调用exit()), 则 wait() 返回子进程 pid;
- 可以通过传入 int 指针来接收子进程正常结束后返回的状态;
----------------------------------------------------------------------------------------------
pid_t fpid; # fpid 接收 fork() 返回的值
fpid = fork();
int s = -1;
if (fpid < 0)
printf("fork 失败");
else if (fpid == 0) {
printf("子进程, pid = %d ",getpid());
sleep(1);
exit(5);
}
else {
printf("父进程, pid = %d ",getpid());
wait(&s); # 诺不关心子进程返回的状态, 可以用 wait(null)
}
printf("子进程退出, 状态 %d", WEXITSTATUS(s)); # wait() 接收的状态码不再是 int 类型, 需要通过宏来提取
4. 僵尸进程
- 当一个子进程在调用 exit() 结束后, 它并没有完全将资源释放, 而是而是成为僵尸进程, 需要其父进程来进行回收;
- 正常情况下, 父进程可以通过调用 wait() 阻塞, 等待子进程结束, 然后将其回收, 再继续后续的运行;
- 如果父进程比子进程先结束, 那么子进程会被 init 进程接管, 由其代替父进程将其回收;
- 如果父进程比子进程后结束, 而且没有调用 wait() 来回收子进程, 那么子进程将一直保持在僵尸进程的状态;
STL
1. STL 基本组成
# STL
- C++ 标准模板库, 提供通用的模板类和函数,可以实现多种流行的数据结构和算法;
- 如动态数组, 链表, (双向)队列, 栈, 堆, (无序)集合, (无序)map;
# 三大组件
- 容器: 一类对象的集合, 底层根据实际应用场景, 依靠不同的数据结构来实现;
- 算法: 应用于容器的元素, 提供不同操作方法, 如初始化, 排序, 搜索, 转换等;
- 迭代器: 提供了访问和遍历容器元素的方法;
2. STL 中 resize, reserve 和 capacity 的区别?
# 区别
- reserve() : 用于让容器预留空间,避免再次分配内存;
- capacity() : 返回在重新进行分配以前所能容纳的元素数量。
3. STL 迭代器的作用 (指针区别)
- 迭代器本质是类模板,能够像指针一样遍历容器元素;
- 迭代器返回的是对象引用而不是对象的地址;
4. STL 迭代器删除元素的方式
# 无迭代删除:remove/remove_if配合erase
- STL 算法模块设计是与具体的容器类型是解耦的,它只接收各类容器的迭代器并操作,所以需要 std::remove 和 erase 方法配合使用;
- 对于连续内存容器,std::remove 和 std::remove_if 的行为是把符合条件的元素全部移到容器的最末尾,并返回第一个待删除元素的迭代器;
- 再通过 erase 方法将这些元素都真正的删除掉即可。示例代码如下:
vector<int> vi{1,2,2,3,4,5,6,6};
vi.erase(remove(vi.begin(), vi.end(), 2), vi.end());
vi.erase(remove_if(vi.begin(), vi.end(), [](int i){
return i > 4;
}), vi.end());
# 有迭代删除:for配合erase来删除
- 对于连续内存容器,调用 erase 删除单个元素使后面元素的迭代器失效后(返回的是已经自加过的迭代器, 即删除元素的下一个);
- 所以在删除元素的循环中要利用 erase 的返回值,在不删除元素的循环中迭代器自加:
vector<int> vi{1,2,2,3,4,5,6,6};
for(auto iter = vi.begin(); iter != vi.end(); )
{
if(*iter == 2)
{
iter = vi.erase(iter);
}
else
{
++iter;
}
}
# 标准关联容器: 有迭代删除-for配合erase来删除
# list: 无迭代删除,有迭代删除方式都可以,也可以直接调用 list::remove() 函数
5. STL 的分配器
- 容器动态内存分配
6. vector 和 list 区别
7. map 和 set 的区别和实现
8. map 和 unordered_map 区别
9. 函数对象和仿函数
# 是什么?
- 在一个类中, 如果重载了()运算符并将其作为成员函数, 那么这个类的对象就是函数对象;
- 函数对象可以像函数一样传入实参, 然后返回结果, 所以又称为仿函数;
# 作用
- 函数调用时, 可以将函数对象作为实参传入, 其作用和函数指针一样;
- 在 STL 中, 可以配合算法对容器中的元素调用不同的批量操作, 如 sort() 函数中, 传入自定义比较函数;
内存
1. 内存分区模型
# 作用
- 对不同数据的存放进行分类, 赋予它们不同的生命周期和管理模式,使编程过程更加灵活方便.
# 内存四区
- 代码区: 存放函数体二进制代码;
- 全局区: 存放全局变量,静态变量,全局常量;
- 栈区: 存放函数参数值,局部变量,局部常量等,内存由编译器自动分配和释放;
- 堆区: 存放程序员自己的数据,内存的分配和释放由程序员自己管理;但是在程序退出时,系统也会自动回收未释放的内存;
# 代码区
- 存放函数体二进制代码,在程序开始运行以前,就被读取到内存中;
- 共享, 频繁执行的函数在代码区中只有一份,并在运行中会被共享和多次执行;
- 只读, 存放的CPU机器指令只能被读取和执行,禁止程序修改;
# 全局区
- 存放全局变量,静态变量,全局/字符串常量,也在程序开始运行以前,就被读取到内存中;
- 该区域数据在程序结束后由系统负责回收释放;
# 栈区
- 存放函数参数值,局部变量/常量等,内存由编译器自动分配和释放;
- 自动释放的时机:函数运行完返回或者执行完代码块后,内存就会被立即释放;
# 堆区
- 需要用 new 关键字来开辟堆区内存, delete 释放内存;
- 存放程序员自己的数据,内存的分配和释放由程序员自己管理;
- 但是在程序退出时,系统也会自动回收未释放的内存;
编译和底层
1. 从 C++ 源文件到可执行文件经历的过程
# 概述
- 分为4步 : 预处理, 编译, 汇编, 链接
- 预处理,对源文件一些`#`号定义的命令或语句(如宏、`#include`、预编译指令`#ifdef`等)进行分析和替换,生成*.i文件;
- 编译,对.i文件进行词法、语法和语义分析,将代码转换为汇编代码, 生成*.s文件;
- 汇编,将.s文件对应的汇编指令翻译成机器指令,生成.o目标文件;
- 链接, 将多个目标文件及所需要的库连接成最终的可执行目标文件
# 链接
- 静态链接和动态链接.
- 静态链接:
- 源文件都是独立编译的, 每个*.c文件都会形成一个*.o目标文件, 但它们之间有依赖关系(如一个源文件调用另一个源文件中定义的函数), 所以要链接;
- 静态链接过程相当于将关联函数的.o目标文件放在一起, 组成一个集合文件, 也称为可执行文件;
- 优点: 包含程序运行的所有机器指令序列, 执行速度快;
- 缺点: 1)链接的.o文件中可能包含不需要函数, 程序中多次调用的函数, 其.o文件也会被重复链接进来, 这些都造成文件数据冗余;
2)每当某处代码修改后, 就需要重新编译并链接整个程序, 造成更新困难;
- 动态链接:
- 将链接过程推迟到程序运行时, 解决静态链接浪费空间和更新困难的问题;
- 编译产生.o目标文件后, 依然让它们保持单独的状态, 当运行开始时再根据依赖情况将它们依次加载到内存中;
- 对于重复调用的函数, 内存中只需保留一个备份, 在需要调用处利用虚拟内存地址映射的方式来重复调用;
- 对于更新过程, 只需要重新编译和更新修改部分的.o文件即可, 在运行过程中仅需替换更新部分数据;
- 缺点: 程序运行时需要先经历动态链接过程, 在内存中建立整个可持续文件, 相对于静态链接直接读取数据并运行来说, 程序运行启动的时间较慢;