开启标准库的调试模式
可以帮助你监测未定义行为
- msvc: Debug 配置
- gcc: 定义
_GLIBCXX_DEBUG
宏
未定义行为?
空指针类
1. 不能解引用空指针(通常会产生崩溃,但也可能被优化产生奇怪的现象)
只要解引用就错了,无论是否读取或写入
1 2 3 4 5
| int *p = nullptr; *p; &*p; *p = 0; int i = *p;
|
1 2 3
| unique_ptr<int> p = nullptr; p.get(); &*p;
|
例如在 Debug 配置的 MSVC STL 中,&*p
会产生断言异常,而 p.get()
不会。
1 2 3 4
| if (&*p != nullptr) { } if (p != nullptr) { }
|
2. 不能解引用 end 迭代器
1 2 3
| std::vector<int> v = {1, 2, 3, 4}; int *begin = &*v.begin(); int *end = &*v.end();
|
1 2 3
| std::vector<int> v = {}; int *begin = &*v.begin(); int *end = &*v.end();
|
建议改用 data 和 size
1 2 3
| std::vector<int> v = {1, 2, 3, 4}; int *begin = v.data(); int *end = v.data() + v.size();
|
3. this 指针不能为空
1 2 3 4 5 6 7 8 9 10 11 12
| struct C { void print() { if (this == nullptr) { std::cout << "this 是空\n"; } } };
void func() { C *c = nullptr; c->print(); }
|
指针别名类
4. reinterpret_cast 后以不兼容的类型访问
1 2 3
| int i; float f = *(float *)&i; *(int *)(uintptr_t)&i;
|
例外:char、signed char、unsigned char 和 std::byte 总是兼容任何类型
1 2 3
| int i; char *buf = (char *)&i; buf[0] = 1;
|
5. union 访问不是激活的成员
1 2 3 4 5 6 7 8
| float bitCast(int i) { union { int i; float f; } u; u.i = i; return u.f; }
|
特例:公共的前缀成员可以安全地访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int foo(int i) { union { struct { int tag; int value; } m1; struct { int tag; float value; } m2; } u; u.m1.tag = i; return u.m2.tag; }
|
如需在 float 和 int 之间按位转换,建议改用 memcpy,因为 memcpy 内部被认为是以 char 指针访问的,char 总是兼容任何类型
1 2 3 4 5
| float bitCast(int i) { float f; memcpy(&f, &i, sizeof(i)); return f; }
|
或 C++20 的 std::bit_cast
1 2 3 4
| float bitCast(int i) { float f = std::bit_cast<float>(i); return f; }
|
6. T 类型指针必须对齐到 alignof(T)
1 2 3 4 5 6 7
| struct alignas(64) C { int i; char c; };
C *p = (C *)malloc(sizeof(C)); C *p = new C;
|
1 2
| char buf[sizeof(int)]; int *p = (int *)buf;
|
1 2
| alignas(alignof(int)) char buf[sizeof(int)]; int *p = (int *)buf;
|
1 2
| char buf[sizeof(int) * 2]; int *p = (int *)(((uintptr_t)buf + sizeof(int) - 1) & ~(alignof(int) - 1));
|
算数类
7. 有符号整数的加减乘除模不能溢出
但无符号可以,无符号整数保证:溢出必定回环 (wrap-around)
1 2
| unsigned int i = UINT_MAX; i + 1;
|
如需对有符号整数做回环,可以先转换为相应的 unsigned 类型,算完后再转回来
1 2
| int i = INT_MAX; (int)((unsigned int)i + 1);
|
如下写法更具有可移植性,因为无符号数向有符号数转型时若超出有符号数的表示范围则为实现定义行为(编译器厂商决定结果,但不是未定义行为)
1
| std::bit_cast<int>((unsigned int)i + i);
|
有符号整数的加减乘除模运算结果结果必须在表示范围内:例如对于 int a 和 int b,若 a/b 的结果不可用 int 表示,那么 a/b 和 a%b 均未定义
1 2
| INT_MIN % -1; INT_MIN / -1;
|
9. 左移或右移的位数,不得超过整数类型上限,不得为负
1 2 3 4 5
| unsigned int i = 0; i << 31; i << 32; i << 0; i << -1;
|
对于有符号整数,左移还不得破坏符号位
1 2 3 4 5
| int i = 0; i << 1; i << 31; unsigned int u = 0; u << 31;
|
如需处理来自用户输入的位移数量,可以先做范围检测
1 2 3 4 5 6 7
| int shift; cin >> shift;
unsigned int u = 0; int i = 0; (shift > 0 && shift < 32) ? (u << shift) : 0; (shift > 0 && shift < 31) ? (i << shift) : 0;
|
10. 除数不能为 0
1 2 3 4
| int i = 42; int j = 0; i / j; i % j;
|
函数类
11. 返回类型不为 void 的函数,必须有 return 语句
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int func() { int i = 42; }
int func() { int i = 42; return i; }
void func() { int i = 42; }
|
坑人之处在于,忘记写,不会报错,编译器只是警告。
为了避免忘记写 return 语句,建议 gcc 编译器开启 -Werror=return-type
选项,将不写返回语句的警告转化为错误
注意,在有分支的非 void 函数中,必须所有可达分支都有 return 语句
1 2 3 4 5 6 7
| int func(int x) { if (x < 0) return -x; if (x > 0) return x; }
|
没有 return 的分支相当于写了一个 std::unreachable()
12. 函数指针被调用时,不能为空
1 2 3 4
| typedef void (*func_t)();
func_t func = nullptr; func();
|
《经典再现》
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #include <cstdio>
static void func() { printf("func called\n"); }
typedef void (*func_t)();
static func_t fp = nullptr;
extern void set_fp() { fp = func; }
int main() { fp(); return 0; }
|
生命周期类
13. 不能读取未初始化的变量
1 2 3 4 5 6 7 8 9 10 11
| int i; cout << i;
int i = 0; cout << i;
int arr[10]; cout << arr[0];
int arr[10] = {}; cout << arr[0];
|
14. 指针的加减法不能超越数组边界
1 2 3 4 5
| int arr[10]; int *p = &arr[0]; p + 1; p + 10; p + 11;
|
15. 可以有指向数组尾部的指针(类似 end 迭代器),但不能解引用
1 2 3 4
| int arr[10]; int *p = &arr[0]; int *end = p + 10; *end;
|
16. 不能访问未初始化的指针
1 2 3 4 5 6 7 8 9 10 11 12 13
| struct Dog { int age; };
struct Person { Dog *dog; };
Person *p = new Person; cout << p->dog->age;
p->dog = new Dog; cout << p->dog->age;
|
17. 不能访问已释放的内存
1 2 3 4
| int *p = new int; *p; delete p; *p;
|
1 2 3 4
| int *p = (int *)malloc(sizeof(int)); *p; free(p); *p;
|
1 2 3 4 5 6 7 8 9
| int *func() { int arr[10]; return arr; }
int main() { int *p = func(); p[0]; }
|
建议改用更安全的 array 或 vector 容器
1 2 3 4 5 6 7 8 9
| array<int, 10> func() { array<int, 10> arr; return arr; }
int main() { auto arr = func(); arr[0]; }
|
18. new / new[] / malloc 和 delete / delete[] / free 必须匹配
1 2
| int *p = new int; free(p);
|
1 2
| int *p = (int *)malloc(sizeof(int)); free(p);
|
1 2
| int *p = new int[3]; delete p;
|
1 2
| int *p = new int[3]; delete[] p;
|
1 2
| vector<int> a(3); unique_ptr<int> a = make_unique<int>(42);
|
19. 不要访问已经析构的对象
1 2 3 4 5 6 7 8 9 10
| struct C { int i; ~C() { i = 0; } };
C *c = (C *)malloc(sizeof(C)); cout << c->i; c->~C(); cout << c->i; free(c);
|
1 2 3 4 5
| std::string func() { std::string s = "hello"; std::string s2 = std::move(s); return s; }
|
库函数类
20. ctype.h 中一系列函数的字符参数,必须在 0~127 范围内(即只支持 ASCII 字符)
1 2 3 4 5 6 7
| isdigit('0'); isdigit('a'); isdigit('\xef');
char s[] = "你好A";
std::transform(std::begin(s), std::end(s), std::begin(s), ::tolower);
|
MSVC STL 中 is 系列函数的断言:
assert(-1 <= c && c < 256);
理论上可以这样断言:
assert(0 <= c && c <= 127);
解决方法:要么改用 iswdigit(MSVC:0-65536,GCC:0-0x010ffff)
1 2 3
| iswdigit('0'); iswdigit('\xef'); iswspace(L'\ufeff');
|
要么自己实现判断
1 2
| if ('0' <= c && c <= '9') if (strchr(" \n\t\r", c))
|
21. memcpy 函数的 src 和 dst 不能为空指针
1 2 3 4
| void *dst = nullptr; void *src = nullptr; size_t size = 0; memcpy(dst, src, size);
|
可以给 size 加个判断
1 2 3 4 5
| void *dst = nullptr; void *src = nullptr; size_t size = 0; if (size != 0) memcpy(dst, src, size);
|
22. memcpy 不能接受带有重叠的 src 和 dst
1 2 3 4 5
| char arr[10]; memcpy(arr, arr + 1, 9); memcpy(arr + 1, arr, 9); memcpy(arr + 5, arr, 5); memcpy(arr, arr + 5, 5);
|
如需拷贝带重复区间的内存,可以用 memmove
1 2 3 4 5
| char arr[10]; memmove(arr, arr + 1, 9); memmove(arr + 1, arr, 9); memmove(arr + 5, arr, 5); memmove(arr, arr + 5, 5);
|
从 memcpy 的 src 和 dst 指针参数是 restrict 修饰的,而 memmove 没有,就可以看出来,memcpy 不允许任何形式的指针重叠,无论先后顺序
23. v.back() 当 v 为空时是未定义行为
1 2 3
| std::vector<int> v = {}; int i = v.back(); int i = v.empty() ? 0 : v.back();
|
24. vector 的 operator[] 当 i 越界时,是未定义行为
1 2
| std::vector<int> v = { 1, 2, 3 }; v[3];
|
可以用 at 成员函数
1 2
| std::vector<int> v = { 1, 2, 3 }; v.at(3);
|
25. 容器迭代器失效
1 2 3 4
| std::vector<int> v = { 1, 2, 3 }; auto it = v.begin(); v.push_back(4); *it = 0;
|
如果不需要连续内存,可以改用分段内存的 deque 容器,其可以保证元素不被移动,迭代器不失效。
1 2 3 4
| std::deque<int> v = { 1, 2, 3 }; auto it = v.begin(); v.push_back(4); *it = 0;
|
26. 容器元素引用失效
1 2 3 4
| std::vector<int> v = {1, 2, 3}; int &ref = v[0]; v.push_back(4); ref = 0;
|
如果不需要连续内存,可以改用分段内存的 deque 容器,其可以保证元素不被移动,引用不失效。
1 2 3 4
| std::deque<int> v = {1, 2, 3}; int &ref = v[0]; v.push_back(4); ref = 0;
|
多线程类
27. 多个线程同时访问同一个对象,其中至少一个线程的访问为写访问,是未定义行为(俗称数据竞争)
1 2 3 4 5 6 7 8 9
| std::string s;
void t1() { s.push_back('a'); }
void t2() { cout << s.size(); }
|
1 2 3 4 5 6 7 8 9
| std::string s;
void t1() { s.push_back('a'); }
void t2() { s.push_back('b'); }
|
更准确的说法是:多个线程(无 happens before 关系地)访问同一个对象,其中至少一个线程的访问带有副作用(写访问或带有volatile的读访问),是未定义行为
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| volatile int ready = 0; int data;
void t1() { data = 42; ready = 1; }
void t2() { while (ready == 0) ; printf("%d\n", data); }
|
建议利用 mutex,counting_semaphore,atomic 等多线程同步工具,保证多个线程访问同一个对象时,顺序有先有后,不会“同时”发生,那就是安全的
1 2 3 4 5 6 7 8 9 10 11 12
| std::string s; std::mutex m;
void t1() { std::lock_guard l(m); s.push_back('a'); }
void t2() { std::lock_guard l(m); s.push_back('b'); }
|
在上面的例子中,互斥锁保证了要么 t1 happens before t2,要么 t2 happens before t1,不会“同时”访问,是安全的
1 2 3 4 5 6 7 8 9 10 11 12
| std::string s; std::counting_semaphore<1> sem(1);
void t1() { s.push_back('a'); sem.release(); }
void t2() { sem.acquire(); s.push_back('b'); }
|
在上面的例子中,信号量保证了 t1 happens before t2,不会“同时”访问,是安全的
1 2 3 4 5 6 7 8 9 10 11 12 13
| std::string s; std::atomic<bool> ready{false};
void t1() { s.push_back('a'); ready.store(true, std::memory_order_release); }
void t2() { while (!ready.load(std::memory_order_acquire)) ; s.push_back('b'); }
|
在上面的例子中,原子变量的 acquire/release 内存序保证了 t1 happens before t2,不会“同时”访问,是安全的
28. 多个线程同时对两个 mutex 上锁,但顺序相反,会产生未定义行为(俗称死锁)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| std::mutex m1, m2;
void t1() { m1.lock(); m2.lock(); m2.unlock(); m1.unlock(); }
void t2() { m2.lock(); m1.lock(); m1.unlock(); m2.unlock(); }
|
解决方法:不要在多个 mutex 上同时上锁,如果确实要多个 mutex,保证顺序一致
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| std::mutex m1, m2;
void t1() { m1.lock(); m2.lock(); m2.unlock(); m1.unlock(); }
void t2() { m1.lock(); m2.lock(); m2.unlock(); m1.unlock(); }
|
或使用 std::lock
1 2 3 4 5 6 7 8 9 10 11
| std::mutex m1, m2;
void t1() { std::lock(m1, m2); std::unlock(m1, m2); }
void t2() { std::lock(m2, m1); std::unlock(m2, m1); }
|
29. 对于非 recursive_mutex,同一个线程对同一个 mutex 重复上锁,会产生未定义行为(俗称递归死锁)
1 2 3 4 5 6 7 8 9 10 11 12 13
| std::mutex m;
void t1() { m.lock(); m.lock(); m.try_lock(); m.unlock(); m.unlock(); }
void t2() { m.try_lock(); }
|
解决方法:改用 recursive_mutex,或使用适当的条件变量
1 2 3 4 5 6 7 8 9 10
| std::recursive_mutex m;
void t1() { m.lock(); m.lock(); m.try_lock(); m.unlock(); m.unlock(); m.unlock(); }
|
总结
- 不要玩空指针
- 不要越界,用更安全的 at,subspan 等
- 不要不初始化变量(auto-idiom)
- 开启
-Werror=return-type
- 不要重复上锁 mutex
- 仔细看库函数的文档
- 用智能指针管理单个对象
- 用 vector 管理多个对象组成的连续内存
- 避免空悬引用
- 开 Debug 模式的 STL
指定 CMake 的模式:cmake -B build -DCMAKE_BUILD_TYPE=Debug
- Debug:
-O0 -g
编译选项
- Release:
-O3 -DNDEBUG
编译选项
指定 MSVC 的模式:cmake --build build --config Debug
- Debug: zenod.dll 链接 Debug 的 ABI
- Release: zeno.dll 链接 Release 的 ABI