为什么C++程序员应该尽量减少使用'new'?

987

我偶然发现了一个Stack Overflow问题Memory leak with std::string when using std::list<std::string>,其中一条评论说:

不要过度使用new。我看不出你在任何地方为什么使用new。在C++中,您可以通过值创建对象,这是使用该语言的巨大优势之一。您不必将所有东西都分配在堆上。停止像Java程序员一样思考。

我真的不确定他的意思是什么。

为什么应尽可能在C++中按值创建对象,内部有什么区别?我是否误解了答案?


21
现代访问者应该注意到,更新的 C++ 标准定义了比 new 和裸指针更安全的动态分配方法。如果今天提出这个问题,答案可能会有所不同。有关动态分配通常是不必要的讨论仍然相关,但大多数答案都早于智能指针的出现。 - thomasrutter
19个回答

1157
有两种广泛使用的内存分配技术:自动分配和动态分配。通常,每个技术都有相应的内存区域:堆栈和堆。
堆栈总是以顺序方式分配内存。这样做是因为它要求您按相反的顺序释放内存(先进后出:FILO)。这是许多编程语言中本地变量的内存分配技术。它非常快,因为它需要最少的簿记,并且下一个要分配的地址是隐含的。
在C ++中,这称为“自动存储”,因为存储会在作用域结束时自动声明。一旦当前代码块(使用{} delimited)的执行完成,该块中所有变量的内存都将自动收集。这也是调用析构函数清理资源的时刻。
堆允许更灵活的内存分配模式。簿记更复杂,分配速度较慢。由于没有隐含的释放点,您必须手动释放内存,使用delete或delete[](在C中使用free)。然而,没有隐含的释放点是堆的灵活性的关键。
即使使用堆比较慢并且可能导致内存泄漏或内存碎片,仍然有完全合适的用例可以使用动态分配,因为它的限制较少。
使用动态分配的两个关键原因:
  • 在编译时你不知道需要多少内存。例如,当将文本文件读入字符串时,通常不知道文件的大小,因此无法在运行程序之前决定要分配多少内存。

  • 你想分配一个离开当前块后仍然存在的内存。例如,你可能想编写一个函数string readfile(string path),它返回文件的内容。在这种情况下,即使堆栈可以容纳整个文件内容,你也不能从函数返回并保留已分配的内存块。

为什么动态分配通常是不必要的

C++ 中有一个称为析构函数的巧妙构造。这种机制允许你通过将资源的生命周期与变量的生命周期对齐来管理资源。这种技术被称为RAII,是 C++ 的区别点。它将资源“包装”到对象中。std::string 就是一个完美的例子。以下代码片段:

int main ( int argc, char* argv[] )
{
    std::string program(argv[0]);
}

实际上分配了可变数量的内存。 std :: string 对象使用堆分配内存,并在其析构函数中释放它。 在这种情况下,您不需要手动管理任何资源,仍然可以获得动态内存分配的好处。
特别是,这意味着在此片段中:
int main ( int argc, char* argv[] )
{
    std::string * program = new std::string(argv[0]);  // Bad!
    delete program;
}

存在不必要的动态内存分配。这会增加程序员的打字工作量,并引入忘记释放内存的风险,而这没有任何明显的好处。

为什么应尽可能经常使用自动存储

基本上,最后一段总结了它的优点。尽可能经常使用自动存储可以使您的程序:

  • 输入速度更快;
  • 运行速度更快;
  • 更不容易出现内存/资源泄漏。

额外加分

在参考的问题中,还有其他问题需要考虑。特别是以下类:

class Line {
public:
    Line();
    ~Line();
    std::string* mString;
};

Line::Line() {
    mString = new std::string("foo_bar");
}

Line::~Line() {
    delete mString;
}

实际上,与以下这个更加冒险使用:

class Line {
public:
    Line();
    std::string mString;
};

Line::Line() {
    mString = "foo_bar";
    // note: there is a cleaner way to write this.
}

原因在于std::string已经正确定义了一个拷贝构造函数。考虑下面的程序:

int main ()
{
    Line l1;
    Line l2 = l1;
}

使用原始版本,该程序很可能会崩溃,因为它在同一个字符串上两次使用了delete。使用修改版,每个Line实例将拥有自己的字符串实例,每个实例都有自己的内存,两者都将在程序结束时释放。

其他注意事项

在C++中广泛使用RAII被认为是最佳实践,因为以上所有原因。然而,还有一个不太明显的额外好处。基本上,它比各个部分的总和更好。整个机制组成。它可以扩展。

