参考链接:
非常详细易懂的C++教程:learncpp
常用STL比较全的菜鸟教程
1. 重点清单
客户端开发通常非常看重内存管理、多线程与UI响应以及设计模式。
因此重点关注C++的内存模型、面向对象特性(特别是虚函数机制)、现代C++(C++11及以后)的核心特性,以及STL的底层原理。
一、 内存管理与指针(绝对重点)
客户端开发对内存泄漏极为敏感。
- 内存分区: 清楚堆(Heap)、栈(Stack)、全局/静态存储区、常量区和代码段的区别。
- 指针与引用的本质区别:
- 引用必须初始化且不可变,底层通过指针实现。
- 指针有自己的内存空间,可以为空,可以重新赋值。
- 危险的指针: 能够准确区分并说明如何避免野指针(未初始化的指针)和悬空指针(指向已被释放内存的指针)。
-
new/delete与malloc/free的区别: 前者会调用构造/析构函数,后者只是纯粹分配内存。
二、 面向对象核心机制(面试必问)
不要只记概念,一定要懂底层的实现原理。
- 多态的底层实现(虚表与虚指针):
- 知道包含虚函数的类会生成虚函数表(vtable)。
- 对象实例中会包含一个指向虚表的指针(vptr)。动态绑定是如何在运行时通过 vptr 查找具体函数的。
- 析构函数必须是虚函数: 知道为什么基类的析构函数必须加
virtual(防止子类对象被delete时只调用基类析构,导致子类成员内存泄漏)。 - 重写(Override)、重载(Overload)、隐藏(Hide): 熟练区分这三个极易混淆的概念。
- RAII(资源获取即初始化): C++ 最核心的资源管理思想,利用栈对象的生命周期管理资源(如锁、堆内存、文件句柄)。
三、 现代 C++ 特性(C++11/14)
现代项目基本都在使用现代 C++。
- 智能指针(重中之重):
-
std::unique_ptr:独占所有权,不可复制,只能移动(std::move)。 -
std::shared_ptr:共享所有权,基于引用计数。 -
std::weak_ptr:解决shared_ptr的循环引用问题,不增加引用计数。
-
- 右值引用与移动语义: 理解
std::move并不真正移动数据,而是将左值强制转换为右值引用,从而触发类的移动构造函数,避免深拷贝。 - Lambda 表达式: 在客户端开发中常用于异步回调,掌握按值捕获
[=]和按引用捕获[&]的生命周期陷阱。 -
auto与nullptr: 知道nullptr如何解决NULL(本质是整型 0)带来的重载二义性问题。
四、 STL 底层原理
STL背后的发生的事情。
| 容器 | 底层数据结构 | 高频考点 |
|---|---|---|
std::vector |
连续动态数组 | 扩容机制(通常是1.5倍或2倍),扩容会导致原有迭代器失效;size() 与 capacity() 的区别。 |
std::map |
红黑树 | 元素有序;查找、插入、删除的时间复杂度均为 O(log n)。 |
std::unordered_map |
哈希表 | 元素无序;哈希冲突的解决方式;扩容时的 rehash 过程;理想查找复杂度 O(1)。 |
std::list |
双向链表 | 内存不连续,不支持随机访问,但插入/删除效率高。 |
std::array |
连续静态数组 | 无扩容机制;固定大小;通常在栈上使用,不适合大数组;随机访问效率最高。 |
std::deque |
分段数组 | 扩容机制不移动内存,不会导致原有迭代器失效;在头尾各维护缓冲区。 |
五、 客户端特定加分项
结合客户端的业务场景,你可以有针对性地准备以下内容:
- 多线程基础知识: 了解
std::thread、互斥锁std::mutex、条件变量std::condition_variable。 - 线程模型意识: 客户端通常有“UI 线程不可阻塞”的铁律。耗时操作(网络请求、复杂计算)必须放在工作线程,然后将结果抛回主线程更新 UI。
- 经典设计模式:
- 单例模式(Singleton): 知道如何写一个线程安全的单例(C++11 中局部静态变量自带线程安全,即 Meyers’ Singleton)。
- 观察者模式(Observer): 客户端事件分发和响应机制的核心。
2. 详细复习
2.1 内存管理与指针
2.1.1 内存分区
清楚堆(Heap)、栈(Stack)、全局/静态存储区、常量区和代码段的区别。
堆:使用new分配指针时,分配的是堆内存,堆内存分配不一定连续,delete该指针不会删除变量、而是归还堆内存。
- 劣势:由于堆内存分配需要遍历堆内存地址,分配非常慢;需要手动释放内存;指针解引用慢于访问变量。
- 优势:内存池巨大。
栈:调用栈追踪程序运行中的函数,存储函数参数与局部变量。调用栈存储栈帧,栈帧追踪函数调用时的所有数据,栈顶的标志是CPU的栈指针寄存器。
- 栈帧:包括
- 返回地址(函数运行完程序应该从哪继续)
- 函数参数
- 局部变量内存
- 被函数修改的寄存器拷贝
- 栈溢出:使用栈内存超出了编译器允许的范围,Windows VS是1MB、g++/clang是8MB
- 劣势:不能分配大数组等数据,需要尽量节省栈内存
- 优势:分配极快、自动销毁、可以直接通过变量访问
静态存储区:bss (block started by symbol) segment,或称未初始化数据段,用来存放未初始化或初始化为0的全局变量和静态变量,它们不会占用可执行文件内存、程序执行前就分配好内存了,指针也默认为nullptr。
全局存储区:data segment,或称已初始化数据段,用来存放已初始化且非0的全局变量和静态变量,它们占用可执行文件体积、程序一启动就会把它们拷贝到内存中。好处是只初始化一次,CPU反复读取相同的内存地址即可,访问迅速。
- .data段:可修改,包括已初始化非0的全局变量和静态变量、和全局字符串指针变量
- .rodata段:只读,包括const修饰的全局变量和字符串字面量,常见误区如下
1 | |
static修饰符:
- 修饰局部变量时,局部变量的内存在
data segment中,作用域仍与局部变量一致,但内存数据不会被销毁; - 修饰全局变量/函数时,使变量/函数的作用域限定在该文件内,对其他文件不可见;
- 修饰类成员变量时,该变量内存在
data/bss segment中,在所有类实例中共享,然而在类内只能声明、必须在类外初始化,除非还有const或constexpr修饰。 - 修饰类成员函数时,不需要通过类实例调用,但它也只能访问类的静态成员变量和函数,不能是虚函数(静态函数不与对象绑定、没有vptr)。
extern修饰符:声明某全局变量/对象/函数来自外部文件,编译器需要通过外部链接获取,不能用于修饰成员变量/函数。
2.1.2 指针与引用的本质区别
- 引用必须初始化且不可变,底层通过指针实现,不可重新引用别的项。
- 指针有自己的内存空间,可以为空,可以重新赋值。
- 引用比指针更安全、使用优先级更高,因为它不会绑定空值、无需拷贝,除非你需要利用空指针,这对于函数参数和返回值都适用。
左值引用:在C++中仅担当左值的别名,左值引用与被引用项可以有不同的生命周期,如果后者先被销毁、前者则变成不合法的悬空引用。引用不是对象,通常不占用内存,编译器会把它们换成被引用项,但不能替换时会占用内存。
std::reference_wrapper是包裹引用的类,是对象并且可以作为函数参数被传递,内部实现为存被引用项的地址指针和解引用。
对常量的左值引用:带const修饰符的引用,使被引用项不可通过引用来修改。它可以绑定const左值、左值和右值,绑定右值时实际上有个临时变量被创建了、引用被绑定到该临时变量。绑定不同类型的左值或右值时,实际上创建了与引用同类型的临时变量、并用该值来初始化,因此修改被绑定的左值实际上不会影响引用值,从而使引用失去意义。
constexpr左值引用:只能引用写在data segment中的全局变量或静态局部变量,因为只有它们的值是在编译期间就已知的(constexpr声明变量/函数值可以在编译期间计算)。
在函数参数中使用左值引用
- 参数无需复制
- 参数可修改
- 只能传可修改的左值
在函数参数中使用对常量的左值引用
- 参数无需复制
- 不会修改原参数
- 如果右值的类型不同,会自动创建临时变量和转换,但这通常不是我们想要的,因为复制还是发生了
永远不要返回非常量静态局部变量和临时变量的引用,这会引起悬空引用。
指针:以内存地址为值的对象,占用内存。不可用其他数据类型的地址和字面量初始化,普通指针也不能指向const对象。
空指针:未初始化的指针应当被初始化为空指针(含义为没有指向任何对象),空指针不可解引用。虽然0经常是空指针的真实值,但由于架构和标准的多样性、不建议用它初始化空指针;NULL继承自C语言,同样不建议,尤其在函数重载的情况下,它在不同编译器中可能导致不同的行为,如VS认为它为0、gcc直接报错。nullptr作为右值的类型为std::nullptr_t,被赋值给其他类型指针后它们的指针类型不变。
1 | |
指向常量的指针:相对指针仅不可通过解引用修改所指对象的值,可以指向常量、非常亮,可以修改指向的内存地址。
常量指针:相对指针仅不可修改指向内存地址,可以通过解引用修改指向的对象值。
在函数参数中使用指针
- 复制指针,无需复制对象
- 对象可修改
- 必须检查指针是否为空指针
- 函数重载比默认传递空指针好,前者无需处理空指针
- 可以传递指针的引用,当且仅当指针自身需要被修改的情况
2.1.3 危险的指针
能够准确区分并说明如何避免野指针(未初始化的指针)和悬空指针(指向已被释放内存的指针)。
野指针:未初始化的指针,存放着未清理的数据,解引用是未定义的。
悬空指针:所指对象已被销毁的指针,可以做除了解引用以外的任何指针操作。
因为指针要么指向有效对象、要么不指向任何事物,野指针和悬空指针都应当被赋为空指针,毕竟检测空指针可以通过布尔值、而上述两种都不行。
2.1.4 **new/delete 与 malloc/free 的区别
前者会调用构造/析构函数,后者只是纯粹分配内存。
new:new返回指针,堆满时报错,如果用new (std::nothrow)返回空指针。调用类构造函数。
delete:delete只是将这块内存还给系统,指针悬空,一定要设为空指针,delete空指针不做任何事因此无需空指针检测。delete[]能自动销毁数组。调用析构函数。
malloc/free只负责管理内存和指针,不会自动调用构造函数和析构函数,因此不适于面向对象的语法。
1 | |
内存泄漏:发生在new分配的指针在被delete前先被作用域销毁,导致PC找不到被分配的这块被占内存,从而导致可用内存减少。此外,在delete前指针被重赋值也会导致内存泄漏。
RAII:Resource Acquisition Is Initialization,总是在构造函数中获取内存资源、在析构函数中释放内存,这是防止内存泄漏的有效手段。
2.2 面向对象核心机制
不要只记概念,一定要懂底层的实现原理。
2.2.1 多态的底层实现(虚表与虚指针)
- 知道包含虚函数的类会生成虚函数表(vtable)。
- 对象实例中会包含一个指向虚表的指针(vptr)。动态绑定是如何在运行时通过 vptr 查找具体函数的。
虚函数的必要性:假如我有一系列的派生类,如果我用基类指针指向它们,只有基类中存在的方法对指针可见、并且只能运行基类的版本,这种现象叫静态绑定。如果用$T$模板函数,无法限制基类;如果新增返回值,产生复制等额外开销。
虚函数:在与基类相同的成员函数(依据名称、参数类型、const和返回类型判别)前添加virtual关键字,指针/引用会自动定位到当前派生类成员方法(当前派生类与基类间最新的虚函数成员方法)。
这对于基类指针/引用非常有效,但是用把派生类实例通过特殊的构造函数复制到基类实例上就无效了。
虚函数的作用机制:每个包含虚函数的类在编译时都会产生一个虚函数表 vtable,该表包含每个虚函数的最新版本地址。每个包含虚函数的类对象都会比类多一个指向虚表的指针 *__vptr,该指针指向虚表的表头。每次调用虚函数时,都需要先用虚指针获取虚表、再在虚表中匹配获取正确版本地址、最后调用函数,这种binding模式称为dynamic dispatch,比同作用域的early-binding和需要二次定位的late-binding都更慢,因此虚函数的性能不及普通成员函数。
override描述符:放在成员方法的const之后或同位置,强制编译器把派生类中的同名函数与基类中的同名虚函数视作重写,如果重写条件不匹配则显式报错。在派生类中可以取代virtual关键字,对虚函数最好总是使用它。
重写条件的唯一例外 Covariant Return Types:当返回类型是基类同名虚函数返回类型的派生类时,可以通过重写条件,此时如果用基类指针调用该方法,虽然会调用派生类的该方法,但是返回对象会重投影成基类的返回类型,这依旧是由于返回类型一致的强制要求。
final描述符:放在成员方法的override之后或同位置,强制编译器禁止派生类重写相同的成员方法;放在类名之后则强制编译器禁止其他类继承它。
2.2.2 析构函数必须是虚函数
知道为什么基类的析构函数必须加 virtual(防止子类对象被 delete 时只调用基类析构,导致子类成员内存泄漏)。
因此基类的析构函数必须public且virtual,但不会被继承的类应当被标记为final且不使用virtual,因为虚函数查找有额外的开销。
但是不要在构造函数和析构函数中调用虚函数,因为在派生类构造时会先调用基类的构造方法完成继承的部分,此时调用虚函数只会使用基类的版本。析构时同理。
2.2.3 重写(Override)、重载(Overload)、隐藏(Hide)
熟练区分这三个极易混淆的概念。
编译时多态是由编译器解决的一种多态,包括函数重载决策、模板决策等。重载的前提是这些函数在同一个作用域内,条件相对较少,只需要函数名相同、传入参数列表不同,const和返回类型都可以不同。
运行时多态是由运行时解决的另一种多态,包括虚函数决策等重写。重写要求严苛,基类必须有virtual,派生类必须名称、参数类型、const和返回类型判别完全相同,唯一例外如Covariant Return Types。
隐藏则是作用域的遮蔽机制。只要派生类定义了一个和基类同名的函数(不管参数相不相同,不管有没有virtual),基类中所有同名的重载函数在派生类对象中全部变得不可见,这是因为编译器查找名称总是从最近的作用域开始、找到一个就停止,因此编译器会找不到基类的重载函数。基类::函数则可以重新引回基类的重载函数,这样也可以使用被重写前的成员函数。
2.2.4 RAII(资源获取即初始化)
C++ 最核心的资源管理思想,利用栈对象自动销毁的能力,把资源(如锁、堆内存、文件句柄)封装进自动析构的类里,从而避免任何资源释放被遗忘。以下是几种常见的现代做法:
| 资源类型 | 传统危险做法 | 现代 C++ 的 RAII 替代方案 |
|---|---|---|
| 堆内存 | new / delete |
智能指针:std::unique_ptr, std::shared_ptr |
| 互斥锁 | mtx.lock() / unlock() |
std::lock_guard, std::unique_lock |
| 文件读写 | fopen() / fclose() |
文件流对象:std::ifstream, std::ofstream |
2.3 现代 C++ 特性(C++11/14)
现代新项目基本都在使用现代 C++,这部分能直接体现你的代码品味。
2.3.1 智能指针
目标:随作用域自动销毁,对数组有效、传入函数时行为不变(C++98 std::auto_ptr踩雷),避免深拷贝。
std::unique_ptr:独占所有权,不可复制,只能移动(std::move)。- 相比
std::auto_ptr采用了右值引用时的移动语义,禁用了复制构造和赋值。 - 因为它很可能为空,使用前需要检查。。
- 能自动处理数组,但是不如
std::vector等智能。 C++14引入的
std::make_unique更便捷,可以作为代替(传入参数为Fraction的构造函数参数):1
auto f1{ std::make_unique<Fraction>(3, 5) };作为函数的传入参数时使用
.get()成员函数传入raw指针,避免资源移动到函数局部变量上。- 坑点:别用一块资源构造多个智能指针、别直接删资源。
- 相比
std::shared_ptr:多指针共享同一块资源的所有权,基于引用计数,只有最后一个ptr被销毁才会释放资源。- 新ptr必须用原ptr构造,不能用原始资源构造,可以由
std::unique_ptr构造但反向可能出错。 - 循环引用问题:当共享指针A1的对象A有一个指向对象B的共享指针A2,而共享指针B1的对象B有一个指向对象A的共享指针B2,这就造成指向两个对象的共享指针各有两个,因此两个对象永远无法销毁。
- 新ptr必须用原ptr构造,不能用原始资源构造,可以由
std::weak_ptr:解决shared_ptr的循环引用问题,不增加引用计数。- 在共享指针的对象内使用弱指针指向另一个对象,因为它不占有该对象,所以可以正常销毁。
- 没有
->运算符,需要用.lock()先转换为共享指针。 .expired()可以获知所指对象是否存活。
2.3.2 右值引用与移动语义
理解 std::move 并不真正移动数据,而是将左值强制转换为右值引用,从而触发类的移动构造函数,避免深拷贝。
问题:C++编译器默认在构造函数和赋值操作时需要复制传入参数,但是浅拷贝,以往需要重写这两个成员函数才能深拷贝并且深拷贝开销大。
右值引用:仅引用右值,可以用于延长右值生命周期(从行内拓展到作用域内)、修改右值,最常用于作为函数参数类型解析右值参数。
1 | |
移动语义:C++11引入,提供仅针对右值的移动构造函数和移动赋值操作,这些操作免除了原本临时对象和真正赋值的总共三次深拷贝,将开销减少为一次指针浅拷贝。类的总共五个构造和析构函数最好同时定义或删除(如Auto_ptr4(T* ptr = nullptr) = delete),被删除的会被禁用。
std::move:把左值投射为右值以使用移动语义,被移动的左值处于有效但未定义的状态。这在左值交换值时非常有效。
noexcept描述符:声明函数绝不会抛出异常,从而让编译器减少异常处理操作、提升执行效率。在移动构造函数和移动赋值操作声明noexcept便于STL容器安全地使用它们。
1 | |
2.3.3 Lambda 表达式
在客户端开发中常用于异步回调,掌握按值捕获 [=] 和按引用捕获 [&] 的生命周期陷阱。
模板:
1 | |
[ captureClause ]捕获作用域内的局部变量,[&]以引用形式捕获,[=]以拷贝形式捕获,[]不捕获。全局变量、静态变量、const局部变量、constexpr常量无需捕获就可访问。
本质会实现函数调用语义的匿名类,定义了构造函数和函数调用运算符operator()。为提高可读性最好分行,如果需要复用可以用一个变量存储它。lambda表达式没有显式类型,是包含重载方法的 Functor 特殊对象。下例:
1 | |
将lambda表达式传入函数,可以通过C++20的auto类型或更老的std::function隐式转换。
通用lambda表达式:有至少一个auto类型参数,编译器会将它处理为不同类型的多个lambda函数。
2.3.4 std::string_view
C++17引入,提供无需复制的只读字符串访问:
- 可用字符串字面量、
std::string和同类实例构造。 - 不会隐式转换为
std::string。 - 可重赋值,不会修改构造时使用的
std::string。 - 可用
constexpr,需要常量字符串时优先。
1 | |
2.3.5 std::lock_guard
C++11引入最基础、最轻量级的互斥锁(Mutex)RAII 包装器。
- 构造函数:接收一个互斥锁对象,并立即调用该锁的
.lock()方法。 - 析构函数:在离开当前作用域时被自动调用,执行
.unlock()方法。
2.3.6 std::ifstream, std::ofstream
std::ifstream(Input File Stream)用于从文件中读取数据。
- 构造函数:尝试打开指定路径的文件。如果成功,底层文件句柄被占用。
- 析构函数:自动调用
.close()方法,关闭文件句柄,将资源还给操作系统。
std::ofstream(Output File Stream)用于向文件写入数据。原理与 ifstream 完全一致,管理的同样是文件句柄,只是处于写入模式。
- 构造函数:尝试打开或创建指定路径的文件。默认行为是清空原文件内容(除非使用追加模式
std::ios::app)。 - 析构函数:自动刷新(Flush)底层的写入缓冲区,并调用
.close()关闭文件。
2.4 STL 底层原理
STL背后的发生的事情。
2.4.1 std::vector
C++中有三种数组:
- 最危险、固定长度的 C-style 数组;
- 功能相对有限但更高效的
std::array; - 功能最丰富的
std::vector。
连续动态数组:
- 顺序访问快:堆上连续内存,Cache通常会一起加载连续几个数据。
- 随机访问:
[]不检查越界,.at()会检查越界。 - 扩容机制:通常是1.5倍或2倍,开销大因此可以通过
.reserve(int)预分配内存。内存地址可能变化,会导致原有迭代器失效。扩容步骤大致如下- 申请更大内存;
- 复制旧元素;
- 释放旧元素。
.size()与.capacity()的区别:前者为元素数量,后者为实际分配的内存容量。.clear()只会清空元素,.capacity()可能不变。.emplace_back()可以原地构造对象,.push_back()可能产生临时对象。
2.4.2 std::map
红黑树:
- 元素有序。
- 查找、插入、删除的时间复杂度均为 O(log n)。
- 可自定义比较函数进行排序。
2.4.3 std::unordered_map
哈希表:
- 元素无序。
- 哈希冲突:每个哈希上存链表,在头部插入。
- 负载因子:哈希表元素数/桶数,大于最大负载因子时触发扩容。
- 扩容时的 rehash 过程:分配更大的内存,用新的桶数和关键字重映射,使用
std::hash<>,C++11的算法基于MurmurHashUnaligned2。此时迭代器失效。 - 理想查找复杂度 O(1),元素数量较多时哈希冲突影响查询。
- 元素数量较少时相比
std::map占用内存可能更多,因为预分配连续内存。
2.4.4 std::list
双向链表:
- 大小动态变化,不需要预分配内存。
- 内存不连续,不支持随机访问,但任意位置的插入/删除效率高。
- 提供双向迭代器,稳定性强。
- 内存使用效率低,因为需要存储前后向指针。
2.4.5 std::deque
分段数组:
map数组:存每个缓冲区的地址,每个缓冲区通常512B。- 缓冲区:头部和尾部分别新建缓冲区,++尾部插入元素,—头部插入元素。
- 随机访问性能不如
std::vector。 - 提供双向迭代器,由于无需迁移内存,迭代器稳定性强。
2.4.6 std::array
固定数组:
- 固定大小。
- 内存连续,访问速度快。
.at()越界检查。- 类型安全:相比C-style,作为函数参数时不会退化为指针。
- 复制时作为一个整体,可直接传给相同类型和长度的实例。
- 由于不需要动态伸缩性和栈分配高效起见,通常只在栈上使用。
2.5 客户端特定加分项
结合客户端的业务场景,你可以有针对性地准备以下内容:
2.5.1 多线程基础知识
了解 std::thread、互斥锁 std::mutex、条件变量 std::condition_variable。
概念:
- 多任务处理:基于进程和基于线程。
- 基于进程的多任务处理是程序的并发执行。
- 基于线程的多任务处理是同一程序的片段的并发执行。
- 线程 (Thread):
- 线程是程序执行中的单一顺序控制流,多个线程可以在同一个进程中独立运行。
- 线程共享进程的地址空间、文件描述符、堆和全局变量等资源,但每个线程有自己的栈、寄存器和程序计数器。
- 并发 (Concurrency) 与并行 (Parallelism):
- 并发:多个任务在时间片段内交替执行,表现出同时进行的效果。
- 并行:多个任务在多个处理器或处理器核上同时执行。
- 线程安全:在多线程环境中,共享资源需要同步访问,以避免数据竞争。
- 线程生命周期:确保在线程执行完毕后正确地处理线程对象,避免资源泄露。
std::thread:来自C++11 <thread>,用于创建线程,分配线程任务和获取线程信息。常用API:std::thread创建线程对象、.join()等待线程执行完毕、.detach()将线程与主线程分离(此后不可再join和detach)、.joinable()检查线程是否被detach、std::this_thread::get_id()静态方法返回当前线程id、std::this_thread()::sleep_for(std::chrono::seconds(int))静态方法让线程休眠、thread_local关键字让变量复制到线程栈中。
1 | |
std::mutex:来自C++11 <mutex>,互斥锁,线程拿到该锁后对共享资源进行操作,释放锁后其他线程才可以操作同一资源,确保了线程安全。常用API:.lock()``.unlock()获取锁和解锁、std::lock_guardRAII的更常用简易锁管理器(自动上/解锁不能手动解锁)、std::unique_lock则可以手动解锁。
1 | |
std::condition_variable:来自C++11 <condition_variable>,用于让线程等待(等待期间释放锁)、唤醒等待中的线程(被唤醒后检查条件,为true时才真正唤醒)。常用API:std::condition_variable创建条件变量、.wait让线程释放锁并等待条件、.notify_one()唤醒一条等待中的线程让其重新获取锁并检查条件(防止系统误唤醒线程)、.notify_all()唤醒所有等待中的线程。
1 | |
2.5.2 线程模型意识
客户端通常有“UI 线程不可阻塞”的铁律。耗时操作(网络请求、复杂计算)必须放在工作线程,然后将结果抛回主线程更新 UI。
2.5.3 经典设计模式
单例模式(Singleton): 知道如何写一个线程安全的单例(C++11 中局部静态变量自带线程安全,即 Meyers’ Singleton)。
模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Singleton {
public:
// 1. Accessor for the single instance
static Singleton& getInstance() {
// C++11 guarantees this initialization is thread-safe
static Singleton instance;
return instance;
}
// 2. Delete copy constructor and assignment operator to prevent duplicates
Singleton(const Singleton&) = delete;
void operator=(const Singleton&) = delete;
private:
// 3. Private constructor to prevent external instantiation
Singleton() { /* initialization code */ }
};分析:
- 单例原理:C++将局部静态变量存放在内存
data segment,它的生命周期从创建开始贯穿程序运行期,永远只会访问同一个内存地址,从物理内存层面实现了单例。 - 线程安全:C++11的编译器在初始化局部静态变量时,会自动添加一个静态护卫变量,原子加载它并用它加锁,从而保证了线程安全。静态护卫变量初始为0,因此会在
bss segment中零初始化,这个阶段发生在main执行前,因此不会出现线程冲突。
- 单例原理:C++将局部静态变量存放在内存
观察者模式(Observer): 客户端事件分发和响应机制的核心。