cpp
左值/右值/移动构造/noexpect/转发引用引用折叠/forword完美转发
1. 左值 / 右值
面试回答
左值 = 有身份、可持续访问的对象;右值 = 临时结果或允许资源被转移的对象。C++11 后右值分 prvalue(纯右值)和 xvalue(将亡值)。
int x = 10;
x; // lvalue
x + 1; // prvalue
std::move(x); // xvalue
易错点
- 左值 ≠ 能修改,
const int x也是左值。 std::move(x)不创建新对象,只是把 x 标记为 xvalue。
2. 为什么引入右值引用 T&&
面试回答
为了让"即将销毁的对象"的内部资源可以被转移 而非深拷贝,同时支撑完美转发。const T& 虽然能接临时对象,但它是 const,无法修改源对象,因此不能转移资源。
std::vector<int> a(1000000);
std::vector<int> b = std::move(a); // 转移 buffer 所有权,不复制 100 万元素
易错点
- 不要答"因为临时对象不能传参"------
const T&本来就能接。 - 真正痛点是
const T&不能改源对象,无法转移资源。
3. 移动构造
面试回答
T(T&&) 的核心是资源所有权转移:接管源对象的堆资源,把源对象置空,使其保持合法可析构状态。
实现
class Buffer {
public:
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // 置空,防止 double free
other.size = 0;
}
~Buffer() { delete[] data; }
private:
int* data = nullptr;
size_t size = 0;
};
移动前: other ──→ Heap
移动后: *this ──→ Heap other ──→ nullptr
易错点
- 不是"浅拷贝",是所有权转移(浅拷贝会导致 double free,移动构造通过置空避免)。
- 必须置空源对象,否则析构时 double free。
4. std::move
面试回答
std::move 本身不移动任何数据 ,本质是 static_cast<T&&>,把表达式无条件转成 xvalue,从而允许后续匹配移动构造/移动赋值。
Buffer a;
Buffer b = std::move(a);
└─ std::move(a) → xvalue → 匹配 Buffer(Buffer&&) → 真正转移资源
易错点
std::move ≠ 移动,std::move = 允许发生移动。- 写了
std::move但目标类型没有移动构造 → 退化为拷贝。
5. move 后源对象还能用吗
面试回答
可以。被移动对象处于**有效但未指定(valid but unspecified)**状态:可以析构、可以重新赋值,但不能依赖它移动前的值。
std::string a = "hello";
std::string b = std::move(a);
a = "new"; // OK
a.clear(); // OK
// 但不要假设 a 还是 "hello" 或一定是空串
易错点
- 不要说"move 后再访问是 UB"------不准确。
- 标准只保证可析构、可赋值,具体值由实现决定。
6. 为什么移动构造写 noexcept
面试回答
noexcept 告诉容器"移动不会抛异常"。vector 扩容时,如果移动可能抛异常,为保证强异常安全会退化为拷贝 ;标记 noexcept 后容器才放心用移动迁移元素。
旧空间 [A][B][C]
↓ 扩容
新空间 [A][B][C][ ][ ][ ] ← noexcept 时逐个 move,否则逐个 copy
易错点
- 不是"不写 noexcept 就不能 move",而是影响容器选择 move 还是 copy。
- 自定义结构体含
string/vector成员却不写 noexcept 移动构造 → 容器扩容全走拷贝,性能灾难。
7. 有名 T&& 是左值(高频追问)
面试回答
右值引用是变量的类型 ,但只要变量有名字,这个名字作为表达式就是左值。这样可以避免参数在函数内每用一次就自动被移走。
void f(Buffer&& arg) {
g(arg); // arg 是左值表达式 → 走拷贝
g(std::move(arg)); // 显式转右值 → 走移动
}
arg 的类型 = Buffer&&
表达式 arg = lvalue
易错点
- 类型是右值引用 ≠ 表达式是右值。
- 函数内想继续移动必须再次
std::move。
8. 转发引用 + 引用折叠
面试回答
模板参数 T 需要推导且参数写成 T&& 时,是转发引用(forwarding reference) ,可同时接收左值和右值。推导结果由引用折叠决定。
template<typename T>
void Func(T&& arg);
int x;
Func(x); // 左值 → T = int& → T&& = int& && → int&
Func(10); // 右值 → T = int → T&& = int&&
引用折叠(直接背)
& + & → &
& + && → &
&& + & → &
&& + && → &&
口诀:只要有 &,结果就是 &;全是 && 才是 &&。
易错点
- 只有"需要推导的模板参数
T&&"才是转发引用。void f(int&& x)不是转发引用,只能接右值。 const T&&也不是转发引用。
9. std::forward + 完美转发
面试回答
std::forward<T> 根据模板参数 T 恢复实参原来的值类别:左值进来继续传左值,右值进来恢复右值。配合转发引用实现完美转发。
template<typename T>
void Wrapper(T&& arg) {
Target(std::forward<T>(arg)); // 保持原值类别
}
std::string s;
Wrapper(s); // 左值 → 转发为左值
Wrapper(std::string("temp")); // 右值 → 转发为右值
易错点
- 不写
std::forward→arg有名字永远是左值 → 右值也被当左值传,移动失效。 - 完美转发解决 N 个参数的
2^N重载组合爆炸。
10. std::move vs std::forward
std::move |
std::forward |
|
|---|---|---|
| 目的 | 允许移动 | 完美转发 |
| 行为 | 无条件转 xvalue | 根据 T 保留原值类别 |
| 场景 | 普通代码、移动实现 | 泛型模板 |
| 左值输入 | 变成右值 | 继续保持左值 |
一句话:move = "我要把你当右值",forward = "你是什么我就传什么"。
11. 两个必背坑
坑 1:const 对象被 move
const std::string s = "hello";
std::string t = std::move(s); // 得到 const string&&
移动构造签名是 string(string&&),无法绑定 const string&& → 退化为拷贝。因为移动需要修改源对象,const 不允许。
坑 2:返回局部变量写 std::move
std::string f() {
std::string s;
return s; // 推荐:允许 NRVO,零开销
// return std::move(s); // 不要:抑制 NRVO,多一次移动
}
标准规定返回局部对象时自动优先尝试移动,写 std::move 反而妨碍返回值优化。
面试答题链(背这条就够)
左值/右值
→ T&& 为什么需要(转移资源,const T& 做不到)
→ 移动构造:所有权转移 + 源对象置空
→ std::move:只做类型转换,不移动
→ noexcept:决定容器扩容用 move 还是 copy
→ 有名 T&& 是左值(函数内需再次 move)
→ 模板 T&& = 转发引用 + 引用折叠
→ std::forward 恢复值类别
→ 完美转发
高频核心:移动构造、std::move、有名右值引用是左值、转发引用、引用折叠、std::forward、noexcept+vector 扩容。左值右值分类只需够支撑以上问题。
堆和栈
一、const 修饰变量
含义:变量初始化后不可修改,成为编译期可检查的常量。
const int x = 10;
x = 20; // ❌ 编译错误:x 是 const
const int y; // ❌ 编译错误:const 变量必须初始化
面试要点:
-
const变量必须在定义时初始化,之后不能赋值。 -
类的
const成员变量必须在构造函数初始化列表中初始化,不能在构造函数体内赋值。class Foo {
const int id;
public:
Foo(int i) : id(i) {} // ✅ 初始化列表
// Foo(int i) { id = i; } // ❌ 构造函数体内赋值不行
};
二、const 修饰指针:顶层 const vs 底层 const
这是面试最高频的考点,核心是区分const 修饰的是谁。
2.1 四种写法
| 写法 | 含义 | 指针本身能改吗 | 指向的值能改吗 |
|---|---|---|---|
int* p |
普通指针 | 能 | 能 |
const int* p |
指向常量的指针(底层 const) | 能 | 不能 |
int* const p |
常量指针(顶层 const) | 不能 | 能 |
const int* const p |
指向常量的常量指针 | 不能 | 不能 |
判断口诀 :看 const 在 * 的左边还是右边。
-
const在*左边 → 修饰指向的值(底层 const,*p不可改) -
const在*右边 → 修饰指针本身(顶层 const,p不可改)int x = 10, y = 20;
const int* p1 = &x; // 底层 const:*p1 不可改,p1 可改
*p1 = 30; // ❌ *p1 是 const
p1 = &y; // ✅ p1 本身可改int* const p2 = &x; // 顶层 const:p2 不可改,*p2 可改
*p2 = 30; // ✅
p2 = &y; // ❌ p2 是 constconst int* const p3 = &x; // 两者都 const
*p3 = 30; // ❌
p3 = &y; // ❌
2.2 顶层 const 和底层 const 的拷贝规则
-
顶层 const 不影响拷贝:常量指针可以拷给普通指针(因为拷的是地址值,源指针 const 不影响副本)。
-
底层 const 不能被忽略:指向常量的指针不能直接赋给指向变量的指针(否则就可以通过后者修改常量了)。
const int* p1 = &x;
int* p2 = p1; // ❌ 底层 const 不能丢:p1 承诺不修改,p2 可以修改int* const p3 = &x;
int* p4 = p3; // ✅ 顶层 const 可以丢:p3 本身不能改,但拷给 p4 后 p4 可以改
三、const 修饰引用
含义 :const T& 是常量引用,不能通过引用修改绑定的对象。
int x = 10;
const int& ref = x;
ref = 20; // ❌ ref 是 const 引用
x = 20; // ✅ 原变量可以改,ref 看到的值也会变
关键特性:const 引用可以绑定到临时对象(右值)和不同类型
const int& r1 = 10; // ✅ 绑定到临时对象(int(10))
int& r2 = 10; // ❌ 非常量引用不能绑定右值
double d = 3.14;
const int& r3 = d; // ✅ 类型转换:创建临时 int,const 引用绑定到临时
int& r4 = d; // ❌ 非常量引用不能绑定到转换后的临时
面试要点 :函数参数用 const T& 是推荐写法,既避免拷贝又保证不修改,还能接受右值和临时对象。
void Process(const std::string& s); // ✅ 推荐:可接受左值、右值、临时对象
void Process(std::string& s); // ⚠️ 只能接受左值,不能传临时对象
四、const 修饰成员函数
含义 :在成员函数声明后加 const,承诺该函数不修改任何成员变量(也不调用非 const 成员函数)。
class Counter {
int count = 0;
public:
int Get() const { // const 成员函数
return count; // ✅ 只读
// count = 1; // ❌ 不能修改成员
// Increment(); // ❌ 不能调用非 const 成员函数
}
void Increment() { // 非 const 成员函数
count++;
}
};
4.1 const 对象只能调用 const 成员函数
const Counter c;
c.Get(); // ✅ const 成员函数
c.Increment(); // ❌ const 对象不能调用非 const 成员函数
4.2 函数重载:const 与非 const 版本
class Buffer {
int data[100];
public:
// 非 const 对象调用,返回可修改引用
int& operator[](int i) { return data[i]; }
// const 对象调用,返回 const 引用
const int& operator[](int i) const { return data[i]; }
};
Buffer b;
b[0] = 42; // ✅ 调用非 const 版本
const Buffer cb;
cb[0] = 42; // ❌ 调用 const 版本,返回 const int&,不能赋值
int x = cb[0]; // ✅
面试要点 :STL 容器的 operator[]、begin()/end()、at() 等都有 const/非 const 两个重载版本,这是标准库的惯用法。
五、const 修饰函数参数
5.1 值传递的 const 参数
void Func(const int x); // x 在函数内不可修改
值传递时参数是副本,const 只是限制函数内不修改这个副本。对调用方无影响,通常只在函数实现需要保护参数时使用。
5.2 指针/引用传递的 const 参数(最常用)
void Print(const std::string& s); // ✅ 推荐:避免拷贝 + 保证不修改
void Process(const int* arr, int n); // ✅ 指针底层 const:不修改数组内容
面试要点:
- 大对象传参优先用
const T&,避免拷贝开销。 - 指针参数加底层
const表示函数不会修改指向的数据。 - 顶层
const参数(如int* const p)对调用方无意义,只在函数内限制指针本身不被改。
六、const 修饰函数返回值
6.1 返回 const 值
const int GetValue(); // 对内置类型无意义,返回的是右值本来就不能改
对内置类型返回 const 没有实际意义(返回值本身就是右值)。但对类类型有意义:
class Matrix { ... };
const Matrix operator+(const Matrix& a, const Matrix& b);
Matrix a, b, c;
(a + b) = c; // ❌ 如果返回 const,不能对临时对象赋值(防止无意义代码)
6.2 返回 const 引用 / const 指针
class Config {
std::string name_;
public:
const std::string& GetName() const { return name_; } // ✅ 返回 const 引用
};
Config c;
c.GetName() = "hacked"; // ❌ 返回 const 引用,不能通过返回值修改内部成员
面试要点 :返回成员变量的引用时,如果不希望外部修改内部状态,必须返回 const T&。否则外部可以通过返回值篡改对象内部数据,破坏封装。
七、mutable:突破 const 限制
含义 :被 mutable 修饰的成员变量,即使在 const 成员函数中也可以被修改。
class Cache {
mutable int hit_count = 0; // mutable 成员
int data = 0;
public:
int Get() const {
hit_count++; // ✅ mutable 成员在 const 函数中可修改
return data;
}
};
适用场景:
-
缓存统计(如上面的 hit_count)
-
互斥锁(
mutable std::mutex,const 函数中加锁) -
延迟计算(lazy evaluation,const 函数中首次调用时计算并缓存)
class ThreadSafeCounter {
mutable std::mutex mtx; // mutable 锁
int count = 0;
public:
int Get() const {
std::lock_guardstd::mutex lock(mtx); // ✅ const 函数中可以锁 mutable 成员
return count;
}
};
八、const_cast:去掉 const 属性
含义 :const_cast 可以去掉指针或引用的底层 const(也可以加上 const),是唯一能去掉 const 的 C++ 转型运算符。
const int x = 10;
int* p = const_cast<int*>(&x); // 去掉底层 const
*p = 20; // ⚠️ 未定义行为!x 本身是 const 对象,修改它是 UB
安全用法:对象本身不是 const,只是当前通过 const 指针/引用访问时,可以安全地去掉 const。
void Log(const char* str); // 旧 C 接口,参数应该是 const 但声明没写
void Func(const std::string& s) {
Log(const_cast<char*>(s.c_str())); // 去掉 const 传给旧接口
// 安全:Log 不会修改字符串,只是接口声明不规范
}
面试要点:
const_cast只能改变底层 const,不能改变类型本身(不能用const_cast把int*转double*)。- 对真正的 const 对象 去掉 const 后修改是未定义行为。
- 常见合理用途:调用 const 不正确的旧接口、在非 const 函数中复用 const 版本的实现(或反过来)。
九、constexpr:编译期常量
constexpr 是 C++11 引入的,比 const 更强的编译期常量约束。
| const | constexpr | |
|---|---|---|
| 含义 | 运行时常量(初始化后不可改) | 编译期常量(值在编译期确定) |
| 初始化时机 | 可以运行时初始化 | 必须编译期可计算 |
| 能用于数组大小 | 不一定(如果是运行时常量则不行) | 一定可以 |
| 能用于模板参数 | 不一定 | 一定可以 |
const int a = 10; // 编译期常量(字面量初始化)
const int b = rand(); // 运行时常量(运行时才知道值)
constexpr int c = 10; // ✅ 编译期常量
constexpr int d = rand(); // ❌ rand() 不是编译期可计算
int arr1[a]; // ✅ a 是编译期常量(但取决于编译器,严格来说 a 是 const 不是 constexpr)
int arr2[c]; // ✅ c 是 constexpr,一定可以
int arr3[b]; // ❌ b 是运行时常量,不能做数组大小(VLA 扩展除外)
constexpr 函数:函数可以在编译期求值(如果参数都是编译期常量),也可以在运行期调用。
constexpr int Factorial(int n) {
return n <= 1 ? 1 : n * Factorial(n - 1);
}
int a = Factorial(5); // 编译期计算 = 120
int b = Factorial(rand()); // 运行期计算
十、面试高频考点速查
| 考点 | 答案 |
|---|---|
const int* p 和 int* const p 区别 |
前者指向的值不可改(底层 const),后者指针本身不可改(顶层 const) |
| const 成员函数能修改成员吗 | 不能,除非成员是 mutable |
| const 对象能调用非 const 成员函数吗 | 不能,只能调用 const 成员函数 |
const T& 能绑定临时对象吗 |
能,这是 const 引用的重要特性 |
函数参数为什么推荐 const T& |
避免拷贝 + 保证不修改 + 能接受右值和临时对象 |
| 返回成员引用为什么要 const | 防止外部通过返回值篡改对象内部状态 |
mutable 作用 |
允许 const 成员函数修改该成员(用于缓存、锁、延迟计算) |
const_cast 安全吗 |
去掉 const 后修改真正的 const 对象是 UB;对象本身非 const 时安全 |
const 和 constexpr 区别 |
const 是运行时常量,constexpr 是编译期常量 |
| 顶层 const 拷贝时会被忽略吗 | 会(源指针 const 不影响副本) |
| 底层 const 拷贝时会被忽略吗 | 不会(不能把 const 指针赋给非 const 指针) |
十一、面试答题模板
"const 是 C++ 中用于保证不可变性的关键字,主要用法有:修饰变量使其初始化后不可修改;修饰指针时分顶层 const(指针本身不可改)和底层 const(指向的值不可改);修饰引用形成 const 引用,可以绑定临时对象;修饰成员函数承诺不修改成员变量,const 对象只能调用 const 成员函数;修饰函数参数避免拷贝并保证不修改;修饰返回值防止外部篡改内部状态。另外
mutable允许 const 函数修改特定成员,const_cast可以去掉底层 const,constexpr是更强的编译期常量。核心思想是 const 正确性(const-correctness)------该不可变的地方都加 const,让编译器帮你检查错误。"
一、static 的五种用法
1. 修饰局部变量(静态局部变量)
含义 :在函数内部用 static 修饰的变量,生命周期贯穿整个程序 ,但作用域仅限于函数内部。函数调用结束后变量不销毁,下次调用时保留上次的值。
cpp
void Counter() {
static int count = 0; // 只在第一次调用时初始化
count++;
std::cout << count << " ";
}
Counter(); // 1
Counter(); // 2
Counter(); // 3
// count 不能在函数外访问
特点:
- 只初始化一次,第一次执行到定义语句时初始化。
- 存储在静态存储区,不在栈上。
- C++11 起初始化是线程安全的(Magic Statics):多线程同时首次进入函数,只有一个线程执行初始化,其余阻塞等待。
2. 修饰全局变量 / 函数(内部链接)
含义 :在全局作用域或命名空间中加 static,该变量/函数具有内部链接(internal linkage),只在当前翻译单元(.cpp 文件)内可见,其他文件无法访问。
cpp
// file1.cpp
static int g_count = 0; // 只在 file1.cpp 内可见
static void Helper() { } // 只在 file1.cpp 内可见
// file2.cpp
// extern int g_count; // ❌ 链接错误:g_count 是内部链接,file2 看不到
作用 :避免多个 .cpp 文件中同名全局变量/函数的符号冲突。
C++ 推荐替代 :用匿名命名空间 代替 static 全局变量/函数,语义相同但更符合 C++ 风格:
cpp
namespace {
int g_count = 0; // 等价于 static int,内部链接
void Helper() { }
}
3. 修饰类成员变量(静态成员变量)
含义 :被 static 修饰的成员变量属于类本身 ,不属于某个对象,所有对象共享同一份。
cpp
class Player {
public:
static int player_count; // 声明:静态成员变量
int hp;
};
// 类外定义和初始化(C++17 前必须在类外)
int Player::player_count = 0;
int main() {
Player::player_count++; // 通过类名访问
Player p1, p2;
p1.player_count++; // 也可以通过对象访问(但本质还是类的)
std::cout << Player::player_count; // 2,所有对象共享
}
特点:
-
不占用对象的内存空间,存储在静态存储区。
-
必须在类外定义(C++17 前),否则链接错误。
-
C++17 起可以用
inline static在类内直接初始化 :cppclass Player { inline static int player_count = 0; // C++17,类内初始化 }; -
静态成员变量的类型可以是类自身(用于单例等模式)。
4. 修饰类成员函数(静态成员函数)
含义 :被 static 修饰的成员函数属于类本身 ,没有 this 指针,只能访问静态成员变量和其他静态成员函数。
cpp
class Math {
static int instance_count;
public:
static int Add(int a, int b) { // 静态成员函数
return a + b;
// return hp; // ❌ 不能访问非静态成员(没有 this)
}
static int GetCount() {
return instance_count; // ✅ 可以访问静态成员变量
}
};
Math::Add(3, 4); // 通过类名调用,不需要对象
特点:
- 没有
this指针,不能访问非静态成员。 - 不能是
virtual、const、volatile(这些都依赖this)。 - 可以通过类名调用,也可以通过对象调用。
- 静态成员函数可以作为回调函数 (因为没有
this,签名和普通函数指针兼容)。
5. 修饰类对象(静态对象)
static 修饰对象时,本质就是静态局部变量或静态全局变量的一种,只是对象类型是类。
cpp
void Func() {
static MyObject obj; // 静态局部对象:第一次调用时构造,程序结束时析构
obj.DoSomething();
}
这是实现**单例模式(Meyers' Singleton)**的标准方式:
cpp
class Singleton {
public:
static Singleton& GetInstance() {
static Singleton instance; // 第一次调用时构造,线程安全(C++11)
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
};
二、初始化时机详解
这是面试核心考点。静态存储期对象的初始化分为两个阶段:
2.1 静态初始化(Static Initialization)
在程序加载时、任何代码执行之前完成,分为两步:
- 零初始化(Zero Initialization):所有静态存储期对象先被清零(包括静态局部变量、全局变量、类静态成员)。
- 常量初始化(Constant Initialization) :如果初始化表达式是编译期常量(如
= 0、= 3.14、constexpr),在编译期就确定值,直接写入,不需要运行时执行。
cpp
int g_a = 0; // 零初始化 + 常量初始化,编译期确定
int g_b = 100; // 常量初始化,编译期确定
static int g_c = 42; // 同上
2.2 动态初始化(Dynamic Initialization)
初始化表达式需要运行时计算(如函数调用、构造函数),在 main 函数之前执行。
cpp
int g_d = ComputeValue(); // 动态初始化:运行时调用函数
std::string g_e = "hello"; // 动态初始化:调用 string 构造函数
2.3 各类 static 的初始化时机
| 类型 | 初始化时机 | 线程安全 |
|---|---|---|
| 全局静态变量 | main 之前,程序启动阶段 |
单线程初始化(启动阶段) |
| 静态局部变量 | 第一次执行到定义语句时 | C++11 起线程安全(Magic Statics) |
| 类静态成员变量 | main 之前,和全局变量同时期 |
单线程初始化 |
| 静态成员函数 | 无初始化概念(代码) | --- |
三、静态初始化顺序问题(SIOF)
Static Initialization Order Fiasco 是面试高频追问。
3.1 问题是什么
不同翻译单元(.cpp 文件)之间的全局静态变量,初始化顺序未定义。如果一个全局变量的初始化依赖另一个翻译单元的全局变量,可能用到未初始化的值。
cpp
// a.cpp
int g_a = 10;
// b.cpp
extern int g_a;
int g_b = g_a * 2; // ⚠️ 危险:如果 b.cpp 先初始化,g_a 还是 0,g_b = 0
// 不同翻译单元间初始化顺序未定义
3.2 解决方法:Construct On First Use
把全局变量改成函数内的静态局部变量,通过函数访问:
cpp
// a.cpp
int& GetA() {
static int a = 10; // 第一次调用时初始化
return a;
}
// b.cpp
int& GetA(); // 声明
int g_b = GetA() * 2; // ✅ 安全:调用 GetA() 时 a 才初始化
原理:静态局部变量在第一次使用时才初始化,打破了翻译单元间的顺序依赖。这也是 Meyers' Singleton 的核心思想。
四、销毁时机
静态对象的销毁顺序和初始化顺序相反 ,通过 atexit() 注册析构函数,在 main 结束后、程序退出前执行。
| 类型 | 销毁时机 |
|---|---|
| 全局静态变量 | main 结束后,程序退出前 |
| 静态局部变量 | 第一次使用后初始化,程序退出前销毁(按初始化逆序) |
| 类静态成员变量 | 和全局变量同时期销毁 |
注意:如果在静态对象的析构函数中访问另一个已经销毁的静态对象,会导致未定义行为(对应 SIOF 的销毁版本,叫 Dead Reference Problem)。Construct On First Use 也不能完全解决销毁顺序问题。
五、面试高频考点速查
| 考点 | 答案 |
|---|---|
| 静态局部变量什么时候初始化 | 第一次执行到定义语句时,只初始化一次 |
| 静态局部变量初始化线程安全吗 | C++11 起安全(Magic Statics),多线程同时首次进入只有一个线程初始化 |
| 全局静态变量什么时候初始化 | main 之前,程序启动阶段 |
| 静态成员变量必须在哪里初始化 | C++17 前必须类外定义;C++17 起可用 inline static 类内初始化 |
| 静态成员函数能访问非静态成员吗 | 不能,没有 this 指针 |
| 静态成员函数能是 virtual 吗 | 不能,virtual 依赖 this |
| 不同翻译单元全局静态变量初始化顺序 | 未定义(SIOF 问题) |
| SIOF 怎么解决 | Construct On First Use(函数内静态局部变量) |
| static 全局变量和匿名命名空间 | 都实现内部链接,C++ 推荐匿名命名空间 |
| 静态成员变量占对象内存吗 | 不占,存在静态存储区,所有对象共享 |
| Meyers' Singleton 原理 | 函数内 static 局部变量,首次调用初始化,线程安全 |
六、面试答题模板
"static 在 C++ 中有五种用法:修饰局部变量使其生命周期贯穿程序但作用域限于函数内,第一次执行到时初始化且 C++11 起线程安全;修饰全局变量/函数使其具有内部链接,只在当前翻译单元可见;修饰类成员变量使其属于类本身、所有对象共享,C++17 前必须类外定义;修饰类成员函数使其没有 this 指针、只能访问静态成员;修饰对象实现单例等模式。初始化时机上,全局静态变量和类静态成员在 main 之前初始化,静态局部变量第一次使用时初始化。需要注意不同翻译单元间全局静态变量初始化顺序未定义(SIOF),解决方法是 Construct On First Use。"
一、四种 cast 用法总结
1. static_cast:静态转换(编译期检查,良性转换)
用途:
-
基本类型间转换:
int → double、char → int等 -
向上转型(派生类 → 基类):安全,编译器自动支持
-
向下转型(基类 → 派生类):不做运行时检查,需程序员保证安全
-
void*→ 具体类型指针 -
显式调用单参数构造函数:
static_cast<string>("hello") -
枚举与整数互转
double d = static_cast
(int_val); // 基本类型
Base* b = static_cast<Base*>(derived_ptr); // 向上转型,安全
Derived* d = static_cast<Derived*>(base_ptr); // 向下转型,不检查!
int* p = static_cast<int*>(void_ptr); // void* 转回具体类型
误区:
- 向下转型不做运行时类型检查 ,如果
base_ptr实际不是Derived*,结果是 UB。多态类型的安全向下转型应该用dynamic_cast。 - 不能去掉 const (要用
const_cast)。 - 不能转换不相关的指针类型 (如
int* → double*,要用reinterpret_cast)。
2. dynamic_cast:动态转换(运行时类型检查,多态专用)
用途:
-
多态类型(基类有虚函数)的安全向下转型
-
交叉转型(cross cast):两个无继承关系的多态类型之间转换
-
转换失败时:指针返回
nullptr,引用抛出std::bad_cast异常class Base { virtual void Foo() {} };
class Derived : public Base { };Base* b = new Derived();
Derived* d = dynamic_cast<Derived*>(b); // ✅ 成功,b 实际是 Derived
if (d == nullptr) { /* 转换失败处理 */ }Base* b2 = new Base();
Derived* d2 = dynamic_cast<Derived*>(b2); // 返回 nullptr(b2 不是 Derived)// 引用版本
try {
Derived& r = dynamic_cast<Derived&>(*b2); // 抛 bad_cast
} catch (const std::bad_cast& e) { }
前提条件 :基类必须至少有一个虚函数(多态类型),否则编译错误。
误区:
- 不能用于非多态类型(基类没有虚函数 → 编译错误)。
- 引用转换失败抛异常,不是返回 null,必须用 try-catch 或确保类型正确。
- 有运行时开销(查 RTTI 类型信息),性能敏感的热路径中慎用。
- 不能去掉 const,不能转基本类型。
3. const_cast:常量转换(唯一能去掉 const 的 cast)
用途:
-
去掉(或加上)指针/引用的底层 const
-
唯一能去掉 const 的 C++ 转型运算符
const int x = 10;
int* p = const_cast<int*>(&x); // 去掉底层 constvoid OldAPI(char* str); // 旧接口参数没写 const,但实际不修改
void Func(const std::string& s) {
OldAPI(const_cast<char*>(s.c_str())); // 安全用法:传给不修改的旧接口
}
误区:
- 去掉 const 后修改真正的 const 对象是 UB 。如果对象本身定义时就是 const(如
const int x = 10),去掉 const 修改它是未定义行为。只有对象本身非 const、只是当前通过 const 指针/引用访问时,去掉 const 才安全。 - 只能改变 const 属性,不能改变类型本身 。
const_cast<int*>(double_ptr)编译错误。 - 不能去掉顶层 const (如
int* const p,顶层 const 本来就可以通过拷贝自然去掉,不需要 cast)。
4. reinterpret_cast:重新解释转换(底层位模式,最危险)
用途:
-
指针 → 整数(如把指针存为
uintptr_t) -
整数 → 指针
-
一种指针 → 另一种不相关指针(如
int* → char*) -
函数指针 → 数据指针(部分平台)
-
完全按位模式重新解释,不做任何检查
int x = 0x41424344;
char* p = reinterpret_cast<char*>(&x); // 按字节查看 int 的内存表示uintptr_t addr = reinterpret_cast<uintptr_t>(ptr); // 指针转整数
void* ptr2 = reinterpret_cast<void*>(addr); // 整数转回指针
误区:
- 几乎所有结果都是实现定义(implementation-defined)的,不可移植。不同平台、不同编译器结果可能不同。
- 不能去掉 const (要用
const_cast)。 - 不能用于多态向下转型 (要用
dynamic_cast)。 - 对齐问题:转换后的指针可能不满足目标类型的对齐要求,访问时崩溃。
- 函数指针和数据指针互转在某些平台(如 Harvard 架构)上不合法。
- 严格别名规则(strict aliasing):通过
reinterpret_cast后的指针访问不同类型可能违反严格别名,导致 UB。安全做法是用char*/std::byte*查看字节,或用memcpy。
二、四种 cast 对比表
| static_cast | dynamic_cast | const_cast | reinterpret_cast | |
|---|---|---|---|---|
| 检查时机 | 编译期 | 运行时 | 编译期 | 无检查 |
| 能否向下转型 | 能,不检查 | 能,运行时检查 | 不能 | 不能 |
| 要求多态类型 | 否 | 是(必须有虚函数) | 否 | 否 |
| 能否去掉 const | 不能 | 不能 | 唯一能 | 不能 |
| 能否转不相关类型 | 不能 | 不能 | 不能 | 能 |
| 运行时开销 | 无 | 有(RTTI 查询) | 无 | 无 |
| 安全性 | 较高 | 高(失败可检测) | 中(需保证对象非 const) | 最低 |
| 典型用途 | 良性转换、向上转型 | 多态向下转型 | 对接旧接口 | 底层位操作、指针整数互转 |
三、cast 使用选择口诀
需要转基本类型/向上转型 → static_cast
需要安全向下转型(多态) → dynamic_cast
需要去掉 const → const_cast
需要按位重新解释/指针整数互转 → reinterpret_cast
面试要点 :C 风格强制转换 (T)x 会依次尝试 const_cast → static_cast → reinterpret_cast,行为不明确、不易搜索,C++ 中应优先使用命名 cast,让意图清晰可见。
四、static 的定义
static 是 C++ 中的存储类说明符(storage class specifier),用于指定实体的存储期(storage duration)和链接属性(linkage)。
具体含义取决于使用位置:
| 使用位置 | 定义 |
|---|---|
| 局部变量 | 指定静态存储期:变量在程序整个运行期间存在,第一次执行到定义时初始化,之后保留值,函数返回不销毁,作用域仍限于函数内 |
| 全局变量/函数 | 指定内部链接(internal linkage):该实体只在当前翻译单元(.cpp)内可见,其他翻译单元无法访问,避免符号冲突 |
| 类成员变量 | 指定该成员属于类本身而非类的实例:所有对象共享同一份,不占用对象内存,存储在静态存储区 |
| 类成员函数 | 指定该函数属于类本身 :没有 this 指针,只能访问静态成员,可通过类名直接调用 |
一句话定义 :static 让变量/函数/成员脱离实例或函数调用栈的生命周期,获得静态存储期或内部链接,从而在程序级别持久存在或在翻译单元内隔离。
一、C++ 标准智能指针
1. unique_ptr:独占所有权
定义 :独占所管理对象的所有权,不可拷贝,只能移动。对象在 unique_ptr 析构时自动 delete。零开销(和裸指针一样大)。
#include <memory>
std::unique_ptr<int> p1(new int(42));
auto p2 = std::make_unique<int>(42); // C++14,推荐工厂函数
// std::unique_ptr<int> p3 = p1; // ❌ 不可拷贝
std::unique_ptr<int> p3 = std::move(p1); // ✅ 可移动,p1 变为 nullptr
p3.reset(); // 释放对象,p3 变为 nullptr
int* raw = p3.get(); // 获取裸指针,不释放所有权
自定义删除器:
struct FileDeleter {
void operator()(FILE* f) const { fclose(f); }
};
std::unique_ptr<FILE, FileDeleter> file(fopen("a.txt", "r"));
面试要点:
- 零开销,优先使用。
- 不可拷贝、可移动,表达"独占"语义。
make_unique异常安全(C++14 引入)。- 可作为工厂函数返回值(移动语义)。
2. shared_ptr:共享所有权
定义 :多个 shared_ptr 共享同一个对象,通过引用计数 管理生命周期。最后一个 shared_ptr 析构时(强引用计数归零),对象被销毁。
auto p1 = std::make_shared<int>(42); // 推荐:一次分配对象+控制块
std::shared_ptr<int> p2 = p1; // 拷贝,强引用计数 +1
std::cout << p1.use_count(); // 2
p1.reset(); // 强引用计数 -1,变为 1,对象还活着
p2.reset(); // 强引用计数归零,对象被 delete
控制块(Control Block)结构:
┌──────────────────────────┐
│ 强引用计数 (strong ref) │ ← 管理对象生命周期
│ 弱引用计数 (weak ref) │ ← 管理控制块生命周期
│ 删除器 (deleter) │
│ 分配器 (allocator) │
└──────────────────────────┘
- 强引用计数归零 → 销毁对象(调用 deleter)
- 弱引用计数归零 → 销毁控制块本身
make_shared 的优势 :对象和控制块一次内存分配 ,比 shared_ptr<T>(new T) 少一次分配,缓存友好。缺点是对象和控制块同生共死,弱引用存在时对象内存无法释放(控制块还在,内存是一起分配的)。
线程安全:
- ✅ 引用计数的增减是原子操作 ,多线程拷贝/销毁
shared_ptr安全。 - ❌ 对象本身的读写不是线程安全的,多线程同时修改指向的对象需要自己加锁。
- ❌ 多线程同时对同一个
shared_ptr实例做reset/赋值不是线程安全的(需要加锁或用atomic_load/atomic_store)。
3. weak_ptr:不拥有所有权的观察者
定义 :从 shared_ptr 构造,不增加强引用计数 ,不影响对象生命周期。用于观察对象是否还活着,需要时通过 lock() 获取 shared_ptr。
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // 强引用计数不变,仍为 1
if (auto p = wp.lock()) { // lock() 返回 shared_ptr,对象活着则成功
std::cout << *p; // 安全使用
} else {
std::cout << "对象已销毁";
}
wp.expired(); // 检查对象是否已销毁(强引用计数是否为0)
核心用途:解决循环引用
// 问题:A 和 B 互相持有 shared_ptr,循环引用,永远不会释放
class A { std::shared_ptr<class B> b; };
class B { std::shared_ptr<A> a; };
// 解决:其中一方改用 weak_ptr
class A { std::shared_ptr<class B> b; };
class B { std::weak_ptr<A> a; }; // 不增加强引用,打破循环
二、UE 智能指针
UE 有自己独立的智能指针库(SharedPointer.h、UniquePtr.h),不使用 std::shared_ptr。原因:跨平台一致性、自定义内存分配器、与 UE 对象系统集成、性能可控。
1. TUniquePtr:对应 std::unique_ptr
独占所有权,不可拷贝、可移动,零开销。
#include "Templates/UniquePtr.h"
TUniquePtr<int> P1 = MakeUnique<int>(42);
TUniquePtr<int> P2 = MoveTemp(P1); // UE 用 MoveTemp 代替 std::move
P1.Reset();
int* Raw = P1.Get();
和 std::unique_ptr 基本一致,UE 中用于管理非 UObject 的原生 C++ 对象。
2. TSharedPtr:对应 std::shared_ptr
共享所有权,引用计数管理。UE 的 TSharedPtr 有一个关键特性:线程安全模式可选。
#include "Templates/SharedPointer.h"
// 默认非线程安全(ESPMode::Fast)
TSharedPtr<int> P1 = MakeShared<int>(42);
TSharedPtr<int> P2 = P1;
// 显式指定线程安全模式
TSharedPtr<int, ESPMode::ThreadSafe> P3 = MakeShared<int, ESPMode::ThreadSafe>(42);
ESPMode 两种模式:
| 模式 | 引用计数操作 | 适用场景 | 开销 |
|---|---|---|---|
ESPMode::Fast |
非原子(普通整数加减) | 单线程游戏逻辑、UI | 零开销 |
ESPMode::ThreadSafe |
原子操作 | 多线程资源管理、异步加载 | 原子操作开销 |
面试要点 :UE 默认是
Fast模式(非线程安全),这和std::shared_ptr默认线程安全不同。游戏主线程逻辑用Fast模式避免不必要的原子开销,多线程场景才显式指定ThreadSafe。
3. TSharedRef:UE 特有,非空共享指针
定义 :TSharedRef 是永远不为空 的 TSharedPtr。必须在构造时初始化,不能默认构造,不能 Reset() 到空。
TSharedRef<int> R1 = MakeShared<int>(42); // ✅ 必须初始化
// TSharedRef<int> R2; // ❌ 不能默认构造(会是空的)
TSharedPtr<int> P1 = R1; // ✅ TSharedRef 隐式转 TSharedPtr
// TSharedRef<int> R2 = P1; // ❌ 不能直接转,P1 可能为空
TSharedRef<int> R2 = P1.ToSharedRef(); // ✅ 需显式转换(P1 为空会崩溃/断言)
TSharedRef vs TSharedPtr:
| TSharedRef | TSharedPtr | |
|---|---|---|
| 能否为空 | 不能,必须初始化 | 可以,默认为空 |
| 默认构造 | 不允许 | 允许 |
| 是否需要判空 | 不需要 | 需要(IsValid()) |
| 适用场景 | 引用一定存在的对象(如单例、已加载资源) | 可能为空的对象(如异步加载中、可选引用) |
面试要点 :
TSharedRef是 UE 独有的概念,标准库没有对应物。它的价值是编译期保证非空,减少运行时判空,表达"这个引用一定有效"的语义。
4. TWeakPtr:对应 std::weak_ptr
不拥有所有权的观察者,不增加强引用计数。
TSharedPtr<int> Sp = MakeShared<int>(42);
TWeakPtr<int> Wp = Sp;
if (TSharedPtr<int> Pinned = Wp.Pin()) { // 对应 std::weak_ptr::lock()
// 对象活着,安全使用
}
Wp.IsValid(); // 检查是否有效
Wp.Reset(); // 释放弱引用
5. TSharedFromThis:对应 std::enable_shared_from_this
继承后可以在成员函数中获取自身的 TSharedPtr/TSharedRef。
class FMyClass : public TSharedFromThis<FMyClass> {
public:
TSharedRef<FMyClass> GetSelf() {
return AsShared(); // 返回自身的 TSharedRef
}
};
TSharedRef<FMyClass> Obj = MakeShared<FMyClass>();
TSharedRef<FMyClass> Self = Obj->GetSelf(); // 安全获取自身引用
注意:对象必须由
MakeShared创建(被TSharedPtr管理),否则AsShared()会崩溃。
三、UE 智能指针的关键约束
1. 不能用于 UObject
UE 的 UObject 有自己的垃圾回收(GC)系统 ,基于 UPROPERTY 引用标记。TSharedPtr/TSharedRef 不能管理 UObject,否则会和 GC 冲突(双重释放或 GC 提前回收)。
// ❌ 错误:UObject 不能用 TSharedPtr 管理
TSharedPtr<UMyObject> BadObj = MakeShared<UMyObject>();
// ✅ 正确:UObject 用 NewObject + UPROPERTY 引用,由 GC 管理
UMyObject* GoodObj = NewObject<UMyObject>();
UPROPERTY() UMyObject* GoodObjRef; // GC 会追踪这个引用
面试要点 :UE 中两套内存管理体系并存------原生 C++ 对象用智能指针,UObject 用 GC。两者不能混用。
2. 不支持多态继承的智能指针转换
TSharedPtr<Derived> 转 TSharedPtr<Base> 需要显式 StaticCastSharedPtr/ConstCastSharedPtr,不能像裸指针那样隐式向上转型(实际上 UE 支持隐式向上转换,但向下转换需要显式 cast)。
四、标准库 vs UE 智能指针对应表
| 标准库 | UE 对应 | 核心区别 |
|---|---|---|
std::unique_ptr |
TUniquePtr |
基本一致 |
std::make_unique |
MakeUnique |
基本一致 |
std::shared_ptr |
TSharedPtr |
UE 默认非线程安全(Fast),可选 ThreadSafe |
std::make_shared |
MakeShared |
基本一致 |
std::weak_ptr |
TWeakPtr |
方法名不同(lock() → Pin()) |
std::enable_shared_from_this |
TSharedFromThis |
基本一致 |
| 无对应 | TSharedRef |
UE 特有,编译期保证非空 |
五、面试高频考点
| 考点 | 答案 |
|---|---|
shared_ptr 控制块里有什么 |
强引用计数、弱引用计数、删除器、分配器 |
make_shared 优势 |
对象+控制块一次分配,少一次 malloc,缓存友好 |
shared_ptr 线程安全范围 |
引用计数增减是原子的;对象本身读写、同一实例的赋值不是线程安全的 |
| 循环引用怎么解决 | 一方改用 weak_ptr,不增加强引用 |
weak_ptr 怎么获取对象 |
lock()(UE 是 Pin()),返回 shared_ptr,对象已销毁则为空 |
unique_ptr 能拷贝吗 |
不能,只能移动 |
UE 为什么不用 std::shared_ptr |
跨平台一致性、自定义分配器、线程安全模式可选、与UE体系集成 |
TSharedRef 和 TSharedPtr 区别 |
TSharedRef 永远非空,必须初始化,不需要判空 |
ESPMode::Fast 和 ThreadSafe 区别 |
Fast 用普通整数计数(零开销,单线程),ThreadSafe 用原子操作(多线程安全) |
TSharedPtr 能管理 UObject 吗 |
不能,UObject 有自己的 GC 系统,混用会冲突 |
TSharedFromThis 作用 |
在成员函数中安全获取自身的 TSharedPtr/TSharedRef |
make_shared 的缺点 |
对象和控制块一起分配,弱引用存在时对象内存无法单独释放 |
六、面试答题模板
"C++ 标准智能指针有三种:
unique_ptr独占所有权、不可拷贝可移动、零开销;shared_ptr共享所有权,通过控制块里的强引用和弱引用计数管理生命周期,make_shared可以一次分配对象和控制块;weak_ptr不拥有所有权,用于打破循环引用和观察对象生命周期。UE 有自己的智能指针体系:TUniquePtr对应unique_ptr,TSharedPtr对应shared_ptr但默认是非线程安全的 Fast 模式,可选 ThreadSafe 模式;UE 特有的TSharedRef保证永远非空,不需要判空;TWeakPtr对应weak_ptr。需要特别注意的是 UE 的智能指针不能管理 UObject,因为 UObject 有自己的 GC 系统,两者不能混用。"
一、TCP 与 UDP 区别
| 维度 | TCP | UDP |
|---|---|---|
| 全称 | 传输控制协议 | 用户数据报协议 |
| 连接性 | 面向连接(三次握手建立) | 无连接(直接发) |
| 可靠性 | 可靠(确认、重传、排序、去重) | 不可靠(尽力而为,丢了不重传) |
| 数据形式 | 面向字节流(无消息边界) | 面向数据报(有边界,一次一个完整包) |
| 传输效率 | 较低(握手、确认、重传开销) | 高(头部仅 8 字节,无额外开销) |
| 流量/拥塞控制 | 有(滑动窗口、慢启动、拥塞避免) | 无 |
| 通信模式 | 仅点对点 | 一对一、一对多(广播)、多对多(组播) |
| 头部大小 | 20~60 字节 | 8 字节 |
| 适用场景 | 文件传输、网页、邮件 | 游戏、直播、DNS、语音视频 |
二、TCP 三次握手与四次挥手
2.1 三次握手
客户端 服务器
| ---- SYN (seq=x) ------------> | 1. 客户端发 SYN,进入 SYN_SENT
| <--- SYN+ACK (seq=y, ack=x+1)- | 2. 服务器回 SYN+ACK,进入 SYN_RCVD
| ---- ACK (ack=y+1) ----------> | 3. 客户端回 ACK,双方进入 ESTABLISHED
为什么是三次不是两次?
- 两次握手无法确认客户端的接收能力和服务器的发送能力。
- 防止已失效的连接请求(网络中延迟的旧 SYN)突然到达服务器,导致错误建立连接、浪费资源。
2.2 四次挥手
客户端 服务器
| ---- FIN (seq=u) ------------> | 1. 客户端发 FIN,进入 FIN_WAIT_1
| <--- ACK (ack=u+1) ----------- | 2. 服务器回 ACK,进入 CLOSE_WAIT
| (服务器可能还有数据要发)
| <--- FIN (seq=w) ------------- | 3. 服务器发 FIN,进入 LAST_ACK
| ---- ACK (ack=w+1) ----------> | 4. 客户端回 ACK,进入 TIME_WAIT
为什么是四次不是三次? TCP 全双工,两个方向分别关闭。服务器收到 FIN 后可能还有数据没发完,不能立即发 FIN,所以 ACK 和 FIN 分两步。
2.3 TIME_WAIT 与 2MSL
客户端发完最后一个 ACK 后等待 2MSL(报文最大生存时间,Linux 默认约 30s~2min)。
为什么等 2MSL?
- 保证最后一个 ACK 能到达服务器:ACK 丢了服务器会重发 FIN,客户端在 TIME_WAIT 期间还能重发 ACK。
- 让本次连接的所有残留报文在网络中消失,防止旧连接的延迟报文被新连接误收。
三、TCP 半连接队列与全连接队列(高频考点)
TCP 三次握手过程中,服务器内核维护两个队列:
3.1 半连接队列(SYN 队列 / SYN Backlog)
时机 :服务器收到客户端的 SYN 并回了 SYN+ACK 之后、尚未收到客户端最终 ACK 之前(三次握手未完成),连接处于 SYN_RCVD 状态,放入半连接队列。
客户端 SYN → 服务器回 SYN+ACK → 连接放入半连接队列(SYN_RCVD)
↓
等客户端 ACK(可能永远不来,即 SYN 洪水攻击)
- 大小由
/proc/sys/net/ipv4/tcp_max_syn_backlog控制。 - 半连接队列满了之后,新的 SYN 会被丢弃(或发 SYN Cookie,取决于
tcp_syncookies配置)。
3.2 全连接队列(Accept 队列)
时机 :服务器收到客户端的第三次握手 ACK,三次握手完成,连接进入 ESTABLISHED 状态,从半连接队列移到全连接队列 ,等待应用层调用 accept() 取走。
客户端 ACK → 三次握手完成 → 连接从半连接队列移到全连接队列(ESTABLISHED)
↓
等应用层 accept() 取走
- 大小由
listen(fd, backlog)的backlog参数和/proc/sys/net/core/somaxconn共同决定(取较小值)。 - 全连接队列满了之后,新完成握手的连接会被丢弃(或忽略 ACK 让客户端重传,取决于
tcp_abort_on_overflow)。
3.3 两个队列的关系
客户端 服务器内核 应用层
│ SYN │ │
│ ─────────────────────> │ 放入半连接队列(SYN_RCVD) │
│ SYN+ACK │ │
│ <───────────────────── │ │
│ ACK │ 从半连接队列移除 │
│ ─────────────────────> │ 放入全连接队列(ESTABLISHED) │
│ │ ─────────────────────────────> │ accept() 取走
3.4 面试追问点
| 问题 | 答案 |
|---|---|
| SYN 洪水攻击原理 | 攻击者发大量 SYN 但不回 ACK,半连接队列被占满,正常连接无法建立 |
| SYN Cookie 作用 | 半连接队列满时,服务器不分配队列资源,而是把连接信息编码到 ISN(初始序列号)中,客户端回 ACK 时再解码恢复,防御 SYN 洪水 |
| 全连接队列满了会怎样 | 新完成握手的连接被丢弃,客户端以为握手成功但服务器没有,后续数据会触发 RST |
| 怎么查看队列溢出 | netstat -s 看 times the listen queue of a socket overflowed |
listen 的 backlog 参数 |
控制全连接队列大小,实际值受 somaxconn 上限约束 |
四、TCP 超时重传与重传计时器(高频考点)
4.1 什么是超时重传
TCP 发送方发出一个数据包后,启动一个重传计时器(Retransmission Timer) 。如果在计时器超时之前没有收到接收方的 ACK 确认,就认为包丢了,重新发送该数据包。
发送方 接收方
│ ---- 数据包(seq=100) ----------> │
│ 启动重传计时器(RTO) │
│ │ (包丢了,接收方没收到)
│ 计时器超时(RTO 到了) │
│ ---- 重传数据包(seq=100) ------> │
│ <--- ACK(ack=101) -------------- │ 收到 ACK,取消计时器
4.2 RTO(Retransmission TimeOut,重传超时时间)
RTO 不是固定值,TCP 根据网络状况动态计算:
- RTT(Round Trip Time):数据包发出到收到 ACK 的往返时间。
- SRTT(Smoothed RTT):平滑后的 RTT,用加权移动平均计算,避免单次波动影响太大。
- RTTVAR(RTT Variance):RTT 的偏差/抖动。
- RTO = SRTT + 4 × RTTVAR(RFC 6298 公式)。
RTO 最小 1 秒(Linux 实现中下限通常 200ms~1s),最大 120 秒。
4.3 指数退避(Exponential Backoff)
如果重传后还是没收到 ACK,下一次的 RTO 会翻倍(指数退避):
第一次超时:RTO = 1s,重传
第二次超时:RTO = 2s,重传
第三次超时:RTO = 4s,重传
第四次超时:RTO = 8s,重传
...
直到达到最大重传次数(tcp_retries2,默认 15 次,约 9~30 分钟),放弃连接
指数退避的目的:网络拥塞时,越重传越慢,避免加剧拥塞。
4.4 快速重传(Fast Retransmit)
不等计时器超时,发送方如果收到3 个重复的 ACK(表示接收方一直没收到某个包,后面的包都到了),就立即重传丢失的包,不用等 RTO 超时。
发送方发了 1,2,3,4,5 号包
接收方收到 1 → ACK 2
接收方收到 3 → ACK 2(重复,等 2 号)
接收方收到 4 → ACK 2(重复)
接收方收到 5 → ACK 2(第 3 个重复 ACK)
发送方收到 3 个重复 ACK → 立即重传 2 号包(不等超时)
4.5 面试追问点
| 问题 | 答案 |
|---|---|
| RTO 怎么计算的 | 基于 SRTT 和 RTTVAR 动态计算,RTO = SRTT + 4×RTTVAR |
| 为什么用指数退避 | 网络拥塞时逐步降低发送速率,避免雪上加霜 |
| 快速重传触发条件 | 收到 3 个重复 ACK,不等超时直接重传 |
| 重传多少次后放弃 | Linux 默认 tcp_retries2=15 次,约 9~30 分钟 |
| 超时重传和快速重传区别 | 超时重传等计时器超时(慢),快速重传靠重复 ACK 触发(快) |
| 已发送但未确认的数据存在哪 | 发送方的发送缓冲区中,直到收到 ACK 才能释放 |
五、TCP 粘包、长连接、心跳包
5.1 粘包
TCP 面向字节流,无消息边界。发送方发了两个包,接收方可能一次收到粘在一起的数据,也可能一个包被拆成两次。
解决方法(应用层):
| 方法 | 原理 | 适用 |
|---|---|---|
| 固定长度 | 每个包固定 N 字节 | 简单但浪费空间 |
| 分隔符 | 包之间用特殊字符分隔 | 数据中不能出现分隔符 |
| 长度字段 | 头部加包体长度,先读长度再读包体 | 最常用,游戏标准做法 |
| 自定义协议 | 固定头(魔数+版本+长度+消息ID)+ 包体 | 游戏/工业级 |
5.2 长连接
连接建立后保持打开,多次请求复用同一连接,避免频繁握手挥手开销。需要心跳包保活。
5.3 心跳包
长连接中定期发小数据包,确认对方在线、防止 NAT/防火墙断连。连续几次收不到心跳就认为断线,触发重连。游戏一般用应用层心跳(如 10s 一次),不用 TCP KeepAlive(默认 2 小时太迟钝)。
六、UDP 怎么改可靠
在应用层实现 TCP 的可靠机制:
- 序列号:每个包加递增序号,用于排序、去重。
- 确认应答(ACK):接收方回 ACK 确认收到。
- 超时重传:发送方启动计时器,超时没收到 ACK 就重传。
- 滑动窗口:连续发送窗口内的包,不用等每个 ACK,实现流量控制。
- 快速重传:收到多个重复 ACK 立即重传。
- 选择性确认(SACK):ACK 中告知哪些包收到了,只重传丢失的。
- 拥塞控制:根据丢包率调整发送速率。
本质:在 UDP 头部之上加一个应用层协议头,在应用层复刻 TCP 的可靠机制。KCP、QUIC、UE 可靠 UDP 都是这个思路。
七、UDP 粘包问题澄清
UDP 不会粘包! UDP 面向数据报,每个数据报独立、有边界。一次 recvfrom 只能收到一个完整的 UDP 包,不会两个包粘在一起,也不会一个包被拆两次。
粘包是 TCP 独有的问题(面向字节流,无边界)。
UDP 真正的问题是:丢包、乱序、重复、缓冲区溢出丢包。另外 UDP 大包超过 MTU 会在 IP 层分片,一个分片丢了整个包就废了,所以应用层需要自己控制包大小或自己分片重组。
八、UE 怎么在 UDP 基础上实现可靠传输
- NetDriver / NetConnection:管理 UDP socket 和每个客户端连接。
- 可靠/不可靠标记 :
Reliable保证送达有序(重要事件),Unreliable不保证(高频状态同步,丢了下一帧更新)。 - 序列号 + ACK:每个包有序列号,接收方回 ACK,ACK 中带丢失包列表(选择性确认)。
- 重传队列:可靠包发送后存入队列,收到 ACK 移除,超时或 ACK 报告丢失则重传。
- 包合并(Bunch):多个小消息合并到一个 UDP 包,减少包数量。
- 信道(Channel):Control/Actor/Voice 等逻辑信道独立管理可靠队列。
- 增量同步 + 相关性 + 频率控制:只同步变化的属性、只同步玩家周围的 Actor、控制同步频率,减少带宽。
九、UDP 优化
| 手段 | 说明 |
|---|---|
| 增大收发缓冲区 | SO_RCVBUF/SO_SNDBUF,减少溢出丢包 |
| 批量收发 | recvmmsg/sendmmsg,减少系统调用 |
| 应用层合包 | 多个小消息合并成一个 UDP 包 |
| 控制包大小 | 避免 IP 分片,单包 < MTU(~1400 字节) |
| 网卡多队列/RSS | 多核并行处理网络包 |
| FEC 前向纠错 | 发冗余数据,接收方直接恢复丢包,不用等重传(低延迟场景) |
十、TCP 和 UDP 可以同时占用同一个端口号吗?
可以。 端口号在传输层协议内部独立编址,TCP 和 UDP 各有自己的端口空间。内核通过协议+IP+端口四元组区分,协议不同就是不同通道,互不干扰。例如 DNS 同时监听 TCP 53 和 UDP 53。
十一、HTTP 与 HTTPS 区别
| 维度 | HTTP | HTTPS |
|---|---|---|
| 端口 | 80 | 443 |
| 加密 | 明文 | TLS/SSL 加密 |
| 证书 | 不需要 | 需要 CA 证书 |
| 安全性 | 低(可窃听/篡改/冒充) | 高(加密+完整性+身份认证) |
| 性能 | 高 | 稍低(TLS 握手+加解密开销) |
| 握手 | TCP 三次握手 | TCP 三次握手 + TLS 握手 |
HTTPS 核心是 TLS:非对称加密(RSA/ECDHE)交换密钥,对称加密(AES)传输数据,HMAC 校验完整性,证书验证身份。
十二、KCP 协议
基于 UDP 的快速可靠传输协议 ,核心思想是用带宽换低延迟。
关键特性:
- 快速重传:收到 2 个重复 ACK 就重传(TCP 要 3 个)。
- 选择性重传:只重传丢失的包。
- 非退让流控:丢包时窗口减小比 TCP 温和,不轻易降速。
- RTO 不指数退避:重传超时增长比 TCP 慢。
- 拥塞控制可关闭:内网/专用链路可以关拥塞控制,固定速率发。
- 纯算法:KCP 只负责算法,需要自己实现 UDP 收发。
弱网(丢包 10%)下 TCP 延迟可能几百毫秒,KCP 可控制在几十毫秒,代价是多耗 10%~30% 带宽。广泛用于手游、音视频。
十三、epoll(非重点,简要了解)
epoll 是 Linux 的 IO 多路复用机制,用红黑树管理 fd、就绪链表返回事件,比 select/poll 高效,适合高并发。两种触发模式:LT(水平触发,默认)和 ET(边缘触发,需非阻塞+循环读)。
游戏客户端岗位通常不深入考 epoll(偏服务端),了解概念即可。如果是服务端方向或 UE DS(专用服务器)相关,需要重点掌握。
网络面试答题链(速记)
TCP/UDP 区别
→ 三次握手(为什么三次)
→ 半连接队列 / 全连接队列(SYN 洪水、SYN Cookie)
→ 四次挥手(为什么四次)
→ TIME_WAIT / 2MSL
→ 超时重传(RTO 计算、指数退避、快速重传)
→ TCP 粘包(字节流无边界,长度字段解决)
→ UDP 改可靠(序列号+ACK+重传+滑动窗口)
→ UE 可靠 UDP 实现
→ KCP(用带宽换延迟)
→ HTTP/HTTPS(TLS 加密)
### 一、const 修饰变量
**含义**:变量初始化后不可修改,成为编译期可检查的常量。
const int x = 10;
x = 20; // ❌ 编译错误:x 是 const
const int y; // ❌ 编译错误:const 变量必须初始化
**面试要点**:
- `const` 变量必须在定义时初始化,之后不能赋值。
- 类的 `const` 成员变量必须在**构造函数初始化列表**中初始化,不能在构造函数体内赋值。
class Foo {
const int id;
public:
Foo(int i) : id(i) {} // ✅ 初始化列表
// Foo(int i) { id = i; } // ❌ 构造函数体内赋值不行
};
---
### 二、const 修饰指针:顶层 const vs 底层 const
这是面试最高频的考点,核心是区分**const 修饰的是谁**。
### 2.1 四种写法
| 写法 | 含义 | 指针本身能改吗 | 指向的值能改吗 |
| --- | --- | --- | --- |
| `int* p` | 普通指针 | 能 | 能 |
| `const int* p` | 指向常量的指针(底层 const) | 能 | **不能** |
| `int* const p` | 常量指针(顶层 const) | **不能** | 能 |
| `const int* const p` | 指向常量的常量指针 | **不能** | **不能** |
**判断口诀**:看 `const` 在 `*` 的**左边还是右边**。
- `const` 在 `*` **左边** → 修饰指向的值(底层 const,`*p` 不可改)
- `const` 在 `*` **右边** → 修饰指针本身(顶层 const,`p` 不可改)
int x = 10, y = 20;
const int* p1 = &x; // 底层 const:*p1 不可改,p1 可改
*p1 = 30; // ❌ *p1 是 const
p1 = &y; // ✅ p1 本身可改
int* const p2 = &x; // 顶层 const:p2 不可改,*p2 可改
*p2 = 30; // ✅
p2 = &y; // ❌ p2 是 const
const int* const p3 = &x; // 两者都 const
*p3 = 30; // ❌
p3 = &y; // ❌
### 2.2 顶层 const 和底层 const 的拷贝规则
- **顶层 const 不影响拷贝**:常量指针可以拷给普通指针(因为拷的是地址值,源指针 const 不影响副本)。
- **底层 const 不能被忽略**:指向常量的指针不能直接赋给指向变量的指针(否则就可以通过后者修改常量了)。
const int* p1 = &x;
int* p2 = p1; // ❌ 底层 const 不能丢:p1 承诺不修改,p2 可以修改
int* const p3 = &x;
int* p4 = p3; // ✅ 顶层 const 可以丢:p3 本身不能改,但拷给 p4 后 p4 可以改
---
### 三、const 修饰引用
**含义**:`const T&` 是常量引用,不能通过引用修改绑定的对象。
int x = 10;
const int& ref = x;
ref = 20; // ❌ ref 是 const 引用
x = 20; // ✅ 原变量可以改,ref 看到的值也会变
**关键特性:const 引用可以绑定到临时对象(右值)和不同类型**
const int& r1 = 10; // ✅ 绑定到临时对象(int(10))
int& r2 = 10; // ❌ 非常量引用不能绑定右值
double d = 3.14;
const int& r3 = d; // ✅ 类型转换:创建临时 int,const 引用绑定到临时
int& r4 = d; // ❌ 非常量引用不能绑定到转换后的临时
**面试要点**:函数参数用 `const T&` 是推荐写法,既避免拷贝又保证不修改,还能接受右值和临时对象。
void Process(const std::string& s); // ✅ 推荐:可接受左值、右值、临时对象
void Process(std::string& s); // ⚠️ 只能接受左值,不能传临时对象
---
### 四、const 修饰成员函数
**含义**:在成员函数声明后加 `const`,承诺该函数**不修改任何成员变量**(也不调用非 const 成员函数)。
class Counter {
int count = 0;
public:
int Get() const { // const 成员函数
return count; // ✅ 只读
// count = 1; // ❌ 不能修改成员
// Increment(); // ❌ 不能调用非 const 成员函数
}
void Increment() { // 非 const 成员函数
count++;
}
};
### 4.1 const 对象只能调用 const 成员函数
const Counter c;
c.Get(); // ✅ const 成员函数
c.Increment(); // ❌ const 对象不能调用非 const 成员函数
### 4.2 函数重载:const 与非 const 版本
class Buffer {
int data100;
public:
// 非 const 对象调用,返回可修改引用
int& operator\[\](int i) { return datai; }
// const 对象调用,返回 const 引用
const int& operator[](int i) const { return data[i]; }
};
Buffer b;
b0 = 42; // ✅ 调用非 const 版本
const Buffer cb;
cb0 = 42; // ❌ 调用 const 版本,返回 const int&,不能赋值
int x = cb0; // ✅
**面试要点**:STL 容器的 `operator[]`、`begin()/end()`、`at()` 等都有 const/非 const 两个重载版本,这是标准库的惯用法。
---
### 五、const 修饰函数参数
### 5.1 值传递的 const 参数
void Func(const int x); // x 在函数内不可修改
值传递时参数是副本,`const` 只是限制函数内不修改这个副本。对调用方无影响,通常只在函数实现需要保护参数时使用。
### 5.2 指针/引用传递的 const 参数(最常用)
void Print(const std::string& s); // ✅ 推荐:避免拷贝 + 保证不修改
void Process(const int* arr, int n); // ✅ 指针底层 const:不修改数组内容
**面试要点**:
- 大对象传参优先用 `const T&`,避免拷贝开销。
- 指针参数加底层 `const` 表示函数不会修改指向的数据。
- 顶层 `const` 参数(如 `int* const p`)对调用方无意义,只在函数内限制指针本身不被改。
---
### 六、const 修饰函数返回值
### 6.1 返回 const 值
const int GetValue(); // 对内置类型无意义,返回的是右值本来就不能改
对内置类型返回 `const` 没有实际意义(返回值本身就是右值)。但对类类型有意义:
class Matrix { ... };
const Matrix operator+(const Matrix& a, const Matrix& b);
Matrix a, b, c;
(a + b) = c; // ❌ 如果返回 const,不能对临时对象赋值(防止无意义代码)
### 6.2 返回 const 引用 / const 指针
class Config {
std::string name_;
public:
const std::string& GetName() const { return name_; } // ✅ 返回 const 引用
};
Config c;
c.GetName() = "hacked"; // ❌ 返回 const 引用,不能通过返回值修改内部成员
**面试要点**:返回成员变量的引用时,如果不希望外部修改内部状态,必须返回 `const T&`。否则外部可以通过返回值篡改对象内部数据,破坏封装。
---
### 七、mutable:突破 const 限制
**含义**:被 `mutable` 修饰的成员变量,即使在 `const` 成员函数中也可以被修改。
class Cache {
mutable int hit_count = 0; // mutable 成员
int data = 0;
public:
int Get() const {
hit_count++; // ✅ mutable 成员在 const 函数中可修改
return data;
}
};
**适用场景**:
- 缓存统计(如上面的 hit_count)
- 互斥锁(`mutable std::mutex`,const 函数中加锁)
- 延迟计算(lazy evaluation,const 函数中首次调用时计算并缓存)
class ThreadSafeCounter {
mutable std::mutex mtx; // mutable 锁
int count = 0;
public:
int Get() const {
std::lock_guardstd::mutex lock(mtx); // ✅ const 函数中可以锁 mutable 成员
return count;
}
};
---
### 八、const_cast:去掉 const 属性
**含义**:`const_cast` 可以去掉指针或引用的底层 const(也可以加上 const),是唯一能去掉 const 的 C++ 转型运算符。
const int x = 10;
int* p = const_cast<int*>(&x); // 去掉底层 const
*p = 20; // ⚠️ 未定义行为!x 本身是 const 对象,修改它是 UB
**安全用法**:对象本身不是 const,只是当前通过 const 指针/引用访问时,可以安全地去掉 const。
void Log(const char* str); // 旧 C 接口,参数应该是 const 但声明没写
void Func(const std::string& s) {
Log(const_cast<char*>(s.c_str())); // 去掉 const 传给旧接口
// 安全:Log 不会修改字符串,只是接口声明不规范
}
**面试要点**:
- `const_cast` 只能改变底层 const,不能改变类型本身(不能用 `const_cast` 把 `int*` 转 `double*`)。
- 对**真正的 const 对象**去掉 const 后修改是**未定义行为**。
- 常见合理用途:调用 const 不正确的旧接口、在非 const 函数中复用 const 版本的实现(或反过来)。
---
### 九、constexpr:编译期常量
`constexpr` 是 C++11 引入的,比 `const` 更强的编译期常量约束。
| | const | constexpr |
| --- | --- | --- |
| 含义 | 运行时常量(初始化后不可改) | 编译期常量(值在编译期确定) |
| 初始化时机 | 可以运行时初始化 | 必须编译期可计算 |
| 能用于数组大小 | 不一定(如果是运行时常量则不行) | 一定可以 |
| 能用于模板参数 | 不一定 | 一定可以 |
const int a = 10; // 编译期常量(字面量初始化)
const int b = rand(); // 运行时常量(运行时才知道值)
constexpr int c = 10; // ✅ 编译期常量
constexpr int d = rand(); // ❌ rand() 不是编译期可计算
int arr1a; // ✅ a 是编译期常量(但取决于编译器,严格来说 a 是 const 不是 constexpr)
int arr2c; // ✅ c 是 constexpr,一定可以
int arr3b; // ❌ b 是运行时常量,不能做数组大小(VLA 扩展除外)
**constexpr 函数**:函数可以在编译期求值(如果参数都是编译期常量),也可以在运行期调用。
constexpr int Factorial(int n) {
return n <= 1 ? 1 : n * Factorial(n - 1);
}
int a = Factorial(5); // 编译期计算 = 120
int b = Factorial(rand()); // 运行期计算
---
### 十、面试高频考点速查
| 考点 | 答案 |
| --- | --- |
| `const int* p` 和 `int* const p` 区别 | 前者指向的值不可改(底层 const),后者指针本身不可改(顶层 const) |
| const 成员函数能修改成员吗 | 不能,除非成员是 `mutable` |
| const 对象能调用非 const 成员函数吗 | 不能,只能调用 const 成员函数 |
| `const T&` 能绑定临时对象吗 | 能,这是 const 引用的重要特性 |
| 函数参数为什么推荐 `const T&` | 避免拷贝 + 保证不修改 + 能接受右值和临时对象 |
| 返回成员引用为什么要 const | 防止外部通过返回值篡改对象内部状态 |
| `mutable` 作用 | 允许 const 成员函数修改该成员(用于缓存、锁、延迟计算) |
| `const_cast` 安全吗 | 去掉 const 后修改真正的 const 对象是 UB;对象本身非 const 时安全 |
| `const` 和 `constexpr` 区别 | const 是运行时常量,constexpr 是编译期常量 |
| 顶层 const 拷贝时会被忽略吗 | 会(源指针 const 不影响副本) |
| 底层 const 拷贝时会被忽略吗 | 不会(不能把 const 指针赋给非 const 指针) |
---
### 十一、面试答题模板
>
> "const 是 C++ 中用于保证不可变性的关键字,主要用法有:修饰变量使其初始化后不可修改;修饰指针时分顶层 const(指针本身不可改)和底层 const(指向的值不可改);修饰引用形成 const 引用,可以绑定临时对象;修饰成员函数承诺不修改成员变量,const 对象只能调用 const 成员函数;修饰函数参数避免拷贝并保证不修改;修饰返回值防止外部篡改内部状态。另外 `mutable` 允许 const 函数修改特定成员,`const_cast` 可以去掉底层 const,`constexpr` 是更强的编译期常量。核心思想是 const 正确性(const-correctness)------该不可变的地方都加 const,让编译器帮你检查错误。"
一、const 修饰变量
面试回答
const 修饰的变量初始化后不可修改,成为编译期可检查的常量。必须在定义时初始化,之后不能赋值。
实现
const int x = 10;
x = 20; // ❌ 编译错误
const int y; // ❌ 必须初始化
// 类的 const 成员必须在初始化列表初始化
class Foo {
const int id;
public:
Foo(int i) : id(i) {} // ✅ 初始化列表
// Foo(int i) { id = i; } // ❌ 函数体内赋值不行
};
易错点
- 类的 const 成员变量不能在构造函数体内赋值,必须用初始化列表。
const变量是"初始化后不可改",不是"编译期常量"------const int b = rand();是合法的运行时常量。
二、const 修饰指针:顶层 const vs 底层 const
面试回答
核心是区分 const 修饰的是指针本身 还是指针指向的值 。看 const 在 * 的左边还是右边:左边 = 修饰值(底层 const),右边 = 修饰指针(顶层 const)。
实现
| 写法 | 含义 | 指针能改吗 | 指向的值能改吗 |
|---|---|---|---|
int* p |
普通指针 | 能 | 能 |
const int* p |
指向常量的指针(底层) | 能 | 不能 |
int* const p |
常量指针(顶层) | 不能 | 能 |
const int* const p |
两者都 const | 不能 | 不能 |
int x = 10, y = 20;
const int* p1 = &x; // 底层 const
*p1 = 30; // ❌ 值不可改
p1 = &y; // ✅ 指针可改
int* const p2 = &x; // 顶层 const
*p2 = 30; // ✅ 值可改
p2 = &y; // ❌ 指针不可改
顶层 const 和底层 const 在各场景下的忽略规则(高频考点)
| 场景 | 顶层 const | 底层 const |
|---|---|---|
| 拷贝赋值 | 被忽略(源指针 const 不影响副本) | 不能忽略(不能把 const 指针赋给非 const 指针) |
| auto 推导 | 被忽略(auto 不保留顶层 const) |
保留(auto 保留底层 const) |
| 值传递参数 | 被忽略(参数是副本,顶层 const 不影响调用方) | 不能忽略(实参底层 const 必须匹配或更严格) |
| 函数签名/重载 | 不算(顶层 const 参数不参与重载) | 算(底层 const 参数参与重载) |
| const_cast | 不需要(拷贝自然去掉) | 可以去掉/加上(const_cast 的唯一用途) |
1. 拷贝时
// 顶层 const 拷贝时被忽略
int* const p3 = &x;
int* p4 = p3; // ✅ 顶层 const 被忽略,p4 是普通指针
// 底层 const 拷贝时不能忽略
const int* p1 = &x;
int* p2 = p1; // ❌ 底层 const 不能丢
const int* p5 = p1; // ✅ 保留底层 const
2. auto 推导时
const int* const p = &x; // 顶层 + 底层 const
auto q = p; // q 的类型是 const int*(底层保留,顶层被忽略)
const int a = 10;
auto b = a; // b 的类型是 int(顶层 const 被忽略)
b = 20; // ✅ b 可以改
const auto c = a; // 要保留顶层 const 需显式写 const auto
// c = 20; // ❌
3. 值传递参数时
// 顶层 const 参数:对调用方无意义,参数是副本
void f1(int* const p); // 函数内 p 不可改,但调用方传 int* 或 int* const 都行
void f1(int* p); // ❌ 和上面是同一个函数,不能重载(顶层 const 不算签名)
// 底层 const 参数:影响调用方
void f2(const int* p); // 可以传 const int* 和 int*(int* 可隐式转 const int*)
void f2(int* p); // ✅ 可以重载(底层 const 算签名)
const int* cp = &x;
f2(cp); // ✅ 调 const int* 版本
int* np = &x;
f2(np); // ✅ 两个版本都能匹配,优先调 int* 版本(更精确)
4. const_cast 时
// const_cast 只能操作底层 const
const int* p1 = &x;
int* p2 = const_cast<int*>(p1); // ✅ 去掉底层 const
// 顶层 const 不需要 const_cast,拷贝时自然去掉
int* const p3 = &x;
int* p4 = p3; // ✅ 顶层 const 拷贝时被忽略
易错点
- 底层 const 不能丢是高频考点:指向常量的指针不能直接赋给指向变量的指针。
const int* p和int const* p完全等价(const 在*左边都行),别被写法迷惑。auto不保留顶层 const,要保留需写const auto。- 顶层 const 参数不参与函数重载,底层 const 参数参与。
三、const 修饰引用
面试回答
const T& 是常量引用,不能通过引用修改绑定对象。关键特性是可以绑定到临时对象(右值)和不同类型,普通引用不行。
实现
int x = 10;
const int& ref = x;
ref = 20; // ❌ 不能通过 ref 修改
x = 20; // ✅ 原变量可以改,ref 看到的值也变
// 绑定临时对象
const int& r1 = 10; // ✅ 绑定到临时 int(10)
int& r2 = 10; // ❌ 普通引用不能绑右值
// 绑定不同类型(创建临时对象)
double d = 3.14;
const int& r3 = d; // ✅ 创建临时 int,const 引用绑定
int& r4 = d; // ❌
易错点
- 函数参数推荐用
const T&:避免拷贝 + 保证不修改 + 能接受右值和临时对象。 void Process(std::string& s)只能接受左值,传临时对象会编译错误。
四、const 修饰成员函数
面试回答
成员函数声明后加 const,本质是给隐含的 this 指针加底层 const(this 的类型变为 const T* const),因此不能通过 this 修改非 mutable 成员,也不能调用非 const 成员函数。const 对象(const 引用/指针)只能调用 const 成员函数。
注意:不是"不修改任何成员变量"------
mutable成员是例外,可以在 const 函数中修改。
实现
class Counter {
int count = 0;
public:
int Get() const { // const 成员函数
return count; // ✅ 只读
// count = 1; // ❌ 不能改非 mutable 成员
// Increment(); // ❌ 不能调非 const 函数
}
void Increment() { count++; }
};
const Counter c;
c.Get(); // ✅
c.Increment(); // ❌ const 对象不能调非 const 函数
mutable:const 成员函数的例外
被 mutable 修饰的成员变量,即使在 const 成员函数中也可以被修改。用于逻辑上不改变对象状态、但物理上需要修改的成员。
class Cache {
mutable int hit_count = 0; // mutable 成员
int data = 0;
public:
int Get() const {
hit_count++; // ✅ const 函数中可修改 mutable 成员
return data;
}
};
// 线程安全:mutable 锁
class ThreadSafeCounter {
mutable std::mutex mtx; // mutable 锁
int count = 0;
public:
int Get() const {
std::lock_guard<std::mutex> lock(mtx); // ✅ const 函数中可以锁 mutable 成员
return count;
}
};
mutable 适用场景:缓存统计、互斥锁、延迟计算(lazy evaluation)。
const / 非 const 重载
class Buffer {
int data[100];
public:
int& operator[](int i) { return data[i]; } // 非 const 对象
const int& operator[](int i) const { return data[i]; } // const 对象
};
STL 容器的 operator[]、begin()/end()、at() 都有这两个重载版本。
重载有效和无效的方式(高频考点)
| 情况 | 能否重载 | 原因 |
|---|---|---|
| 成员函数 const vs 非 const | ✅ 能 | 成员函数的 const 是签名的一部分(隐含的 this 指针底层 const 不同) |
值参数顶层 const(int vs const int) |
❌ 不能 | 顶层 const 不算签名,参数是副本 |
指针参数顶层 const(int* vs int* const) |
❌ 不能 | 顶层 const 不算签名 |
指针参数底层 const(int* vs const int*) |
✅ 能 | 底层 const 算签名 |
引用参数底层 const(int& vs const int&) |
✅ 能 | 底层 const 算签名 |
| 仅返回值 const 不同 | ❌ 不能 | 返回值不参与重载决议 |
// ✅ 有效:成员函数 const 重载
int Get();
int Get() const;
// ✅ 有效:指针/引用底层 const 重载
void f(int* p);
void f(const int* p);
void f(int& r);
void f(const int& r);
// ❌ 无效:值参数顶层 const(重复定义)
void g(int x);
void g(const int x); // 和上面是同一个函数
// ❌ 无效:指针参数顶层 const(重复定义)
void h(int* p);
void h(int* const p); // 和上面是同一个函数
// ❌ 无效:仅返回值不同
int k();
const int k(); // 不能仅靠返回值重载
易错点
- const 成员函数的本质是
this指针变为const T*(底层 const),不是"不修改任何成员"------mutable成员可以改。 - 成员函数的 const 算签名的一部分,可以和非 const 版本重载。
- 顶层 const 参数不参与重载,底层 const 参数参与。
mutable只能修饰非静态成员变量,不能修饰局部变量、函数或类;不能滥用,是为"物理修改但逻辑不变"的场景设计的。
五、const 修饰函数参数
面试回答
大对象传参优先用 const T&,既避免拷贝又保证不修改,还能接受右值。指针参数加底层 const 表示函数不修改指向的数据。
实现
void Print(const std::string& s); // ✅ 推荐:避免拷贝 + 不修改
void Process(const int* arr, int n); // ✅ 底层 const:不修改数组
void Func(const int x); // 值传递 const:只限制函数内不改副本,对调用方无意义
易错点
- 顶层 const 参数(如
int* const p)对调用方无意义,只在函数内限制指针本身,且不参与重载。 - 小对象(int、float)值传递就行,不需要
const T&(引用反而有间接寻址开销)。
六、const 修饰函数返回值
面试回答
返回成员变量的引用时,如果不希望外部修改内部状态,必须返回 const T&,否则外部可以通过返回值篡改对象内部数据,破坏封装。
实现
class Config {
std::string name_;
public:
const std::string& GetName() const { return name_; } // ✅ 返回 const 引用
};
Config c;
c.GetName() = "hacked"; // ❌ 返回 const 引用,不能赋值
返回 const 值对内置类型无意义(返回值本身就是右值),对类类型有意义------可以防止对临时对象赋值(如 (a+b)=c)。
易错点
- 返回
T&而不是const T&,外部就能直接修改内部成员,等于把私有成员暴露了。 - 返回局部变量的引用是悬垂引用,和 const 无关,直接 UB。
- 仅返回值 const 不同不能重载。
七、const_cast:去掉 const 属性
面试回答
const_cast 是唯一能去掉 const 的 C++ 转型运算符,只能改变指针/引用的底层 const,不能改变类型本身。对真正的 const 对象去掉 const 后修改是未定义行为。
实现
const int x = 10;
int* p = const_cast<int*>(&x);
*p = 20; // ⚠️ UB!x 本身是 const 对象
// 安全用法:对象本身非 const,只是当前通过 const 访问
void Log(char* str); // 旧接口声明不规范,但实际不修改
void Func(const std::string& s) {
Log(const_cast<char*>(s.c_str())); // ✅ 安全
}
const_cast 与顶层/底层 const
-
const_cast只能操作底层 const(指针/引用指向的东西的 const 性)。 -
顶层 const 不需要
const_cast,拷贝时自然去掉。const int* p1 = &x;
int* p2 = const_cast<int*>(p1); // ✅ 去掉底层 constint* const p3 = &x;
int* p4 = p3; // ✅ 顶层 const 拷贝时被忽略,不需要 const_cast
易错点
const_cast不能改变类型:const_cast<double*>(int_ptr)编译错误。- 去掉 const 后修改真正的 const 对象是 UB(编译器可能把 const 变量放只读段,修改触发段错误)。
- 合理用途:对接 const 声明不正确的旧接口、在非 const 函数中复用 const 版本实现。
const_cast不能去掉顶层 const(也不需要)。
八、constexpr:编译期常量
面试回答
constexpr 是 C++11 引入的,比 const 更强的约束------值必须在编译期确定 。const 只是"运行时不可改",constexpr 是"编译期就能算出来"。
实现
| const | constexpr | |
|---|---|---|
| 含义 | 运行时常量 | 编译期常量 |
| 初始化 | 可以运行时初始化 | 必须编译期可计算 |
| 数组大小 | 不一定 | 一定可以 |
| 模板参数 | 不一定 | 一定可以 |
const int a = 10; // 编译期常量(字面量初始化)
const int b = rand(); // 运行时常量(合法!)
constexpr int c = 10; // ✅ 编译期常量
constexpr int d = rand(); // ❌ rand() 不是编译期可计算
int arr2[c]; // ✅ constexpr 一定可以做数组大小
int arr3[b]; // ❌ 运行时常量不能做数组大小
constexpr 函数:参数都是编译期常量时在编译期求值,否则运行期求值。
constexpr int Factorial(int n) {
return n <= 1 ? 1 : n * Factorial(n - 1);
}
int a = Factorial(5); // 编译期 = 120
int b = Factorial(rand()); // 运行期计算
易错点
const不等于编译期常量,const int b = rand();是合法的。constexpr变量必须用编译期可计算的表达式初始化。- C++11 的 constexpr 函数限制很多(只能有 return 语句),C++14 起放松。
九、面试速查表
| 考点 | 答案 |
|---|---|
const int* p vs int* const p |
前者值不可改(底层),后者指针不可改(顶层) |
| const 成员函数能改成员吗 | 不能改非 mutable 成员,mutable 成员可以改 |
| const 成员函数的本质 | 隐含的 this 指针变为 const T*(底层 const) |
| const 对象能调非 const 函数吗 | 不能,只能调 const 成员函数 |
const T& 能绑临时对象吗 |
能,这是关键特性 |
函数参数为什么推荐 const T& |
避免拷贝 + 不修改 + 能接受右值 |
| 返回成员引用为什么要 const | 防止外部通过返回值篡改内部状态 |
mutable 作用 |
允许 const 函数修改该成员(缓存、锁、延迟计算) |
mutable 属于哪里 |
属于 const 成员函数的例外机制,和 const 成员函数一起讲 |
const_cast 安全吗 |
改真正的 const 对象是 UB;对象本身非 const 时安全 |
const vs constexpr |
const 是运行时常量,constexpr 是编译期常量 |
| 顶层 const 拷贝时能忽略吗 | 能 |
| 底层 const 拷贝时能忽略吗 | 不能 |
| auto 推导保留顶层 const 吗 | 不保留,要保留需写 const auto |
| auto 推导保留底层 const 吗 | 保留 |
| 顶层 const 参数参与重载吗 | 不参与 |
| 底层 const 参数参与重载吗 | 参与 |
| 成员函数 const 能重载吗 | 能(this 指针底层 const 不同) |
| 仅返回值 const 不同能重载吗 | 不能 |
const_cast 能去掉顶层 const 吗 |
不能(也不需要,拷贝自然去掉) |
const_cast 能去掉底层 const 吗 |
能(这是它的唯一用途) |
| 值传递顶层 const 对调用方有意义吗 | 没有,参数是副本 |
十、面试答题模板
"const 是 C++ 中保证不可变性的关键字,主要用法:修饰变量初始化后不可改;修饰指针分顶层 const(指针本身不可改)和底层 const(指向的值不可改);修饰引用形成 const 引用,可以绑定临时对象;修饰成员函数本质是给隐含的 this 指针加底层 const,因此不能修改非 mutable 成员,const 对象只能调 const 函数,mutable 是 const 函数的例外,用于缓存、锁等物理修改但逻辑不变的场景;修饰函数参数用
const T&避免拷贝并保证不修改;修饰返回值用const T&防止外部篡改内部状态。顶层 const 在拷贝、auto 推导、值传递时会被忽略,不参与函数重载;底层 const 不能被忽略,参与重载。另外const_cast可以去掉底层 const,constexpr是更强的编译期常量。核心思想是 const 正确性------该不可变的地方都加 const,让编译器帮你检查错误。"