9.3. 简化nothrow new的应用

如第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() 函数
}

考虑到并非所有开发者都熟悉可变参数宏,我们将逐步解析该实现:

小而有趣的副作用

由于使用圆括号(若改用花括号效果相同),当__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>智能指针。