关于内存管理:为什么C++程序员要尽量少使用”new”?

Why should C++ programmers minimize use of 'new'?

我在使用std::list时偶然发现堆栈溢出问题内存泄漏,其中一条评论说:

Stop using new so much. I can't see any reason you used new anywhere
you did. You can create objects by value in C++ and it's one of the
huge advantages to using the language. You do not have to allocate
everything on the heap. Stop thinking like a Java programmer.

我不太确定他说的是什么意思。为什么对象应该在C++中以尽可能频繁的方式创建,而它在内部又有什么区别呢?我误解了答案吗?


内存分配技术有两种:自动分配和动态分配。通常,每个都有一个对应的内存区域:堆栈和堆。好的。栈

堆栈总是按顺序分配内存。它可以这样做,因为它要求您以相反的顺序释放内存(先入后出:filo)。这是许多编程语言中局部变量的内存分配技术。它非常非常快,因为它需要最少的簿记,并且要分配的下一个地址是隐式的。好的。

在C++中,这称为自动存储,因为存储在范围结束时自动声明。一旦当前代码块(使用{}分隔)的执行完成,将自动收集该块中所有变量的内存。这也是调用析构函数清理资源的时刻。好的。堆

堆允许更灵活的内存分配模式。簿记更复杂,分配更慢。由于没有隐式释放点,您必须使用deletedelete[](c中的free手动释放内存。但是,缺少隐式发布点是堆灵活性的关键。好的。使用动态分配的原因

即使使用堆速度较慢并且可能导致内存泄漏或内存碎片,动态分配也有非常好的用例,因为它的限制较小。好的。

使用动态分配的两个主要原因:好的。

  • 您不知道在编译时需要多少内存。例如,当将文本文件读取到字符串中时,通常不知道文件的大小,因此在运行程序之前,您无法决定要分配多少内存。好的。

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

为什么不需要动态分配

在C++中有一个完整的构造,称为析构函数。此机制允许您通过将资源的生存期与变量的生存期对齐来管理资源。这种技术被称为RAII,是C++的识别点。它将资源"包装"成对象。std::string就是一个很好的例子。这个片段:好的。

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

实际上分配的内存量是可变的。std::string对象使用堆分配内存,并在其析构函数中释放内存。在这种情况下,您不需要手动管理任何资源,仍然可以获得动态内存分配的好处。好的。

特别是,它意味着在这段代码中:好的。

1
2
3
4
5
int main ( int argc, char* argv[] )
{
    std::string * program = new std::string(argv[0]);  // Bad!
    delete program;
}

存在不需要的动态内存分配。程序需要更多的输入!!)并且会带来忘记释放内存的风险。这样做没有明显的好处。好的。为什么要尽可能频繁地使用自动存储

基本上,最后一段总结了这一点。尽可能频繁地使用自动存储使您的程序:好的。

  • 更快的类型;
  • 跑得更快;
  • 不太容易发生内存/资源泄漏。

加分

在引用的问题中,还有其他问题。尤其是以下类别:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Line {
public:
    Line();
    ~Line();
    std::string* mString;
};

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

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

实际使用的风险要比以下风险大得多:好的。

1
2
3
4
5
6
7
8
9
10
class Line {
public:
    Line();
    std::string mString;
};

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

原因是std::string正确地定义了一个复制构造函数。考虑以下程序:好的。

1
2
3
4
5
int main ()
{
    Line l1;
    Line l2 = l1;
}

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

由于上面的原因,RAII的广泛使用被认为是C++中的最佳实践。然而,还有一个额外的好处,并不是很明显。基本上,它比各部分的总和要好。整个机构组成。它有鳞片。好的。

如果使用Line类作为构建基块:好的。

1
2
3
4
 class Table
 {
      Line borders[4];
 };

然后好的。

1
2
3
4
 int main ()
 {
     Table table;
 }

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


因为这堆东西又快又简单

在C++中,只有一个指令来分配空间——在堆栈上——对于给定函数中的每个局部作用域对象,并且不可能泄漏任何内存。该注释打算(或者应该)说"使用堆栈而不是堆"。


这很复杂。

首先,C++不是垃圾回收。因此,对于每一个新的,都必须有相应的删除。如果您未能将此删除内容放入,则会出现内存泄漏。现在,对于这样一个简单的例子:

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

这很简单。但是如果"做事情"抛出异常会发生什么?糟糕:内存泄漏。如果"做东西"提早发布return,会发生什么?糟糕:内存泄漏。

这是最简单的情况。如果您碰巧将该字符串返回给某人,现在他们必须删除它。如果他们把它作为一个论点传递,接收它的人需要删除它吗?他们什么时候应该删除它?

或者,您可以这样做:

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

