-
面向对象 vs 面向过程
面向对象三大特性:封装、继承、多态
c++ 不是完全面向对象的语言
封装:把数据和方法打包成一个类,隐藏实现细节,只提供接口
继承:子类继承父类的属性和方法,子类可以扩展父类的功能,实现代码复用
多态:在运行时,根据对象的实际类型,调用对应的函数。主要通过虚函数和继承实现 -
C++ 编译过程
预处理 -> 编译 -> 汇编 -> 链接
预处理:预处理指令、宏定义、包含文件 (处理 #include, #define, 条件编译等)
编译:把源代码编译成汇编代码 (将 .cpp 转为 .s)
汇编:把汇编代码编译成机器码
链接:把多个对象文件链接成可执行文件 (将多个目标文件和库合并成可执行文件)
第三方库就是在第 4 步 —— 链接(Linking)阶段加入的
在 链接阶段,通过 -lxxx(如 -lcurl)告诉链接器:“我要用这个库”,然后链接器去找到对应的 静态库(.a 或 .lib)或动态库(.so 或 .dll)
静态库在在编译过程中(链接阶段)被载入可执行程序,生成的可执行文件体积较大
动态库是在运行时才载入内存,但更新库后需要重新编译。动态库是运行时库 -
静态链接和动态链接
静态链接:把所有用到的库文件都链接到可执行文件中,编译速度慢,但运行速度快
动态链接:把所有用到的库文件都放到一个单独的文件中,运行时再加载,编译速度快,但运行速度慢 -
静态多态(编译时多态) 和 动态多态(运行时多态)
- 动态多态(Dynamic Polymorphism)—— 运行时多态
这是通过 继承 + 虚函数(virtual function) 实现的。动态多态是通过虚函数重写实现的,是在运行期间确定的多态,是一种晚绑定机制(或动态绑定),在运行期间才能确定调用哪一个函数
基类中的虚函数:使用 virtual 关键字声明。
派生类重写(override)虚函数。
通过基类指针或引用调用虚函数。
必须通过 基类指针或引用 调用虚函数,涉及虚函数表(vtable)和虚指针(vptr)
虚函数如何实现晚绑定?
每个有虚函数的类有一个 虚函数表(vtable),存函数指针。(编译时候产生的,放在静态存储区中)
每个对象构造时会有一个 虚指针(vptr),指向自己的 vtable。
调用虚函数时:
程序运行时,通过对象的 vptr 找到 vtable
在 vtable 中查找对应函数地址
调用该函数
这个过程在运行时完成,所以是晚绑定。
- 静态多态(Static Polymorphism)—— 编译时多态
在编译期就确定了调用哪个函数,不涉及运行时开销,属于 “早绑定(Early Binding)”
函数重载(Function Overloading)
运算符重载(Operator Overloading)
函数模板(Function Templates)
1 |
|
- 参数推导;2. 实例化具体函数 (在符号表中对应不同的条目); 3. 编译链接
- 必须通过指针或引用调用 才能触发多态。如果直接用对象调用,会是静态绑定。
想用多态,必须用基类指针或基类引用去操作派生类对象
1 |
|
- 通过指针调用 → 触发多态(动态绑定)
1 | int main() { |
- 通过引用调用 → 也触发多态
1 | void makeAnimalSpeak(Animal& animal) { |
- 直接对对象调用,静态绑定
1 | int main() { |
- 指针 vs 引用
引用是别名,指针是表示地址的变量
引用的特性:
必须初始化,引用定义时必须绑定到一个变量
不能重新绑定,一旦绑定,就不能再指向其他变量
操作即原变量,对引用的所有操作都作用于原变量
1 | int& ref; // 错误!未初始化 |
引用的常见用法:
函数参数传递:传递大对象(如 string、vector)
函数返回值(返回对象本身,支持链式调用)
1 | class MyInt { |
不能返回局部变量的引用
1 | int& getRef() { |
遍历容器(避免拷贝元素)
1 | vector<string> names = {"Alice", "Bob", "Charlie"}; |
作为输出参数(替代指针)
- const 引用
引用是别名,const 引用是常量别名,不能修改别名指向的变量
1 | const int& cref = x; // cref 是 x 的常引用 |
可以用来绑定临时对象(延长生命周期)
1 | double getValue() { |
getValue () 的值在表达式结束就应该销毁了,但是当一个 const 引用绑定到临时对象时,这个临时对象的生命周期会被 “延长”,直到引用 r 结束为止。
用法:
避免不必要的拷贝
1 | const string& s = "hello"; // 不拷贝字符串 |
支持函数链式调用
1 | const string& result = getPrefix() + getName() + getSuffix(); |
STL 中使用
1 | for (const auto& item : vec) { ... } // 避免拷贝,安全高效 |
- 左值引用 vs 右值引用
左值:有名字、能取地址的变量,可以放在赋值号左边,生命周期较长
右值:临时变量,不能取地址,生命周期较短,通常在表达式中 “用完就扔”
1 | 10; // 字面量,右值 |
右值引用(T&&)就是一种可以绑定到右值(临时对象)的引用。
1 | //类型&& 右值引用名 = 右值; |
用法:对临时变量移动构造,避免深拷贝
1 | // std:move(x) 将左值转换为右值引用,触发移动语义 |
- 继承
1 | class Animal { |
| 继承方式 | 基类 public 成员 | 基类 protected 成员 | 基类 private 成员 |
|---|---|---|---|
| public | 在派生类中仍是 public | 在派生类中是 protected | 不可访问 |
| protected | 在派生类中是 protected | 在派生类中是 protected | 不可访问 |
| private | 在派生类中是 private | 在派生类中是 private | 不可访问 |
继承中的构造与析构顺序
构造顺序:
基类构造函数
成员对象构造函数(按声明顺序)
派生类构造函数
析构顺序:与构造相反
派生类析构函数
成员对象析构函数(按声明逆序)
基类析构函数
1 | class Base { |
输出:
Base 构造
Member 构造
Derived 构造
Derived 析构
Member 析构
Base 析构
继承是实现 动态多态 的基础。
条件:
- 基类有
virtual函数 - 派生类
override它 - 通过
基类指针或引用调用
继承类不能访问基类的私有成员
构造函数不能继承,C++11 起支持using Base::Base;继承构造函数
析构函数应为虚函数:如果类可能被继承,析构函数必须是virtual,否则delete基类指针会只调用基类析构,导致派生类资源泄漏
多重继承
如果一个类可能被继承(作为基类),那么它的析构函数就应该声明为 virtual
否则,当用基类指针(或引用)删除派生类对象时,派生类的析构函数不会被调用,造成资源泄漏
原因:virtual 让析构函数变成动态绑定,delete 时,程序会通过虚函数表(vtable)找到实际对象的类型
- 为什么构造函数不能是虚函数?
虚函数依赖对象的 vptr
构造函数执行时,对象还没完全构造,vptr 还没初始化
每个有虚函数的类,编译器会生成一个 虚函数表(vtable),里面存着虚函数的地址。
每个对象内部有一个虚指针(vptr),指向自己的 vtable。
调用虚函数时:
对象 -> vptr -> vtable -> 找到函数地址 -> 调用
- 构造函数执行时:
1 | class Animal { |
调用 Dog dog;
先调用 Animal 的构造函数
此时,dog 对象的 vptr 被初始化为指向 Animal 的 vtable
再调用 Dog 的构造函数
vptr 被更新为指向 Dog 的 vtable
虚拟构造函数(后面在学)
- 重载(Overload)、重写(Override)、隐藏(Hiding)
Overload: 同一个作用域(通常是同一个类),函数名相同,参数列表不同(类型、个数、顺序),返回类型可以不同(但不能仅靠返回类型区分)
Override: 发生在继承关系中,基类函数必须是 virtual,函数名、参数列表、返回类型(或协变)完全相同,派生类中使用 override
Hiding: 两种情况① 函数名相同,参数不同;② 函数名相同,但基类函数不是 virtual
1 | class Animal { |
解除隐藏:添加 using Base::speak;
- 深拷贝浅拷贝
深拷贝复制相同大小的内容到堆区,浅拷贝把源对象的指针赋给目标对象,多个对象的指针会指向同一块内存,容易导致双重释放
类里面有动态分配的资源比如 int* , char* 必须进行深拷贝:
- 析构函数
- 构造函数和拷贝构造需要手动实现深拷贝(比如 strcpy)
- 重载赋值运算符
1 |
|
-
拷贝构造参数不是引用行吗?
拷贝构造的参数不是引用会导致无限递归,加 const 是为了避免通过形参修改实参 -
堆和栈的区别?
堆是由程序员手动申请和释放的,可以使用 new 和 malloc 申请,释放使用 delete 和 free。内存空间较大,从低地址到高地址,访问速度较慢,有碎片问题,适合用于大对象、动态数组等
栈由编译器自动分配和释放,内存空间较小,从高地址到低地址,访问速度快,无碎片问题,适合用于局部变量、函数参数、返回地址
- 为什么栈快,堆慢?
栈:内存分配只是移动栈指针(esp),是 CPU 指令级操作,极快。
堆:需要调用操作系统 API,查找合适的内存块,维护空闲链表,效率较低。
- 内联函数
请求编译器将函数体直接插入到调用处的机制,避免函数调用的开销
但是只是对编译器的建议,编译器不一定会实现
一般来说递归函数不适合内联,因为递归调用会生成临时变量,这些变量在函数退出时会被销毁,内联函数的临时变量会一直存在,导致内存泄漏;虚函数也不适合内联,内联函数在编译的时候确定代码,但虚函数是运行时多态
- 使用
inline关键字 - 类内定义函数自动内联
内联函数比宏定义安全:
1 |
|
定义写在类内不需要显式写 inline 关键字,定义在类外需要
- 友元
允许某个函数或类 “突破封装”,访问另一个类的私有(private)和保护(protected)成员。
- 将非成员函数声明为友元函数:非成员函数可以直接访问
private和protected成员 - 将其他类的成员函数声明为友元函数:其他类成员函数可以直接访问
private和protected成员
1 | friend void Student::show(Address *addr); |
- 友元类:一个类声明为另一个类的友元类,则该类中的所有成员函数都可以访问该类的
private和protected成员
1 | class Address{ |
- 智能指针
new 了没有 delete —— 内存泄漏
delete 之后没有置为 nullptr / 指针指向的对象生命周期结束 —— 野指针
多个指针指向同一块内存 —— 悬空指针
智能指针本质是封装指针的类模板
RAII(Resource Acquisition Is Initialization)原则:资源在构造时获取,在析构时释放。
unique_ptr:独占指针,只能有一个指针指向一块内存,不能拷贝,不能赋值,析构时会自动释放内存。赋值只能用std::move()转移所有权,此时u1的所有权被转移给u3,u1变成空指针。
unique_ptr支持管理数组,它会使用delete[]来释放数组内存(区别于auto_ptr,它使用delete来释放内存)
1 | std::unique_ptr<int> u1(new int(10)); |
shared_ptr:共享指针,多个指针可以指向一块内存,通过引用计数机制来管理对象的生命周期 。当一个shared_ptr指向对象时,引用计数加 1;当shared_ptr离开作用域或被重新赋值时,引用计数减 1,当引用计数为 0 时,对象会被自动释放
shared_ptr在多线程环境中使用时,需要注意线程安全问题,因为引用计数的修改不是原子操作,可能会导致数据竞争 。为了解决这个问题,可以使用std::atomic来实现原子操作,或者使用互斥锁来保护引用计数的修改weak_ptr:不能访问指针指向的对象,不控制对象的生命周期,只能判断指针是否为空,不能赋值,析构时会自动释放内存。主要是用来解决两个shared_ptr对象之间形成循环引用的问题(循环引用计数永远不会归零,导致这两个对象永远无法被销毁),当一个shared_ptr对象被另一个shared_ptr对象所引用时,两个对象之间形成循环引用,导致内存泄漏。weak_ptr对象用来监视shared_ptr中管理的资源是否存在,可以判断指针是否为空,也可以获取指针指向的对象,但无法访问对象。weak_ptr对象可以通过lock()方法获取对应的shared_ptr对象,如果对象仍然存在,则返回对应的shared_ptr对象,否则返回一个空的shared_ptr对象。
使用weak_ptr时,需要通过lock函数将其提升为shared_ptr,才能访问对象 。如果对象已经被释放,lock函数会返回一个空的shared_ptr
- 模板
允许你写一份代码,编译器根据不同的类型自动生成对应的函数或类
比如swap:
1 | template <typename T> |
基本语法:
1 | template <typename T, typename U> |
1 | // template <typename T1, typename T2, ...> |
模板的实现(函数体)必须和声明放在同一个头文件中,STL 就是模板
- volatile
原因:多处理器访问变量,缓存和主存可能存在不一致的问题
volatile 让变量的读写都从主存中读写,而不是从缓存中读写
- 易变性(可见性): … 可能不一致
- 不可优化的:编译器不要做优化
1 | volatile int a; |
不加 volatile 的话编译器可能会优化成 `printf (% d, 1);
- 顺序执行的:编译器会对无关的语句进行优化,可能会把
printf放到a = 1的前面执行
1 | int i = 1; |
实际上执行顺序可能不是这样的(原因详见编译原理),写了 volatile 之后,编译器就不会改变顺序
- 使用场景:多线程共享字段(标志位)且经常被修改;中断服务程序和硬件设备访问相关的情况(指针可以是
volatile) - 误区:
volatile修饰的变量不能保证原子性,原子性需要通过原子操作或者锁来实现
- static
c 语言中:
- 用于局部变量(函数内部)
- 作用:使局部变量具有静态存储期(即程序运行期间一直存在),但作用域仍限于当前括号之内。
- 初始化:只在第一次进入函数时初始化一次。
- 默认值:未显式初始化则为 0。
- 用于全局变量或函数
- 作用:将变量或函数的链接属性(linkage)设为内部链接(internal linkage),即仅在当前源文件中可见,其他文件无法访问。
- 目的:实现封装,避免命名冲突。
c++ 中:
C++继承了C中static的所有用法,还增加了面向对象场景下的新用途
- 静态成员变量:
- 属于类本身,而不是类的某个对象。
- 所有对象共享同一个静态成员。
- 必须在类外定义(C17 前),C17 起可用 inline static 在类内定义。
1 | class MyClass { |
- 静态成员函数:
- 不属于任何对象,不能访问非静态成员(因为没有 this 指针)。
- 可直接通过类名调用。
1 | class MyClass { |
C++11 起,static 局部变量的初始化自动线程安全,c 语言没有这种保证
- c 语言保证线程安全
C 语言标准库本身(C89/C99/C11 等)在早期版本中并不直接提供线程或锁操作,但 C11 标准(ISO/IEC 9899:2011)引入了多线程支持,包括锁(mutex)等同步原语。
C11 引入了 <threads.h> 头文件,提供了类似 POSIX 的线程和锁接口。
1 |
|
比如线程安全计数器
1 |
|
实际上用得多的可能是 pthreads
1 |
|
示例
1 |
|
-
typedef 和 define
#define 是 “文本替换”,像 Word 的 “查找替换”,预处理的时候替换;
typedef 是 “类型定义” 的关键字,是编译器理解的真正类型别名。typedef 可以定义新的类型,也可以定义新的类型别名,但不能定义常量 / 宏函数,在编译的时候处理 -
数组指针和指针数组
数组指针:
指向数组的指针
1 | int *p[4]; |
指针数组:
数组的元素是指针
1 | int* p[4]; |
- 函数指针
函数指针:
指向函数的指针
1 | int (*p)(int, int); // 括号不能省略 |
函数指针没有自增和自减运算
区分 int *p 和 int (*p)
前者是一个指向 int 类型的指针,后者有可能是一个数组指针或是返回 int 类型的函数指针
- 如何保证线程安全?
- 锁
- 原子操作
1 |
|
- 线程同步原语
const用法
- 常量变量
必须在定义时初始化 - 常量指针 vs 指针常量:见上
- 引用与 const:见上
const 引用 - 类中的 const:
- const 成员函数:不能修改对象成员变量,不能调用非 const 成员函数
- const 对象:const 对象只能调用 const 成员函数
-
多线程
https://www.runoob.com/cplusplus/cpp-multithreading.html -
协程