Cpp体系架构
前言
本文旨在帮助读者快速复习Cpp,建立起一个完整的Cpp知识体系架构。
基础语法
函数重载和函数重写
函数重载是在一个类中声明多个同名,但参数列表不同的方法。参数列表不同可以表现在参数类型不同,参数数量不同,参数顺序不同。但是不可以根据返回值类型不同而区分。
函数重写是指基类定义虚函数,子类重写基类虚函数实现多态。
运算符重载
当应用于类的实例时,operator 关键字将声明一个用于指定 operator-symbol 含义的函数。 这将为运算符提供多个含义,或者将“重载”它。 编译器通过检查其操作数类型来区分运算符不同的含义。
基本语法
type operator<op>(param-list)
内存管理
cpp内存布局 / 内存模型
cpp内存模型中大致分为以下部分:栈、堆、全局/静态存储区、常量储存区、代码区。
栈(Stack)
用途:用于存储局部变量、函数参数、返回地址等。
特点:
- 内存分配和释放由编译器自动管理。
- 栈的大小有限,通常较小(几MB)。
- 栈的分配和释放速度非常快。
- 栈内存的生命周期与函数的调用周期一致,函数结束时自动释放。
示例:
1 2 3
void foo() { int x = 10; // x 存储在栈上 }
如何分配和释放:
随着局部变量的创建而分配,退出作用域而释放。
堆(Heap)
用途:用于动态内存分配,存储生命周期不确定的对象。
特点:
- 内存分配和释放由程序员手动管理(使用
new和delete)。 - 堆的大小通常较大,受限于系统的可用内存。
- 堆的分配和释放速度较慢。
- 堆内存的生命周期由程序员控制,需要手动释放,否则会导致内存泄漏。
- 内存分配和释放由程序员手动管理(使用
分配和释放:
1 2 3
T* arr = new T(args); delete arr; arr = nullptr;
new和delete相关问题
new和delete的工作原理:
- new:首先调用operator new申请未初始化的内存,大小为size_of(T);然后在这块内存上调用构造函数创建对象;
- delete:delete工作过程与new相反,先调用析构函数,再调用operator delete来释放指向的内存;
new[]和delete[]的工作原理:
- new[]: 首先调用operator new[]申请一块足够大的未初始化的内存,大小为size_of(T*num),然后调用num次构造函数将对象初始化在对应位置;
- delete[]: 逆序的调用对象的析构函数,再调用operator delete[]释放指针指向的整块内存;
new和malloc的区别
- new是C++的关键字,malloc是C的函数,这是根本区别
- new和malloc的返回值不同,new返回的是对象的指针,malloc返回的是void*,需要强制类型转换
- new通常会调用构造函数,malloc不会
- new会自动计算需要分配的内存大小,malloc需要手动计算
- malloc类似于c++关键字中的operator new
如果使用new[]申请个动态数组,但是你却使用delete去释放内存,会发生什么?
首先我们要知道new[]是和delete[]搭配使用的,new[num] 是指申请num个对象的内存,delete[]是释放num个对象的内存,会依次调用所有成员的析构函数。如果你使用delete去释放new[],那么只会调用第一个对象的析构函数,并且其他对象的内存没有被释放。如果你在使用头指针去偏移找元素可能会出现未定义的行为。
int a[10] / 静态数组 和 int* a = new int[10] / 动态数组 有什么区别?
最本质的区别就是申请的内存的位置不同:
- int a[10] 是在栈上申请的内存,会随函数执行的结束而被释放,不能改变大小;
- int* a = new int[10] 是在堆上申请的内存,需要手动释放,可以改变大小,即重新分配内存。
堆栈相关问题
如何限制对象创建在栈堆上
- 栈:就是把类的new和delete操作符给禁了(用=delete关键字)。
- 堆:先把类的构造函数设置为私有,在类内定义一个静态的构造函数和析构函数,然后在构造函数中new一个对象,然后在析构函数中delete这个对象,这样就可以限制对象的创建在堆上。
全局/静态存储区(Global/Static Storage)
在Linux系统中,位于进程内存分布中的data区和bss区。
- 用途:用于存储全局变量、静态变量(包括静态局部变量和静态成员变量)。
- 特点:
- 内存分配在程序启动时完成,程序结束时释放。
- 全局变量和静态变量的生命周期贯穿整个程序运行期间。
- 未初始化的全局变量和静态变量会被自动初始化为零。
全局变量和静态全局变量
主要区别在于作用域,全局变量的在所有源文件中都可见,可以通过声明extern来在其他的源文件中访问;但是静态全局变量仅在当前文件可见。
静态变量
静态变量分为局部和全局
- 局部静量在函数或者类中定义的静态变量,只在定义的函数或者类中可见,生命周期和全局变量一样,但是作用域只在定义的函数或者类中;也就是编译的时候会在内存的bss段分配空间,但是只有在第一次调用的时候才会初始化,并移入data段。
- 全局静态变量在函数和类外定义的静态变量,只在定义的文件中可见,生命周期和全局变量一样,但是作用域只在定义的文件中;也就是编译的时候会在内存的bss段分配空间,但是只有在第一次调用的时候才会初始化,并移入data段。
静态变量的初始化问题
- 全局:初始化发生在main函数执行之前,但是C++没有规定多个编译单元中的non-local static对象的初始化顺序,初始化顺序是随机的
- 局部:初始化发生在第一次执行到初始化语句的时候,C++11之后解决了线程安全的问题。C++11规定,在一个线程开始local static 对象的初始化后到完成初始化前,其他线程执行到这个local static对象的初始化语句就会等待,直到该local static 对象初始化完成。
静态类成员函数和静态类成员变量
静态成员变量属于类本身而非任何单个对象,因此类的所有实例共享该变量的唯一副本,它不占用对象的内存空间。必须在类内声明并通常在类外定义和初始化,其生命周期贯穿整个程序运行期,存储于静态存储区。可通过类名直接访问,常用于记录实例数量或保存共享配置。
静态成员函数同样属于类而非具体对象,调用时无需实例,通过类名即可访问。其关键特性是不包含 this 指针,因此只能访问类的其他静态成员(变量或函数),无法直接操作非静态成员。它们常被用作工厂方法、工具函数或提供对静态数据的访问接口,是实现类级别操作的重要机制。
常量存储区(Constant Storage)
- 用途:用于存储常量数据,如字符串常量。
- 特点:
- 内存分配在程序启动时完成,程序结束时释放。
- 常量数据通常是只读的,尝试修改会导致未定义行为。
常量指针和指针常量
const void* ptr : 指向不可修改的左值,但指针可修改;
void* const ptr :指针本身是不可修改的左值,但指向的值可修改;
5. 代码区(Code Segment)
用途:用于存储程序的执行代码(机器指令)。
特点:
- 通常是只读的,防止程序意外修改指令。
- 代码区的内存分配在程序启动时完成,程序结束时释放。
示例:
1 2 3
void qux() { // 函数体中的代码存储在代码区 }
cpp类对象内存布局
- 普通对象
- 成员变量:
- 静态成员变量:静态存储区
- 非静态成员变量:存放在对象内,遵循字节对齐原则存放;存在位置根据对象的存放位置不同而不同;
- 成员函数:
- 虚函数:存在在代码区,函数地址存放在相关联的虚表里
- 成员函数:存放在代码区,函数地址在编译期间确定
- 继承对象
- 单继承:首先是父类的成员变量,然后是自己的成员变量;如果带有虚函数,派生类对象中开始的部分是虚函数表指针vptr,然后才是父类的成员变量
- 多继承:这里说一下多继承带有虚函数的情况:多继承下带有几个父类带有虚函数,就有几个虚函数表指针
智能内存管理 RAII
介绍下三种智能指针、阿把阿把说说独占、共享、弱引用指针,在介绍弱指针的时候,可以不仅仅说为了解决共享指针的循环引用问题,还可以说说可以解决生命周期的问题。A持有B的弱指针,在需要延长生命周期B的地方去将弱指针升级为共享指针,不需要时再降级。
独占指针
独占指针默认成本和裸指针一致,只有一个字节。独占指针内部禁用拷贝语义 / copy,只允许移动语义。用法如下:
1
2
3
4
5
6
auto ptr = std::make_unique<A>();
A* a = new A();
std::unique_str<A> ptr(a); // 两种创建方式:一种从裸指针转换, 一种通过构造。
// 转移所有权
auto ptr_move = std::move(ptr);
unique pointer 使用场景
- 通过get调用获取裸指针,但不获取其所有权,资源的释放根据RAII理念负责,在unique指针被销毁时释放资源。
- 用作工厂模式的返回值,返回其所有权。
unique pointer 如何实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
template<typename T, typename Deleter = std::default_delete<T>>
class unique_ptr {
T* ptr_; // 指向管理的对象
Deleter del_; // 删除器(可调用对象)
public:
// 构造函数
explicit unique_ptr(T* p = nullptr) : ptr_(p) {}
// 自定义删除器构造
unique_ptr(T* p, Deleter d) : ptr_(p), del_(std::move(d)) {}
// 析构函数:自动释放资源
~unique_ptr() {
if (ptr_) {
del_(ptr_); // 调用删除器
}
}
// 禁止拷贝
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
// 移动构造
unique_ptr(unique_ptr&& other) noexcept
: ptr_(other.ptr_), del_(std::move(other.del_)) {
other.ptr_ = nullptr; // 转移所有权
}
// 移动赋值
unique_ptr& operator=(unique_ptr&& other) noexcept {
reset(); // 释放当前资源
ptr_ = other.ptr_;
del_ = std::move(other.del_);
other.ptr_ = nullptr;
return *this;
}
// 释放并置空
void reset(T* p = nullptr) {
T* old = ptr_;
ptr_ = p;
if (old) del_(old); // 调用删除器
}
// 释放所有权(不释放内存)
T* release() {
T* p = ptr_;
ptr_ = nullptr;
return p;
}
// 访问内部指针
T* get() const { return ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
// 布尔转换
explicit operator bool() const { return ptr_ != nullptr; }
};
共享指针
和独占指针不同,共享指针共享对象的所有权,也就是某一个共享指针的销毁不会带来所指对象的内存释放,只有最后一个指向的对象的共享指针销毁才会使得内存释放。需要维护引用计数,同时保证线程安全,会提高多核开销。
共享指针基本使用
1
2
3
4
5
6
7
8
9
10
11
std::shared_pointer<T> ptr = std::make_shared(args); // 创建共享指针
std::shared_pointer<T> ptr(q); // q 为共享指针
std::shared_pointer<T> ptr(u); // u 为独占指针,将u的所有权转移给共享指针,并重置u
ptr.get(); // 获取底层指针
ptr.use_count(); // 获取目前引用计数
ptr.unique(); // 如果引用计数为1,则返回true
ptr.reset();
ptr.reset(q);
ptr.reset(q, d);
ptr.swap(q); swap(q, ptr);
共享指针的内存模型
共享指针不向独占指针是零成本开销的,是有额外的空间成本的。共享指针和虚弱指针之间需要共享一个控制块,这个控制块维持着一些信息用于实现共享功能。这些信息包括引用计数,弱引用计数。控制块还有个删除器,用于用户自定义释放共享指针所指内存。
make_share 和 直接创建share指针的区别
- 直接使用裸指针创建share指针存在内存泄漏的风险,比如下面的代码:
1
2
3
4
auto* p1 = new A();
auto* p2 = new B();
shared_ptr<A> sp1(p1); // 抛出异常,导致p2所指内存无法被释放,造成内存泄漏
shared_ptr<B> sp2(p2);
- make_share 只需要分配一次内存,而直接使用裸指针需要分配两次内存。make_share内存分布具有连续性
虚弱指针
智能指针最佳实践
智能指针仅用于管理内存,不要用于管理非内存资源。非内存资源使用RAII类封装
用unique_ptr表达唯一所有权
用shared_ptr表达共享所有权
优先采用unique_ptr而不是shared_ptr,除非需要共享所有权
针对共享情况考虑使用引用计数
使用make_unique()创建unique_ptr
使用make_shared()创建shared_ptr
- 优势:减少分配次数;内存具有本地性;异常安全
- 劣势:weak_ptr拖延整块内存释放时间
使用weak_ptr防止shared_ptr的循环引用
原始指针(T*)或引用(T&)没有所有权,
以智能指针为参数,仅用于明确表达生存期语义
使用unique_ptr参数表达所有权转移
使用shared_ptr参数表达不同共享所有权意图
如果不传递所有权,应当接受T*或T&参数而不是智能指针
不要把来自智能指针别名的指针或引用传递出去
- 单个表达式仅进行一次显式资源分配
Cpp 新特性
掌握 auto、decltype
auto:变量类型推断;decltype:表达式类型推断
类型推断可以在编译器就推导出变量或者表达式的类型,方便开发者编码简化代码。
- decltype:
decltype(expression) var将 var 的类型定义为 expression 的类型。- decltype 只会返回表达式的类型,不会对表达式进行求值。
- 如果表达式是一个变量,decltype 返回该变量的类型;如果表达式是一个函数调用,decltype 返回函数的返回类型。
for range
for range:for(auto& var : container)
function & bind & lambda 函数绑定
function 对象通常使用bind和lambda函数绑定。
- bind:
std::function<int(int, int)> func = std::bind(&A::print, &a, std::placeholders::_1, std::placeholders::_2);
std::placeholders::_n 表示占位符,表示func调用中的第n个参数。
- lambda
1 2
std::function<void(int)> func = [&a](int x) { a.print(x); }; std::function<void(int)> func = [](int x) -> int { return x; };
捕获:
- [&]:捕获所有外部变量
- [=]:捕获所有外部变量的值
- [a]:捕获 a 变量
- [&a]:捕获 a 变量的引用
- [&, a]:捕获所有外部变量的引用,但 a 除外
- [=, &a]:捕获所有外部变量的值,但 a 除外
- []
smart pointer / 智能指针
智能指针:std::shared_ptr, std::unique_ptr, std::weak_ptr
- unique_ptr / 独占指针:
std::unique_ptr<int> p(new int(10));/std::unique_ptr<int> p = std::make_unique<int>(10);
独占指针拥有持有资源的所有权,资源不能拷贝,只能移动所有权
- shared_ptr / 共享指针
相比于独占指针,共享指针持有的资源可以在多个共享指针中共享,每多一个共享指针,资源的引用计数加一。当共享指针析构时,引用计数减一,当判断到引用计数为0时,资源被释放。
1
2
std::shared_ptr<int> p1(new int(10));
std::shared_ptr<int> p2 = p1;
- weak_ptr / 弱指针
弱指针的存在是为了解决share_ptr的引用循环的问题。弱指针不会增加资源的引用计数,当资源被释放后,弱指针不会自动释放。什么是引用循环的问题呢?两个对象互相引用,导致资源无法释放。
1
std::weak_ptr<int> p3 = p1;
share_ptr和weak_ptr的底层实现
共享指针和弱指针的实现比较类似,都是指向数据的指针和指向控制块的指针。使用共享指针创建出来的弱指针内部的控制块共享。
控制块的结构如下:
1
2
3
4
5
6
struct ControlBlock {
std::atomic<size_t> share_count;
std::atomic<size_t> weak_count;
void* ptr;
Deleter deleter; // 用于释放ptr所指的资源
};
所指资源由共享指针的析构负责释放;控制块由弱指针的析构负责释放。
explicit default delete
- explicit:显示构造函数,禁止隐式构造。
- default:声明默认构造函数,就不用显式定义函数体了
- delete:禁用函数,比如禁用拷贝构造函数和拷贝赋值运算符,在unique_ptr中就需要使用delete禁用拷贝构造函数和拷贝赋值运算符。 default和delete搭配使用可以让对象只能显式构造生成。
移动语义
在移动语义出现前,对象的构造只能通过复制数据进行,这种构造称作拷贝构造和拷贝赋值;拷贝存在性能和内存上的开销,于是在cpp11之后,cpp支持移动语义,为对象的构造提供移动数据的方式进行。移动数据是指不产生新的数据,而是将原有的数据的指针赋值给新的指针,并将原来的指针置空。这个过程称为移动所有权。移动同样可以为对象的构造提供数据,但是原来的对象的数据会失效,所以不是所有的对象引用都可以用于移动构造的,只有声明为右值引用的对象才可以被移动构造函数作为参数承认。通常情况下,移动开销低于拷贝开销。
右值引用
前面提到移动语义第一个重点,只有右值引用作为形参类型传入,才会被编译器识别调用移动构造赋值函数。
1
2
T(T&& t) {};
T operator=(T&& t) {};
那什么是右值,什么是右值引用呢?右值被定义为生命周期短的,无法被寻址的,使用一次后被释放的对象。右值引用就是绑定到右值上的引用,可以作为具备移动特性的函数的形参类型声明,表示需要传入右值。什么表达式的返回值能被绑定到右值引用上呢?大致分为三类:
- 字面量
- 常量表达式
- move函数
字面量和常量表达式的返回值都是右值,生命周期仅为当前语句。move函数接收左值对象,并将其声明为右值,返回其右值引用。
std::move()
move函数底层实现如下:
1
2
3
4
5
6
7
8
9
template<typename T> //在std命名空间
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = //别名声明,见条款9
typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
可见move的底层执行非常简单,就是将一个通用引用的引用消除找到原始的左值类型并转化为对应的右值引用。
如何使用移动去创建对象呢?常见的做法是指针赋值,并将原指针置空。形参是右值引用的函数,其都具备移动特性,传入的右值将不能再使用,这需要注意。
移动益处 / 移动何时对提高性能有效
- 临时对象的传递和返回
函数返回内部局部变量时,编译器自动执行移动构造而不是拷贝构造。
1
2
3
4
std::vector<int> createVec() {
std::vector<int> vec = std::vector<int>();
return vec; // 这里执行的对返回值执行的是移动语义而不是拷贝语义
}
- 容器扩容:容器扩容时,移动数据而不是拷贝数据
- 储存大对象的容器的插入操作
完美转发
完美转发允许函数模板将其参数完美地转发给另一个函数,无论参数是左值还是右值。完美转发通常使用 std::forward 实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <utility>
void process(int& x) {
std::cout << "Lvalue reference" << std::endl;
}
void process(int&& x) {
std::cout << "Rvalue reference" << std::endl;
}
template <typename T>
void forwarder(T&& arg) {
process(std::forward<T>(arg));
}
int main() {
int a = 10;
forwarder(a); // 调用 process(int& x)
forwarder(20); // 调用 process(int&& x)
forwarder(std::move(a)); // 调用 process(int&& x)
return 0;
}
完美转发通常在函数模板中使用,函数模板参数是万能引用,传入左值被解析为左值引用,右值被解析为右值引用;在通过forward函数转发给内部函数。内部函数如果有左值引用和右值引用的重载,就可以被正确区分;
委托构造与继承构造
委托构造:在一个类中有多个构造函数时,构造函数可以调用其他构造函数,减少代码冗余。
1
2
3
4
5
6
7
8
class A {
public:
A(int a, int b) : _a(a), _b(b) {}
A(int a) : A(a, 0) {}
private:
int _a;
int _b;
};
继承构造:子类构造函数可以调用父类构造函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <string>
// 父类
class Base {
public:
int baseVar;
// 父类构造函数
Base(int var) : baseVar(var) {
std::cout << "Base class constructor called with baseVar = " << baseVar << std::endl;
}
};
// 子类
class Derived : public Base {
public:
std::string derivedVar;
// 继承父类构造函数
using Base::Base;
// 子类构造函数
Derived(int baseVar, const std::string& var) : Base(baseVar), derivedVar(var) {
std::cout << "Derived class constructor called with derivedVar = " << derivedVar << std::endl;
}
};
int main() {
// 使用继承的父类构造函数创建子类对象
Derived obj1(10);
std::cout << "Base class variable: " << obj1.baseVar << std::endl;
// 使用子类自己的构造函数创建子类对象
Derived obj2(20, "Hello");
std::cout << "Base class variable: " << obj2.baseVar << std::endl;
std::cout << "Derived class variable: " << obj2.derivedVar << std::endl;
return 0;
}
random 随机值的获取
random库,组件分为两种,一种是随机数引擎类、另一种是随机数分布类
随机数引擎类
随机数分布类
to_string()
- to_string():将数字转换为字符串
1
2
int a = 10; // long, long long, unsigned, unsigned long, unsigned long long, float, double, long double
std::string str = std::to_string(a);
面向对象
虚函数相关
虚函数是怎么实现的?它存放在哪里,在内存的哪个区里?什么时候生成的?
在CPP中,虚函数的实现原理基于两个关键概念:虚函数指针指针(vptr)和虚函数表(vtable)。
- 虚函数指针:每个包含虚函数的类对象中都会生成一个指向虚表的指针,这个指针被称为虚表指针。虚表是一个函数指针数组,里面存放着虚函数的地址。这个虚表在编译期间生成,并且会放在文本段,由所有的类对象共享。这个指针的初始化是在构造函数中执行的
- 虚函数表:本质上就是函数指针数组,存放着类中所有虚函数的实现的地址(在代码段中)
当基类和派生类中都包含虚函数时,在构造的时候就会初始化虚函数表。同时派生类会继承父类的基函数表,如果派生类没有重写基类中的某个虚函数,表中就继承这个父类中实现这个虚函数的函数指针。
当一个指针/引用调用一个函数时,被调用的函数是取决于这个指针/引用指向的对象。如果是基类对象,就调用对象的指针;如果是派生类就调用派生类对象的方法。如果派生类中没有实现,由于虚表的继承特性,会直接调用到继承下来的基类的虚函数实现。
虚函数指针存放在对象内存的头四个字节(64位8个字节),虚函数存放在代码区,在编译的时候生成。
父类的构造函数和析构函数是否能为虚函数?
- 构造函数不能为虚函数,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存放在对象的内部空间之中,需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化,导致无法构造对象。
- 构造函数设置为虚函数是不合理的,虚函数是为了多态调用,指针在声明周期内调用不同对象的同一虚函数,但是构造函数在对象的生命周期中只会调用一次,没有多次调用的可能;
- 析构函数必须设为虚函数:当我们使用父类指针指向子类时,只会调用父类的析构函数,子类的析构函数不会被调用,容易造成内存泄漏。
1
2
A* a = new B();
delete a;
如果没有定义析构函数为虚函数,这个时候只会执行A的析构函数,而不会执行B的析构函数,导致B的资源没有被释放,造成内存泄漏。
在构造函数(析构函数)中调用虚函数会发生什么?
这个问题的本质就是想跟你说,虚函数在构造函数和析构函数中无法进行动态联遍,只能进行早绑定这个问题。
会导致未定义的行为, 也就是程序会出现莫名其妙的行为. 给个代码的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
class A {
public:
virtual void show(){
cout<<"in A"<<endl;
}
virtual ~A(){show();}
};
class B:public A {
public:
void show(){
cout<<"in B"<<endl;
}
};
int main() {
A a;
B b;
}
在这个例子中, 你来猜一猜最后会输出什么? 答案是:
1
2
in A
in A
为什么子类定义的虚函数在父类的析构函数中没有被动态联遍呢? 在构造和析构函数里实际上会发生的是静态联编,也就是不会对虚函数去动态绑定;如果你在一个父类的构造函数中使用了虚函数,那么子类构造的时候会先调用父类的构造函数, 这个时候不会是我们以为的那样会调用重写的虚函数,导致一些意想不到的情况发生. 比如你以为在子类重写后会调用子类的版本。
在多继承的情况下, 子类中会有多少个虚表指针?
不同的编译器实现不一样,有的编译器会将多个父类的虚函数表合并,使得子类只需拥有一个虚函数指针即可;有的编译器不做处理,子类拥有多个vptr;
vtable里除了虚函数指针还有什么?
偏移量信息:当存在虚继承的时候,子类不会直接存储虚父类的成员变量,而是会在vtable中储存一个跳转指针vbase_offset,来访问虚父类的数据。但是vbase并不是虚父类的地址,vbase + vbase_offset才是虚父类的内存地址;
volatile 和 mutable
多继承与内存布局
简单非多态
虚函数 + 静态数据成员
单继承对象
多继承对象 + 虚函数
避免多继承变量歧义的机制
作用域解析运算符:
使用作用域解析运算符 :: 明确指定要访问的基类成员。 虚继承:
使用虚继承(virtual inheritance)来确保只有一个基类子对象被共享,从而避免重复继承带来的歧义。
虚继承内存布局
虚函数相关
实现多态的机制–虚函数表
基类指针通过指向不同的类对象,调用相同声明的不同实现的虚函数的过程是多态。其实现机理是通过虚函数表。当子类继承父类,父类中含有虚函数时,子类构造时会在data区创建一张虚函数表,并在子类的内存中开辟4字节的空间存放虚函数指针vptr指向这张虚表。
调用基类指针的虚函数时,会在执行期间使用所指对象的虚函数指针查找所指对象的虚函数表中虚函数的实现。这就是多态的实现机制。
虚析构
解决使用基类指针释放子类对象内存时,调用子类对象的析构函数,而不是父类自己的。防止内存泄漏。
运算符重载
如果类对象也要使用类似基本运算符操作,就需要进行类的运算符重载。除了以下运算符不能重载:
- 成员访问运算符:.(点运算符)
- 成员指针访问运算符:.* 和 ->*
- 作用域解析运算符:::
- 条件运算符:?:(三元运算符)
- sizeof 运算符
- 类型信息运算符:typeid
- 静态成员选择运算符:::
- 对齐运算符:alignof
- lambda 表达式运算符:[]
单目运算符与双目运算符
- 双目运算符重载为类的成员函数时,函数只显式声明一个参数,形参为该运算符的右操作数。比如你重载+,写在类外面可以写两个参数,但写在类里面是一个参数,因为该函数调用的时候会自动传入一个this指针,就是对象本身。
- 前置单目运算符(前置++):没有参数,返回值为引用。
- 后置单目运算符(后置++):多一个int参数,返回值为对象。(不是引用) / 待形参只是为了区分前置和后置,实际上不会用到这个参数。
注意后置:
1
2
3
4
5
Counter operator++(int) {
Counter temp = *this; // 创建当前对象的副本
++value; // 增加当前对象的值
return temp; // 返回增加前的副本
}
友元运算符 « » 运算符重载为友元函数,因为左操作数是cout,右操作数是对象,不是类的成员函数,而是标准头文件的类的函数。声明为友元函数后,那个类就可以访问你的私有成员了。
友元
在C++中,友元(friend)是一种允许一个函数或另一个类访问某个类的私有成员和保护成员的机制。友元可以是函数、类或成员函数。友元关系是单向的和非传递的,即被声明为友元的函数或类可以访问该类的私有和保护成员,但反过来不行。
友元函数
友元函数是一个可以访问类的私有和保护成员的非成员函数。它在类的定义中使用 friend 关键字声明。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}
// 声明友元函数
friend void display(const MyClass& obj);
};
// 友元函数定义
void display(const MyClass& obj) {
std::cout << "Data: " << obj.data << std::endl;
}
int main() {
MyClass obj(42);
display(obj); // 友元函数可以访问私有成员
return 0;
}
友元类
友元类是一个可以访问另一个类的私有和保护成员的类。它在类的定义中使用 friend 关键字声明。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}
// 声明友元类
friend class FriendClass;
};
class FriendClass {
public:
void display(const MyClass& obj) {
std::cout << "Data: " << obj.data << std::endl; // 友元类可以访问私有成员
}
};
int main() {
MyClass obj(42);
FriendClass friendObj;
friendObj.display(obj);
return 0;
}
友元成员函数
友元成员函数是另一个类的成员函数,可以访问该类的私有和保护成员。它在类的定义中使用 friend 关键字声明。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
class MyClass;
class AnotherClass {
public:
void display(const MyClass& obj);
};
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}
// 声明友元成员函数
friend void AnotherClass::display(const MyClass& obj);
};
void AnotherClass::display(const MyClass& obj) {
std::cout << "Data: " << obj.data << std::endl; // 友元成员函数可以访问私有成员
}
int main() {
MyClass obj(42);
AnotherClass anotherObj;
anotherObj.display(obj);
return 0;
}
访问控制
访问控制:
继承控制注意:默认是private继承,所以通常都要指定public继承。
template 模板 / 泛型编程
为什么模板不能份文件实现
函数模板
通过建立一个通用函数,其返回值类型和形参类型可以不具体制定,用一个虚拟的类型来表示
1
2
3
4
template <typename T, typename U, ...>
void func(T a, U b, ...) {
}
注意:模板函数在发生自动类型推导的时候不会进行隐式类型转换。只用显示制定类型才会触发隐式类型转换。
类模板
类模板的定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
class Myclass {
public:
T data;
void print() {
std::cout << data << std::endl;
}
};
int main() {
Myclass<int> obj; // 类模板需要显式指定类型
obj.data = 10;
obj.print();
return 0;
}
可变参数
基本概念:
- 参数包:在函数原型的声明中Args… args同理存在0个或者一个1以上的类型参数,C++中将“typename… Args”这样的语义,称为参数包 (parameter pack)。
- 包展开:参数包只有在使用时就必須把它展开变成一个个的参数,概念上称为包展开(pack expansion),将参数包当作普通的参数一样放到被调用函数的参数列表的最后一个位置,并在后面加上 …
可变参数模板的本质是允许函数或类接受任意数量的参数,并通过递归或参数包展开的方式处理这些参数。在这个示例中,make_unique 函数模板通过展开参数包,将所有参数传递给 T 的构造函数,从而创建一个 T 类型的对象。
1
2
3
4
5
6
7
8
9
10
11
12
// 函数模板中的可变参数 / 递归使用
template <typename T, typename... Args>
void func(T a, Args... args) {
std::cout << a << std::endl;
func(args...);
}
// 类模板中的可变参数 / 函数包展开使用
template <typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique<T>(new T(std::forward<Args>(args)...));
}
STL
容器 / Container
- 序列容器:array、vector、queue、deque、priority_queue、stack、list、forward_list
关联容器:map、set multimap、multiset 关联容器:unordered_map、unordered_set unordered_multimap、unordered_multiset
vector
基本操作:
迭代器:
vector的底层原理 vector底层是一个动态数组,维护着三个指针,start指针 / 指向储存元素的首地址,finish指针 / 指向当前储存元素的末尾的下一个地址,end_of_storage指针 / 指向当前分配的内存空间的末尾地址。start和finish可以计算size,end_of_storage可以计算capacity。
vector如何实现自动缩扩容?
- 扩容的过程:当finish指针超过end_of_storage指针时,会触发扩容。会申请新的动态数组,大小为之前的两倍。拷贝旧数据,释放旧数据,更新内部指针和容量值。
- 缩容的过程:实际上vector不会自动缩容,而是需要我们自己手动调用shrink_to_fit()函数来缩容。这个函数会将容量缩小到当前的size大小。
array
底层和vector很像,就是大小固定,并且没有所扩容机制。
list
vector和list的区别
思路:分别说说两种数据类型的特性,并进行比较。
- vector底层数据结构和连续数组类似,拥有一块连续的内存空间。因为是连续的内存空间,vector能够实现常数级查找,查找效率高;但是插入删除效率低,因为需要移动插入点和删除点之后的元素保证连续性。
- list底层数据结构采用双向链表,内存分布是随机的;list的查找效率低,访问需要遍历整个链表,线性级别;但是插入删除效率高,不需要移动已有的内存。
- list的删除操作不会导致迭代器失效,也是唯一的操作不会导致迭代器失效的容器;
deque && stack
deque是双端队列,底层是一个动态数组,但是是一个双向的动态数组。deque的特点是可以在两端进行插入和删除操作,而vector只能在尾部进行插入和删除操作。deque的底层是一个动态数组的数组,每个动态数组的大小是固定的,当一个动态数组满了,会再申请一个新的动态数组。deque的迭代器是随机访问迭代器。
栈通过底层容器来实现接口服务,底层的容器时可插拔的。所以栈 / stack一般不归为STL容器内,它是由STL容器实现的,而是被归为container adapter。栈的底层容器实现可以选择3个:vector, deque(默认), list。
常见的面试题,问你如何使用stack实现deque,需要两个stack,分别是输入栈和输出栈;push推入输入栈,pop推出输出栈,如果输出栈为空,查看输入栈是否为空,不为空全部推入输出站,然后推出。
问你如何用deque实现stack?其实使用一个队列就行,pop的时候把队列中【0,size-1)个元素推到队尾,其实对头就是需要返回的元素,推出返回即可;top同理,就是推出后推入队尾不丢弃。
map
map保存的是键值对,可以通过key来快速查找添加删除,但是还是O(logN)。底层使用红黑树。map中的元素是有序的。
基本操作:
- 迭代器:
1
2
3
4
5
6
7
for(auto it = m.begin(); it != m.end(); ++it) {
std::cout << it->first << " " << it->second << std::endl;
}
for(auto& [key, value] : m) {
std::cout << key << " " << value << std::endl;
}
- CURD
unordered_map
unordered_map是基于哈希表实现的,查找、插入、删除的时间复杂度是O(1)。unordered_map中的元素是无序的。
基本操作可以上网查
unordered_map 与 map的区别
为什么容器操作中的emplace的执行效率要更高?
如果要将一个结构体类型的实例,放入到容器中,一般有两个步骤:
- 构造这个实例
- 将这个实例copy到容器中
而这个copy的过程可以使用两个函数,一是拷贝构造函数,二是移动构造函数。push_back()和insert()函数就是按照这个步骤来的。
但是对于emplace_back() 和 emplace()函数,它们是直接在容器中的指定直接构造这个实例,而不是先构造再拷贝。所以效率更高。这就是区别。只有一个步骤
为什么map需要比vector多一次移动构造,应该是在构造pair的时候多出来的。
unordered_map 如何解决链地址法在哈希冲突发生频繁时查询性能退化为线性的问题
红黑树退化,unordered_map在检测到某个桶的链表长度超过一定阈值的时候,会将桶中的链表重新组织成红黑树。
迭代器 / iteration
迭代器用于遍历访问容器内的元素,使用类似于指针,作用是使得所有容器的访问拥有一套相同的流程;本质的作用是解耦容器的访问方式和容器的类型,使得算法和容器进行解耦,不同的容器可以使用相同的算法,相当于提供相同的接口。
迭代器的获取:迭代器的获取根据迭代器的范围的不同而不同。
正向迭代器:c.begin() c.end() 返回指向首元素和尾元素的后一个元素0
反向迭代器:c.rbegin() c.rend() 返回指向首元素的前一个元素和尾元素
迭代器类型
迭代器也分为const和非const两种类型,根据容器本身的可写性和获取方法决定。
c.cbegin() c.cend() 返回const类型的迭代器
迭代器失效
迭代器失效是stl篇的重要内容,迭代器使用不善会带来指针使用不当相同的问题。指针失效的问题在迭代器上同样会产生,所以掌握迭代器失效时机和如何处理迭代器失效就非常重要 了。迭代器失效的时机通常发生在对容器的操作时发生,下面来一次看看对容器的操作会如何导致迭代器失效:
迭代器失效有个指导思想:操作导致的内存重新分配的部分的迭代器失效。
insert / push_back / push :插入类型操作。
插入操作会导致容器size变大,当size大到触碰cap时会触发扩容,导致当前迭代器失效,也就是指向无效内存。
解决方案有两个:一是为容器resize足够大的内存空间;二是每次执行insert类型的操作后使用begin+cur_pos更新迭代器;
erase:删除操作
还记得我们的迭代器失效的思想吗,erase操作后,什么内存会被重新分配,删除点之后的内存会被前移,也就是当前迭代器之后的迭代器全部失效。
解决方案同样有两个:一是重新获取当前迭代器,使用begin;二是erase调用通常会返回下一个迭代器,直接更新到当前迭代器即可。
序列容器和关系容器删除操作返回值不同,序列容器返回删除迭代器的下一个迭代器,关系容器返回void;所以关系容器要这样删除:map.erase(it++);
迭代器操作
*iter,表示返回迭代器iter所指元素的引用iter->num,解引用iter并且获得该元素中名为num的成员,等价于(*iter).mem,即箭头运算符把解引用和成员访问两个操作结合在了一起。++iter,让iter指向容器中的下一个元素--iter,让iter指向容器中的上一个元素iter1==iter2iter1!=iter2,判断两个迭代器是否相等,即是否指向同一个元素或者指向同一个容器的尾后迭代器。
尽量使用!=而不是使用<,因为所有的标准容器和string都定义了==和!=,而只有部分容器和string定义了<,所以==和!=是比较通用的。
迭代器种类
迭代器共分为五种种类,分别是输入迭代器,输出迭代器,前向迭代器,双向迭代器,随机访问迭代器;下面一一介绍:
输入迭代器
重要的特性是只读,只可以读取但不能修改,支持*操作读取迭代器指向的对象。
输出迭代器
与输入迭代器相反,输出迭代器的功能是写入,但不能读取。
前向迭代器
前向迭代器是支持*解引用运算符和++递增运算符的迭代器,用于向后访问迭代器;vector迭代器就是前向迭代器。
双向迭代器
在前向迭代器的基础上支持向前遍历的能力,也就是–运算符;list的迭代器就是双向迭代器。
随机访问迭代器
能够支持常数级访问,+,-运算符的时间复杂度是常数级,只有vector和deque是常数级的。
并发模型
聊聊cpp并发编程
对cpp并发编程思想和方法论进行一个总的阐述和说明,作为本篇章的开始。现代计算机cpu架构越来越先进,核数越来越多,并行计算能力越来越强。想要将cpu多核计算的能力发挥到极致,需要强大的并发编程的能力。绝大多数语言都支持并发编程,有大量的库可以去使用,cpp更不用说。
首先我们先讲讲并发这个最最基本的概念,什么是并发。很多同学可能了解大量的并发编程技术,但是对于并发这一概念的理解仍然不够透彻。并发就是指多个任务的执行存在重叠的部分,也就是某任务未执行完时,另一个任务开始执行。任务不会等待当前运行任务执行完在执行。这其实是合理的,符合操作系统底层线程调度思想的。所以并发编程本质上也是对操作系统多任务处理特性进行服务的。相较于并发,还存在并行这个概念,并行和并发并不相同。我们讲到并发任务之间存在重叠,但是并行任务之间不存在重叠,并行任务完全独立并同时运行。只有多核环境下能够并行,但是单核环境下能够并发。
知道何为并发,再来讲讲为什么并发。首先肯定是性能,并发编程能够提高任务处理的效率。试想一下,有两种任务,一种在单线程下完成,一种使用并发技术能够在多线程下完成。完成该任务所需的计算量是一致的,肯定是能被同时调度的多线程程序更快完成。对于并行程序的设计,大致采用两种思路:
- 任务被分解为多个子任务交由不同的线程并发执行
- 不拆分任务,但是让任务冗余运行。
第二个好处是关注点分离 / SOC,将复杂任务进行拆解,专注于当前线程应该完成的任务和不同线程之间通信的问题。
当然并发并不是无脑选择的,需要评估并发带来的收益和代价。代价有两种形式:一是线程的开销,线程创建和上下文的切换和销毁的代价和线程任务的运行时间差不多的情况下,并发是低效的;二是心智成本,并发编程的代码通常是可读性较差的,并发带来的问题通常不直观,比如数据竞争的问题。
讲完为什么自然就是怎么做了。下面讲讲如何使用c++11标准库进行并发编程。并发编程通过线程来实现,将不同的任务分配到指定的线程中去执行。所以了解线程管理是必不可少的。线程管理部分会分成四个部分来讲解:线程函数,线程创建,线程结束控制和向线程传递对象;
线程函数
我们知道,进程一定存在入口函数,当内核准备好进程的各种状态时,就会交由入口函数继续执行。线程也一样需要入口函数,这个函数就称为线程函数。线程函数有四种情况:
- 普通函数
- 非静态类成员函数
- 闭包匿名函数
- 可调用类对象
线程创建
线程创建使用线程函数,c++11之后推荐使用列表初始化来创建线程对象。现代c++提供统一的方法去创建线程,让我们向创建文件、网络资源一样创建线程资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::thread t {std::function<void()> func}; // 普通函数
ClassA classa;
std::thread t {classa.funca, &classa}; // 非静态类成员函数
std::threadt {[](){}}; // 匿名闭包函数
class ThreadFunc {
public:
void operator()() const {
call();
}
};
ThreadFunc func;
std::thread t {func}; // 可调用类对象
注意类成员函数需要传递类对象指针。
线程结束
正如动态申请的内存必须主动释放一样,线程作为一种系统资源,也必须做到“有始有终”。在 C++ 中,std::thread 对象代表了对底层线程的控制权。一旦一个线程启动,我们必须在 std::thread 实例被销毁之前,显式地决定该线程的最终命运——是等待其完成(接合),还是将其分离并交由系统托管。
如果一个 std::thread 对象在仍处于“可连接”(joinable)状态时被销毁(例如函数返回或异常抛出导致栈展开),其析构函数会自动调用 std::terminate(),直接终止整个程序。这是一个非常严重的问题,因此确保每个线程都被正确地 join() 或 detach() 是编写安全多线程程序的基本要求。
接合(Joining)线程
调用 join() 会阻塞当前线程,直到目标线程执行完毕。这通常用于需要等待子线程完成任务后再继续执行后续逻辑的场景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void do_work() {
// 模拟耗时操作
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Work done.\n";
}
int main() {
std::thread t{do_work};
std::cout << "Main thread waiting...\n";
t.join(); // 阻塞直到 t 执行完成
std::cout << "Main thread continues.\n";
return 0;
}
⚠️ 注意:
join()只能调用一次。一旦线程被join(),std::thread对象将不再处于joinable()状态,再次调用join()会导致未定义行为。
分离(Detaching)线程
调用 detach() 会将线程与 std::thread 对象解耦,使其在后台独立运行。此后,原线程对象不再能控制或等待该线程。这种线程常用于“火与忘”(fire-and-forget)任务,例如日志记录、心跳检测等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void background_task() {
for (int i = 0; i < 5; ++i) {
std::cout << "Background: " << i << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
}
int main() {
std::thread t{background_task};
t.detach(); // 分离线程,不再跟踪
std::this_thread::sleep_for(std::chrono::seconds(3));
std::cout << "Main thread exits.\n";
// 此时 background_task 可能仍在运行
return 0;
}
❗ 重要提醒:分离的线程必须确保其访问的所有数据在其生命周期内有效。否则,将导致悬空引用或未定义行为。
典型错误:悬空引用问题
以下是一个常见的陷阱示例:主线程中局部变量在函数返回后被销毁,而分离的子线程仍试图访问它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct func {
int& i_;
func(int& i) : i_(i) {}
void operator()() {
for (int j = 0; j < 1000000; ++j) {
++i_; // 危险!i_ 引用的变量可能已被销毁
}
}
};
void bad_reference_example() {
int local = 42;
func f{local};
std::thread t{f};
t.detach(); // 子线程开始运行
return; // local 被销毁,但子线程还在用它!
}
上述代码中,local 在函数退出时被销毁,但子线程可能仍在运行并访问 i_,这是典型的未定义行为。解决方法包括:
- 使用值传递而非引用;
- 使用智能指针(如
std::shared_ptr)共享数据; - 确保数据的生命周期长于线程。
异常安全与 RAII:使用 ThreadGuard
在复杂逻辑或可能发生异常的代码中,很难保证 join() 或 detach() 一定会被执行。为此,我们可以利用 RAII(Resource Acquisition Is Initialization)思想,设计一个 ThreadGuard 类,确保线程总能被正确处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <thread>
class ThreadGuard {
std::thread& t_;
public:
explicit ThreadGuard(std::thread& t) : t_(t) {}
~ThreadGuard() {
if (t_.joinable()) {
t_.join(); // 安全地等待线程结束
}
}
ThreadGuard(const ThreadGuard&) = delete;
ThreadGuard& operator=(const ThreadGuard&) = delete;
};
使用示例:
1
2
3
4
5
6
7
8
9
10
11
void safe_thread_usage() {
std::thread t{[] {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Task completed.\n";
}};
ThreadGuard guard{t}; // RAII 守卫
// 即使这里抛出异常,guard 的析构也会调用 join()
do_something_risky(); // 可能抛异常
} // guard 析构,自动 join
通过 ThreadGuard,我们无需担心异常路径下线程资源的泄漏或程序崩溃,极大地提升了代码的健壮性。
向线程传递参数
前面我们只展示了无参线程函数的使用。实际上,std::thread 支持向线程函数传递任意数量的参数。这些参数会被拷贝到新线程的内部存储空间中,然后作为实参传递给线程函数。
基本传参方式
1
2
3
4
5
6
void demo(int num, const std::string& str) {
std::cout << "Num: " << num << ", Str: " << str << "\n";
}
std::thread t{demo, 42, "hello thread"};
t.join();
上述代码中,"hello thread" 是 const char* 类型,它首先被拷贝进线程内部,然后在调用 demo 时转换为 std::string。这个过程是安全的,因为字符串字面量的生命周期是全局的。
陷阱:延迟转换导致悬空指针
当传入的是局部数组或 C 风格字符串时,必须格外小心:
1
2
3
4
5
6
7
8
void bad_buffer(int param) {
char buffer[2048];
sprintf(buffer, "%d", param);
std::thread t{demo, 42, buffer}; // buffer 是 char*
t.detach();
return; // buffer 被销毁,但线程可能还未完成 string 转换!
}
问题在于:std::thread 构造函数只是拷贝了 buffer 的指针,而不是字符串内容。如果线程在 buffer 被销毁后才尝试将其转换为 std::string,就会访问非法内存。
✅ 正确做法:在传递前完成类型转换:
1
std::thread t{demo, 42, std::string{buffer}}; // 立即构造 string 对象
传递引用:使用 std::ref
默认情况下,所有参数都是按值拷贝的。如果你希望线程函数通过引用修改外部变量,必须使用 std::ref:
1
2
3
4
5
6
7
8
9
10
void update(double weight, WeightedData& data) {
data.set_weight(weight);
}
void correct_update(double weight) {
WeightedData data;
std::thread t{update, weight, std::ref(data)}; // 传递引用
t.join();
process(data); // data 已被修改
}
🔍 提示:
std::ref返回一个std::reference_wrapper,它可以被拷贝,但保留对原始对象的引用。
移动语义:使用 std::move
对于不可拷贝的对象(如 std::unique_ptr),可以通过 std::move 将其所有权转移给线程:
1
2
3
4
5
6
7
void process_data(std::unique_ptr<Data> ptr) {
// 处理数据
}
std::unique_ptr<Data> data = std::make_unique<Data>();
std::thread t{process_data, std::move(data)};
t.join();
此时,data 的所有权被转移到新线程中,主线程不能再访问它。
以非静态成员函数作为线程函数
非静态成员函数可以作为线程入口,但需注意两点:
- 第一个参数是隐式的
this指针; - 必须确保对象的生命周期长于线程。
1
2
3
4
5
6
7
8
9
10
class Worker {
public:
void work(int id) {
std::cout << "Worker " << id << " is working.\n";
}
};
Worker worker;
std::thread t{&Worker::work, &worker, 42}; // &worker 是 this 指针
t.join();
⚠️ 同样要注意对象生命周期问题。如果
worker是局部变量且线程被分离,则可能导致访问已销毁对象。
进程、线程、协程
进程间通信 IPC
常用的进程间通信的机制有:管道、消息队列、信号量、共享内存、信号、套接字等。
线程模型 / 线程函数
1
2
3
std::thread(std::function<void()> func);
thread.join();
thread.deptch();
线程间通信
消息队列、信号量、锁机制、条件变量等。对于线程间通信,大致分为两个问题:资源竞争问题和资源同步问题。下面分别介绍这两种问题在cpp这门语言中如何解决。
共享资源
当有一个资源,在多个线程中都需要访问,那么就会出现资源竞争的问题。那么如何处理资源竞争的问题呢?通常使用互斥量和条件变量来解决。
互斥量(Mutex)有锁定和未锁定两个状态。互斥锁的解锁不推荐使用lock和unlock这样的方法,而是使用管理类std::lock_guard,std::unique_lock等。
std::lock_guard
1
2
3
4
std::mutex m;
{
std::lock_guard<std::mutex> lc{m};
}
我们来关注一下底层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
_EXPORT_STD template <class _Mutex>
class _NODISCARD_LOCK lock_guard { // class with destructor that unlocks a mutex
public:
using mutex_type = _Mutex;
explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
_MyMutex.lock();
}
lock_guard(_Mutex& _Mtx, adopt_lock_t) noexcept // strengthened
: _MyMutex(_Mtx) {} // construct but don't lock
~lock_guard() noexcept {
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
这个锁的特点是会持有互斥量的引用。
可以看到,当lock_guard对象创建的时候,会将传入的互斥量进行上锁,在析构的时候解锁。这样能够防止忘记解锁导致的死锁问题。所以我们通常会重新声明一个作用域来访问临界区。什么是临界区,就是我们在访问共享资源的上下文。讲到这里可以引出另一个概念,这个概念对多线程性能优化的问题有很大的帮助,就是锁的粒度问题。什么是锁的粒度,就是持有锁的作用域的大小。如果锁的粒度太大,那么就会导致其他线程在等待锁的时候,无法访问共享资源,从而导致性能的下降。如果锁的粒度太小,那么就会导致频繁的上锁和解锁,也会导致性能的下降。所以我们要根据实际情况来调整锁的粒度。但是为了最求效率,我们更加希望锁的粒度能够小一点。
std::try_lock try_lock 是互斥量中的一种尝试上锁的方式。与常规的 lock 不同,try_lock 会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。利用这个特性,可以实现另一种锁,叫做自旋锁。自旋锁什么时候使用呢,就是等待锁释放的时间要比线程上下文切换的时间要更短的时候使用。也就是锁的粒度非常小的时候。
我来提个问题:有锁保护的临界区是绝对线程安全的吗?答案肯定不是,如果外部持有锁内的资源的指针或者引用,那么就可以直接突破锁的控制直接访问资源。所以我们要注意不能让受保护的数据的指针或者引用传递到互斥量的作用域外。
死锁 / deadlock
在操作系统中,我们对死锁的研究比较深入。同样的,在cpp中使用互斥量同样会导致死锁的问题。死锁是怎么造成的,可以在我另一篇文章关于操作系统的文章中查看。这里讲cpp如何防止死锁情况的发生。
避免死锁有三个原则:
- 避免嵌套锁,线程不要在一个作用域内获取多个锁;如果需要,就不要使用lock_guard而是lock手动上锁解锁;
- 避免在持有锁时去调用外部代码。
- 使用固定的顺序获取锁
std::unique_lock / 灵活的锁
1
2
3
4
5
6
7
void swap(X& lhs, X& rhs) {
if (&lhs == &rhs) return;
std::unique_lock<std::mutex> lock1{ lhs.m, std::defer_lock };
std::unique_lock<std::mutex> lock2{ rhs.m, std::defer_lock };
std::lock(lock1, lock2);
swap(lhs.object, rhs.object);
}
和lock_guard的不同是,unique_lock的底层是持有互斥量的指针和一个表示对象是否拥有互斥量所有权的bool类型。当上锁时,这个bool会被设置为true,解锁时会被设置为false。所以unique_lock是一个灵活的锁,可以在任何时候上锁和解锁。
这里unique_lock有两个上锁策略:
- defer_lock:构造函数不上锁,要求在构造之后手动上锁;
- adopt_lock:构造函数不上锁,要求在构造之前手动上锁;
- 默认:构造函数会上锁,但构造函数前后不能上锁。
那为什么说这个锁灵活呢?答案就是其有lock和unlock方法,可以随时上锁和解锁。而不用像lock_guard那样只能在构造和析构的时候上锁和解锁,不用限定作用域。
互斥锁不可复制不可移动!所谓的在多个作用域传递互斥量,本质就是传递它的指针或引用而已。这里就可以体现unique_lock的灵活性了,可以通过定义unique_lock的移动构造来实现所有权的转移。
共享数据的初始化
这里只介绍三种方式:双检锁、std::call_once、静态局部变量初始化从 C++11 开始是线程安全
双检锁: 上锁前检查,上锁后修改前检查,这样可以避免多次初始化。但是仍然存在问题。就是修改可能修改一半就被调度了,导致初始化完成一半,但是被其他线程访问,发现已经有内存但是实际上没有初始化完成。
std::call_once / std::once_flag 为解决上述问题,c++11提供了std::call_once和std::once_flag。std::call_once接受一个函数和一个标志,这个函数只会被调用一次。这个函数会被调用一次,然后标志会被设置为已调用,之后再调用这个函数就不会再执行了。
1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<some> ptr;
std::once_flag resource_flag;
void init_resource(){
ptr.reset(new some);
}
void foo(){
std::call_once(resource_flag, init_resource); // 线程安全的一次初始化
ptr->do_something();
}
cpp中的读写锁–std::shared_timed_mutex / std::shared_mutex
如果存在多读少写的情况,使用mutex去保护资源会降低多线程程序的资源。std::shared_mutex 同样支持 std::lock_guard、std::unique_lock。和 std::mutex 做的一样,保证写线程的独占访问。而那些无需修改数据结构的读线程,可以使用 std::shared_lock<std::shared_mutex> 获取访问权,多个线程可以一起读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Settings {
private:
std::map<std::string, std::string> data_;
mutable std::shared_mutex mutex_; // “M&M 规则”:mutable 与 mutex 一起出现
public:
void set(const std::string& key, const std::string& value) {
std::lock_guard<std::shared_mutex> lock{ mutex_ };
data_[key] = value;
}
std::string get(const std::string& key) const {
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = data_.find(key);
return (it != data_.end()) ? it->second : ""; // 如果没有找到键返回空字符串
}
};
来个面试问题:new、delete是线程安全的吗?
在C++11之后,new和delete操作时线程安全的。但是如果我们自己重载了new和delete操作符,那么其线程安全性就需要我们自己来保护了。
同步操作
多个线程之间访问共享资源,除了有资源竞争的问题,还有资源同步的问题,也就是资源的操作可能需要有先后顺序,如果多线程访问没有顺序,就会出现问题。同步就是为了保证这个问题而存在的。
现代cpp中保证同步的最佳实践是 条件变量;
现代cpp中对于条件变量有两套实现:condition_variable 和 condition_variable_any。condition_variable_any 是对 condition_variable 的一个封装,它可以接受任何类型的互斥量,而 condition_variable 只能接受 std::mutex。
下面给个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::mutex mtx;
std::condition_variable cv;
bool arrived = false;
void wait_for_arrival() {
std::unique_lock<std::mutex> lck(mtx);
cv.wait(lck, []{ return arrived; }); // 等待 arrived 变为 true
std::cout << "到达目的地,可以下车了!" << std::endl;
}
void simulate_arrival() {
std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟地铁到站,假设5秒后到达目的地
{
std::lock_guard<std::mutex> lck(mtx);
arrived = true; // 设置条件变量为 true,表示到达目的地
}
cv.notify_one(); // 通知等待的线程
}
可以看到条件变量的使用就是获取锁、判断条件是否为真、如果为真直接执行,如果为假,就释放锁,并进入阻塞态等待被其他线程唤醒,条件为真的时候被唤醒。
条件变量的wait调用的声明其实有两种:
1
2
3
4
void wait(std::unique_lock<std::mutex>& lock); // 1
template<class Predicate>
void wait(std::unique_lock<std::mutex>& lock, Predicate pred); // 2
第二种就是要传入一个谓词,就是一个返回bool的函数,用来判断条件是否为真。
来看看底层实现:
1
2
3
4
5
6
7
8
9
10
void wait(std::unique_lock<std::mutex>& _Lck) {
_Wait_impl(_Lck, _Pred());
}
template <class _Predicate>
void wait(unique_lock<mutex>& _Lck, _Predicate _Pred) {
while (!_Pred()) {
wait(_Lck);
}
}
使用future
future这个特性可是多线程编程中非常重要的特性。用于处理线程中需要等待某个事件的情况,线程知道预期结果。等待的同时也可以执行其他的任务。是不是有点异步的味道。
首先futrue的重要作用就是,如果我们线程任务有返回值,可以通过future来捕获!下面的主题是创建异步任务获取其返回值。假设需要执行一个耗时任务并获取其返回值,但是并不急切的需要它。那么就可以启动新线程计算,然而 std::thread 没提供直接从线程获取返回值的机制。所以我们可以使用 std::async 函数模板。
使用 std::async 启动一个异步任务,它会返回一个 std::future 对象,这个对象和任务关联,将持有最终计算出来的结果。当需要任务执行完的结果的时候,只需要调用 get() 成员函数,就会阻塞直到 future 为就绪为止(即任务执行完毕),返回执行结果。valid() 成员函数检查 future 当前是否关联共享状态,即是否当前关联任务。还未关联,或者任务已经执行完(调用了 get()、set()),都会返回 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <thread>
#include <future> // 引入 future 头文件
int task(int n) {
std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\n';
return n * n;
}
int main() {
std::future<int> future = std::async(task, 10);
std::cout << "main: " << std::this_thread::get_id() << '\n';
std::cout << std::boolalpha << future.valid() << '\n'; // true
std::cout << future.get() << '\n';
std::cout << std::boolalpha << future.valid() << '\n'; // false
}
像thread一样,async也有自己的执行策略:
- std::launch::async 在不同的线程上执行异步任务
- std::launch::deferred 惰性求值,不创建线程,等待future对象调用wait或get在执行任务。
future and packaged_task
packaged_task / 封装任务,可以包装任何可调用的对象(函数,lambda表达式,function对象,bind表达式),并异步调用它们。其返回值或者抛出的异常能够被储存于能通过future对象访问的共享状态中,它通常与future一起使用。
来看看如何异步获取封装任务的返回值:
1
2
3
4
5
6
std::packaged_task<double(int, int)> task([](int a, int b){
return std::pow(a, b);
});
std::future<double>future = task.get_future();
task(10, 2); // 此处执行任务
std::cout << future.get() << '\n'; // 不阻塞,此处获取返回值
注意这样的任务不会在新的线程中执行,要想要新的线程中执行,需要调用 std::thread 来执行。
1
2
3
4
5
6
7
8
9
std::packaged_task<double(int, int)> task([](int a, int b){
return std::pow(a, b);
});
std::future<double> future = task.get_future();
std::thread t{ std::move(task),10,2 }; // 任务在线程中执行
// todo.. 幻想还有许多耗时的代码
t.join();
std::cout << future.get() << '\n'; // 并不阻塞,获取任务返回值罢了
使用promise 类模板promise用于储存一个值或一个异常,之后通过promise对象所创建的future对象来异步获得。
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 计算函数,接受一个整数并返回它的平方
void calculate_square(std::promise<int> promiseObj, int num) {
// 模拟一些计算
std::this_thread::sleep_for(std::chrono::seconds(1));
// 计算平方并设置值到 promise 中
promiseObj.set_value(num * num);
}
// 创建一个 promise 对象,用于存储计算结果
std::promise<int> promise;
// 从 promise 获取 future 对象进行关联
std::future<int> future = promise.get_future();
// 启动一个线程进行计算
int num = 5;
std::thread t(calculate_square, std::move(promise), num);
// 阻塞,直到结果可用
int result = future.get();
std::cout << num << " 的平方是:" << result << std::endl;
t.join();
通俗的来讲就是线程函数中传入一个类似于引用的对象,将任务的返回值放入到这个对象中,然后再另一个线程可以访问到。
C++20 信号量
熟悉操作系统进程间通信机制IPC的同学可能对信号量并不陌生,虽然信号量是个非常老的同步技术,但是它在C++20才被正式采用。
信号量是什么?信号量维护一个计数,这个计数不能小于0。信号量提供两个基本操作:释放(V)/ 增加计数 和 等待(P)/ 减少计数。当你执行等待操作时,在减少计数之前,如果计数为0,就会进入阻塞,直到计数大于0。正如我们在操作系统中讨论的那样,信号量被分为两种:二元信号量 / std::binary_semaphore 和计数信号量 / std::counting_semaphore。注意binary_semaphore 只是 counting_semaphore 的一个特化别名: counting_semaphore<1>。
信号量通常用于发信/提醒,而不是互斥(你互斥直接用互斥量不就完事了)。通过初始化该信号量为 0 从而阻塞尝试 acquire() 的接收者,直至提醒者通过调用 release(n) “发信”。在此方面可把信号量当作条件变量的替代品,通常它有更好的性能。
这里来看下限制web并发数的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 定义一个信号量,最大并发数为 3
std::counting_semaphore<3> semaphore{ 3 };
void handle_request(int request_id) {
// 请求到达,尝试获取信号量
std::cout << "进入 handle_request 尝试获取信号量\n";
semaphore.acquire();
std::cout << "成功获取信号量\n";
// 此处延时三秒可以方便测试,会看到先输出 3 个“成功获取信号量”,因为只有三个线程能成功调用 acquire,剩余的会被阻塞
std::this_thread::sleep_for(3s);
// 模拟处理时间
std::random_device rd;
std::mt19937 gen{ rd() };
std::uniform_int_distribution<> dis(1, 5);
int processing_time = dis(gen);
std::this_thread::sleep_for(std::chrono::seconds(processing_time));
std::cout << std::format("请求 {} 已被处理\n", request_id);
semaphore.release();
}
int main() {
// 模拟 10 个并发请求
std::vector<std::jthread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(handle_request, i);
}
}
无锁技术 / Lock - Free
无锁队列如何实现
原子操作
原始操作是一种不可中断的操作,即使在多线程环境下执行,也不会被其他线程打断或观察到部分完成的状态。原子操作通常用于多线程编程,以确保数据一致性和避免竞争条件。在C++中,atomic可以提供对基本类型的原子操作;
atomic原子性是如何实现的 atomic
std::atomic
- 硬件指令:
现代处理器提供了专门的原子操作指令,例如x86架构上的LOCK前缀指令(如LOCK XADD、LOCK CMPXCHG等),ARM架构上的LDREX和STREX指令等。 这些指令能够在多处理器环境中保证操作的原子性,即操作不可分割,不会被其他线程中断。
- 内存屏障(Memory Barrier)用于确保内存操作的顺序,防止编译器和处理器对内存操作进行重排序,从而保证多线程环境中的内存可见性。 std::atomic使用内存屏障来确保原子操作的正确性。
无锁队列
这里只介绍无锁的单消费者单生成者队列,使用环形队列。
- atomic head / 插入:nexthead = (head + 1) % size;
- atomic tail / 弹出:nexttail = (tail + 1) % size;




