如果您将Line类用作构建块:

 class Table
 {
      Line borders[4];
 };

那么

 int main ()
 {
     Table table;
 }

分配四个std::string实例,四个Line实例,一个Table实例和所有字符串的内容,一切都会自动释放


75
在结尾提到 RAII 得到了加分,但是应该再加一些关于异常和栈展开的内容。 - Tobu
9
@Tobu:是的,但这篇文章已经相当长了,我希望将重点放在OP提出的问题上。我最后会写一篇博客文章或者类似的内容,并从这里链接过去。 - André Caron
18
提到堆栈分配(至少在C++1x之前)的缺点会是一个很好的补充 - 如果不小心,你经常需要无谓地复制东西。例如,当Monster死亡时,它会向World喷出一枚Treasure。在其Die()方法中,它将宝藏添加到世界中。为了保留死亡后的财宝,必须使用world->Add(new Treasure(/*...*/))。其他选择包括shared_ptr(可能过于复杂)、auto_ptr(所有权转移的语义不佳)、传值(浪费资源)和move + unique_ptr(尚未被广泛实现)。 - kizzx2
8
你提到的关于栈分配本地变量的说法可能有点误导。 "栈"指的是调用栈,它存储栈帧。正是这些栈帧以LIFO的方式存储。特定帧的本地变量被分配,就好像它们是结构体的成员一样。 - someguy
8
@someguy: 的确,这个解释并不完美。在分配政策上的实现有一定的自由度。然而,变量必须按照后进先出的顺序进行初始化和销毁,因此这个比喻是正确的。我认为没有必要让答案更加复杂。 - André Caron
显示剩余12条评论

183

由于栈更快且不会泄漏

在C++中,为给定函数中的每个本地作用域对象分配空间只需一条指令即可,在栈上进行分配,并且无法泄漏任何内存。这个注释的意图(或应该有意)是表达类似于"使用栈而不是堆"的意思。


25
“仅需一条指令即可分配空间”——哦,胡说八道。确实,只需要一条指令来添加到栈指针,但是如果类具有任何有趣的内部结构,那么除了添加到栈指针之外还会发生很多事情。同样可以说,在Java中不需要任何指令来分配空间,因为编译器将在编译时管理引用。 - Charlie Martin
40
@Charlie 是正确的。"自动变量快速且无误" 更准确。 - Oliver Charlesworth
31
@Charlie: 无论哪种方式,都需要设置类的内部。这里正在比较所需空间的分配情况。 - Oliver Charlesworth
60
cough int x; return &x; - peterchen
25
快,没错。但绝不是百分之百可靠的。 没有什么是绝对可靠的。你可能会遇到StackOverflow :) - rxantos
显示剩余12条评论

123

原因很复杂。

首先,C++不是垃圾回收的。因此,对于每一个new操作,都必须有相应的delete操作。如果你没有写这个delete操作,那么就会出现内存泄漏的问题。现在来看这个简单的例子:

std::string *someString = new std::string(...);
//Do stuff
delete someString;

这很简单。但如果“Do stuff”抛出异常会发生什么?糟糕了:内存泄漏。如果“Do stuff”提前发出return呢?糟糕了:内存泄漏。

而这只是最简单的情况。如果你把这个字符串返回给别人,他们现在必须删除它。如果他们把它作为参数传递,接收到它的人是否需要删除它?他们应该什么时候删除它?

或者,你可以这样做:

std::string someString(...);
//Do stuff

