放置新的数组能够以可移植的方式使用吗?

48

当我们在处理数组时,是否有可能在可移植的代码中实际使用placement new呢?

看起来从new[]返回的指针并不总是与传入的地址相同(标准中的5.3.4, 注意12似乎确认了这一点),但我不知道如果情况是如此,如何为数组分配缓冲区。

以下示例展示了这个问题。在Visual Studio中编译,此示例将导致内存损坏:

#include <new>
#include <stdio.h>

class A
{
    public:

    A() : data(0) {}
    virtual ~A() {}
    int data;
};

int main()
{
    const int NUMELEMENTS=20;

    char *pBuffer = new char[NUMELEMENTS*sizeof(A)];
    A *pA = new(pBuffer) A[NUMELEMENTS];

    // With VC++, pA will be four bytes higher than pBuffer
    printf("Buffer address: %x, Array address: %x\n", pBuffer, pA);

    // Debug runtime will assert here due to heap corruption
    delete[] pBuffer;

    return 0;
}

看着内存,编译器似乎使用缓冲区的前四个字节来存储其中项的数量计数。这意味着,因为缓冲区大小仅为sizeof(A)*NUMELEMENTS,数组中的最后一个元素被写入未分配的堆。

因此,问题在于您能否找出实现安全使用placement new[]所需的额外开销。理想情况下,我需要一种可在不同编译器之间移植的技术。请注意,至少在VC的情况下,开销似乎因不同类而异。例如,如果我从示例中删除虚析构函数,则从new[]返回的地址与我传递的地址相同。


1
啊,糟糕了。我错误地复制了你的问题 :( Array placement-new requires unspecified overhead in the buffer? - Mooing Duck
1
如果你移除了虚析构函数后开销消失了,那就意味着这个开销很可能是由类的虚表或者VStudio的RTTI实现引起的。 - Justin Time - Reinstate Monica
1
或者至少部分开销是如此。如果类具有非平凡的析构函数,也有可能只使用开销。 - Justin Time - Reinstate Monica
8个回答

33

个人来说,我会选择不在数组上使用placement new,而是逐个在数组项上使用placement new。例如:

int main(int argc, char* argv[])
{
  const int NUMELEMENTS=20;

  char *pBuffer = new char[NUMELEMENTS*sizeof(A)];
  A *pA = (A*)pBuffer;

  for(int i = 0; i < NUMELEMENTS; ++i)
  {
    pA[i] = new (pA + i) A();
  }

  printf("Buffer address: %x, Array address: %x\n", pBuffer, pA);

  // dont forget to destroy!
  for(int i = 0; i < NUMELEMENTS; ++i)
  {
    pA[i].~A();
  }    

  delete[] pBuffer;

  return 0;
}

无论使用哪种方法,确保在删除pBuffer之前手动销毁数组中的每个项,否则可能会导致内存泄漏 ;)

注意:我没有编译这个代码,但我认为它应该能工作(我正在一个没有安装C++编译器的机器上)。它仍然表明了关键点:) 希望它以某种方式有所帮助!


编辑:

需要跟踪元素数量的原因是当您调用delete删除数组并确保调用每个对象的析构函数时,它可以迭代这些元素。 如果不知道有多少元素,就无法做到这一点。


1
VC++是一个糟糕的编译器。默认的放置new数组,即new(void*) type[n]执行类型为typen个对象的就地构造。提供的指针必须正确对齐以匹配alignof(type)(注意:由于填充,sizeof(type)alignof(type)的倍数)。因为通常需要携带数组的长度,所以实际上没有将其存储在数组内的要求,因为您将使用for循环销毁它(没有放置删除运算符)。 - bit2shift
1
@bit2shift C++标准明确规定new[]会填充分配的内存,尽管它允许填充0字节。 (请参见“expr.new”部分,特别是示例(14.3)和(14.4)以及其下面的解释。)因此,在这方面,它实际上符合标准。 [如果您没有C++14标准的最终版本副本,请参见此处的第133页]。 - Justin Time - Reinstate Monica
2
@JustinTime 这是一个缺陷,似乎没有人关注到。 - bit2shift
4
void* operator new[](std::size_t count, void* ptr);这个运算符已知是“无操作”(不分配内存),并且明确知道由此运算符或其标量兄弟返回的指针不能传递给deletedelete[],需要程序员手动销毁每个元素时,允许对其应用开销本身就是标准本身的一个缺陷。 - bit2shift
2
关于将一组相邻对象作为数组对象在连续内存中单独构造的处理,请参见C++ Core Issue 2182。不幸的是,这个问题似乎还没有得到解决,这意味着可能没有任何方法可以在标准下保证不会出现未定义行为的情况下就地构造数组对象。这个问题也在这个视频中进行了分析。 - Matthijs
显示剩余5条评论

5

@Derek

5.3.4章节的第12节讨论了数组分配开销,除非我理解错了,它似乎暗示编译器也可以在placement new中添加它:

这个开销可以应用于所有数组new表达式,包括那些引用库函数operator new[](std::size_t, void*)和其他放置分配函数的表达式。这种开销的数量可能因每次调用new而异。

话虽如此,我认为VC是唯一给我带来麻烦的编译器,其他的如GCC、Codewarrior和ProDG都没有问题。不过我需要再次检查才能确定。


如果VC是唯一一个添加额外空间的编译器,我会感到震惊,我认为所有编译器都会在那里存储要调用的析构函数数量。没有其他合理的地方可以放置它。 - Mooing Duck
3
@MooingDuck提到可以利用上述“数组长度”来删除操作符,但实际上使用new(pointer) type[length]构造的数组需要手动调用析构函数。VC是一个糟糕的编译器,这点大家都应该知道。 - bit2shift

4

@James

