为什么C++中存在delete[]语法?

127
每次有人在这里问关于delete[]的问题时,总会有一个相当普遍的回答:“这就是C++的做法,使用delete[]”。作为一个纯C背景的人,我不明白为什么需要有不同的调用方式。
使用malloc()/free()时,你的选择是获取一个连续的内存块的指针,并释放一个连续的内存块。在实现层面,有一个东西知道你分配的块的大小,基于基地址,以便在你需要释放它时使用。
没有free_array()这样的函数。我在其他与此有关的问题上看到了一些疯狂的理论,比如调用delete ptr只会释放数组的顶部,而不是整个数组。或者更正确地说,这由实现定义。当然...如果这是C++的第一个版本,并且你做了一个奇怪的设计选择,那是有道理的。但是为什么在现在的C++标准下,它还没有被重载呢?
似乎C++唯一额外增加的部分就是遍历数组并调用析构函数,我认为这可能是关键所在,它实际上是使用一个单独的函数来节省我们一个运行时长度查找,或者在列表末尾使用nullptr来换取折磨每个新的C++程序员或者那些有模糊的一天并忘记了有一个不同的保留字的程序员。
请有人能够最终澄清一下,除了“这是标准规定的,没有人质疑”之外,是否还有其他原因?

2
如果你想测试你的内存分配和释放,以查看那些疯狂的理论是否正确,你可以使用Valgrind来查看实际情况。我怀疑重载delete有更多问题,而不仅仅是答案中描述的那些,但我没有专业知识。 - ttbek
9
相关问题:delete[]如何知道它是一个数组?,特别注意这个答案 - Fred Larson
7个回答

174

C++中的对象通常有析构函数,需要在其生命周期结束时运行。 delete[] 确保调用数组每个元素的析构函数。但这样做有未指定的开销,而 delete 则没有。这就是为什么存在两种形式的 delete 表达式。一种用于数组,它支付开销,另一种用于单个对象,它则不支付。

为了只有一个版本,实现需要一种机制来跟踪关于每个指针的额外信息。但 C++ 的创立原则之一是用户不应被迫承担他们绝对不必要的成本。

始终使用 delete 删除由 new 创建的对象,使用 delete[] 删除由 new[] 创建的对象。但在现代C++中,newnew[] 通常不再使用。使用更具表现力和更安全的替代方案,如 std::make_uniquestd::make_sharedstd::vector 或其他替代方案。


65
哇,你的回复真快,感谢你提供有关分配函数的提示。令人惊讶的是,在C++中经常的答案是“不要使用那个关键字”,而是使用std::一些在C++11中引入的奇怪函数。 - awiebe
26
C++提供了工具,让你可以尽可能地接近硬件。但是这些工具通常很强大,也很粗糙,使用起来危险且难以有效运用。因此,它还通过标准库提供了一些稍微远离硬件的工具,但非常安全和易用。这就是为什么你学习了那么多功能,但被告知不要使用它们的原因。因为除非你正在做一些非常独特或奇怪的事情,否则那些低级别的工具并没有用处。更方便的功能通常就可以了。 - François Andrieux
31
您说得对,大多数情况下,如果有一个整洁的标准库特性可以替代内置机制,那通常是来自C++11或更高版本。 C++11基本上革命了这门语言,使之能够实现以前无法实现的标准库特性。 C++11和之前的版本之间的差异如此之大,以至于它们基本上可以被认为是两种不同的语言。在学习C++时要注意区分针对C++03及更早版本的教育材料与针对C++11及更高版本的材料。 - François Andrieux
19
请注意,存在诸如 new 之类的底层机制使得大多数标准库(和其他库)可以用纯 C++ 编写(某些部分可能需要编译器支持)。因此,建议是“仅使用这些来构建更高级别的抽象”。 - Carsten S
14
@FrançoisAndrieux: 对于“那些工具通常很强大但粗糙…”这个用词的小问题:我实际上认为它们是超级锋利、手术用的工具:你可以按自己的意愿得到完全符合要求的结果。但缝合或清理手术程序需要同等的技能和材料,一块创可贴是不够的。 - Willem van Rumpt
显示剩余19条评论

41