没有 delete。该对象在“栈”上创建,并且一旦超出范围,它将被销毁。您甚至可以返回对象,从而将其内容传递给调用函数。您可以将该对象传递给函数(通常作为引用或const引用:void SomeFunc(std::string &iCanModifyThis, const std::string &iCantModifyThis)等等。

所有这些都可以不使用newdelete进行操作。没有谁拥有内存或负责删除它的问题。如果您这样做:

std::string someString(...);
std::string otherString;
otherString = someString;

理解为otherString拥有someString数据的副本。它不是一个指针,而是一个独立的对象。它们可能具有相同的内容,但您可以更改其中一个而不影响另一个:

someString += "More text.";
if(otherString == someString) { /*Will never get here */ }

你明白这个想法了吗?


2
说到这个问题...如果一个对象在main()中动态分配,存在于程序的整个生命周期中,由于情况无法轻松地在堆栈上创建,并且指向它的指针被传递给需要访问它的任何函数,那么在程序崩溃的情况下,这会导致泄漏吗?还是安全的?我认为后者是安全的,因为操作系统释放程序的所有内存应该逻辑上也会释放它,但是当涉及到new时,我不想做任何假设。 - Justin Time - Reinstate Monica
6
@JustinTime 你不需要担心动态分配的对象在程序生命周期内不释放内存。当程序执行时,操作系统会为它创建一个物理内存或虚拟内存的地图。虚拟内存空间中的每个地址都映射到物理内存的一个地址,当程序退出时,所有映射到它的虚拟内存都将被释放。因此,只要程序完全退出,你就不必担心已分配的内存永远不会被删除。 - Aiman Al-Eryani

83

new创建的对象必须最终使用delete删除,否则它们会泄漏。析构函数不会被调用,内存也不会被释放,整个过程都会出问题。由于C++没有垃圾回收机制,这是一个问题。

按值创建的对象(例如在堆栈上)会在超出范围时自动销毁。编译器会插入析构函数调用,并且在函数返回时自动释放内存。

unique_ptrshared_ptr这样的智能指针解决了悬空引用问题,但需要编码纪律并且存在其他潜在问题(可复制性、引用循环等)。

此外,在高度多线程的情况下,new是线程之间争夺的一点;过度使用new可能会对性能产生影响。堆栈对象的创建定义为线程本地,因为每个线程都有自己的堆栈。

按值对象的缺点是一旦主机函数返回,它们就会死亡-你不能将这些对象的引用传递回调用者,只能通过复制、返回或移动来实现。


10
关于 "Objects created by new must be eventually deleted lest they leak." 这个说法,更糟糕的是,使用 new[] 创建的对象必须使用 delete[] 进行匹配,如果您删除了使用 new[] 分配的内存或者使用 delete[] 删除使用 new 分配的内存,那么会导致未定义的行为。很少有编译器会对此发出警告(某些工具如 Cppcheck 可以在可能的情况下发出警告)。 - Tony Delroy
3
有些情况下编译器无法发出警告。如果一个函数返回指针,它可以通过new(单个元素)或new[]创建。 - fbafelipe

37
  • C++没有自己的内存管理器。其他语言如C#和Java有垃圾回收器来处理内存。
  • C++实现通常使用操作系统例程来分配内存,过多的new/delete可能会使可用内存碎片化。
  • 对于任何应用程序,如果频繁使用内存,则建议预先分配内存并在不需要时释放。
  • 不适当的内存管理可能导致内存泄漏,并且很难跟踪。因此,在函数范围内使用堆栈对象是一种有效的技术。
  • 使用堆栈对象的缺点是,在返回、传递到函数等情况下会创建多个对象副本。然而,智能编译器非常清楚这些情况,并已进行了良好的性能优化。
  • 如果在两个不同的地方分配和释放内存,那么在C++中确实很麻烦。释放的责任总是一个问题,大多数时候我们依赖于一些通常可访问的指针、堆栈对象(最大可能)和类似于auto_ptrRAII对象)的技术。
  • 最好的事情是,您可以控制内存,最糟糕的事情是,如果我们为应用程序使用不当的内存管理,那么您将无法控制内存。由于内存破坏导致的崩溃是最恶劣且难以跟踪的。

5
实际上,任何分配内存的语言都有内存管理器,包括C语言。大多数内存管理器都非常简单,例如 int *x = malloc(4); int *y = malloc(4); ... 第一次调用将分配内存,也就是请求操作系统获取内存(通常为1KB/4KB的块),这样第二次调用时,不会实际分配内存,而是为您提供它上次分配的块的一部分。在我看来,垃圾回收器不是内存管理器,因为它只处理内存的自动释放。要称之为内存管理器,它不仅应该处理内存的释放,还应该处理内存的分配。 - Rahly
1
本地变量使用堆栈,因此编译器不会发出调用malloc()或其相关函数来分配所需的内存。然而,堆栈无法释放堆栈中的任何项,唯一释放堆栈内存的方法是从堆栈顶部展开。 - Mikko Rantalainen
1
C++并不“使用操作系统例程”;这不是语言的一部分,而只是常见实现。C++甚至可以在没有任何操作系统的情况下运行。 - einpoklum

30
我看到有几个重要的原因被忽略了,尽可能少使用new的原因如下:

new操作符执行时间不确定

