为什么必须通过 C++ 中的 delete[] 删除数组

本说明适用于 C++ 初学者程序员,他们想知道为什么每个人都一直告诉他们对数组使用 delete[]。但是,高级开发人员并没有给出明确的解释,而是一直躲在神奇的“未定义行为”术语后面。一点点代码、几张图片以及编译器的基本细节——如果有兴趣,欢迎阅读。

介绍

你可能没有注意到,甚至只是没有注意,但是当你编写代码释放数组占用的内存空间时,你不必输入要删除的项数。不过,这一切都很好。

int *p = new SomeClass[42];  // Specify the quantity
delete[] p;                  // Don't specify the quantity

这是什么,魔法?部分,是的。编译器开发人员有不同的方法来描述和实现它。

编译器记住数组中元素数量的方式有两种主要方法:

  • 记录已分配数组中的元素数量(“过度分配”)
  • 将元素的数量存储在单独的关联数组(“关联数组”)中

过度分配

顾名思义,第一种策略是通过简单地在数组的第一个元素之前插入元素的数量来完成的。请注意,在这种情况下,执行operator new后获得的指针将指向数组的第一个元素,而不是它的实际开头。

在任何情况下都不应将此指针传递给通常的运算符 delete。最有可能的是,它只会删除数组的第一个元素并保持其他元素不变。请注意,我之所以写“最有可能”是有原因的,因为没有人可以预测所有可能的结果以及程序的行为方式。这完全取决于数组中的对象以及它们的析构函数是否做了重要的事情。结果,我们得到了传统的未定义行为。这不是您在尝试删除数组时所期望的。

有趣的事实:在标准库的大多数实现中,运算符 delete只是从其自身内部调用free函数。如果我们将一个指向数组的指针传递给它,我们会得到一个未定义的行为。这是因为这个函数需要一个来自callocmallocrealloc函数的指针正如我们在上面发现的那样,它失败了,因为数组开头的变量被隐藏了,并且指针被移到了数组的开头。

delete[]运算符有什么不同?它只是计算数组中元素的数量,为每个对象调用析构函数,然后释放内存(连同隐藏变量)。

其实这基本上就是delete[] p;的伪代码。使用此策略时变成:

// Get the number of elements in an array
size_t n = * (size_t*) ((char*)p - sizeof(size_t));

// Call the destructor for each of them
while (n-- != 0)
{
  p[n].~SomeClass();
}

// And finally cleaning up the memory
operator delete[] ((char*)p - sizeof(size_t));

MSVC、GCC 和 Clang 编译器使用这种策略。您可以通过查看相关存储库(GCCClang)中的内存管理代码或使用Compiler Explorer服务轻松验证这一点。

在上图中(上半部分是代码,下半部分是编译器的汇编输出),我画了一个简单的代码片段,其中定义了一个结构和函数来创建一个由这些结构组成的数组。

注意:结构的空析构函数绝不是额外的代码。事实上,根据 Itanium CXX ABI,编译器应该使用不同的方法来管理由可简单破坏类型的对象组成的数组。实际上,还有更多条件,您可以在第 2.7 节“Array Operator new Cookies” Itanium CXX ABI中看到它们。它还列出了有关数组中元素数量信息的位置和方式的要求。

那么,简而言之,汇编器会发生什么:

  • 第 N3 行:将所需的内存量(5 个对象 20 字节 + 数组大小 8 字节)存储到寄存器;
  • 第N4行:调用operator new分配内存;
  • 第N5行:存储分配内存开头的元素个数;
  • 第 N6 行:将指针移到数组的开头sizeof(size_t),结果是返回值。

这种方法的优点是易于实现和性能,但缺点是错误的致命性错误选择运算符 delete。在最好的情况下,程序会因错误“Heap Corrupt”而崩溃,最坏的情况是您将长期努力地寻找程序奇怪行为的原因。

关联数组

第二种策略涉及一个隐藏的全局容器的存在,该容器存储指向数组的指针及其包含的元素数量。在这种情况下,数组前面没有隐藏数据,而delete[] p; call 实现如下:

// Getting the size of an array from the hidden global storage
size_t n = arrayLengthAssociation.lookup(p);

// Calling destructors for each element
while (n-- != 0)
{
  p[n].~SomeClass();
}

// Cleaning up the memory
operator delete[] (p);

好吧,它看起来不像以前那样“神奇”。还有其他区别吗?是的。

除了前面提到的数组前面缺少隐藏数据之外,需要在全局存储中搜索数据会导致速度略有下降。但是我们平衡了这一点,即程序可能更能容忍错误选择操作符 delete

此方法已在Cfront编译器中使用。我们不会详述它的实现,但如果你想了解更多关于第一个 C++ 编译器的信息,你可以在GitHub 上查看它。

一个简短的结语

以上所有内容都是编译器的具体细节,您不应仅依赖于特定行为。在计划将程序移植到不同平台时尤其如此。幸运的是,有几种方法可以避免此类错误:

  • 使用std::make_*函数模板。例如:std::make_unique , std::make_shared ,…
  • 使用静态分析工具及早发现错误,例如 PVS-Studio