基本上,mallocfree是用于分配内存的,而newdelete则用于创建和销毁对象,因此您需要知道这些对象是什么。

针对弗朗索瓦·安德里厄克斯(François Andrieux)所提到的未指明开销的问题,您可以查看我对这个问题的回答,在其中我考察了一个特定实现(Visual C++ 2013, 32-bit)的具体操作。其他实现可能会或不会执行类似的操作。

如果使用new[]来处理带有非平凡析构函数的对象数组,则它会多分配4个字节,并返回指针向前移动4个字节的位置,以便在调用delete[]时能够确定有多少个对象。delete[]将指针前移4个字节,获取该地址处的数字,并将其视为在那里存储的对象数。然后,它对每个对象调用析构函数(对象的大小由传递的指针类型确定)。最后,为了释放确切的地址,它会传递比传递的地址更早的地址。

在这种实现中,将使用new[]分配的数组传递给常规delete将导致调用第一个元素的单个析构函数,然后传递错误的地址给释放内存的函数,从而破坏堆。不要这样做!


34

在其他所有优秀的答案中没有提到的是,这个问题的根本原因在于数组(继承自C语言)从未成为C++中的一个“一等公民”。

它们具有基本的C语义,没有C++语义,因此也没有C++编译器和运行时支持,这会让你或者编译器运行时系统无法对它们的指针进行有用的操作。

实际上,它们在C++中受到的支持非常有限,以至于指向多个元素的数组与指向单个元素的指针看起来没有什么不同。这尤其不会发生在数组作为语言的正确部分的情况下,即使它们只是语言库的一部分,例如string或vector。

C++语言中的这个缺点源自于其从C语言继承而来的遗产。尽管我们现在已经有了std::array来表示固定长度的数组,以及(一直都有)std::vector来表示可变长度的数组,但这些问题依然是语言的一部分,主要是为了保持兼容性:能够使用C语言交互调用操作系统API和使用其他语言编写的库。

此外,还因为出版了大量介绍C++的书籍、网站和课堂,它们的教学早期就涉及了数组的内容。这主要是因为a)在很早的时候就能编写有用/有趣的示例以调用操作系统API,当然还有b)“这种做法一直都是这样”的强大力量。