调用new可能会导致操作系统为进程分配新的物理页面,也可能已经有合适的内存位置可用,我们无法得知。如果您经常这样做,速度会非常慢。或者它可能已经有一个合适的内存位置; 我们不知道。如果您的程序需要具有一致和可预测的执行时间(例如实时系统或游戏/物理模拟),则需要避免在时间关键循环中使用new

new操作符是隐式线程同步

是的,你没听错。您的操作系统需要确保您的页表是一致的,因此调用new将导致您的线程获取隐式互斥锁。如果您从许多线程一致地调用new,则实际上是在串行化您的线程(我曾在32个CPU上这样做,每个CPU都会命中new以获得几百个字节,痛苦!那是一个王牌p.i.t.a.调试)。

其他问题,例如速度慢、碎片化、容易出错等,已经在其他答案中提到。


4
可以通过使用placement new/delete以及预先分配内存来避免这两个问题。或者您可以自己分配/释放内存,然后调用构造函数/析构函数。这通常是std::vector的工作方式。 - rxantos
1
@rxantos 请阅读原始帖子,这个问题是关于避免不必要的内存分配。另外,没有放置删除操作。 - Emily L.
1
使用堆栈在执行时间上也不是确定性的,除非您调用了mlock()或类似的函数。这是因为系统可能会出现内存不足的情况,没有可用于堆栈的物理内存页面,因此操作系统可能需要交换或写入一些缓存(清除脏内存)到磁盘,然后才能继续执行。 - Mikko Rantalainen
1
@mikkorantalainen,从技术上讲这是正确的,但在低内存情况下,性能方面所有的赌注都已经无效了,因为你正在推向磁盘,所以你无能为力。这并不以任何方式否定了避免新调用的建议,只要情况合理。 - Emily L.
1
@einpoklum,我看到你似乎对这个问题的许多答案有意见,这些答案在大多数情况下都是正确的,如果不是所有实现都是如此,但在标准上并没有技术上规定必须这样做。甚至声称应该删除指出合法的、与主题相关的问题的答案。即使标准上并没有要求这样做,理解和解决最常见的实现策略的怪癖也有价值,因为否则就会影响您的应用程序... - Emily L.
显示剩余4条评论

24

Pre-C++17:

因为即使将结果封装在智能指针中,它也容易出现细微泄漏。

考虑一个“小心谨慎”的用户,他记得将对象封装在智能指针中:

foo(shared_ptr<T1>(new T1()), shared_ptr<T2>(new T2()));

这段代码存在危险,因为无法保证T1T2之前是否已经构建了shared_ptr,所以如果new T1()new T2() 中的一个在另一个成功之后失败,那么第一个对象将被泄漏,因为不存在shared_ptr来销毁和释放它。

解决方法:使用make_shared

C++17之后:

这不再是问题:C++17对这些操作的顺序施加了约束,即确保每次调用new()必须紧接着构造相应的智能指针,中间不能有其他操作。这意味着,在调用第二个new()时,可以保证第一个对象已经被包装在其智能指针中,从而防止在抛出异常时发生任何泄漏。

Barry in another answer提供了关于C++17引入的新评估顺序的更详细说明。

感谢@Remy Lebeau指出这仍然是C++17下的一个问题(虽然不那么严重):`shared_ptr`构造函数可能无法分配其控制块并抛出异常,在这种情况下,传递给它的指针将不被删除。
解决方案:使用`make_shared`。

5
其他解决方案:永远不要在一行动态分配超过一个对象。 - Antimony
3
@Antimony:是的,当你已经分配了一个对象时,分配多个对象会更具诱惑性,相比之下,当你没有分配任何对象时,就不那么具有诱惑性了。 - user541686
1
我认为更好的答案是,如果调用了异常并且没有任何东西捕获它,smart_ptr将会泄漏。 - Natalie Adams
3
即使在C++17之后,如果new成功分配内存,但后续的shared_ptr构造失败,仍可能发生内存泄漏。使用std::make_shared()也可以解决这个问题。 - Remy Lebeau
1
@Mehrdad,问题中的shared_ptr构造函数分配内存用于存储共享指针和删除器的控制块,因此它理论上可能会抛出内存错误。只有复制、移动和别名构造函数是不抛出异常的。make_shared在控制块本身内分配共享对象,因此只有1次分配而不是2次。 - Remy Lebeau
显示剩余5条评论

17
在很大程度上,这是将某人自己的弱点提升成一般规则。使用new运算符创建对象本身没有什么问题。
但是有一些争议的是,如果你要创建一个对象,就需要确保它会被销毁,因此需要遵循一定的纪律。最简单的方法是在自动存储中创建对象,这样C++就知道在超出范围时销毁它:
 {
    File foo = File("foo.dat");

    // Do things

 }