我甚至不清楚为什么需要额外的数据,因为你不会在数组上调用delete[],所以我不完全明白为什么它需要知道其中有多少项。

经过一番思考,我同意你的观点。放置new没有必要存储元素数量,因为没有放置delete。既然没有放置delete,放置new也没有必要存储元素数量。

我还在我的Mac上使用带有析构函数的类对此进行了测试。在我的系统上,放置new并未更改指针。这让我想知道这是否是VC++的问题,以及这是否可能违反了标准(就我所知,标准并没有特别涉及到这个问题)。


我在Coliru上使用clang 3.7.0和GCC 5.3.0进行了测试,都使用了-std=c++14-pedantic。两者都没有显示存在开销,特别是对于具有非平凡析构函数的类。因此,我认为这是Visual C++编译器如此糟糕的又一个例子。 - bit2shift
我非常怀疑,@Nik-Lz,因为他们仍然使用可怕的nothrownew.obj来使new表现得像new(std::nothrow) - bit2shift

3
感谢您的回复。当我遇到这个问题时,为数组中的每个项目使用放置new是我最终采用的解决方案(抱歉,应该在问题中提到这一点)。我只是觉得使用放置new[]肯定有些我不知道的东西。实际上,由于标准允许编译器向数组添加额外的未指定开销,因此似乎放置new[]基本上是无法使用的。我不明白如何安全且可移植地使用它。
我甚至不太清楚为什么它需要额外的数据,因为你永远不会在数组上调用delete[],所以我不完全明白为什么它需要知道其中有多少项。

3

Placement new本身是可移植的,但是你对其在指定内存块中所做的假设并不是可移植的。就像之前所说的那样,如果你是编译器,并且给定了一块内存,你如何知道如何分配数组并正确销毁每个元素,如果你只有一个指针?(请参阅operator delete[] 的接口。)

编辑:

实际上确实存在一个放置删除函数placement delete,但只有在使用placement new[]分配数组时构造函数抛出异常时才会调用它。

是否需要new[]跟踪元素数量实际上取决于标准,这由编译器来决定。不幸的是,在这种情况下。


1
如果它覆盖任意大量的存储空间,那么它怎么可能是“可移植”的呢?你永远不知道它会写入多少数据,因此你永远无法安全地调用它。 - BeeOnRope
1
delete[]表达式需要知道有多少元素需要被删除,但它只能用于使用堆内存分配(抛出异常或不抛出异常)的new[]表达式的结果。对于放置new[]来说,这真的是不必要的。我对VC的行为唯一的解释是堆new[]以某种方式基于放置new[]来实现,并且不会自己存储元素的数量。 - Arne Vogel

2
与计算单个放置点的大小类似,使用该元素数组来计算所需数组的大小。如果您需要计算其他元素数量未知的大小,请使用sizeof(A [1])并乘以所需的元素计数。例如:
char *pBuffer = new char[ sizeof(A[NUMELEMENTS]) ];
A *pA = (A*)pBuffer;

for(int i = 0; i < NUMELEMENTS; ++i)
{
    pA[i] = new (pA + i) A();
}

1
问题在于,MSVC显然在new []的情况下需要比sizeof(A[NUMELEMENTS])的值更多的空间。通常sizeof(A[N])只是N * sizeof(N),并不反映这个额外所需的空间。 - BeeOnRope

1
C++17(草案N4659)在[expr.new]第15段中指出:
“在所有数组new表达式中,包括那些引用库函数operator new[](std::size_t, void*)和其他放置分配函数的表达式中,都可能会应用开销。开销的数量可能因为每次new调用而异。”
因此,在C++17(以及更早版本)中似乎不可能安全地使用(void*)放置new[],我甚至不清楚为什么它被规定存在。
在C++20(草案N4861)中,这一点已经改变了:
“在所有数组new表达式中,包括那些引用放置分配函数的表达式中,都可能会应用开销,除了引用库函数operator new[](std::size_t, void*)的情况。开销的数量可能因为每次new调用而异。”
因此,如果您确定正在使用C++20,则可以安全地使用它,但只能使用该放置形式,并且只有在不覆盖标准定义的情况下才能使用。

即使是 C++20 的文本也似乎有些荒谬,因为额外的空间只用于存储数组大小元数据,但在使用任何自定义放置形式的 new[] 时都无法访问它。它以私有格式存在,只有 delete[] 知道如何读取,并且使用自定义分配时无法使用 delete[],因此最多只是浪费空间。

实际上,据我所知,根本没有安全的方法来使用自定义形式的 operator new[]。没有办法正确调用析构函数,因为必要的信息没有传递给 operator new[]。即使您知道对象是可以自动销毁的,new 表达式也可以返回指向内存块中间的任意位置的指针(跳过无用的元数据),因此您不能包装仅提供 mallocfree 等价物的分配库:它还需要一种按指向其中间的指针搜索块的方法,即使存在这种方法,速度也很慢。

我不明白他们(或只有Stroustrup?)是如何搞砸了这件事。显然正确的做法是将数组元素数量和每个元素的大小作为两个参数传递给operator new[],并让每个分配器选择如何存储它。也许我漏掉了什么。

你觉得 override placement-new 可以吗?只要你像 std::construct_at 所做的那样调用 ::new((void*)addr) type 即可。 - HolyBlackCat

0

我认为gcc和MSVC做的事情是一样的,但这当然不意味着它是“可移植”的。

我认为当NUMELEMENTS确实是编译时常量时,你可以通过以下方式解决问题:

typedef A Arr[NUMELEMENTS];

A* p = new (buffer) Arr;

这应该使用标量放置new。


5
这句话的意思是,operator new()operator new[]() 的区别取决于是否涉及到数组类型,而不是源代码中是否有 [] - Ben Voigt

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