C++基础补充
1.inline之所以有下图这些限制的原因
1. 为什么不能有循环或过多的条件判断?
核心原因:性价比极低(收益递减)。
开销对比:
inline的主要目的是消除函数调用的“额外开销”(Call Overhead),比如保存寄存器、压栈参数、指令跳转等。这部分开销通常很小(假设耗时 5ns)。循环的影响: 如果函数体内包含循环(比如执行 1000 次),或者复杂的递归/Switch 逻辑,那么函数本身的执行时间可能非常长(假设耗时 5000ns)。
结论: 此时,节省下来的 5ns 调用开销相对于 5000ns 的执行时间来说,几乎可以忽略不计。编译器认为对这种“重型”函数进行内联,既增加了代码体积,又没有带来明显的性能提升,属于“捡了芝麻丢了西瓜”。
2. 为什么函数体不能过于庞大?
核心原因:代码膨胀(Code Bloat)与 缓存失效(Cache Miss)。
代码膨胀: 如果一个函数体有 100 行代码,且在程序中被调用了 50 次。
普通调用: 内存中只有 1 份该函数的代码。
内联调用: 编译器会将这 100 行代码复制 50 次,导致生成的可执行文件(.exe/binary)体积急剧增大。
指令缓存(Instruction Cache)雪崩: 这是更深层的性能杀手。CPU 内部有速度极快但容量很小的指令缓存(I-Cache)。
如果代码体积过大,CPU 的缓存存不下,就需要频繁地从慢速的内存(RAM)中去读取指令。
结果: 由此带来的缓存未命中(Cache Miss) 的性能损耗,远远大于省去的那个小小的“函数调用”开销。所以,大函数内联反而会变慢。
3. 为什么声明必须在调用之前?
核心原因:编译器需要看到“函数肉体”才能进行“器官移植”。
编译原理: C++ 编译器通常是以“翻译单元”(Translation Unit)为单位进行编译的。
复制的前提: 当编译器读到一行代码调用
foo()时,如果它决定内联,它必须此刻就已经知道foo()具体的代码(函数体)长什么样,才能把它“填”进去。如果只有声明: 如果编译器只看到了
void foo();这种声明(通常在 .h 文件),而具体的函数体写在文件末尾或其他 .cpp 文件里,编译器就无法进行复制操作,只能生成一个普通的函数调用指令。
总结与现代编译器的演变
图片中列出的这些限制,更多是传统的 C++ 规范或是给程序员的建议。
在现代 C++(如使用 GCC -O2 或 -O3 优化时):
inline 只是一个“建议”: 即使你加了
inline关键字,如果函数体太大或太复杂,编译器也会无视你的请求,将其作为普通函数编译。编译器很聪明: 即使你没有加
inline,只要函数足够小且调用频繁,编译器也会自动帮你内联。
一句话总结: 这些限制是为了防止代码体积失控以及防止CPU 缓存频繁失效,从而在“空间”和“时间”之间寻找最佳平衡点。
2.有的时候我们为什么宁愿用namespace而不用typedef
这是一个非常好的架构设计问题。简洁来说,namespace 解决的是 “归属” 和 “组织” 的问题,而 typedef 解决的是 “称呼” 的问题。
我们宁愿用 namespace 而不用 typedef(或者前缀命名法),通常是因为以下几个核心原因:
1. 作用域隔离 vs. 仅仅是别名
Typedef: 只是给现有的类型起了一个别名。它依然存在于当前的命名空间中。如果你有两个库都定义了
typedef int Handle;,它们会直接冲突。Namespace: 创造了一个新的作用域(Scope)。它像一堵墙,把代码隔离开来,从根本上杜绝了命名冲突(Name Collision)。
2. 逻辑聚合(封装性)
typedef 只能针对类型。但一个功能模块通常包含:类型、常量、辅助函数、结构体。
Namespace 可以将这些所有相关联的东西打包在一起。
Typedef 无法包裹函数或常量,你只能被迫使用长前缀(如
OpenGL_Init,OpenGL_Texture),代码显得冗余且松散。
3. 可扩展性(Open-ended)
Namespace 是“开放”的:你可以在多个文件中分别声明同一个
namespace,编译器会将它们自动合并。这非常适合大型项目的模块化开发。Typedef 是“封闭”的:定义一次就结束了,无法在其他地方向其内部“添加”内容。
直观对比
场景:定义一个数学库的矩阵类型
做法 A:使用 typedef(容易导致全局污染)
// 只是给 float[16] 起了个名字,无法阻止别人也叫 Matrix
typedef float Matrix[16];
// 函数名必须加前缀,否则容易冲突
void Math_Invert(Matrix m);
做法 B:使用 namespace(结构清晰,安全)
namespace Math {
// 这是一个归属于 Math 的类,不仅是别名
class Matrix { ... };
// 函数名简洁,且不会与全局 Invert 冲突
void Invert(Matrix& m);
// 还可以放常量
const float PI = 3.14159f;
}
// 调用时非常清晰
Math::Matrix m;
Math::Invert(m);
总结
typedef只是换个“马甲”(别名),本质没变,还在原地。namespace是建个“房子”(作用域),把人和东西都装进去,互不干扰。
当你需要构建一个包含逻辑、数据和操作的模块时,请使用 namespace。当你仅仅想简化一个冗长类型的书写时,才使用 typedef(或现代 C++ 的 using)。
using 与 typedef的区别
既然你感兴趣,那我们来深入聊聊这个 C++ 现代化的重要细节。
在 C++11 之后,标准委员会引入了 using(被称为 Type Alias,类型别名),目的就是为了逐渐替代 typedef。虽然 typedef 依然保留以维持兼容性,但在现代 C++ 开发中,using 几乎在所有场景下都是更好的选择。
主要原因有两点:强大的模板支持 和 更符合直觉的语法。
1. 核心杀手锏:模板别名 (Template Aliases)
这是 typedef 彻底输给 using 的地方。typedef 不支持直接给“模板”起别名。
假设你想定义一个 Map,它的 Key 固定是 std::string,但 Value 的类型想要由用户指定:
❌ 做法 A:用 typedef (非常痛苦)
typedef 无法直接模板化,你不得不把它包裹在一个结构体里,这是老式 C++ 的“黑魔法”:
template <typename T>
struct StringMap {
// 必须嵌套在 struct 里面
typedef std::map<std::string, T> type;
};
// 使用时非常繁琐,还要加 ::type
StringMap<int>::type inventory;
✅ 做法 B:用 using (非常自然)
using 支持模板化,这被称为 Alias Template:
template <typename T>
using StringMap = std::map<std::string, T>;
// 使用时就像原生模板一样流畅
StringMap<int> inventory;
结论: 这一点直接决定了泛型编程中
using的统治地位。
2. 语法逻辑:赋值 vs. 声明
typedef 的语法是继承自 C 语言的变量声明逻辑,而 using 采用的是赋值逻辑(名字 = 类型),后者阅读起来大大降低了认知负担。
这点在 函数指针 的定义上体现得淋漓尽致:
❌ 做法 A:用 typedef (可读性差)
你需要像剥洋葱一样去寻找新类型的名字在哪里:
// 名字 "Handler" 被埋在了一堆符号中间
typedef void (*Handler)(int, double);
✅ 做法 B:用 using (清晰明了)
逻辑是:把右边的类型,赋值给左边的名字。
// 名字 = 类型
using Handler = void (*)(int, double);
3. 快速对比总结
| 特性 | typedef |
using (C++11) |
评价 |
|---|---|---|---|
| 基础别名 | typedef int* IntPtr; |
using IntPtr = int*; |
using 更像赋值,更直观 |
| 函数指针 | typedef void (*P)(int); |
using P = void(*)(int); |
using 不需要把名字混在声明里 |
| 模板别名 | 不支持 (需要 struct 包装) | 原生支持 | using 完胜 |
| 兼容性 | C 语言和所有 C++ 版本 | C++11 及以上 | 现代项目无压力 |
建议
除非你在维护非常古老的 C++98 代码或纯 C 代码,否则请**全面使用 using**。它不仅涵盖了 typedef 的所有功能,还提供了更清晰的语义和强大的模板支持。
Volatile
在 C++ 中,volatile 是一个非常容易被误解,但在特定领域(如嵌入式开发)又极其重要的关键字。
用一句话总结:volatile 告诉编译器,“别自作聪明地优化这个变量,每次都要去内存里老老实实读它”。
1. 核心作用:禁止编译器优化
编译器为了提高速度,通常会把变量的值缓存在 CPU 寄存器(Register)中。
普通变量:如果编译器分析代码后发现,“哎,这个变量在循环里好像没人改过它嘛”,编译器就会把它的值读到寄存器里,以后只读寄存器,不再去访问内存(RAM)。
volatile 变量:加上
volatile后,编译器必须每次都直接去内存地址读取/写入数据,不能使用寄存器缓存,也不能随意调整指令顺序。
直观的代码对比
假设我们有一个从硬件状态寄存器读取信号的代码:
// ❌ 没有 volatile
// 编译器发现 while 循环里没人修改 status,
// 它会把 status 的值缓存起来,导致程序变成死循环,检测不到外部硬件的变化。
int status = *HARDWARE_ADDRESS;
while (status == 0) {
// Do nothing
}
// ✅ 加上 volatile
// 编译器知道 status 可能随时被外部(硬件)修改,
// 所以每次 while 判断,都会生成一条指令去内存地址重新读取。
volatile int status = *HARDWARE_ADDRESS;
while (status == 0) {
// Do nothing
}
2. 什么时候必须用?
主要有且仅有以下两个场景:
- 内存映射 I/O (Memory-Mapped I/O)
- 这是
volatile最正统的用法。当你通过指针访问硬件寄存器(如单片机的 GPIO、状态位)时,硬件的状态随时会变,必须阻止编译器优化读取操作。
- 信号处理 (Signal Handling)
- 当变量在中断服务程序 (ISR) 或 Unix 信号处理函数 中被修改,而在主程序中被检测时,该变量需要是
volatile(通常还需要是sig_atomic_t类型)。
3. 最大的误区:它不是为了多线程安全!
这是面试和实际开发中最大的坑。
❌ 错误观念:多个线程共享变量时,加上
volatile就可以不用锁了。 ✅ 正确事实:volatile不能保证线程安全。
为什么 volatile 搞不定多线程?
原子性 (Atomicity):
volatile int i = 0; i++;不是原子的。i++分为“读-改-写”三步,多线程并发时依然会数据竞争。乱序执行 (Reordering):
volatile只能禁止编译器重排指令,但现在的 CPU 硬件本身也会为了性能乱序执行指令(Out-of-Order Execution)。volatile无法阻止 CPU 层面的乱序。内存屏障 (Memory Barrier):它不具备内存屏障的功能,无法保证不同 CPU 核心缓存之间的数据一致性。
总结:volatile vs std::atomic
如果你是在做多线程开发,请忘记 volatile,使用 std::atomic。
| 特性 | volatile | std::atomic (C++11) |
|---|---|---|
| 主要用途 | 硬件通信 (告诉编译器别优化) | 多线程通信 (保证原子性和内存序) |
| 禁止编译器优化 | ✅ 是 | ✅ 是 |
| 保证操作原子性 | ❌ 否 (i++ 不安全) | ✅ 是 (i++ 安全) |
| 禁止 CPU 乱序 | ❌ 否 | ✅ 是 (默认强一致性) |
| 线程安全 | ❌ 不安全 | ✅ 安全 |
结论
如果你在写驱动程序、嵌入式逻辑,或者处理中断信号,请用
volatile。如果你在写多线程并发(比如之前的
Zkclient代码),请用std::atomic或std::mutex,**千万别用volatile**。
extern关键字
你好!很高兴为你解释 C++ 中 extern 关键字的用途。
简单来说,extern 的核心作用是告诉编译器:“这个变量(或函数)已经在别的地方定义过了,这里只是引用它,请去别处找它的内存地址。”
它主要用于跨文件共享全局变量以及C++ 与 C 语言的混合编程。
为了让你更清晰地理解,我将通过以下几个方面来详细拆解:
1. 核心概念:声明 vs 定义
在使用 extern 之前,必须区分两个概念,这是理解它的关键:
定义 (Definition): 创建变量,分配内存。例如:
int count = 10;声明 (Declaration): 告诉编译器变量的类型和名字,不分配内存。例如:
extern int count;
extern 的作用就是进行“声明”,而不是“定义”。
2. 用途一:在多个文件间共享全局变量
这是 extern 最常见的用法。在一个大型项目中,如果你想让 File1.cpp 定义的变量在 File2.cpp 中也能被使用,就需要用到 extern。
示例场景
假设我们有一个全局变量 global_counter,我们希望在多个文件中访问它。
第一步:在一个源文件中“定义”它
// source.cpp
#include <iostream>
// 这里定义了变量,分配了内存,并初始化为 0
int global_counter = 0;
void printCounter() {
std::cout << "Current count: " << global_counter << std::endl;
}
第二步:在另一个源文件中用 extern “声明”它
// main.cpp
#include <iostream>
// 告诉编译器:global_counter 在别的地方定义过了,信我!
// 注意:这里绝对不能赋值(如 extern int global_counter = 0; 是错误的)
extern int global_counter;
// 告诉编译器:printCounter 函数也在别处
extern void printCounter();
int main() {
global_counter = 10; // 修改全局变量
printCounter(); // 输出:Current count: 10
return 0;
}
注意: 如果你在
main.cpp中漏掉了extern,直接写int global_counter;,编译器会认为你创建了一个新的独立变量,或者报“重复定义”的错误(链接阶段)。
3. 用途二:extern "C" (C++ 调用 C 代码)
C++ 支持函数重载(Overloading),这意味着编译器在编译时会把函数名“修饰”(Name Mangling)成独特的名字(例如 _Z3fooii)。而 C 语言不支持重载,函数名编译后就是原样。
当你在 C++ 中调用现有的 C 语言库时,C++ 编译器会去寻找修饰后的名字,结果找不到 C 语言生成的那份原样名字,导致链接错误。
解决方法: 使用 extern "C" 告诉 C++ 编译器:“请按照 C 语言的规则来处理这些函数名,不要乱改名字。”
示例
// 这是一个 C++ 文件
#include <iostream>
// 告诉编译器:这里面的代码按 C 语言方式编译/链接
extern "C" {
void c_function(int a); // 这是一个纯 C 语言写的函数
#include "legacy_c_header.h" // 或者直接包含 C 的头文件
}
int main() {
c_function(10); // 现在可以正常链接并调用了
return 0;
}
4. 常见陷阱与最佳实践
为了避免 Bug,使用 extern 时请注意以下几点:
- 不要在 extern 时初始化:
❌ 错误:
extern int a = 10;—— 这会被编译器视为定义,如果其他地方也有定义,就会报错“重复定义”。✅ 正确:
extern int a;
- 推荐做法:放在头文件 (.h) 中: 通常我们不会在
.cpp文件里直接写extern,而是将其放在头文件中。
global.h: 写
extern int g_val;global.cpp: 写
int g_val = 10;main.cpp: 写
#include "global.h"这样管理更清晰,避免到处手写extern容易出错。
- 函数默认是 extern 的: 对于函数声明(如
void func();),编译器默认它就是extern的。所以extern void func();和void func();效果一样。但对于变量,必须显式加上extern。
总结表格
| 特性 | 说明 | 例子 |
|---|---|---|
| 主要功能 | 声明外部链接,扩展可见域 | extern int x; |
| 内存分配 | 不分配内存 (仅做引用) | - |
| 最常见场景 | 跨文件使用全局变量 | FileA.cpp 定义 -> FileB.cpp 使用 |
| 特殊场景 | 兼容 C 语言 | extern "C" { ... } |
static关键字
这是一个非常棒的后续问题!如果说 extern 是为了“向外社交”(让大家都能看见),那么 static 的性格就比较复杂,它像是一个多面手。
在 C++ 中,static 关键字根据它出现的位置不同,有三种截然不同的含义。这通常是初学者最容易晕的地方。
我们可以把它拆解为三个场景:
1. 场景一:在函数内部(局部静态变量)
作用: “记忆力”。延长变量的生命周期。
通常,函数里的局部变量在函数执行完后就会被销毁。但加上 static 后,这个变量就获得了“长生不老”的能力。它只初始化一次,并且在程序运行期间一直存在,直到程序结束。
生命周期: 整个程序运行期间。
作用域: 依然只在该函数内可见(出了函数别人看不见,但它还在内存里)。
代码示例:
#include <iostream>
void countCall() {
// 普通变量:每次调用都会重新变成 1
int normal = 1;
// 静态变量:只初始化一次,后续调用会保留上次的值
static int memory = 1;
normal++;
memory++;
std::cout << "Normal: " << normal << ", Static: " << memory << std::endl;
}
int main() {
countCall(); // Normal: 2, Static: 2
countCall(); // Normal: 2, Static: 3 <-- 注意这里
countCall(); // Normal: 2, Static: 4
return 0;
}
用途: 比如用来统计一个函数被调用了多少次。
2. 场景二:在文件级别,函数外部(静态全局变量/函数)
作用: “私有化”。限制作用域(隐藏符号)。
这正好是 extern 的反义词。如果你在全局变量或函数前加上 static,就是告诉编译器:“这个变量/函数只能在我这个 .cpp 文件里用,别的文件(即使是用 extern)也别想访问它。”
生命周期: 整个程序运行期间。
作用域: 仅限于当前文件(Internal Linkage)。
代码示例:
// File1.cpp
static int secret = 123; // 只有 File1 能看见
// File2.cpp
extern int secret; // ❌ 链接报错!找不到 secret,因为它被 File1 "私藏" (static) 了。
用途: 避免命名冲突。比如两个文件都想定义一个叫 helper() 的辅助函数,只要都加上 static,它们就不会打架。
3. 场景三:在类(Class)内部(静态成员)
作用: “共享”。属于类而不属于对象。
当 static 用在类成员(变量或函数)上时,意味着这个成员不属于某个具体的对象(实例),而是属于整个类。所有该类的对象共用这一份数据。
A. 静态成员变量
无论你创建了多少个对象,静态成员变量在内存中只有一份拷贝。
代码示例:
class Player {
public:
// 声明:大家共享这一份数据
static int playerCount;
Player() {
playerCount++; // 每创建一个对象,总数+1
}
};
// 定义并初始化(必须在类外面!)
int Player::playerCount = 0;
int main() {
Player p1;
Player p2;
Player p3;
// 所有的对象看到的都是同一个值
std::cout << p1.playerCount << std::endl; // 输出 3
std::cout << Player::playerCount << std::endl; // 推荐写法:直接通过类名访问,输出 3
}
B. 静态成员函数
可以直接通过类名调用:
ClassName::function()。关键限制: 静态成员函数里面不能访问普通的非静态成员变量(因为它没有
this指针,不知道该访问哪个对象的变量)。它只能访问静态成员变量。
总结表格
为了方便记忆,请看下表:
| 使用位置 | 术语 | 核心作用 | 可见范围 (Scope) | 生命周期 |
|---|---|---|---|---|
| 函数内部 | 局部静态变量 | 保持记忆 | 仅函数内 | 永久 (直到程序结束) |
| 文件全局 | 静态全局变量 | 隐藏/私有 | 仅当前文件 | 永久 |
| 类内部 | 静态成员 | 全员共享 | 取决于 public/private | 永久 |
它可以用来做什么?(最佳实践)
实现单例模式 (Singleton): 利用局部静态变量实现“懒加载”的单例,既线程安全又简洁。
模块封装: 写
.cpp文件时,辅助用的内部函数尽量加上static,防止污染全局命名空间。计数器/配置项: 类的所有实例都需要共享的常数或计数器。
这就解释完了 static 的三个分身!
为了让你不仅停留在理论上,你希望我演示一下如何利用 static 实现一个简单的“单例模式”吗?这是面试中非常高频的考点。
单例模式
太好了!单例模式(Singleton Pattern)是展示 static 威力最经典的舞台。
什么是单例模式?
简单来说,单例模式保证一个类在整个程序中只有一个实例(对象),并提供一个全局访问点来获取它。
生活中的例子: 想象一下你们公司的打印机。虽然有很多员工(不同的代码模块)都在发送打印任务,但实际上只有一台物理打印机(单例对象)在处理这些任务。如果每个人都认为自己拥有独立的打印机并同时发送指令,打印出来的纸就会乱套。
为什么它离不开 static?
要实现单例,我们需要解决两个问题:
数据的唯一性: 我们需要一个地方存放这个唯一的实例,且生命周期要贯穿全程 —— 这正是
static变量 的特长。访问的全局性: 我们需要不创建对象就能调用获取实例的方法 —— 这正是
static函数 的特长。
最优雅的实现:Meyers Singleton (C++11 及以后推荐)
在现代 C++ 中,利用**局部静态变量(Local Static)**来实现单例是最著名、最简洁且线程安全的做法。这种写法被称为 "Meyers Singleton"(以 C++ 大师 Scott Meyers 命名)。
请看代码演示:
#include <iostream>
class Logger {
public:
// 1. 获取唯一实例的静态方法 (全局访问点)
static Logger& getInstance() {
// 【核心魔法】局部静态变量
// 特性 A:只在第一次执行到这里时初始化,之后直接跳过
// 特性 B:C++11 标准保证了这是"线程安全"的
static Logger instance;
return instance;
}
// 示例功能函数
void log(const std::string& msg) {
std::cout << "[Log]: " << msg << std::endl;
}
// 3. 【关键】禁止拷贝和赋值
// 如果允许拷贝,别人就能复制出一个新的 Logger,单例就不单纯了
Logger(const Logger&) = delete;
void operator=(const Logger&) = delete;
private:
// 2. 【关键】构造函数私有化
// 这意味着外部不能通过 `Logger log;` 或 `new Logger()` 来创建对象
Logger() {
std::cout << "Logger system initialized!" << std::endl;
}
// 析构函数也通常设为私有或保持默认,取决于需求
~Logger() {
std::cout << "Logger system shut down!" << std::endl;
}
};
int main() {
// ❌ 错误:构造函数是私有的,不能直接创建
// Logger myLog;
// ✅ 正确:通过静态方法获取唯一的那个实例
std::cout << "App started." << std::endl;
// 第一次调用:会触发构造函数,输出 "Logger system initialized!"
Logger::getInstance().log("System is booting...");
// 第二次调用:直接返回已存在的实例,不会再次构造
Logger::getInstance().log("User logged in.");
return 0;
}
代码深度解析
让我们回顾一下刚才学的 static 知识是如何在这里发挥作用的:
static Logger instance;(局部静态变量)
这就是单例的本尊。
因为它是
static的,所以它存在于静态存储区,生命周期一直到程序结束。因为它是在函数内部定义的,具有**懒加载(Lazy Initialization)**的效果:如果你的程序从来没有调用过
getInstance(),这个对象就永远不会被创建,节省了内存。
static Logger& getInstance()(静态成员函数)
- 因为它是
static的,所以我们不需要先有一个Logger对象就能调用它(直接用Logger::getInstance())。
= delete(删除拷贝)
- 这虽然不是
static的语法,但对单例至关重要。它防止了你无意中写出Logger log2 = Logger::getInstance();这样的代码,从而制造出第二个副本。
什么时候该用单例?
虽然单例很有用,但也不要滥用。
✅ 适合场景:
配置管理器 (Config Manager): 系统配置通常只需要读取一次,且全局共享。
日志记录器 (Logger): 所有的日志通常汇总到一个输出流或文件中。
线程池 (Thread Pool): 管理全局的计算资源。
❌ 缺点与风险:
- 隐藏的依赖关系: 单例本质上是一个“披着外衣的全局变量”。如果在代码深处随意调用单例,会让代码耦合度变高,难以测试(Unit Testing 很难模拟/Mock 一个静态单例)。
总结
C++ 的单例模式就是 private 构造函数 + static 局部变量 的完美结合。
构造函数私有 禁止别人私自生产。
静态局部变量 保证只生产一个,且大家共用。
觉得这个模式怎么样?如果你对“多线程安全”或者“为什么 C++11 之前写单例很麻烦”感兴趣,我可以继续为你补充!
函数指针
这是一个非常棒的问题!函数指针(Function Pointer)是 C 和 C++ 语言中最强大但也最容易让人困惑的特性之一。掌握它,你的代码水平会直接上一个台阶,从“写死逻辑”进阶到“写架构”。
简单来说,普通指针指向的是数据(变量的地址),而函数指针指向的是代码(函数的入口地址)。
既然你提到了图片中的几个具体场景,那我们跳过枯燥的定义,我将结合图片中的要点,用最通俗的例子带你逐一攻破这些场景。
0. 基础:如何定义和使用
在深入场景前,我们先看一眼语法(这通常是劝退新手的门槛,但别怕):
语法公式: 返回值类型 (*指针变量名)(参数列表);
// 假设有一个普通函数
int add(int a, int b) { return a + b; }
// 1. 定义函数指针:它的模样必须和它要指向的函数“门当户对”
int (*pFunc)(int, int);
// 2. 赋值:把函数的地址给指针
pFunc = add;
// 3. 调用:像调用普通函数一样使用指针
int result = pFunc(2, 3); // 结果是 5
💡 小技巧:为了避免语法太丑,我们通常使用
typedef:typedef int (*OperationFunc)(int, int);OperationFunc pFunc = add;(这样看起来清爽多了)
1. 场景一:回调函数 (Callback) & 函数指针作参数
图片对应点: 回调函数、函数指针作为参数
这是什么? 这是函数指针最经典的用法。你想写一个通用的函数(比如“遍历数组”),但具体做什么(是打印?是求和?还是重置为0?)你不确定,你想把这个决定权交给调用者。
生活类比: 你是一家保洁公司(主函数),你派人去打扫房间。但遇到贵重物品怎么处理?你不知道。于是你要求客户留一个电话(函数指针)。遇到贵重物品,你就打这个电话(调用回调函数),让客户决定怎么做。
代码示例:
#include <stdio.h>
// 这是一个回调函数的类型定义:接收一个int,返回void
typedef void (*HandlerFunc)(int);
// 这是一个通用的处理函数
// 它不在乎具体怎么处理数字,它只负责遍历,具体的动作通过 handler 传进来
void processArray(int *arr, int size, HandlerFunc handler) {
for (int i = 0; i < size; i++) {
handler(arr[i]); // 这里就是“回调”,调用了传进来的函数
}
}
// 具体的业务逻辑 A:打印
void printNum(int num) {
printf("Number: %d\n", num);
}
// 具体的业务逻辑 B:打印平方
void printSquare(int num) {
printf("Square: %d\n", num * num);
}
int main() {
int myData[] = {1, 2, 3, 4};
// 场景:也就是图片里的“可插拔的行为”
// 想打印?传 printNum
processArray(myData, 4, printNum);
printf("----\n");
// 想算平方?传 printSquare。主逻辑不用改,插件随便换!
processArray(myData, 4, printSquare);
return 0;
}
2. 场景二:函数指针数组 (状态机/策略模式)
图片对应点: 函数指针数组、实现函数映射表
这是什么? 你是否写过超长的 switch-case 或者 if-else if-else?如果条件有100个,代码会非常难看。函数指针数组可以消除这些 if-else,实现 O(1) 的快速跳转。
生活类比: 就像酒店的前台。
Switch-case: 客人来了,前台一张张翻记录本,“你是1号房吗?不是。你是2号房吗?不是...”
函数指针数组: 墙上挂着一排钥匙。客人说“我是3号”,前台直接伸手拿第3个挂钩上的钥匙。
代码示例(计算器):
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int div(int a, int b) { return a / b; }
int main() {
// 定义一个数组,里面存的全部是函数地址
// 顺序对应:0:+, 1:-, 2:*, 3:/
int (*operations[4])(int, int) = {add, sub, mul, div};
int op_code = 2; // 假设用户输入了 2 (代表乘法)
int x = 10, y = 5;
// 【完全没有 switch-case!】直接通过数组下标找到对应的函数并执行
// 这就是图片里说的“类似于状态机的逻辑”
if (op_code >= 0 && op_code <= 3) {
int result = operations[op_code](x, y);
printf("Result: %d\n", result);
}
return 0;
}
- 函数映射表的逻辑也是一样的,只不过你可能会用一个
Map<String, FunctionPointer>(在C++中)或者是结构体数组来通过名字查找函数。
3. 场景三:多态实现 (C语言模拟面向对象)
图片对应点: 多态实现
这是什么? 在 C++ 中,virtual 虚函数其实底层就是靠“虚函数表”(本质就是函数指针数组)实现的。在 C 语言这种没有“类”的语言里,我们可以手动用结构体+函数指针来模拟“多态”。
代码示例:
#include <stdio.h>
// 定义一个通用的“基类”结构
struct Hero {
char* name;
// 这是一个函数指针,代表“攻击”这个行为
void (*attack)(void);
};
// 具体的实现:战士的攻击
void warriorAttack() {
printf("Warrior: Slash with sword!\n");
}
// 具体的实现:法师的攻击
void mageAttack() {
printf("Mage: Cast fire ball!\n");
}
int main() {
// 创建一个战士
struct Hero h1;
h1.name = "Arthur";
h1.attack = warriorAttack; // 绑定战士的技能
// 创建一个法师
struct Hero h2;
h2.name = "Merlin";
h2.attack = mageAttack; // 绑定法师的技能
// 统一调用接口!虽然代码一样,但行为不同(这就是多态)
h1.attack();
h2.attack();
return 0;
}
4. 场景四:动态加载库 (插件系统)
图片对应点: 动态加载库
这是什么? 这是最高级的用法。程序运行起来之后,我突然想加个功能,但我不想重启程序,也不想重新编译。 比如 Photoshop 的滤镜,或者游戏的 Mod。
原理简介:
你把函数写在
.dll(Windows) 或.so(Linux) 文件里。主程序运行中,使用系统 API(如
LoadLibrary或dlopen)加载这个文件。主程序通过字符串名字(比如 "applyFilter")找到那个函数的地址。
将这个地址赋给一个函数指针。
现在,主程序就可以调用这个原本不存在于它代码里的函数了。
(由于涉及操作系统API,这里不展示代码,但这正是函数指针在大型软件架构中的核心作用)
总结
回到你提供的图片,我们可以这样串联起来记忆:
想代码灵活(解耦): 用 回调函数(函数指针作参数)。让调用者决定逻辑。
想代码整洁(去if-else): 用 函数指针数组。
想在C里写面向对象: 结构体里放函数指针,实现 多态。
想搞插件化开发: 配合系统API,做 动态加载
Thread_local
- Thread_Local 是C++11的关键字,其作用是将某些全局变量在每个线程中复制一份,让线程单独享用这个变量,同时也是为了控制线程的行为,这里常用在线程绑定上
weak_ptr
- weak_ptr 它是为了配合 std::shared_ptr 而设计的,不单独使用,主要用来解决 shared_ptr 可能导致的**循环引用(circular reference)**问题。
| 特性 | 说明 |
|---|---|
| 不控制对象生命周期 | weak_ptr 不会增加引用计数,因此不影响对象是否被销毁。 |
| 可能悬空(dangling) | 对象被销毁后,weak_ptr 会变为“过期”,使用前必须检查。 |
必须转换为 shared_ptr 才能访问对象 |
通过 lock() 方法获取一个 shared_ptr,如果对象还存在,就能访问;否则返回空指针。 |
decltype()
- decltype(&deleteElement) 就是自动推导出“自定义删除函数”的指针类型,免得手写冗长签名,还能随函数声明变化而自动更新——纯粹为了省事+可维护。
substr()
- substr(位置,长度) 这里是左开右闭区间
std::string::npos
- std::string::npos 代表没有找到的意思
模板类的实现
- 模板类的实现必须在头文件中,这样编译器才能为不同的模板参数生成代码
exit
exit(EXIT_FAILURE); // 告诉操作系统:我没成功 等价于exit(1)
exit(EXIT_SUCCESS); // 告诉操作系统:我正常结束(一般等于 0)
getopt()
getopt 是 POSIX 标准 提供的一个 C 语言函数,专门用来 解析命令行短选项(-a、-b value、-abc 这种形式)。
头文件:#include <unistd.h>(Linux/macOS)
Windows 下没有原生实现,可用 getopt-win32 或自己移植。
✅ 它能做什么?
识别 以
-开头的单字符选项支持 选项后带参数(
-i config.ini)支持 多选项合并(
-abc等价于-a -b -c)自动处理 未知选项 和 缺失参数 的错误提示
✅ 最简示例
#include <unistd.h> // getopt
#include <iostream>
int main(int argc, char* argv[]) {
int opt;
while ((opt = getopt(argc, argv, "i:h")) != -1) { // 字符串里放合法选项
switch (opt) {
case 'i':
std::cout << "配置文件路径:" << optarg << '\n';
break;
case 'h':
std::cout << "用法:demo -i <配置文件>\n";
return 0;
case '?': // 遇到未声明的选项
std::cerr << "未知选项\n";
return 1;
}
}
return 0;
}
编译运行:
g++ demo.cpp -o demo
./demo -i config.ini
输出:
配置文件路径:config.ini
✅ 选项字符串语法
| 写法 | 含义 |
|---|---|
"i:h" |
-i 后面 必须带参数;-h 不带参数 |
"v" |
-v 不带参数 |
"o::" |
-o 后面的参数 可选(GNU 扩展) |
✅ 与 getopt_long 的关系
getopt只支持 短选项(单字符)。getopt_long支持 长选项(--help、--version等),接口几乎一样。
✅ 一句话总结 getopt 就是 命令行短选项解析器,用它写参数解析 省心、标准化、代码少。
如果你的程序只跑在 Linux/macOS,直接用它;要跨 Windows,就改用 C++17 的 <cxxopts>、CLI11 等库,或者自己简单手写。
atexit
atexit() 是 C/C++ 标准库提供的一个注册函数:
把“程序正常结束时要调用的函数”登记到一张退出处理列表里,当 main 返回或 exit() 被调用时,系统会自动逆序执行这些注册的函数。
✅ 函数原型
#include <cstdlib> // C++
// 或 #include <stdlib.h> // C
int atexit(void (*func)(void));
参数:无参、无返回值的函数指针。
返回值:成功注册返回
0,失败返回非0。
✅ 最简例子
#include <cstdlib>
#include <iostream>
void goodbye1() { std::cout << "bye 1\n"; }
void goodbye2() { std::cout << "bye 2\n"; }
int main() {
std::atexit(goodbye1);
std::atexit(goodbye2);
std::cout << "main done\n";
return 0; // 这里会触发注册的函数
}
输出:
main done
bye 2
bye 1
注意:后注册的先执行(栈顺序)。
✅ 关键要点
| 要点 | 说明 |
|---|---|
| 何时调用 | 只有正常退出才执行:main 返回、exit()、quick_exit() 不会触发。 |
| 不会调用 | 如果程序 _Exit()、abort()、信号终止、崩溃、kill -9 等,不会执行。 |
| 注册上限 | C 标准保证至少 32 个(具体实现可更多)。 |
| 可重入 | 处理函数里不能再调 atexit(C++ 未定义行为)。 |
| C++ 析构 | 全局/静态对象的析构函数比 atexit 更早执行。 |
✅ 一句话总结 atexit 就是“给程序正常退出时留个后门”——
把收尾工作(刷日志、释放锁、打印统计)写成函数注册进去,无需显式调用,确保 main 返回时一定会跑,简单又可靠。
.get()取出裸指针,fgets
- 取出裸指针
pf.get()
while (fgets(buf, 1024, pf.get()) != nullptr)
get()把unique_ptr<FILE,…>里的裸FILE*拿出来,因为fgets只认识裸指针。不要
fclose(pf.get())自己,否则智能指针二次fclose→ UB(双重释放)。
fgets的“带回车”特性
char buf[1024];
fgets(buf, 1024, pf.get());
fgets会 把行尾的\n也读进来(如果缓冲区足够)。返回
nullptr表示 EOF 或出错;这里只判断nullptr就够,因为后面用Trim清掉\n。
动态多态---运行时多态
- 动态多态回顾(基类指针指向子类对象) 典型例子:
class Animal {
public:
virtual void speak() { std::cout << "Animal\n"; }
};
class Cat : public Animal {
public:
void speak() override { std::cout << "Meow\n"; }
};
class Dog : public Animal {
public:
void speak() override { std::cout << "Woof\n"; }
};
void MakeSound(Animal* a) {
a->speak(); // 这里用的是“虚函数 + 基类指针”
}
使用:
cpp
Cat c;
Dog d;
MakeSound(&c); // 输出 Meow
MakeSound(&d); // 输出 Woof
关键点:
函数 MakeSound 的参数类型是 Animal*(基类指针),不关心具体是 Cat 还是 Dog; 真正调用的是运行时对象的那个版本: 传 Cat 调用 Cat::speak 传 Dog 调用 Dog::speak 这就是 C++ 的动态多态(运行时多态): “基类指针 / 引用 + 虚函数*。*
闭包
一句话版本
闭包 = 函数 + 它“诞生”时捕获的所有外部变量,且这些变量在函数离开定义作用域后依旧活着。
- 从“普通函数”到“闭包”的演化
① 普通函数
int add(int a, int b) { return a + b; }
函数体只用参数,没用任何外部变量,不是闭包。
② 带“自由变量”的代码块
int x = 10;
auto f = [x](int a) { return a + x; }; // C++ lambda
x 不是参数,而是定义在函数外部的变量。
把 x 绑进 f 的那一刻,f 就从“普通函数”升级成“闭包”。
- 关键三要素(记住口诀:捕·延·态)
| 要素 | 说明 | 示例 |
|---|---|---|
| 捕获(Capture) | 把外部变量打包进来 | [x,y] |
| 延长(Extend) | 变量生命周期被延长到与闭包对象相同 | 离开作用域也不销毁 |
| 状态(State) | 多次调用闭包,数据可保持状态 | 计数器、累加器 |
- 不同语言的“闭包”长什么样
| 语言 | 写法 | 捕获方式 |
|---|---|---|
| C++11 | auto f = [=](){}; |
值/引用显式写 |
| Python | def f(a): return a + x |
默认按引用捕 |
| JavaScript | const f = (a) => a + x; |
默认按引用捕 |
| Go | f := func(a int) int { return a + x } |
按引用捕 |
- 直观例子:自增器工厂(带状态)
C++ 版
auto make_counter(int start = 0) {
return [start]() mutable { return start++; };
}
auto c1 = make_counter(10);
std::cout << c1() << '\n'; // 10
std::cout << c1() << '\n'; // 11
start 本该在 make_counter 返回后销毁,但因被闭包捕获,生命周期延长,成为闭包对象的内部状态。
Python 版
def make_counter(start=0):
def closure():
nonlocal start
start += 1
return start
return closure
c = make_counter(5)
print(c(), c()) # 5 6
- 闭包常见用途
事件回调 / 异步任务(把上下文带进去)
迭代器 / 生成器(保持遍历状态)
函数式编程:高阶函数
map/filter/reduce的参数装饰器、中间件(AOP)
DSL 配置(如前端 React Hooks)
- 容易混淆的概念
| 概念 | 区别 |
|---|---|
| 匿名函数 | 只是“没名字”,不一定捕获变量,不一定是闭包。 |
| lambda 表达式 | 语法糖,如果捕获了外部变量就成了闭包对象。 |
| 函数指针 | 只指向代码,不带数据,无法保持状态。 |
- 一句话再总结
闭包让函数“携带私有背包”去旅行——背包里装着它出生那一刻能看到的所有变量,且背包随函数一起走,走多远都不会丢。
优先级队列
我来为你详细介绍C++中的优先级队列(priority_queue)的使用方法。
什么是优先级队列
优先级队列是一种特殊的队列,它的特点是按照元素的优先级进行排序,每次取出的都是优先级最高的元素。在C++中,优先级队列默认是大顶堆(最大元素在顶部)。
基本语法
#include <queue> // 需要包含这个头文件
// 基本声明
std::priority_queue<int> pq; // 默认大顶堆
// 完整声明格式
template<
class T,
class Container = std::vector<T>,
class Compare = std::less<typename Container::value_type>
> class priority_queue;
常用操作
#include <queue>
#include <iostream>
int main() {
std::priority_queue<int> pq;
// 1. 插入元素
pq.push(10);
pq.push(30);
pq.push(20);
// 2. 访问顶部元素(最大值)
std::cout << "Top element: " << pq.top() << std::endl; // 输出: 30
// 3. 删除顶部元素
pq.pop();
std::cout << "New top: " << pq.top() << std::endl; // 输出: 20
// 4. 检查是否为空
if (!pq.empty()) {
std::cout << "Queue is not empty" << std::endl;
}
// 5. 获取大小
std::cout << "Size: " << pq.size() << std::endl;
return 0;
}
自定义排序方式
1. 小顶堆(最小值优先)
// 方法1:使用greater
std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq;
// 方法2:自定义比较函数
struct Compare {
bool operator()(int a, int b) {
return a > b; // 小顶堆
}
};
std::priority_queue<int, std::vector<int>, Compare> min_pq2;
2. 自定义类型的优先级队列
struct Task {
int priority;
std::string name;
// 重载小于运算符
bool operator<(const Task& other) const {
return priority < other.priority; // 优先级高的排在前面
}
};
std::priority_queue<Task> task_queue;
// 或者使用自定义比较函数
struct TaskComparator {
bool operator()(const Task& a, const Task& b) {
return a.priority < b.priority;
}
};
std::priority_queue<Task, std::vector<Task>, TaskComparator> task_queue2;
实际应用示例
示例1:任务调度
#include <queue>
#include <iostream>
#include <string>
struct Task {
int priority;
std::string description;
bool operator<(const Task& other) const {
return priority < other.priority; // 优先级高的先执行
}
};
int main() {
std::priority_queue<Task> tasks;
tasks.push({3, "写报告"});
tasks.push({1, "回复邮件"});
tasks.push({5, "修复紧急bug"});
tasks.push({2, "开会"});
std::cout << "任务执行顺序:" << std::endl;
while (!tasks.empty()) {
Task current = tasks.top();
std::cout << "优先级 " << current.priority << ": " << current.description << std::endl;
tasks.pop();
}
return 0;
}
示例2:查找数组中前K个最大元素
#include <queue>
#include <vector>
std::vector<int> topK(const std::vector<int>& nums, int k) {
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
for (int num : nums) {
if (min_heap.size() < k) {
min_heap.push(num);
} else if (num > min_heap.top()) {
min_heap.pop();
min_heap.push(num);
}
}
std::vector<int> result;
while (!min_heap.empty()) {
result.push_back(min_heap.top());
min_heap.pop();
}
return result;
}
使用注意事项
不支持随机访问:只能访问顶部元素
不支持迭代器:不能像vector那样遍历
时间复杂度:
push(): O(log n)
pop(): O(log n)
top(): O(1)
内存开销:基于vector实现,内存连续,缓存友好
总结
优先级队列非常适合处理需要按照优先级处理元素的场景,比如:
任务调度系统
Dijkstra算法
合并K个有序链表
数据流中的中位数
滑动窗口最大值
掌握优先级队列可以让你的代码在处理这类问题时更加简洁高效!
什么是字面类型?
在 C++ 中,字面类型(Literal Types) 是一个非常关键的概念,尤其是在你想要编写高性能、编译期优化的代码时。
简单来说,字面类型是指其布局和初始化逻辑足够简单,以至于编译器可以在“编译阶段”就完全确定其值并进行求值的类型。它是实现 constexpr(常量表达式)功能的核心基石。
1. 哪些类型属于字面类型?
根据 C++ 标准,一个类型必须满足以下条件之一才能被称为字面类型:
算术类型(Scalar Types):
所有整数类型(
int,char,long等)。浮点类型(
float,double)。枚举类型(
enum或enum class)。指针类型(包括函数指针和成员指针)。
引用类型:
- 所有左值引用(如
int&)或右值引用(如int&&)。
- 所有左值引用(如
字面类型数组:
- 只要数组的元素类型是字面类型,该数组就是字面类型。
字面类类型(Literal Classes): 这是最复杂的部分,一个类(
class或struct)要成为字面类型,必须满足:析构函数是平平凡凡的(Trivial): 在 C++20 之前,必须是平凡析构;C++20 之后,可以是
constexpr析构函数。构造函数: 必须拥有至少一个
constexpr构造函数,或者是一个聚合类(Aggregate)。成员变量: 所有的非静态数据成员和基类都必须是字面类型。
2. 为什么需要字面类型?
字面类型的主要作用是告诉编译器:“这个东西很安全,你可以在编译的时候就把它算出来。”
示例:字面类 vs 非字面类
C++
#include <iostream>
#include <string>
// 这是一个字面类型
struct Point {
int x;
int y;
// 聚合类或者有 constexpr 构造函数
constexpr Point(int _x, int _y) : x(_x), y(_y) {}
};
// 这不是一个字面类型,因为它包含了 std::string
// std::string 内部管理动态内存,构造/析构函数不满足字面类型要求
struct Person {
std::string name;
int age;
};
int main() {
// 因为 Point 是字面类型,我们可以将其定义为 constexpr
constexpr Point p(10, 20); // 编译期就确定了,不消耗运行时时间
// 下面这行会报错,因为 Person 不是字面类型
// constexpr Person user{"Alice", 25};
return 0;
}
3. 核心判定标准:Trivial (平凡性)
字面类型通常与“平凡类型”挂钩。如果一个类通过 std::is_literal_type<T>::value(注意:该工具在 C++17 中被弃用,C++20 中移除,因为它的定义随标准演进而变得复杂)或手动检查发现:
它没有虚函数。
它没有虚基类。
它的成员没有复杂的动态空间申请。
那么它通常就是字面类型。
总结
你可以把字面类型想象成“编译器眼中的透明人”。编译器看一眼就知道它长什么样、值是多少,不需要等到程序运行起来去内存里反复确认。
优点: 允许在
constexpr函数和变量中使用,极大提升运行效率。常见反例:
std::string,std::vector等使用了堆内存管理的容器(在 C++20 之前完全不是,C++20 之后在特定环境下有限制地支持)。
你想了解如何利用字面类型和 constexpr 来优化你现有的 C++ 代码性能吗?
什么是平凡析构?
简单来说,平凡析构函数(Trivial Destructor) 就是一个“什么都不干”的析构函数。
在 C++ 中,如果一个类型的析构函数非常简单,以至于编译器认为它在对象生命周期结束时不需要执行任何实际的操作(比如释放内存、关闭文件、或者调用其他复杂的逻辑),那么它就是“平凡”的。
1. 成为“平凡析构函数”的硬性条件
一个类想要拥有平凡析构函数,必须同时满足以下所有条件:
不能是自定义的: 你不能手动写
~ClassName() { ... }。它必须是编译器自动生成的,或者你显式地写~ClassName() = default;。不能是虚函数: 析构函数不能被
virtual修饰。基类必须平凡: 所有的直接基类都必须拥有平凡析构函数。
成员变量必须平凡: 类中所有的非静态数据成员(比如
int,double或其他结构体)都必须拥有平凡析构函数。
2. 代码对比:平凡 vs 不平凡
平凡析构的例子(Trivial)
C++
struct Trivial {
int x;
double y;
// 编译器自动生成:~Trivial() = default;
};
struct AnotherTrivial {
Trivial t; // 成员也是平凡的
int arr[10];
};
这些类型在内存中就像一块简单的积木,程序结束时直接把这块内存“当成垃圾”回收掉就行了,不需要走任何特殊的清理流程。
非平凡析构的例子(Non-Trivial)
C++
struct NonTrivial {
~NonTrivial() {
// 只要你写了花括号,哪怕里面是空的,
// 在某些标准下它也会被视为“不平凡”
std::cout << "Cleaning up...";
}
};
struct HasString {
std::string name; // std::string 有自定义析构函数(释放堆内存)
// 所以 HasString 的析构函数也是不平凡的
};
3. 为什么要区分“平凡”?
为什么编译器要专门搞出这么一个概念?主要是为了性能优化:
内存管理: 如果一个数组里的元素都是平凡析构的,当数组销毁时,编译器可以直接丢弃整块内存,而不需要去循环调用每一个元素的析构函数。这在处理海量数据时,速度差距非常大。
兼容性: 平凡类型通常是 POD (Plain Old Data) 类型。这意味着你可以放心地使用 C 语言的
memcpy或memmove来拷贝它们,而不会破坏对象的结构。字面类型(Literal Types): 正如你之前问到的,字面类型要求析构函数必须是平凡的(C++20 之后放宽到了可以是
constexpr析构函数),因为编译器需要确保在编译期就能完成对象的创建和销毁,不能涉及复杂的运行时逻辑。
4. 如何检查?
如果你不确定自己的类是不是平凡析构,可以用 C++ 的标准库工具来测一下:
#include <type_traits>
std::cout << std::is_trivially_destructible<MyClass>::value;
// 如果输出 1,就是平凡析构;输出 0,则不是。
总结
平凡析构 = 编译器不用操心 = 极高的性能 = 可以在编译期处理。
既然提到了析构函数,你想了解一下为什么在处理多态(继承)时,析构函数必须是 virtual(而一旦是 virtual 就不再平凡)的原因吗?
什么是POD类型?
POD 是 Plain Old Data(平平凡凡的旧数据)的缩写。
通俗地理解,一个 POD 类型就是一个可以像 C 语言结构体那样操作的类。它只是一块连续的内存区域,不包含任何复杂的 C++ 特性(比如虚函数、继承逻辑或特殊的构造行为)。
在 C++11 之后,POD 的定义被进一步拆解为两个更精确的概念:平凡性 (Trivial) 和 标准布局 (Standard-layout)。
1. POD 的两大支柱
A. 平凡性 (Trivial)
指这个类型的“生老病死”都很简单:
平凡构造/析构: 编译器可以用
malloc分配内存而不必调用复杂的构造函数;销毁时直接丢弃内存即可(正如我们之前聊过的平凡析构)。平凡拷贝/移动: 拷贝这种对象就像使用
memcpy一样,直接按位复制内存,不需要考虑深拷贝等逻辑。
B. 标准布局 (Standard-layout)
指这个类型在内存里的排布是“规整”且“可预测”的,这保证了它能和 C 语言或其他语言编写的程序完美兼容:
没有虚函数或虚基类: 否则对象里会多出一个隐藏的虚表指针(vptr)。
访问控制一致: 所有的非静态成员必须要么全是
public,要么全是private,不能混用(因为编译器可能会为了优化而重新排列不同权限成员的顺序)。基类兼容: 如果有继承,基类也必须是标准布局,且不能在多个层次中定义非静态成员变量。
2. 代码直观对比
这是 POD 类型:
C++
struct MyPod {
int id;
float score;
char status[10];
};
// 它可以放心地用 memset(obj, 0, sizeof(obj)) 来清空
这不是 POD 类型:
C++
struct NotPod {
virtual ~NotPod() {} // 有虚函数,布局不再“标准”
std::string name; // 成员 std::string 有非平凡的构造/析构
};
3. 为什么要关注 POD?
二进制兼容性: 如果你想把一个 C++ 结构体直接通过网络发送给另一台机器,或者传递给一个 C 语言编写的库,这个结构体必须是 POD 类型。
性能巅峰: 对于 POD 类型的数组,编译器可以利用底层指令(如
SIMD或memmove)进行极速拷贝,而不是一个一个元素地调用构造函数。静态初始化: POD 类型的全局变量通常在程序加载时就初始化好了,不会有“构造函数执行顺序”带来的隐患。
4. 如何在代码中判断?
虽然 POD 的概念在 C++20 中被进一步细化了,但你依然可以使用标准库来检测:
C++
#include <iostream>
#include <type_traits>
struct Test { int a; };
int main() {
// C++11/14/17 常用方式
std::cout << std::is_pod<Test>::value << std::endl;
// C++20 后的更细化检查
std::cout << std::is_trivial<Test>::value << std::endl;
std::cout << std::is_standard_layout<Test>::value << std::endl;
}
一句话总结
POD 类型就是“内存透明”的类型,它是 C++ 向高性能底层操作和 C 语言兼容性留下的桥梁。
既然我们聊到了 POD 的内存布局,你想看看编译器是如何在内存里排列这些成员,以及什么是“内存对齐(Alignment)”吗?
※在C++ STL中萃取机有什么作用?针对于不同的情况,其设计思想有什么不同的?
https://gemini.google.com/share/b08ff1cc076e
※为什么deque的内存大小需要这样来设计?
inline size_t _deque_buf_size(size_t n, size_t sz) {
return n != 0 ? n :
(sz < 512 ? size_t(512 / sz) : size_t(1));
}
点击链接查看和 Kimi 的对话 https://www.kimi.com/share/19c4d3f5-6a92-8ef9-8000-00007b6e3a3f
※deque中start 以及finish迭代器的理解
//迭代器的关键行为,其中要注意的是一旦遇到缓冲区边缘,可能需要跳一个缓存区
void set_node(map_pointer new_node) {
node = new_node;
first = *new_node;
last = first + difference_type(buffer_size());
}
//接下来重载运算子是_deque_iterator<>成功运作的关键
reference operator*() const { return *cur; }
pointer operator->() const { return &(operator*()); }
difference_type operator— (const self &x) const {
return difference_type (buffer_szie()) * (node-x.node-1)+(cur-first)+(x.last-x.cur);
}
self &operator++() {
++cur;
if (cur == last) {
set_node(node + 1);
cur = first;
}
return *this;
}
self operator++(int) {
self temp = *this;
++*this;
return temp;
}
self &operator--() {
if (cur == first) {
set_node(node - 1);
cur = last;
}
--cur;
return *this;
}
self operator-(int) {
self temp = *this;
--*this;
return temp;
}
//以下实现随机存取,迭代器可以直接跳跃n个距离
self &operator+=(difference_type n) {
difference_type offest = n + (cur - first);
if (offest > 0 && offest < difference_type(buffer_size()))
cur += n;
else {
offest > 0 ? offest / fifference_type(buffer_size()) : -difference_type((-offest - 1) / buffer_size()) - 1;
set_node(node + node_offest);
cur = first + (offest - node_offest * difference_type(buffer_size()));
}
return *this;
}
self operator+(differnece_type n) {
self tmp = *this;
return tmp += n;
}
self operator-=() { return *this += -n; }
self operator-(difference_type n) {
self temp = *this;
return *this -= n;
}
rference operator[](difference_type n) {
return *(*this + n);
}
bool operator==(const self &x) const { return cur == x.cur; }
bool operator!=(const self &x) const { return !(*this == x); }
bool operatoe<(const self &x) const {
return (node == x.node) ? (cur < x.cur) : (node - x.node);
}
https://gemini.google.com/share/b62c4791b1ac
priority_queue(优先队列)剖析
template<class T, class Sequence=vector <T>, class Compare=less<typename Sequence::value_type>>
class priority_queue {
public:
typedef typename Sequence::value_type value_type;
typedef typename Sequence::size_type size_type;
typedef typename Sequence::reference reference;
typedef typename Sequence::const_reference const_refernece;
protected:
Sequence c;//底层容器
Compare comp//容器比较大小标准
public:
priority_queue() : c() {}
explicit priority_queue(const Compare &x) : c(), comp(x) {}
//以下用到的make_heap(),push_heap(),pop_heap()都是泛型算法
//任何一个构造函数都可以立即在底层产生一个heap
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last const Compare &x)
:c(first, last), comp(x) { make_heap(c.begin(), c.end(), comp); }
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last const Compare &x)
:c(first, last) { make_heap(c.begin(), c.end(), comp); }
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
const_reference top() const { return c.front(); }
void push(const value_type &x) {
_STL_TRy {
c.push_back(X);
push_heap(c.begin(), c.end(), comp);
}
_STL_UNWIND{c.clear()};
}
void pop() {
_STL_TRY {
pop_heap(c.begin(), c.end(), comp);
c.pop_back();
}
_STL_UNWEIND{c.clear()};
}
};
// priority_queue无迭代器
虽然它也是一个容器配接器 (Container Adapter),但它比 stack 聪明得多:它不仅限制了接口,还在内部维护了一套**堆(Heap)**的逻辑。
1. 设计核心:不仅是包装,更是重组
priority_queue 的本质是:一个随机访问容器 + 一套堆算法。
为什么默认是 vector?
C++
template<class T, class Sequence = vector<T>, ...>
在 stack 中,默认是 deque;但在 priority_queue 中,默认换成了 vector。
- 原因:堆算法(如
push_heap)需要频繁地通过下标访问节点(父节点 $i$ 的子节点在 \(2i+1\) 和 \(2i+2\))。vector提供的连续内存随机访问速度(operator[])是所有容器中最快的。
为什么默认是 less?
C++
class Compare = less<typename Sequence::value_type>
这是一个有趣的“反直觉”设计:
- 虽然名字叫
less,但在 STL 的priority_queue中,默认得到的是一个大顶堆(Max-heap)。因为堆算法规定,如果comp(parent, child)为true,则交换位置。
2. 关键代码细节剖析
A. 构造函数的“即时建堆”
C++
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last)
: c(first, last) {
make_heap(c.begin(), c.end(), comp);
}
当你传入一个区间构造优先队列时,它会先将数据全部拷贝进
vector,然后立即调用make_heap。复杂度分析:
make_heap的时间复杂度是 $O(N)$,这比一个一个push进去(\(O(N \log N)\))要高效得多。
B. push 的“先加后调”
C++
void push(const value_type &x) {
c.push_back(x); // 1. 先把新元素塞到最后
push_heap(c.begin(), c.end(), comp); // 2. 执行“上溯”调整
}
- 细节:
push_heap算法假设[begin, end-1)已经是一个堆,它会将end-1位置的元素向上移动到合适的位置。
C. pop 的“先调后删”
这是最精妙的地方:
C++
void pop() {
pop_heap(c.begin(), c.end(), comp); // 1. 执行“下溯”调整
c.pop_back(); // 2. 真正弹出尾部元素
}
为什么要这么做? 在堆结构中,你不能直接删掉根节点(数组下标 0),否则堆就断了。
原理:
pop_heap会把堆顶元素(最大值)交换到容器的最后(back()),并把剩下的元素重新调整为一个堆。此时,你只需要调用pop_back()就能完美移除最大值。
3. 异常处理:_STL_TRY 与 _STL_UNWIND
你会注意到代码中出现了奇怪的宏:
C++
_STL_TRY { ... }
_STL_UNWIND{ c.clear() };
这是 STL 源码中为了保证**异常安全性(Exception Safety)**的做法:
- 如果在
push_heap或pop_heap过程中(比如自定义的比较函数comp)抛出了异常,_STL_UNWIND会确保清理容器,防止容器处于一种不可预知的破坏状态。
4. 总结:它与 Stack 的区别
特性 | Stack (栈) | Priority Queue (优先队列) |
底层数据结构 | 线性表 (通常是 | 逻辑上是完全二叉树,物理上是 |
排序规则 | 时间顺序 (LIFO) | 优先级顺序 (由 |
核心算法 | 无 (纯接口转发) | 堆算法 ( |
操作复杂度 |
|
|
深度思考
你会发现 priority_queue 的源码里完全没有定义如何去“实现”一个堆,它只是巧妙地调用了 <algorithm> 库里的泛型堆算法。这体现了 STL 的一个核心思想:容器、算法、配接器之间的解耦与协作。
为什么map set不可以修改Key?
简单来说,map 和 set 之所以要把 key 锁死为 const,是因为它们不仅仅是数据的“容器”,更是数据的**“管理者”**。
1. 核心原因:维护“树”的秩序
map 和 set 在底层通常是使用 红黑树(Red-Black Tree) 这种平衡二叉搜索树实现的。
秩序即生命:在红黑树中,每一个节点的位置都是根据它的
key严格计算出来的。左子树的key一定比根节点小,右子树的key一定比根节点大。牵一发而动全身:如果你能直接修改一个已经存在于树中的
key,比如把一个值为10的节点改成了100,但它物理位置还在原来那个“小于 50”的区域。后果:整棵树的搜索性质会被瞬间破坏。当你下次想找
100时,算法会去右子树找,结果空手而归;而当你遍历整棵树时,你会发现数据不再是有序的。
2. 迭代器解引用的真相
为了防止这种“毁灭性”的修改,STL 在接口设计上做了严格的类型限制:
对于
set<K>:迭代器解引用后的类型是const K&。你拿到的直接就是一个常量。对于
map<K, V>:迭代器解引用后的类型是pair<const K, V>。注意,只有key是const,而value是可以自由修改的。
小比喻:
就像图书馆的索引卡片。你可以修改卡片后面记录的“借阅人数”(value),但你绝对不能直接涂改卡片上的“书名”(key)。如果你改了书名,这本书在书架上的物理位置就跟索引对不上了,这本书就彻底“丢”了。
3. 如果我非要改 key 怎么办?
在 C++ 中,如果你需要修改一个 key,标准的“合法”操作流程必须是:
取出:先把旧的元素从容器中
erase掉(删除)。修改:在外部修改这个值。
放入:将修改后的新值重新
insert进去。
这样,底层容器就能触发红黑树的**重新平衡(Rebalancing)**逻辑,为这个新 key 找到它该呆的正确位置。
4. C++17 的“黑科技”:节点提取 (Node Extraction)
在 C++17 之后,为了避免删除再插入导致的内存申请/释放开销,STL 引入了一个高效的操作:extract()。
C++
auto node = my_set.extract(old_key); // 将节点从树中“摘”下来,不销毁内存
node.value() = new_key; // 修改 key(此时节点不在树里,怎么改都安全)
my_set.insert(std::move(node)); // 把节点重新“挂”回树里,自动重新排序
总结
key 设为 const 是为了强制保护底层数据结构的完整性。如果允许随意修改,红黑树就会退化成一个逻辑混乱的普通链表,其 \(O(\log n)\) 的搜索效率也就无从谈起。
在智能指针的使用场景中,什么是循环引用?
简单来说,**循环引用(Circular Reference)**是指两个或多个对象通过智能指针(通常是 std::shared_ptr)互相指向,形成了一个“死结”。
1. 核心问题:内存泄漏
在 C++ 的引用计数机制下,只有当一个对象的引用计数降为 0 时,它才会被销毁。
场景:A 指向 B,B 也指向 A。
后果:即使外部已经没有指针指向 A 和 B 了,它们的内部计数依然保持为 1。
结局:这两个对象永远不会被析构,内存无法释放,造成内存泄漏。
2. 解决方案:std::weak_ptr
为了打破这个死结,C++ 引入了 弱指针(Weak Pointer)。
原理:
std::weak_ptr指向对象,但不会增加引用计数。用法:将其中一方的
shared_ptr改为weak_ptr。结果:当外部最后一个
shared_ptr销毁时,引用计数归零,对象正常析构,从而解开了“死循环”。
3. 一句话总结
循环引用就是“我等你死,你等我死,结果大家都活成了僵尸”;解决办法就是把其中一个变备胎(
weak_ptr)。
请介绍一下C++中的future 和 async是做什么的?
“std::future 是 C++11 引入的用于表示异步操作结果的对象。它本身不执行任务,而是作为‘结果的占位符’存在。 而 std::async 是一个便捷函数,它可以自动启动一个异步任务(默认可能开新线程),并返回一个 future 对象,让我们后续获取结果。 它们通常一起使用,实现‘提交任务 → 继续做其他事 → 需要时取结果’的异步编程模型。”
类比:
就像点外卖:我下单(调用
async),拿到订单号(future),继续工作;等我想吃的时候,就查物流(.get()),如果还没到就等着,到了就取餐。”
“这避免了主线程被长时间阻塞,提升了程序响应性和并发能力。”
future和直接使用std::thread有什么区别?
“用 std::thread 需要手动管理线程生命周期(比如必须 join() 或 detach()),而且无法直接获取返回值。
而 future + async 封装了这些细节:
自动管理线程(或延迟执行)
支持返回值和异常传递
通过
.get()自动完成同步和资源回收
所以在需要返回值的异步任务场景下,future 更安全、简洁。”
.get()会阻塞吗? 有没有非阻塞的方式?
“是的,.get() 是阻塞调用——如果任务未完成,调用线程会一直等待。
如果不想阻塞,可以用:
.wait_for(timeout)或.wait_until(time_point):带超时等待先用
.valid()判断 future 是否有效(避免重复 get)
但要注意:每个 future 只能调用一次 .get(),之后就失效了。”
future能不能多个线程同时get?能不能复制?
std::future不可复制的,只能移动,因为他代表的是唯一的结果通道。如果需要多个地方等待同一个结果 应该用std::shared_future 他是可复制的 至于.get()只能调用一次 第二次就会泡出异常。所以多线程同时get是不安全的 除非用shared_future。
现场手撕demo举例
#include <iostream>
#include <future>
#include <chrono>
#include <thread>
// 模拟耗时服务
int fetchProduct() {
std::this_thread::sleep_for(std::chrono::seconds(1));
return 1001; // 商品ID
}
int fetchUser() {
std::this_thread::sleep_for(std::chrono::seconds(1));
return 2001; // 用户ID
}
int fetchShipping() {
std::this_thread::sleep_for(std::chrono::seconds(1));
return 3001; // 物流单号
}
int main() {
// 并行启动三个异步任务
auto futProd = std::async(std::launch::async, fetchProduct);
auto futUser = std::async(std::launch::async, fetchUser);
auto futShip = std::async(std::launch::async, fetchShipping);
// 主线程可以做其他事(这里省略)
// 汇总结果(此时会阻塞等待所有任务完成)
int product = futProd.get();
int user = futUser.get();
int shipping = futShip.get();
std::cout << "Order ready: Product=" << product
<< ", User=" << user
<< ", Shipping=" << shipping << std::endl;
return 0;
}
字符串操作函数
#include <iostream>
#include <cassert>
//需要用到assert断言函数
using namespace std;
char* strcpy(char* strDest,const char* strSrc){
assert((strDest!=NULL) && (strSrc!=NULL));
//建立一个新的指针
char* address=strDest;
while((*strDest++ =*strSrc++)!='\0');
return address;
}
int strlen(const char* str){
//先检查断言字符串
assert(str!=NULL);
int len;
while((*str++)!='\0'){
len++;
}
return len;
}
char* strcat(char* dest,char* src){
assert((dest!=NULL) && (src!=NULL));
char* ret=dest;
while(*dest){
dest++;//走到dest字符串的末尾
}
while(*dest++=*src++){}
return ret;
}
//比较两个字符串的大小
int strcmp(char* str1,char* str2){
/*
相等返回0
str1<str2 返回负数
str1> str2 返回正数
*/
assert(str1!=NULL && str2!=NULL);
while((*str1 && *str2) && (*str1==*str2)){
*str1++;
*str2++;
}
return *str1-*str2;
}
int main()
{
char strs[]="hello world!";
cout << strlen(strs) << endl;
//测试strcat
char str1[]="hello ";
char str2[]="world";
cout << strcat(str1,str2) << endl;
//测试比较函数
cout << strcmp(str1,str2) << endl;
return 0;
}
Redis的RDB快照模式?
RDB,全称Redis Database,其每隔一段时间,给整个内存数据拍一张完整的“全家福”。优点是文件小,恢复快,但是如果两次拍照之间宕机了,中间的数据就丢了,比如每5分钟拍一次,第4分59秒宕机,这近5分钟的数据就没了)。
Redis的AOF日志模式?
AOF,全称是Append Only File。其每做一个操作,就立刻记录在文件里,比如“某时某刻,用户执行了 SET key value”。这样设计的优点是,数据非常安全,最多只丢最后一条命令(甚至可以不丢),但缺点也很明显就是文件越来越大(日记越记越厚),恢复速度慢(要把日记从头到尾重新念一遍/执行一遍)。
Redis的混合日志记录模式?
从 Redis 4.0 开始,官方推出了混合持久化模式,结合了两者的优点:
原理:
当触发 AOF 重写时,不再只写命令,而是先把当前内存数据以 RDB 格式 写到 AOF 文件开头。
然后把重写期间产生的新命令以 AOF 格式 追加在后面。
结果:
文件前半部分是 RDB(加载快)。
文件后半部分是 AOF(数据新)。
既保证了恢复速度,又保证了数据安全性。