将空指针传递给放置 new

44

在18.6 [support.dynamic] ¶1中,默认的 new 运算符被声明为一个非抛出异常规定:

void* operator new (std::size_t size, void* ptr) noexcept;

该函数除了return ptr;以外什么也不做,因此它被声明为noexcept是合理的。然而,根据5.3.4 [expr.new] ¶15的说明,这意味着编译器在调用对象的构造函数之前必须检查它不会返回空指针:

-15-
[注: 除非分配函数使用了非抛出异常规格说明 (15.4),否则通过抛出std::bad_alloc异常(第15、18.6.2.1节)来表示无法分配存储空间;否则返回非空指针。如果分配函数使用了非抛出异常规格说明,则返回null表示无法分配存储空间,并返回非空指针。 —end note] 如果分配函数返回null,那么不应进行初始化,不应调用释放函数,并且new表达式的值应为null。

对我来说(特别是对于定位new,而不是一般情况),这个null检查会对性能产生不必要的影响,尽管很小。

我一直在调试一些代码,在其中非常敏感的性能代码路径上使用了定位new,以改进编译器的代码生成并在汇编中观察到了null检查。通过提供一个类特定的定位new重载,即使它不可能抛出异常也声明了一个带有抛出异常规格说明的函数,这样就可以去除条件分支,从而允许编译器为周围的内联函数生成更小的代码。结果是说定位new函数可能会抛出异常,尽管它不会,但生成的代码效果明显更好。

因此,我一直在想定位new是否真的需要进行null检查。它只能返回null,如果你传递了null指针。虽然这是可能的,并且显然是合法的:

void* ptr = nullptr;
Obj* obj = new (ptr) Obj();
assert( obj == nullptr );

我看不出为什么这会有用,我建议程序员在使用放置new之前明确检查null,例如。

Obj* obj = ptr ? new (ptr) Obj() : nullptr;

有人曾经需要使用放置new以正确处理空指针情况吗?(即,不添加明确检查ptr是否是有效的内存位置。)

我想知道是否合理禁止将空指针传递给默认的放置new函数,如果不行,是否有其他更好的方法可以避免不必要的分支,而不是试图告诉编译器该值不为null,例如。

void* ptr = getAddress();
(void) *(Obj*)ptr;   // inform the optimiser that dereferencing pointer is valid
Obj* obj = new (ptr) Obj();
或:
void* ptr = getAddress();
if (!ptr)
  __builtin_unreachable();  // same, but not portable
Obj* obj = new (ptr) Obj();

N.B. 这个问题特意标记为微观优化,我并不建议你在所有类型上过度使用重载放置new来"提高"性能。这种效果是在非常特定的性能关键情况下注意到的,基于分析和测量。

更新: DR 1748使得使用空指针进行放置new操作成为未定义行为,所以编译器不再需要执行该检查。


如果在创建/销毁这些对象时的空值检查是显而易见的,那么似乎总体方法存在问题。 - Mats Petersson
1
在使用放置 new 之前检查 null 如何改善与在调用构造函数之前执行该检查的情况相比?这是相同的检查 - 只是在不同的时刻。无论您在何处进行 null 检查,否则您将冒着为 null 调用构造函数的风险。标准试图避免后者。 - Sander De Dycker
2
@SanderDeDycker,区别在于如果知道指针不为空,调用放置新操作之前可以省略检查。由于标准的哲学之一是“不为你不需要的东西付费”,因此我认为无条件执行的检查是标准中的缺陷。 - Arne Mertz
1
@ArneMertz,JonathanWakely:可能有点过分了,但程序员唯一能知道指针不为空的方法是,如果代码保证它不会为空(例如:静态分配、计算结果、代码中存在先前检查等)。在所有这些情况下,编译器也可以弄清楚它不可能为空(与程序员相同的方式),并且可以在构造函数调用之前优化掉空检查。我没有任何编译器这样做的例子,但这样做将结合标准的安全性和您的性能角度。 - Sander De Dycker
2
@SanderDeDycker,也许是因为函数调用了abort()如果无法获取内存,但它在不同的翻译单元中定义,并且我没有使用LTO。编译器如何知道我所知道的一切?它是由NSA编写的吗? - Jonathan Wakely
显示剩余11条评论
1个回答

14
虽然我看不出其中除了“是否有人需要placement new正确处理null指针情况?”这个问题外还有什么问题(我没有),但我认为这种情况足够有趣,可以对该问题进行一些思考。
我认为标准在placement new函数和分配函数要求方面存在缺陷或不完整。如果您仔细查看引用的§5.3.4,13,它意味着每个分配函数都必须检查返回的null指针,即使它不是“noexcept”。因此,应重写如下:
如果分配函数声明为非抛出异常规范并返回null,则不得进行初始化,不得调用解分配函数,并且new-expression的值应为null。
这不会损害抛出异常的分配函数的有效性,因为它们必须遵守§3.7.4.1:
如果成功,则应返回长度(以字节为单位)至少与请求的大小相同的存储块的开头地址。返回的指针应适当对齐,以便将其转换为具有基本对齐要求(3.11)的任何完整对象类型的指针,然后用于访问已分配的存储器中的对象或数组(直到通过调用相应的解分配函数显式取消分配存储器为止)。
而且§5.3.4,14:
[注意:当分配函数返回除null以外的值时,它必须是已保留对象的存储块的指针。假定存储块已适当对齐并具有请求的大小。[...] -end note]
显然,只返回给定指针的placement new无法合理检查可用的存储器大小和对齐方式。因此:
关于placement new的§18.6.1.3,1
这些保留的operator new和operator delete形式不适用于(3.7.4)的规定。
(我想他们在那个地方忘记提到§5.3.4,14了。)
但是,这些段落一起间接地表明“如果您将垃圾指针传递给placement function,则会产生UB,因为违反了§5.3.4,14”。因此,由您来检查分配给placement new的任何指针的合理性。
在这种精神下,并且根据重写的§5.3.4,13,标准可以从placement new中去掉noexcept,导致添加了一个间接结论:“...如果您传递null,您也会获得UB”。另一方面,与具有null指针相比,出现不正确对齐的指针或内存不足的指针的可能性要小得多。
但是,这将消除对null的检查的必要性,并且它非常适合于哲学“不为您不需要的内容付费”。分配函数本身不需要检查,因为§18.6.1.3,1明确说明了这一点。
为了使事情更完整,可以考虑添加第二个重载。
 void* operator new(std::size_t size, void* ptr, const std::nothrow_t&) noexcept;
很遗憾,向委员会提议这个变化不太可能成功,因为这会导致现有的代码无法处理空指针的情况。

感谢您的深入分析。我会再多留一天或两天,以防其他人有什么要补充的,但我预计我会接受这个建议。 - Jonathan Wakely

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接