如第7章所述,当operator new()无法完成分配请求时,其默认行为是抛出异常。可能触发异常的情况包括:内存耗尽或其他分配失败(通常抛出std::bad_alloc)、非法数组长度(如负值或超出实现定义限制,通常抛出std::bad_array_new_length)、或对象构造失败(此时抛出来自构造函数的异常)。
异常是C++函数用于表示“无法满足后置条件”的标准机制。对于构造函数或重载操作符这类特殊函数,它是唯一可行的方案 —— 构造函数没有返回值,而操作符重载的签名通常也不支持额外参数或错误返回值(像std::optional或std::expected等类型,也会为某些操作符重载场景提供了替代方案)。
但某些领域通常禁用异常:例如视频游戏和嵌入式程序会禁用异常支持。技术原因(担心内存/性能开销)和理念原因(反对隐藏控制路径)兼而有之。无论动机如何,事实是存在大量禁用异常的C++代码,这正是operator new()的nothrow版本存在的意义。
即便是如下看似简单的代码也可能引发未定义行为(UB):
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
std::cout << p->n; // <-- HERE
delete p;
}
这种潜在未定义行为(UB)的根源在于:如果operator new()的nothrow版本失败(虽然概率低但并非不可能,尤其在内存受限环境中),p将为空指针,此时通过p访问数据成员n将会导致……灾难性后果。
当然,解决方案很简单 —— 相信敏锐的读者们已经发现 —— 只需在使用指针前进行判空检查即可:
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
if(p) {
std::cout << p->n; // ...根据需要使用*p...
}
delete p; // 即便p为空也没事
}
但这种方法会导致代码被判空检查污染(毕竟程序中很少只有一个指针),这反衬出异常机制的优雅之处 —— 使用异常时,要么operator new()及后续构造全部成功(可安全使用指针),要么某个步骤失败(根本不会执行到危险代码):
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new X{ 3 }; // 如果 operator new() 分配内存失败 或 X::X(int) 构造函数抛出异常,则会抛出异常。
std::cout << p->n; // ...根据需要使用通过 p 指向的对象...
delete p;
}
当然,即便使用异常机制也可能出现问题 ——某些执行路径导致p保持为空或未初始化状态(通常可通过声明时初始化对象避免,但并非总能实现)。我们暂不讨论这类代码规范问题,以免偏离主题。
面对内存分配失败时,关键考量在于应对策略。无论代码库是否使用异常,通常都不应让程序继续执行 —— 否则可能因空指针误用等行为导致未定义行为(UB)。常见的处理方式是:将试探性分配-构造操作、指针有效性检查,及失败处理封装为特定代码结构。假设需要分配并构造int对象:
// ...
int *p = new int{ 3 };
if(!p) std::abort(); // 举个栗子
return p;
// ...
此示例选用std::abort()终止程序执行。虽然异常机制能提供可恢复的错误处理,但在禁用异常时,标准库提供的多数方案最终都会终止程序,此时std::abort()是合理选择。
C++程序有多种终止方式:
常规终止
执行完main()函数是最常见方式
std::exit()执行清理操作后正常退出
std::quick_exit()无清理直接退出
可通过std::atexit()/std::at_quick_exit()注册退出回调
异常终止
std::abort()立即终止程序(无清理)
std::terminate()处理特定异常场景:
静态变量构造函数抛出异常
noexcept函数体内抛出异常
在本例场景中,std::abort()是最合适的终止机制。
解决此问题的一种可行方案是使用宏与立即调用函数表达式(IIFE) —— 即创建后立即执行并销毁的匿名lambda表达式。为使方案具有通用性,需实现以下特性:
类型泛化:支持指定任意待创建对象类型
参数可变:宏需支持可变参数,以传递任意数量/类型的构造参数
示例实现(TRY_NEW宏):
#include <new>
#include <cstdlib>
#define TRY_NEW(T,...) [&] { \
auto p = new (std::nothrow) T(__VA_ARGS__); \
if(!p) std::abort(); \
return p; \
}()
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 是 int* 类型,指向一个值为 int{ 0 } 的对象
auto p0 = TRY_NEW(int);
// p1 是 int* 类型,指向一个值为 int{ 3 } 的对象
auto p1 = TRY_NEW(int, 3);
auto q = TRY_NEW(dies_when_newed); // 会调用 abort() 函数
}
考虑到并非所有开发者都熟悉可变参数宏,我们将逐步解析该实现:
宏签名结构:TRY_NEW(T,...)中,T为必填参数(指定构造类型),...表示可变参数列表(可为空,用于传递构造参数)
多行宏编写规范:每行结尾添加反斜杠\(最后一行除外),告知预处理器继续解析下一行
参数传递机制:通过__VA_ARGS__宏展开...的内容,使用圆括号而非花括号调用构造函数,避免参数类型相同时意外构造initializer_list(此语法同时兼容C/C++)
安全校验逻辑:检测std::nothrow版operator new()返回的指针p,若p为空则调用std::abort()
IIFE封装特性:整个操作序列封装在立即执行的lambda中,通过[&]捕获确保__VA_ARGS__在lambda作用域内可用;可扩展性:如需返回智能指针,可修改为返回std::unique_ptr
由于使用圆括号(若改用花括号效果相同),当__VA_ARGS__为空时,该宏会导致基本类型(如int)零初始化而非保持未初始化状态。对比以下C++23行为:
new int; → 返回指向未初始化int对象的指针
new int(); / new int{}; → 将分配的内存块初始化为零
这种设计存在双重影响:
优势:即使对于简单类型,也绝不会返回指向未初始化对象的指针
劣势:在部分无需初始化的场景,仍会产生初始化开销
另一种实现方案是采用可变参数函数模板,这种方式能提供更优的调试体验。其使用端代码语法略有不同,但使用方式和最终效果与宏方案类似:
#include <new>
#include <cstdlib>
#include <utility>
template <class T, class ... Args>
auto try_new(Args &&... args) {
auto p =
new (std::nothrow) T(std::forward<Args>(args)...);
if(!p) std::abort();
return p;
}
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 是 int* 类型,指向一个值为 int{ 0 } 的对象
auto p0 = try_new<int>();
// p1 是 int* 类型,指向一个值为 int{ 3 } 的对象
auto p1 = try_new<int>(3);
auto q = try_new<dies_when_newed>(); // calls abort()
}
该方案特点:调用语法类似类型转换操作,通过std::forward完美转发参数;确保调用目标构造函数;与宏方案相同,可修改为返回std::unique_ptr<T>智能指针。