delete。对象是在"堆栈"上创建的,一旦超出范围,它将被销毁。您甚至可以返回对象,从而将其内容转移到调用函数。可以将对象传递给函数(通常作为引用或常量引用:void SomeFunc(std::string &iCanModifyThis, const std::string &iCantModifyThis))。诸如此类。

都没有newdelete。毫无疑问谁拥有内存或者谁负责删除内存。如果你这样做:

1
2
3
std::string someString(...);
std::string otherString;
otherString = someString;

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

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

看到这个想法了吗?


new创建的对象最终必须是deleted,以免泄漏。不会调用析构函数,不会释放内存,整个位。因为C++没有垃圾收集,这是个问题。

由值创建的对象(即堆栈上的对象)在超出范围时自动死亡。析构函数调用由编译器插入,函数返回时内存自动释放。

auto_ptrshared_ptr这样的智能指针解决了悬空的引用问题,但是它们需要编码规则,并且还有其他问题(可复制性、引用循环等)。

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

值对象的缺点是,一旦宿主函数返回,它们就会消失——您不能仅通过复制或返回值,将对这些对象的引用传递回调用方。


  • C++不使用任何内存管理器。其他语言,如C.Y.,Java有垃圾收集器来处理内存。
  • 使用操作系统例程分配内存和过多的新/删除可能会破坏可用内存。
  • 对于任何应用程序,如果经常使用内存,建议在不需要时预先分配和释放内存。
  • 不正确的内存管理可能导致内存泄漏,很难跟踪。因此,在函数范围内使用堆栈对象是一种行之有效的技术
  • 使用堆栈对象的缺点是,它在返回、传递到函数等时创建多个对象副本。但是,智能编译器很清楚这些情况,并且已经对性能进行了很好的优化。
  • 如果内存分配和释放在两个不同的地方,这真的很乏味。发布的责任始终是一个问题,我们主要依赖于一些常见的可访问指针、堆栈对象(尽可能多)和诸如auto-ptr(raii对象)之类的技术。
  • 最好的是,您可以控制内存,最糟糕的是,如果我们对应用程序使用不正确的内存管理,您将无法控制内存。由于内存损坏而导致的崩溃是最糟糕和最难追踪的。


我发现遗漏了尽可能少做新工作的几个重要原因:

运算符new的执行时间不确定

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

operator new是一种隐式线程同步

是的,你听我说,你的操作系统需要确保你的页面表是一致的,因此调用new会导致你的线程获得一个隐式互斥锁。如果您总是从许多线程中调用new,那么您实际上是在序列化您的线程(我已经用32个CPU完成了这项工作,每个CPU访问new,以获得几百个字节,哎呀!这是一个皇家P.I.T.A.调试)

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


前C ++ 17:因为即使用智能指针包装结果,它也容易发生细微的泄漏。

考虑一个"小心"的用户,他记得用智能指针包装对象:

1
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对这些操作的顺序施加了约束,在这种情况下,确保每次调用EDCOX1〔7〕必须立即跟随相应的智能指针的构造,而在其间没有其他操作。这意味着,在调用第二个new()时,可以保证第一个对象已经被它的智能指针包装,从而在引发异常时防止任何泄漏。

由C++提供的新的评价顺序的更详细的解释是由巴里在另一个答案中提供的。

感谢@ Remy Lebeau指出这仍然是C++ 17下的一个问题(虽然不那么):EDCOX1×0的构造函数不能分配它的控制块并抛出,在这种情况下,传递给它的指针不会被删除。

解决方法:使用make_shared


在很大程度上,这是一个人把自己的弱点提升到一个普遍规律。使用new操作符创建对象本身没有任何错误。有一种观点认为,你必须用一些规则来实现这一点:如果你创建了一个对象,你需要确保它将被销毁。

最简单的方法是在自动存储中创建对象,因此C++知道当它超出范围时销毁它:

1
2
3
4
5
6
 {
    File foo = File("foo.dat");

    // do things

 }

现在,请注意,当您从端部支撑后的挡块上跌落时,foo超出范围。C++会自动调用它的DROR。与Java不同,您不需要等待GC找到它。

你写了吗?

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

你想把它和

1
2
     delete foo;
  }

或者更好的方法是,将您的File *分配为一个"智能指针"。如果你不小心,可能会导致泄漏。

答案本身就是错误的假设,如果你不使用EDCOX1,0,你就不会在堆上分配;事实上,C++中你不知道。至多,您知道一小部分内存(比如一个指针)肯定是在堆栈上分配的。但是,考虑文件的实现是否类似于