现在,注意当你在结尾括号后从那个块中掉落时,foo超出了作用域。C++会自动为您调用其析构函数。与Java不同,您无需等待垃圾回收来找到它。

如果你写成这样

 {
     File * foo = new File("foo.dat");

你需要显式地匹配它。

     delete foo;
  }

甚至更好的方法是将你的 File * 分配为一个“智能指针”。如果你不小心处理,它会导致泄漏。

答案本身有个错误的假设,即如果你不使用new,那么你就没有在堆上分配;事实上,在C++中你并不知道这一点。最多,你只知道少量的内存,比如一个指针,肯定是分配在栈上的。然而,考虑如果File的实现是这样的:

  class File {
    private:
      FileImpl * fd;
    public:
      File(String fn){ fd = new FileImpl(fn);}

那么FileImpl仍将在堆栈上分配。

而且是的,你最好确定已经有。

     ~File(){ delete fd ; }

同样也要在类中定义; 如果没有它,即使你根本没有显式地在堆上分配内存,你也会从堆中泄漏内存。


4
您应该查看所引用问题中的代码。那段代码肯定存在许多问题。 - André Caron
7
我同意使用 new 本身并没有什么问题,但如果你查看原始代码中的注释,就会发现 new 被滥用了。这段代码的编写方式就像是 Java 或 C#,几乎每个变量都使用 new,当实际上很多时候使用栈更加合理。 - luke
5
有道理。但是通常会执行一般规则以避免常见陷阱。无论这是个人的弱点还是不是,内存管理足够复杂,需要制定这样一条普遍规则! :) - Robben_Ford_Fan_boy
9
@Charlie: 这条评论并没有说你永远不应该使用 new。它的意思是,如果你可以在动态分配和自动存储之间做出选择的话,使用自动存储。 - André Caron
8
@CharlieпјҡдҪҝз”ЁnewжІЎжңүй—®йўҳпјҢдҪҶеҰӮжһңдҪ дҪҝз”ЁdeleteпјҢйӮЈе°ұй”ҷдәҶпјҒ - Matthieu M.
显示剩余9条评论

16

new()应该尽可能谨慎地使用,而不是尽可能少用它。在实际情况下,需要根据实用主义的指导来尽可能经常地使用它。

将对象分配到堆栈上并依赖于它们的隐式销毁是一种简单的模型。如果对象所需的范围适合该模型,则无需使用new(),也就不需要使用相关的delete()和检查空指针的操作。在有许多短生命周期对象的情况下,分配到堆栈上可以减少堆碎片的问题。

但是,如果您的对象的生命周期需要超出当前作用域,则new()是正确的选择。只需确保注意何时以及如何调用delete() 和空指针的可能性,以及使用已删除对象和所有其他指针使用中可能遇到的问题。


9
如果您的对象的生命周期需要超出当前作用域,则new()是正确的选择。为什么不优先返回值或通过非const引用或指针接受调用方作用域的变量呢? - Tony Delroy
2
@Tony:是的,是的!我很高兴听到有人提倡引用。它们被创建出来就是为了防止这个问题。 - Nathan Osman
2
@TonyD...或者将它们组合起来:通过值返回智能指针。这样,调用者和在许多情况下(例如在make_shared/_unique可用的情况下)调用方都不需要newdelete。该答案忽略了真正的要点:(A)C++提供了像RVO、移动语义和输出参数这样的东西——这通常意味着通过返回动态分配的内存处理对象创建和生存期扩展变得不必要和粗心。(B)即使在需要动态分配的情况下,stdlib也提供了RAII包装器,减轻了用户的丑陋内部细节。 - underscore_d

15

当你使用 new 时,对象将被分配到堆中。通常在预期需要扩展对象时使用。例如,当你声明一个对象时:

Class var;

如果你使用了 new 在堆上创建了一个对象,你总是需要调用 destroy 来销毁它。否则就可能会存在内存泄漏的风险。而放在栈上的对象则不容易出现内存泄漏的问题!


2
+1 "[heap] 通常用于预期扩展" - 比如向 std::stringstd::map 追加,确实很敏锐。我的最初反应是“但也经常用于将对象的生命周期与创建代码的作用域分离”,但实际上通过值返回或通过非 const 引用或指针接受调用者作用域的值更好,除非也涉及到“扩展”。还有一些其他合理的用途,比如工厂方法... - Tony Delroy

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