重新审视前面的示例,但这次遵循现代 C++ 的最佳实践,将原生数组和原生指针替换为相应的 STL 容器。
首先来看数组越界的例子。按照现代 C++ 的建议,将原生数组替换为 std::vector
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values[0] = value1;
values[1] = value2;
values[3] = value3;
values[4] = value4;
return values[0] + values[1] + values[3] + values[4];
}
TEST_CASE("try vector bounds"){
int result = doSomeWork(1, 234, 543, 23423);
CHECK_EQ(1 + 234 + 543 + 23423, result);
}
遗憾的是,运行这个示例的结果并不理想。无论是g++还是clang都没有报错,运行测试时得到以下结果:
TEST CASE: try vector bounds
test.cpp:5: FATAL ERROR: test case CRASHED: SIGSEGV - Segmentation violation signal
难道 std::vector<> 也不安全吗?实际上,仍然需要注意与内存分配相关的问题。面对这种情况,有几种选择:正确初始化容器、使用其提供的方法追加元素,或者预先为容器预留特定数量的内存空间。前两种方式通常是更安全、更推荐的做法,因为它们不容易引发错误。但即使采用第三种方式 —— 预分配内存,测试也仍然可以顺利通过:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values.reserve(5);
values[0] = value1;
values[1] = value2;
values[3] = value3;
values[4] = value4;
return values[0] + values[1] + values[3] + values[4];
}
一个令人颇感意外的发现是 std::vector 的行为(至少在 g++ 中如此):在我调用 reserve() 后尝试访问未赋值的 values[2] 时,得到的结果居然是 0。这比直接访问原始内存中残留的旧值要“友好”得多。我猜测这可能是 std::vector 默认分配器在分配内存时进行了零初始化,但这并非标准保证的行为。
这种差异源于 operator[] 的设计特性 —— 不会执行边界检查,因此访问超出当前有效元素范围的位置将导致未定义行为。而如果改用 vector::at() 方法,则会触发运行时边界检查,并在越界访问时抛出异常,从而有效避免这类问题。
尽管如此,我们仍需保持谨慎:即使使用现代 STL,也依然可能写出存在内存安全问题的代码。当然,只要不“耍小聪明”,老老实实地采用推荐的简单方式,许多问题就可以完全避免。
例如,当使用初始化列表语法创建 std::vector 时,它会根据传入的数据自动构造内容,完全不需要手动计数或管理容量:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values{value1, value2, 0, value3, value4};
return values[0] + values[1] + values[3] + values[4];
}
当然,这种语法要求我们一次性添加所有元素,而非部分元素,从而有效规避“差一错误”。另一种方式是逐个添加元素:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values.push_back(value1);
values.push_back(value2);
values.push_back(0);
values.push_back(value3);
values.push_back(value4);
return values[0] + values[1] + values[3] + values[4];
}
正如预期,这个版本同样完美运行。启示在于:使用最朴素的写法,99%的情况下都能获得预期行为。这是所有编程语言的通用准则,对C++而言更是如此。
现在,重新审视那个使用指针运算和void*越界访问内存的例子。其原始代码如下:
int pointerBounds() {
int *aPointerToInt;
void *aPointerToVoid;
aPointerToVoid = new int();
aPointerToInt = (int*)aPointerToVoid;
*aPointerToInt = 234;
aPointerToInt = (int*)((char*)aPointerToVoid + sizeof(int));
*aPointerToInt = 2423;
int value = *aPointerToInt;
delete aPointerToVoid;
return value;
}
我已尽力将这段代码改用std::unique_ptr或std::shared_ptr实现,虽然可行但极其繁琐。首要难题在于处理所有的指针类型转换——无法直接将std::unique_ptr
第二个难点是void*无法直接对应到std::shared_ptr
悬垂指针的例子同样棘手:
int danglingPointer() {
int *aPointerToInt = new int(234);
delete aPointerToInt;
return *aPointerToInt;
}
智能指针无法在返回其指向的数值的同时主动释放内存。虽然可以通过unique_ptr::reset重新分配内存,但这又绕回了原生指针。最简洁的智能指针实现如下:
int danglingPointer() {
unique_ptr<int> aPointerToInt = make_unique<int>(234);
return *aPointerToInt;
}
唯有这样,才能在正确返回值的同时实现内存的自动释放,从根本上杜绝悬垂指针的风险!当然,如果你手动分配内存并为 std::unique_ptr<> 指定一个空的删除器,理论上仍可能制造悬垂指针 —— 但在绝大多数实际场景中,完全没有这样做的理由。如果确实需要让多段代码共享同一块内存的所有权,还可以选择 std::shared_ptr<>,从而安全地实现多归属管理。至此,现代 C++ 已经覆盖了绝大部分常见的资源管理需求。
由此我们得出结论:现代C++通过默认机制大幅提升了安全性,但仍存在局限性 —— 这正是接下来要探讨的重点。