2
这个答案提出了许多完全不正确的观点,显然是基于不知道C和C++都支持“指向数组”的类型。这不是无法表达指向数组的能力,而是在实践中没有使用这种能力。 - Ben Voigt
6
指向数组的指针会立即衰变为指向元素的指针,这就是它的使用方式,对吧?有多少个C++(或C)函数/方法签名采用指向数组类型的指针?没有人教授这种知识,也没有人这样使用。你不同意吗?例如,在Unix/Linux API中,哪个函数签名使用指向数组的指针而不是文档假定为数组的裸指针?@BenVoigt - davidbak
6
无论是《Effective C++ - 3rd ed》(Meyers,2008)还是《More Effective C++》(Meyers,1996),都没有提到指向数组类型。我可以列举我书库里的其他书籍,但我并不在意。关键不是这些语言,在技术上是否原本拥有此功能。而是从来没有人使用过它。事实上,我在答案中没有提到它,并不意味着我不知道它的存在。只是我知道这是编译器作者存储的无用遗物,从未被使用,也从未被教授。 - davidbak
5
这里的核心问题是指向数组的指针和引用类型非常难以阅读,因此人们养成了不使用它们的习惯,导致相关知识被忽视。最简单的处理方式是使用模板或 decltype,但通常使用它们很快就会变得难以理解(如链接 https://www.ideone.com/nVDnRz 中的代码)。在这里,函数 create() 已经够糟糕的了(多种方面),想象一下一个函数接受两个数组的指针并返回一个指向不同类型数组的指针会是怎样的局面。 - Justin Time - Reinstate Monica
8
由于new[]常用于在编译时大小未知的情况下分配数组,因此C语言中的指向数组的指针也无法提供太多帮助。 - Jack Aidley
显示剩余15条评论

7

一般来说,C++编译器及其相关运行时是基于平台的C运行时构建的。特别是在这种情况下,使用C内存管理器。

C内存管理器允许您释放一个内存块而不知道其大小,但是没有标准方法从运行时获取块的大小,并且不能保证实际分配的块恰好是您请求的大小。它可能更大。

因此,由C内存管理器存储的块大小不能用于启用更高级别的功能。如果更高级别的功能需要有关分配大小的信息,则必须自己存储它。(并且C++ delete[]对于具有析构函数的类型需要此操作,以对每个元素运行它们。)

C++还有一种“只付费用于所使用的内容”的态度,为每个分配存储额外的长度字段(与底层分配器的记账不同)将不符合这种态度。

由于在C和C++中表示未知(在编译时)大小的数组的通常方法是使用指向其第一个元素的指针,因此编译器无法根据类型系统区分单个对象分配和数组分配。因此,它留给程序员来区分。


4
封面故事是,由于C++与C的关系,需要使用deletenew运算符可以创建几乎任何对象类型的动态分配对象。
但是,由于其继承自C语言,指向对象类型的指针在两种抽象之间存在歧义:
  • 是单个对象的位置,或
  • 是动态数组的基础。
因此,deletedelete[]的情况遵循此规则。
然而,这并不是真的,尽管上述观察结果是正确的,但一个单独的delete操作符就可以使用。逻辑上并不需要两个操作符。
这里有一份非正式的证明。new T运算符调用(单个对象情况)可以隐式地行为类似于new T[1]。也就是说,每个new都可以始终分配一个数组。当未提及数组语法时,可以默认分配一个[1]大小的数组。然后,只需要存在一个像今天的delete[]一样的单个delete操作符即可。
为什么不遵循这种设计?
我认为这归结于通常的原因:它是为了效率而牺牲的代价。当你用new []分配数组时,还会为元数据分配额外的存储空间以跟踪元素数量,以便delete[]知道需要迭代多少个元素进行销毁。当你用new分配单个对象时,不需要任何这样的元数据。对象可以直接在底层分配器分配的内存中构造,没有额外的头文件。
这是在运行时成本方面“不支付你不使用”的一部分。如果你只分配单个对象,你就不必“付费”来处理可能由指针引用的任何动态对象都可能是数组的可能性的任何表示开销。然而,在用数组new分配并随后删除对象时,你要承担编码该信息的责任。

2
一个例子可能会有所帮助。当您分配一个C风格的对象数组时,这些对象可能具有需要调用的自己的析构函数。 delete 运算符不会处理这个问题。它可以处理容器对象,但不适用于C风格的数组。你需要使用 delete[] 来释放它们。
以下是一个例子:
#include <iostream>
#include <stdlib.h>
#include <string>

using std::cerr;
using std::cout;
using std::endl;

class silly_string : private std::string {
  public:
    silly_string(const char* const s) :
      std::string(s) {}
    ~silly_string() {
      cout.flush();
      cerr << "Deleting \"" << *this << "\"."
           << endl;
      // The destructor of the base class is now implicitly invoked.
    }

  friend std::ostream& operator<< ( std::ostream&, const silly_string& );
};

std::ostream& operator<< ( std::ostream& out, const silly_string& s )
{
  return out << static_cast<const std::string>(s);
}

int main()
{
  constexpr size_t nwords = 2;
  silly_string *const words = new silly_string[nwords]{
    "hello,",
    "world!" };

  cout << words[0] << ' '
       << words[1] << '\n';

  delete[] words;

  return EXIT_SUCCESS;
}

那个测试程序明确地插入了析构函数调用。这显然是一个人为的例子。首先,程序在终止并释放所有资源之前不需要立即释放内存。但它确实演示了发生了什么以及发生的顺序。

一些编译器(例如clang++)足够聪明,如果您在delete[] words;中省略了[],会向您发出警告,但是如果您强制编译有缺陷的代码,则会导致堆损坏。


1

删除是一种运算符,用于销毁由new表达式生成的数组和非数组(指针)对象。

它可以通过使用Delete运算符或Delete [ ]运算符来使用。 new运算符用于动态内存分配,将变量放置在堆内存中。 这意味着Delete运算符从堆中释放内存。 对象的指针不会被销毁,指针所指向的值或内存块被销毁。 delete运算符具有无返回值的void返回类型。


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