
五、C++ 新特性、关键字与编译原理(第 1~13 项)
0. 知识点之间的联系
C/C++ 程序构建链路
源代码 .cpp/.h
│
├─ 预处理阶段
│ ├─ 1. #define 宏
│ └─ 8. #include <> 与 #include ""
│
├─ 声明、类型与作用域阶段
│ ├─ 2. typedef:给类型起别名
│ ├─ 3. register:旧式寄存器存储提示
│ ├─ 4. const:只读约束
│ ├─ 5. extern:外部链接声明
│ ├─ 6. volatile:禁止特定内存访问优化
│ └─ 10. static:生命周期、作用域与类级共享
│
├─ 编译与运行时类型阶段
│ └─ 7. dynamic_cast / RTTI
│
├─ C 风格接口与内存操作
│ └─ 11. sprintf / strcpy / memcpy
│
├─ 现代 C++ 替代与增强
│ ├─ 9. const 替代 #define
│ ├─ 12. C++11 新特性
│ └─ 13. sizeof 与 strlen
│
└─ 汇编、链接、可执行文件
这一部分的主线是:预处理指令在正式编译前做文本替换;关键字决定变量的类型、作用域、生命周期、链接属性和编译器优化方式;C++11 用更安全、类型化、面向对象的方式替代大量 C 风格写法。
1. C/C++ 中宏定义的相关知识
核心:宏由预处理器处理,发生在正式语法编译之前,本质是文本替换。宏不具备类型系统,也不遵循普通作用域规则,使用方便但容易产生隐蔽错误。
宏使用 #define 定义,使用 #undef 取消:
#define PI 3.14159
#define MAX_BUFFER_SIZE 1024
#undef PI
1.1 对象式宏
#define BUFFER_SIZE 1024
int buffer[BUFFER_SIZE];
预处理后,代码中的 BUFFER_SIZE 会被直接替换成 1024。注意这是文本替换,不是变量,也不占用运行时对象空间。
1.2 函数式宏
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define SQUARE(x) ((x) * (x))
宏参数必须充分加括号,否则会因运算符优先级出错:
#define BAD_ADD(a, b) a + b
int x = BAD_ADD(1, 2) * 10;
// 文本展开:1 + 2 * 10 = 21,而不是 30
函数式宏还可能多次求值参数:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int n = 3;
int y = SQUARE(n++);
// 展开为 ((n++) * (n++))
// n 被自增两次,结果和求值顺序都非常危险
}
普通内联函数不会这样:
template <class T>
inline T square(T x) {
return x * x;
}
1.3 字符串化与标记拼接
# 把宏参数变成字符串,## 拼接标记:
#define TO_STRING(x) #x
#define CONCAT(a, b) a##b
TO_STRING(hello); // "hello"
int CONCAT(value, 1); // int value1;
1.4 条件编译
#ifdef DEBUG
std::cout << "debug mode\n";
#else
std::cout << "release mode\n";
#endif
#ifndef HEADER_FILE_H
#define HEADER_FILE_H
// 头文件内容
#endif
#ifndef/#define/#endif 是传统头文件保护,防止头文件被重复包含。C++ 也可以使用:
#pragma once
1.5 常见预定义宏
#include <iostream>
int main() {
std::cout << __FILE__ << '\n'; // 当前文件名
std::cout << __LINE__ << '\n'; // 当前行号
std::cout << __DATE__ << '\n'; // 编译日期
std::cout << __TIME__ << '\n'; // 编译时间
}
宏适合做编译期开关、平台判断、通用调试输出、头文件保护;但常量优先使用 constexpr,短函数优先使用 inline 函数或模板,类型集合优先使用 enum class。宏没有类型检查、没有命名空间、不会进入正常调试符号,调试难度也更高。现代 C++ 的原则是:能用语言特性解决,就不要滥用宏。
2. C/C++ 中 typedef 关键字的相关知识
核心:typedef 用来给已有类型起别名,可以提高可读性、屏蔽平台差异、简化复杂类型声明。它不是创建全新类型,而是给原类型增加一个同义名称。
2.1 基本用法
typedef unsigned long size_type;
typedef int Integer;
size_type len = 100;
Integer x = 10;
预处理后类型仍然是原来的类型,Integer 只是 int 的别名。
2.2 结构体别名
C 语言中定义结构体变量时经常需要写 struct,因此常用 typedef 简化:
typedef struct Point {
int x;
int y;
} Point;
Point p;
C++ 中结构体标签本身可以直接作为类型名,但这种写法仍常见于跨 C/C++ 头文件。
2.3 函数指针别名
#include <iostream>
typedef int (*CompareFunc)(int, int);
int ascending(int a, int b) {
return a - b;
}
void sort_array(int* arr, int n, CompareFunc cmp) {
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (cmp(arr[i], arr[j]) > 0) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
}
}
如果没有 typedef,函数指针参数会非常难读。
2.4 跨平台类型抽象
#ifdef _WIN32
typedef unsigned __int64 uint64;
#else
typedef unsigned long long uint64;
#endif
标准库中的 std::size_t、std::uint32_t 等类型本质上也承担了平台抽象作用。
2.5 typedef 与 using
C++11 推荐使用 using 写类型别名:
using IntPtr = int*;
using CompareFunc = int (*)(int, int);
普通类型别名中二者等价,但 using 更直观,并且支持别名模板:
template <class T>
using Vec = std::vector<T>;
Vec<int> nums; // std::vector<int>
Vec<double> scores; // std::vector<double>
typedef 很难直接实现别名模板。
2.6 typedef 与 #define 的区别
typedef int* IntPtr;
#define PTR_INT int*
IntPtr a, b; // a、b 都是 int*
PTR_INT c, d; // 展开为 int* c, d; c 是 int*,d 是 int
区别如下:
| 对比项 | typedef | #define |
|---|---|---|
| 处理阶段 | 编译阶段 | 预处理阶段 |
| 是否有类型检查 | 有 | 没有 |
| 是否遵循作用域 | 是 | 否,直到 #undef 或文件结束 |
| 复杂类型 | 更安全 | 容易被文本展开坑害 |
| 模板别名 | 不直接支持 | 不支持 |
| 推荐程度 | C 风格兼容 | 现代 C++ 优先 using |
需要注意,typedef 并不创建强类型。例如:
typedef int UserId;
typedef int OrderId;
UserId 和 OrderId 仍可互相赋值,因为它们本质都是 int。如果需要类型安全的强类型 ID,应封装结构体或使用强类型别名库。现代 C++ 代码中,普通别名优先写 using,阅读顺序是“新名字 = 原类型”,比 typedef 更清晰。
3. 介绍一下 register 关键字
核心:register 是 C/C++ 早期的存储类说明符,用来“建议”编译器把频繁使用的变量放入 CPU 寄存器,以减少内存访问。现代编译器的寄存器分配算法远比人工提示强大,因此该关键字基本失去实际意义,现代 C++ 中不再推荐使用。
旧式写法:
#include <stdio.h>
int sum(int n) {
register int i;
register int result = 0;
for (i = 1; i <= n; ++i) {
result += i;
}
return result;
}
在早期编译器优化能力较弱时,程序员可能通过 register 提示循环变量适合放在寄存器中。但现代编译器会基于数据流分析、循环频率、指令调度和目标平台寄存器数量自动决定变量分配,程序员的提示反而可能干扰优化。
3.1 存储位置并非由程序员保证
register 只是建议,不是命令。编译器完全可以忽略它:
程序员:希望 i 在寄存器中
编译器:根据实际情况决定
├─ 寄存器足够 → 可能放寄存器
└─ 寄存器紧张 → 仍放栈上
3.2 早期限制:不能取地址
在 C 的传统规则中,被 register 修饰的变量不能使用 & 取地址,因为寄存器没有内存地址:
register int x = 10;
// int* p = &x; // 传统 C 中错误
C++ 的历史规则略有差异,但总体思想一致:既然你暗示它可能不在内存中,就不应依赖它的地址。
3.3 与 auto、static 的区别
| 关键字 | 作用 | 生命周期 | 存储位置 |
|---|---|---|---|
| auto(C++ 旧含义) | 自动局部变量 | 作用域内 | 通常栈上 |
| register | 建议使用寄存器 | 作用域内 | 编译器决定 |
| static 局部变量 | 延长生命周期 | 程序运行期 | 静态存储区 |
| global | 全局变量 | 程序运行期 | 静态存储区 |
3.4 现代替代方式
现代代码不需要写 register,直接让编译器优化即可:
long long sum(int n) {
long long result = 0;
for (int i = 1; i <= n; ++i) {
result += i;
}
return result;
}
编译时开启优化:
g++ -O2 main.cpp -o main
在 -O2、-O3 下,编译器会自动进行循环优化、寄存器分配、内联和向量化。
C++17 起,register 不再作为存储类说明符使用;保留它主要是为了兼容旧代码。现代面试中回答此题应强调三点:第一,它是历史遗留关键字;第二,它只是建议而不是强制;第三,现代编译器优化比人工指定寄存器更可靠,因此不要在新代码中使用。真正影响性能时,应使用性能分析工具找到热点,再优化算法、数据布局、缓存命中和编译选项,而不是盲目添加 register。
4. 介绍一下 const 关键字
核心:const 表示“只读约束”,用于保护变量、指针、引用、参数、返回值和成员函数不被意外修改。合理使用 const 可以让编译器帮助检查逻辑错误,提高代码可读性和安全性。
4.1 const 普通变量
const int max_count = 100;
// max_count = 200; // 错误:const 变量初始化后不能修改
const 变量必须初始化,因为之后没有机会再赋值。
4.2 const 与指针
这是最容易混淆的部分:
int x = 10;
int y = 20;
const int* p1 = &x;
// *p1 = 30; // 错误:不能通过 p1 修改指向的值
p1 = &y; // 正确:指针本身可以改指向
int* const p2 = &x;
*p2 = 30; // 正确:可以通过 p2 修改值
// p2 = &y; // 错误:指针本身不能改指向
const int* const p3 = &x;
// *p3 = 30; // 错误
// p3 = &y; // 错误
记忆方法:看 const 位于 * 左边还是右边。
const int* p → 指向常量的指针,不能改“值”
int* const p → 常量指针,不能改“指向”
const int* const p → 值和指向都不能改
4.3 const 引用
void print_value(const std::string& s) {
std::cout << s << '\n';
// s = "new"; // 错误
}
const T& 可以避免拷贝,同时承诺函数不会修改参数,还能绑定临时对象。
4.4 const 成员函数
#include <iostream>
class Account {
private:
int balance;
public:
explicit Account(int b) : balance(b) {}
int get_balance() const {
// this 类型近似为 const Account*
// balance = 0; // 错误
return balance;
}
void deposit(int money) {
balance += money;
}
};
const 对象只能调用 const 成员函数:
const Account account(100);
account.get_balance(); // 正确
// account.deposit(10); // 错误
4.5 const 与 constexpr
const 表示运行期或编译期只读;constexpr 强制要求值可在编译期计算:
const int a = get_runtime_value(); // 运行期初始化也可以
constexpr int b = 10 * 10; // 必须编译期可得
constexpr int square(int x) {
return x * x;
}
4.6 const 正确性
如果一个函数不会修改对象,就应该尽量使用 const:
std::size_t length(const std::vector<int>& v) {
return v.size();
}
这样既能接收 const 对象,也能防止函数内部意外修改。mutable 是极少数例外,用于锁、缓存等“不影响逻辑状态”的成员。
4.7 顶层 const 与底层 const
const int* const p = &x;
左侧 const int 是底层 const,限制指向值;右侧 const p 是顶层 const,限制指针本身。拷贝时顶层 const 不影响类型匹配,底层 const 不能随意丢掉。
int* q = nullptr;
const int* cp = q; // 正确:权限收窄
// q = cp; // 错误:不能把只读权限重新放大
总结:const 不是“变量一定放在只读内存”,而是语言层面的修改约束。它的价值在于让接口意图明确、让非法修改提前在编译期暴露,是现代 C++ const correctness 的基础。
5. 介绍一下 external(extern)关键字
核心:C++ 中没有 external 关键字,正确名称是 extern。它用于声明一个变量或函数定义在其他翻译单元中,并告诉链接器在链接阶段解析符号。
一个 C++ 程序通常由多个 .cpp 文件分别编译成目标文件,最后由链接器合并。extern 的作用就是“这里只声明,不在这里定义”。
5.1 变量声明与定义
// global.cpp
int g_count = 10;
// main.cpp
#include <iostream>
extern int g_count; // 声明:g_count 在别处定义
int main() {
std::cout << g_count << '\n';
}
区别如下:
extern int x; // 声明,不分配内存
int x; // 定义,分配内存
extern int y = 10; // 带初始化时是定义
如果多个文件都写 int g_count;,可能产生重复定义;如果都只写 extern int g_count; 但没有任何地方定义,链接阶段会报未定义符号错误。
5.2 函数的 extern
函数默认具有外部链接属性,因此:
extern void foo();
void foo();
在大多数情况下含义接近。实际代码中更常见的是把函数声明放在头文件:
// math_utils.h
#pragma once
int add(int a, int b);
// math_utils.cpp
int add(int a, int b) {
return a + b;
}
5.3 extern const
C++ 中命名空间作用域下的 const 变量默认是内部链接,类似 static。如果想跨文件访问,需要在定义处加 extern:
// config.cpp
extern const int model_size = 128;
// main.cpp
extern const int model_size;
更推荐的工程做法是把 inline constexpr 变量放进头文件:
// config.hpp
#pragma once
#include <cstddef>
inline constexpr std::size_t model_size = 128;
5.4 extern “C”
C++ 支持函数重载,编译后会对函数名进行 name mangling,例如 foo(int) 和 foo(double) 生成不同符号。C 编译器不做这种修饰。调用 C 库时需要:
extern "C" {
int c_library_function(int);
}
如果头文件要同时兼容 C 和 C++:
#ifdef __cplusplus
extern "C" {
#endif
int c_library_function(int);
#ifdef __cplusplus
}
#endif
编译链接流程如下:
a.cpp ──编译──> a.o ┐
├──链接──> 可执行文件
b.cpp ──编译──> b.o ┘
extern 告诉链接器:符号可能在另一个 .o 中
5.5 常见错误
- 只有
extern int x;,没有任何.cpp中定义int x;:链接错误 undefined reference。 - 头文件中定义变量并被多个
.cpp包含:重复定义。 - 混淆声明和定义,带初始化的
extern int x = 1;是定义。 - C++ 调用 C 库忘记
extern "C":找不到被 C++ 修饰后的符号。
现代工程中,跨文件共享变量应谨慎,因为全局变量会增加耦合。更好的方式是通过接口传参、单例对象、配置类或头文件中的 inline constexpr 常量管理。
6. 介绍一下 volatile 关键字
核心:volatile 告诉编译器:被修饰的内存可能在程序当前控制流之外被改变,因此每次访问都必须真实读取或写入,不能把它缓存到寄存器或直接优化掉。它主要用于内存映射硬件寄存器、信号处理中的特殊变量等底层场景。
先看编译器优化可能做什么:
int flag = 0;
void wait() {
while (flag == 0) {
// 如果编译器认为当前线程不会修改 flag,
// 可能把 flag 读取提到循环外,导致死循环
}
}
volatile 要求每次循环都重新从内存读取:
volatile int flag = 0;
void wait() {
while (flag == 0) {
// 每次都从 flag 对应内存重新加载
}
}
6.1 内存映射 IO
嵌入式开发中,硬件寄存器可能由硬件改变:
// 示意:某状态寄存器地址
#define UART_STATUS (*(volatile unsigned int*)0x40001000)
void wait_ready() {
while ((UART_STATUS & 0x01) == 0) {
// 每次都必须真实读取硬件寄存器
}
}
如果不加 volatile,编译器可能发现第一次读取后程序没有修改它,就不再读取后续硬件状态。
6.2 volatile 指针写法
volatile int* p; // 指向 volatile int 的指针
int* volatile p2; // 指针本身 volatile,较少见
volatile int* volatile p3; // 值和指针都带 volatile
判断方式与 const 类似。
6.3 volatile 成员函数
class HardwareRegister {
private:
volatile unsigned int reg;
public:
unsigned int read() const volatile {
return reg;
}
};
6.4 常见误区:volatile 不等于线程同步
很多初学者误以为 volatile 能保证多线程可见性,这是错误的。在 C++ 内存模型中,volatile 不保证原子性,不保证内存顺序,也不建立 happens-before 关系。多线程应该使用 std::atomic:
#include <atomic>
#include <thread>
std::atomic<bool> stop_flag{false};
void worker() {
while (!stop_flag.load()) {
// 正确:原子变量保证线程间可见性和内存序
}
}
对比:
| 场景 | 应使用 |
|---|---|
| 硬件寄存器 | volatile |
| 信号处理中可被异步修改的标志 | volatile sig_atomic_t |
| 多线程共享标志 | std::atomic |
| 互斥访问复杂临界区 | std::mutex |
| 防止普通变量被优化 | 先确认是否真的需要 |
6.5 volatile 与 const 可以同时存在
只读硬件状态寄存器可能对程序只读,但硬件自身会改变:
const volatile unsigned int* status;
const:程序不能写入。volatile:编译器不能假设值不变,必须每次读取。
总结:volatile 的核心不是“线程可见”,而是“禁止编译器省略或重排对这块内存的访问”。在普通业务代码中几乎不需要它;在嵌入式、驱动、内核和信号处理中才常见。现代多线程并发应使用 std::atomic 和互斥锁,而不是用 volatile 模拟线程同步。
7. 介绍一下 dynamic 在什么时候使用
核心:C++ 没有名为 dynamic 的普通关键字,面试通常问的是 dynamic_cast。它用于在继承层次中进行运行时类型转换,最典型场景是把基类指针或引用安全转换为派生类指针或引用,即安全向下转型。
7.1 为什么需要 dynamic_cast
多态中通常通过基类接口操作对象:
class Animal {
public:
virtual void speak() const = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override;
void guard() const;
};
如果当前只有 Animal*,但想调用 Dog 独有方法 guard(),就需要判断它是否真的是 Dog:
#include <iostream>
void process(const Animal* animal) {
const Dog* dog = dynamic_cast<const Dog*>(animal);
if (dog) {
dog->guard();
}
}
7.2 指针和引用的失败行为
指针转换失败返回 nullptr:
Base* base = new Derived;
auto* derived = dynamic_cast<Derived*>(base);
if (derived) {
// 类型正确
}
引用没有空引用,转换失败抛出 std::bad_cast:
#include <stdexcept>
void process(const Base& base) {
try {
const Derived& d = dynamic_cast<const Derived&>(base);
} catch (const std::bad_cast& e) {
// 类型不匹配
}
}
7.3 必须有多态条件
dynamic_cast 依赖运行时类型信息 RTTI,因此基类必须至少有一个虚函数,通常是虚析构函数:
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {};
如果类没有虚函数,向下转型会编译失败,因为运行时没有类型信息可用。
7.4 向上、向下与横向转换
Animal
├─ Dog
└─ Cat
- 向上转型:
Dog* → Animal*,天然安全,可隐式转换,不需要dynamic_cast。 - 向下转型:
Animal* → Dog*,可能失败,需要运行时检查。 - 横向转换:多重继承中,从一个兄弟基类子对象转到另一个基类子对象,也可借助
dynamic_cast。
7.5 与 static_cast 的区别
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {};
Base* p = new Base;
auto* p1 = dynamic_cast<Derived*>(p); // 安全,返回 nullptr
auto* p2 = static_cast<Derived*>(p); // 编译通过,但对象实际不是 Derived,使用后是未定义行为
| 转换方式 | 检查时机 | 安全性 | 开销 |
|---|---|---|---|
static_cast | 编译期 | 不检查真实类型 | 几乎无额外开销 |
dynamic_cast | 运行期 | 类型不符可发现 | 有 RTTI 查询开销 |
reinterpret_cast | 不做类型安全转换 | 危险 | 底层位模式重解释 |
7.6 什么时候不该用
如果代码中大量出现 dynamic_cast,往往说明基类接口设计不足。更好的做法是把通用行为抽象成虚函数:
class Animal {
public:
virtual void action() const = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void action() const override {
guard();
}
private:
void guard() const;
};
调用方只写 animal->action(),不需要判断具体类型,这更符合多态思想。
因此,dynamic_cast 适合少量、确实需要恢复具体类型的场景,例如反序列化、事件对象分发、调试检查、插件接口查询;不适合把它当成弥补抽象设计缺陷的常规手段。使用时还要注意,部分嵌入式或高性能项目会关闭 RTTI,此时 dynamic_cast 和 typeid 可能不可用。
8. #include <file.h> 与 #include "file.h" 的区别
核心:二者都用于把头文件内容在预处理阶段插入当前位置,主要区别是搜索路径和搜索顺序不同。尖括号通常用于系统或标准库头文件,双引号通常用于项目内部头文件。
预处理过程:
main.cpp
│
│ #include "student.h"
▼
把 student.h 的内容原样插入
│
▼
得到一个更大的翻译单元,再交给编译器
8.1 尖括号形式
#include <iostream>
#include <vector>
#include <string>
#include <cstdint>
尖括号通常告诉预处理器:不要优先在当前源文件目录寻找,而是在系统头文件路径、编译器标准库路径和通过编译选项指定的外部 include 路径中查找。
例如:
g++ main.cpp -I/third_party/include
-I 指定的路径通常也可用于查找尖括号头文件。
8.2 双引号形式
#include "student.h"
#include "../common/config.h"
#include "network/socket_client.h"
双引号通常先在当前文件所在目录或项目本地路径查找;如果找不到,再按尖括号的路径继续查找。标准表述是:双引号形式使用“由实现定义的本地文件搜索规则”,找不到后回退到尖括号形式。
8.3 对比表
| 写法 | 常见用途 | 搜索特点 |
|---|---|---|
#include <file.h> | 标准库、第三方库、系统头文件 | 优先搜索系统/外部 include 路径 |
#include "file.h" | 当前项目自己的头文件 | 优先搜索当前项目本地目录,失败后再走系统路径 |
8.4 头文件保护
无论哪种形式,都应防止重复包含:
// student.h
#pragma once
class Student {
};
或传统写法:
#ifndef STUDENT_H
#define STUDENT_H
class Student {
};
#endif
如果没有保护,一个头文件被间接包含多次,可能出现类重复定义:
a.h 定义 class A
b.h 包含 a.h
main.cpp 同时包含 a.h 和 b.h
→ class A 出现两次
8.5 C++ 标准头为什么没有 .h
#include <iostream> // C++ 标准库
#include <stdio.h> // C 旧头文件
#include <cstdio> // C++ 对 C 头文件的封装,名称在 std 命名空间
推荐 C++ 代码使用 <cstdio>、<cstring>、<cstdint> 这类 C++ 版本。
8.6 路径组织建议
project/
├─ include/
│ └─ mylib/
│ ├─ parser.h
│ └─ token.h
├─ src/
│ ├─ parser.cpp
│ └─ main.cpp
项目内部可以写:
#include "mylib/parser.h"
并在构建系统中配置:
-Iinclude
不要写大量脆弱的相对路径:
#include "../../include/mylib/parser.h" // 可维护性差
总结:二者不是“一个只能包含系统文件、一个只能包含本地文件”的绝对区别,而是搜索顺序和工程约定不同。规范写法是:标准库和外部库用尖括号,项目内部头文件用双引号,并统一 include 根目录,避免混乱的相对路径。
9. const 和 #define 有什么区别?
核心:const 是语言层面的只读变量,有类型、作用域和调试信息;#define 是预处理阶段的文本替换,没有类型检查和作用域概念。现代 C++ 中普通常量优先使用 constexpr,其次使用 const,尽量不用宏定义常量。
9.1 基本对比
#define MAX_SIZE 100
const int max_size = 100;
预处理后,MAX_SIZE 会被直接替换为 100;而 max_size 是一个有类型的只读符号。
9.2 类型检查
#define VALUE 123
const int value = 123;
void print_double(double x);
宏没有类型,编译器只看到替换后的文本;const int 明确知道类型,函数重载、模板推导和类型转换都更可控。
9.3 作用域
class Config {
public:
static constexpr int timeout = 30;
};
void f() {
// Config::timeout 有明确作用域
}
宏没有类作用域和命名空间:
#define MAX 100
namespace A {
// MAX 仍然是全局文本替换
}
宏一旦定义,在后续文本中都会生效,直到 #undef 或文件结束。
9.4 调试体验
#define DEBUG_LEVEL 3
const int debug_level = 3;
宏在预处理阶段被替换,调试器可能看不到 DEBUG_LEVEL 这个符号;const 常量通常能进入符号表,调试时更容易观察。
9.5 宏的副作用
宏不会求值一次,也不遵循函数调用规则:
#define DOUBLE(x) ((x) + (x))
int n = 1;
int y = DOUBLE(n++); // n++ 执行两次
const 常量不存在这个问题:
const int n = 1;
int y = n + n; // n 不会变化
9.6 指针和字符串
const char* name1 = "Alice";
#define NAME2 "Bob"
const char* 可以进一步使用 const std::string 或 std::string_view:
#include <string_view>
constexpr std::string_view model_name = "resnet50";
9.7 对比表
| 维度 | const / constexpr | #define |
|---|---|---|
| 处理阶段 | 编译阶段 | 预处理阶段 |
| 类型检查 | 有 | 无 |
| 作用域 | 遵循命名空间、类、块作用域 | 文本全局生效 |
| 调试 | 更容易 | 替换后原名字可能消失 |
| 内存 | const 可能占存储,constexpr 可编译期折叠 | 纯文本替换 |
| 安全性 | 高 | 容易产生优先级、重复求值问题 |
| 现代推荐 | 推荐 | 常量不推荐 |
9.8 宏仍有价值的场景
宏并非完全无用,以下场景仍常见:
- 条件编译:
#ifdef _WIN32 - 头文件保护:
#ifndef - 日志中自动记录
__FILE__、__LINE__ - 跨编译器属性封装
- 确实需要文本拼接或字符串化
#ifdef NDEBUG
#define LOG(x)
#else
#define LOG(x) std::cout << __FILE__ << ":" << __LINE__ << " " << x << '\n'
#endif
但普通数值常量、字符串常量、简单函数,应优先使用 constexpr、inline 函数和枚举。总结一句:const 是类型系统保护下的只读实体,#define 是绕过类型系统的文本替换;现代 C++ 应把宏限制在条件编译和编译器扩展等真正需要预处理的场景。
10. 介绍一下 static 关键字的作用
核心:static 在 C/C++ 中有五类典型作用:控制局部变量生命周期、限制全局变量或函数的链接范围、声明类共享静态成员变量、声明类静态成员函数,以及 C++11 后保证局部静态变量线程安全初始化。
10.1 静态局部变量:延长生命周期
普通局部变量每次进入函数都会创建,离开作用域销毁:
int next_id() {
int id = 0;
return ++id; // 每次都从 1 开始
}
加 static 后只初始化一次,生命周期持续到程序结束:
#include <iostream>
int next_id() {
static int id = 0;
return ++id;
}
int main() {
std::cout << next_id() << '\n'; // 1
std::cout << next_id() << '\n'; // 2
std::cout << next_id() << '\n'; // 3
}
10.2 静态全局变量/函数:内部链接
// file1.cpp
static void helper() {} // 只能在当前 .cpp 中使用
static int cache = 0; // 只能在当前翻译单元访问
这可以避免不同 .cpp 文件中的同名函数或全局变量冲突。现代 C++ 更推荐使用匿名命名空间:
namespace {
void helper() {}
}
10.3 静态成员变量:所有对象共享
#include <iostream>
class Student {
private:
inline static int total = 0; // C++17
public:
Student() { ++total; }
~Student() { --total; }
static int count() {
return total;
}
};
所有 Student 对象共享同一份 total,它属于类而不是某个对象。
10.4 静态成员函数
class MathUtil {
public:
static int square(int x) {
return x * x;
}
};
int main() {
int x = MathUtil::square(5);
}
静态成员函数没有 this 指针,因此:
- 不能直接访问普通非静态成员。
- 不能声明为
const。 - 不能是虚函数。
- 可以直接访问静态成员变量。
class Counter {
private:
int x = 1;
static int y;
public:
static void f() {
// ++x; // 错误:没有 this,不知道是哪个对象的 x
++y; // 正确
}
};
10.5 C++11 局部 static 线程安全初始化
class Config {
public:
static Config& instance() {
static Config config;
return config;
}
private:
Config() = default;
};
C++11 保证函数内局部静态对象只被初始化一次,并且并发首次进入时初始化过程是线程安全的,这叫 magic statics。
10.6 五种作用总结
| 场景 | 作用 |
|---|---|
| 函数内 static 变量 | 只初始化一次,生命周期延续到程序结束 |
| 文件内 static 变量 | 内部链接,只在当前翻译单元可见 |
| static 函数 | 内部链接,只在当前翻译单元可见 |
| 类 static 成员变量 | 所有对象共享 |
| 类 static 成员函数 | 属于类,没有 this |
10.7 生命周期与作用域区别
普通局部变量:
进入作用域创建,离开作用域销毁
静态局部变量:
第一次进入时初始化,程序结束时销毁,只初始化一次
普通全局变量:
外部链接,其他文件可 extern 声明后访问
static 全局变量:
内部链接,只在当前编译文件可见
使用 static 时要注意,静态共享状态在多线程下可能产生数据竞争,需要 std::mutex 或 std::atomic 保护;静态对象之间也不应依赖复杂初始化顺序。合理使用 static 可以实现对象计数、单例、私有辅助函数和持久缓存,但滥用会让函数带有隐式状态,降低可测试性。
11. sprintf、strcpy 及 memcpy 函数有什么区别
核心:sprintf 负责格式化输出到字符串;strcpy 负责复制以 \0 结尾的 C 字符串;memcpy 负责按字节复制指定长度内存。三者处理对象、结束条件、重叠规则和安全性都不同。
函数原型:
int sprintf(char* buffer, const char* format, ...);
char* strcpy(char* dest, const char* src);
void* memcpy(void* dest, const void* src, std::size_t count);
11.1 sprintf:格式化拼接
#include <cstdio>
int main() {
char buf[64];
int age = 20;
sprintf(buf, "age = %d", age);
// buf 中为 "age = 20\0"
}
sprintf 会按照格式字符串把整数、浮点数、字符串等格式化到目标缓冲区,并自动追加字符串结束符 \0。返回值是写入的字符数,不包括末尾 \0。
它的最大问题是无法限制输出长度:
char small[8];
sprintf(small, "this is a very long string"); // 缓冲区溢出
应使用 snprintf:
char small[8];
std::snprintf(small, sizeof(small), "%s", "this is a very long string");
snprintf 最多写入 size - 1 个有效字符,并保留 \0。
11.2 strcpy:复制 C 字符串
#include <cstring>
int main() {
const char* src = "hello";
char dest[16];
strcpy(dest, src);
}
strcpy 从 src 逐个字符复制,直到遇到 \0,并把 \0 一起复制。它不要求传入长度,因此调用者必须保证目标缓冲区足够大。
src: h e l l o \0
复制:h e l l o \0
危险示例:
char dest[4];
strcpy(dest, "hello"); // 目标空间不足,溢出
strncpy 可以限制复制数量,但它有反直觉行为:如果源字符串长度达到指定数量,它不会自动补 \0,必须手动保证:
char dest[8];
std::strncpy(dest, "hello", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';
11.3 memcpy:按字节复制内存
#include <cstring>
struct Data {
int id;
double score;
};
int main() {
int a[5] = {1, 2, 3, 4, 5};
int b[5];
std::memcpy(b, a, sizeof(a));
Data x{1, 90.5};
Data y;
std::memcpy(&y, &x, sizeof(Data));
}
memcpy 不关心内容是不是字符串,也不会遇到 \0 停止,而是严格复制第三个参数指定的字节数。
11.4 memcpy 与内存重叠
memcpy 要求源内存和目标内存不能重叠,否则行为未定义。重叠场景使用 memmove:
int arr[5] = {1, 2, 3, 4, 5};
// 同一块内存内移动,使用 memmove
std::memmove(arr + 1, arr, 3 * sizeof(int));
11.5 对比表
| 函数 | 处理对象 | 停止/长度依据 | 是否补 \0 | 是否允许重叠 | 安全建议 |
|---|---|---|---|---|---|
sprintf | 格式化文本 | 格式字符串和参数 | 是 | 不建议重叠 | 用 snprintf |
strcpy | C 字符串 | 遇到源串 \0 | 是 | 不允许 | 确保容量或用更安全接口 |
memcpy | 任意内存字节 | 显式传入字节数 | 否 | 不允许 | 重叠用 memmove |
11.6 C++ 推荐方式
普通字符串优先使用 std::string:
#include <format> // C++20
#include <string>
std::string s = "age = " + std::to_string(20);
原始内存复制应明确对象生命周期和可平凡复制要求。对于非平凡 C++ 对象,不能简单 memcpy,例如含 std::string、虚函数、智能指针的类,应使用拷贝构造、拷贝赋值或容器算法,否则会绕过构造函数和资源管理,造成严重错误。
12. C++11 的新特性都有哪些?
核心:C++11 是现代 C++ 的分水岭,引入了自动类型推导、移动语义、lambda、智能指针、统一初始化、并发库、范围 for、枚举类、空指针常量等大量特性,使 C++ 从“带类的 C”显著转向更安全、更高层、更具表达力的现代语言。
12.1 auto 类型推导
std::map<std::string, std::vector<int>>::iterator it1 = data.begin();
auto it2 = data.begin(); // 简洁且不易写错类型
auto 不代表动态类型,编译期仍然确定静态类型。
12.2 范围 for 循环
std::vector<int> nums = {1, 2, 3};
for (int x : nums) {
std::cout << x << ' ';
}
for (auto& x : nums) {
x *= 2; // 修改元素使用引用
}
12.3 nullptr
void f(int);
void f(int*);
f(0); // 可能调用 int 版本
f(nullptr); // 明确是空指针
NULL 可能只是整数 0 的宏,nullptr 类型是 std::nullptr_t,重载解析更安全。
12.4 统一初始化与 initializer_list
int x{10};
std::vector<int> nums{1, 2, 3, 4};
class Point {
public:
int x, y;
};
Point p{1, 2};
花括号初始化还能防止窄化转换:
int value{3.14}; // 编译错误或警告,避免精度丢失
12.5 lambda 表达式
#include <algorithm>
#include <vector>
int main() {
std::vector<int> nums = {3, 1, 4, 2};
std::sort(nums.begin(), nums.end(), [](int a, int b) {
return a > b;
});
int base = 10;
auto add = [base](int x) {
return base + x;
};
}
lambda 本质上会生成一个匿名函数对象,捕获方式包括值捕获、引用捕获和隐式捕获。
12.6 右值引用与移动语义
#include <iostream>
#include <string>
class Buffer {
public:
Buffer() = default;
Buffer(Buffer&& other) noexcept {
// 转移资源,而不是深拷贝
}
};
int main() {
std::string s1 = "hello";
std::string s2 = std::move(s1); // s1 资源移动给 s2
}
移动语义减少临时对象深拷贝,是高性能 C++ 的基础。
12.7 智能指针
#include <memory>
void f() {
auto p = std::make_unique<int>(10); // 独占所有权
auto q = std::make_shared<int>(20); // 共享所有权
} // 自动释放,避免 delete 泄漏
unique_ptr:独占、轻量、不能拷贝但可移动。shared_ptr:引用计数共享。weak_ptr:打破循环引用。
12.8 constexpr
constexpr int square(int x) {
return x * x;
}
constexpr int v = square(10); // 编译期计算
12.9 override、final、noexcept、default、delete
class Base {
public:
virtual void f() {}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void f() override {} // 编译器检查是否真的覆盖
};
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
void safe_func() noexcept {
// 承诺不抛异常
}
12.10 enum class
enum class Color { Red, Green, Blue };
Color c = Color::Red;
// if (c == 1) {} // 错误:不会隐式转换成整数
它避免传统枚举的名字污染和隐式整数转换。
12.11 并发与标准库增强
#include <thread>
#include <mutex>
#include <future>
#include <chrono>
#include <array>
#include <unordered_map>
#include <tuple>
#include <type_traits>
C++11 第一次在语言标准层面引入线程、互斥锁、条件变量、future、异步任务和统一内存模型;标准库增加 array、unordered_map/set、tuple、function、bind、正则、随机数、chrono 时间库等。
12.12 特性分组图
C++11
├─ 类型表达:auto、decltype、尾置返回类型
├─ 初始化:{}、initializer_list、类成员默认初始化
├─ 函数增强:lambda、constexpr、noexcept、变参模板
├─ 面向对象:override/final/default/delete、委托构造、继承构造
├─ 资源管理:unique_ptr/shared_ptr/weak_ptr、移动语义
├─ 类型安全:nullptr、enum class、static_assert
└─ 并发与标准库:thread、mutex、future、chrono、unordered 容器
面试回答时不必只罗列名词,应说明每类特性解决什么问题:auto 简化复杂类型;移动语义减少不必要拷贝;智能指针解决裸指针资源释放;lambda 让算法回调更自然;并发库让多线程从平台 API 上升到标准层面。
13. sizeof 与 strlen 的区别
核心:sizeof 是编译期运算符,用来求类型或对象占用的字节数;strlen 是 C 标准库函数,用来在运行时统计 C 字符串中第一个 \0 之前的字符个数。前者看“内存大小”,后者看“字符串长度”。
13.1 基本示例
#include <cstring>
#include <iostream>
int main() {
char s[] = "hello";
std::cout << sizeof(s) << '\n'; // 6:h e l l o \0
std::cout << std::strlen(s) << '\n'; // 5:不包含 \0
}
内存结构:
下标: 0 1 2 3 4 5
内容: h e l l o \0
strlen 数到 \0 前停止:5
sizeof 统计整个数组字节数:6
13.2 对比表
| 维度 | sizeof | strlen |
|---|---|---|
| 本质 | 运算符 | 库函数 |
| 求值时间 | 通常编译期 | 运行时 |
是否需要 \0 | 不需要 | 必须依赖 \0 |
| 统计内容 | 类型/对象占用字节数 | 字符串结束前字符数 |
| 参数 | 类型或对象 | const char* |
是否包含 \0 | 字符数组包含 | 不包含 |
| 对指针 | 得到指针大小 | 从指针地址开始找 \0 |
13.3 指针陷阱
char s1[] = "hello";
const char* s2 = "hello";
sizeof(s1); // 数组大小,6
sizeof(s2); // 指针大小,64 位平台通常是 8
strlen(s1); // 5
strlen(s2); // 5
数组作为函数参数会退化为指针:
void bad(char s[100]) {
sizeof(s); // 不是 100,而是 char* 的大小
}
正确做法是额外传入长度:
void good(const char* s, std::size_t size) {
// size 由调用者传入
}
13.4 没有结束符时 strlen 很危险
char buf[5] = {'h', 'e', 'l', 'l', 'o'}; // 没有 \0
std::strlen(buf); // 继续向后越界寻找 \0,未定义行为
而 sizeof(buf) 不关心 \0,结果固定为 5。
13.5 不同类型上的 sizeof
int arr[10];
sizeof(arr); // 10 × sizeof(int)
sizeof(arr[0]); // sizeof(int)
sizeof(arr) / sizeof(arr[0]); // 元素个数:10
struct Node {
char c;
int x;
};
sizeof(Node); // 可能大于 5,因为结构体内存对齐
C++ 中推荐更类型安全的数组大小写法:
#include <array>
std::array<int, 10> arr;
arr.size(); // 10,不会发生指针退化
13.6 sizeof 与动态数组
int* p = new int[10];
sizeof(p); // 只是指针大小,不是 40
delete[] p;
new[] 分配后的内存大小不能通过 sizeof 获得,因为 p 已经是指针。使用 std::vector 可以同时保存元素数量和容量:
std::vector<int> v(10);
v.size(); // 10
v.capacity(); // 当前容量
总结:sizeof 问的是“这个类型或对象占多少字节”,编译期基本确定;strlen 问的是“这个以 \0 结尾的字符串有多少个有效字符”,运行时逐个扫描。 对字符数组而言,sizeof 通常包含末尾 \0,strlen 一定不包含;对指针而言,sizeof 只会得到指针大小,不能得到它指向内存块的大小。
本部分总体总结
| 模块 | 核心结论 |
|---|---|
#define | 预处理文本替换,灵活但无类型检查,常量优先用 constexpr |
typedef | 类型别名,现代 C++ 优先用 using |
register | 旧式寄存器提示,现代编译器自动优化,基本废弃 |
const | 只读约束,是 const correctness 的核心 |
extern | 外部链接声明,extern "C" 用于 C/C++ 混合编程 |
volatile | 禁止省略内存访问,主要用于硬件寄存器,不等于线程同步 |
dynamic_cast | 运行时安全向下转型,依赖虚函数和 RTTI |
#include | 尖括号偏系统/外部库,双引号偏项目本地头文件 |
| const vs define | const 有类型、作用域和调试信息,宏只是文本替换 |
static | 控制生命周期、内部链接和类级共享 |
| 字符串/内存函数 | sprintf 格式化、strcpy 复制字符串、memcpy 复制字节 |
| C++11 | 现代 C++ 分水岭,核心是自动推导、移动语义、lambda、智能指针和并发 |
| sizeof/strlen | sizeof 看字节大小,strlen 看 \0 前字符数 |
记忆主线:
预处理:#include / #define
类型声明:typedef / const / static / extern / volatile
运行时识别:dynamic_cast / RTTI
旧式 C 接口:sprintf / strcpy / memcpy / strlen
现代 C++:constexpr、using、lambda、智能指针、移动语义、auto、nullptr、enum class
(一)&spm=1001.2101.3001.5002&articleId=164191920&d=1&t=3&u=3262242eee1742d19083c464e711a84b)
3386

被折叠的 条评论
为什么被折叠?