1
2
3
4
5
  class File {
    private:
      FileImpl * fd;
    public:
      File(String fn){ fd = new FileImpl(fn);}

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

是的,你最好

1
     ~File(){ delete fd ; }

在类中也是如此;没有它,即使在堆上根本没有明显分配内存,也会从堆中泄漏内存。


不应尽可能少地使用new()。应尽可能小心使用。它的使用频率应根据实用主义的需要而定。

在堆栈上分配对象,依赖于它们的隐式销毁,是一个简单的模型。如果一个对象所需的范围适合该模型,那么就不需要使用new(),使用相关的delete()并检查空指针。如果在堆栈上分配了大量短期对象,那么应该减少堆碎片问题。

但是,如果您的对象的寿命需要延长到当前范围之外,那么new()是正确的答案。只需确保您注意调用delete()的时间和方式以及空指针的可能性,使用已删除的对象以及使用指针时附带的所有其他gotcha。


使用new时,对象将分配给堆。它通常在您预期扩展时使用。当您声明一个对象时,例如,

1
Class var;

它被放在堆栈上。

您将始终必须调用destroy来销毁您用new放置在堆中的对象。这会打开内存泄漏的可能性。放置在堆栈上的对象不容易发生内存泄漏!


避免过度使用堆的一个显著的原因是性能——具体涉及C++所使用的默认内存管理机制的性能。虽然在一般情况下分配速度很快,但是在不严格顺序的非均匀大小对象上进行大量的newdelete操作不仅会导致内存碎片化,而且会使分配算法复杂化,在某些情况下会完全破坏性能。

这就是创建内存池来解决的问题,允许减轻传统堆实现的固有缺点,同时允许您根据需要使用堆。

不过,最好还是完全避免这个问题。如果你能把它放在堆栈上,那么就这样做。


我倾向于不同意使用新的"太多"的想法。尽管最初的海报使用了新的系统类有点可笑。(int *i; i = new int[9999];?真的?我认为这正是引起评论人愤怒的原因。

当您处理系统对象时,很少需要对完全相同的对象进行多个引用。只要价值是一样的,这就是一切。系统对象通常不会占用内存太多空间。(字符串中每个字符一个字节)。如果他们这样做了,库的设计应该考虑到内存管理(如果他们写得好的话)。在这些情况下(除了他代码中的一两条新闻之外),new实际上是毫无意义的,只会引入混乱和潜在的bug。

但是,当您使用自己的类/对象(例如原始海报的line类)时,您必须开始自己思考内存占用、数据持久性等问题。此时,允许对同一个值进行多个引用是非常宝贵的——它允许链表、字典和图表等结构,其中多个变量不仅需要具有相同的值,还需要引用内存中完全相同的对象。但是,行类没有这些要求。所以原来海报的代码实际上完全不需要new


我想海报是说You do not have to allocate everything on theheap而不是stack

基本上,对象是在堆栈上分配的(当然,如果对象大小允许的话),因为堆栈分配的成本很低,而不是基于堆的分配,它涉及分配器的相当多的工作,并且添加了冗长的内容,因为这样您就必须管理在堆上分配的数据。


原因有二:

  • 在这种情况下是不必要的。你让你的代码变得不必要的复杂。
  • 它在堆上分配空间,这意味着稍后必须记住delete,否则会导致内存泄漏。

  • 核心原因是堆上的对象总是比简单的值更难使用和管理。编写易于阅读和维护的代码始终是任何严肃的程序员的首要任务。

    另一个场景是我们正在使用的库提供了值语义并使动态分配变得不必要。Std::string就是一个很好的例子。

    但是,对于面向对象的代码,必须使用指针(也就是说使用new预先创建它)。为了简化资源管理的复杂性,我们有许多工具可以使其尽可能简单,例如智能指针。基于对象的范式或通用范式假定值语义,并且需要较少或不需要new,正如其他地方的海报所述。

    传统的设计模式,特别是在GOF书中提到的模式,使用了很多new,因为它们是典型的OO代码。


    再指出上面所有的正确答案,这取决于你在做什么样的编程。例如,在Windows中开发内核时,堆栈受到严重限制,您可能无法像在用户模式中那样接受页面错误。

    在这种环境中,新的或类C的API调用是首选的,甚至是必需的。

    当然,这只是一个例外。


    new是新的goto

    回想一下为什么goto被如此贬低:虽然它是一个强大的、低级的流控制工具,但人们经常以不必要的复杂方式使用它,这使得代码难以遵循。此外,最有用和最容易阅读的模式是用结构化的编程语句(如forwhile编码的;最终的效果是,goto是合适的方式的代码非常罕见,如果你想写goto的话,你可能做得很糟糕(除非你真的知道你在做什么)。

    new是类似的,它经常被用来使事情变得不必要的复杂和难以阅读,最有用的使用模式可以被编码到各种类中。此外,如果您需要使用任何还没有标准类的新使用模式,您可以编写自己的类来编码它们!

    我甚至认为,由于需要将newdelete两种说法配对,newgoto更糟。

    goto一样,如果您认为需要使用new时,可能会做得很糟糕,尤其是在类的实现之外,这样做的目的是封装您需要执行的任何动态分配。


    new在堆上分配对象。否则,对象将在堆栈上分配。看看这两者的区别。