为什么不对C ++中的所有内容使用指针?


75

假设我定义了一些类:

class Pixel {
    public:
      Pixel(){ x=0; y=0;};
      int x;
      int y;
}

然后使用它编写一些代码。为什么要执行以下操作?

Pixel p;
p.x = 2;
p.y = 5;

来自Java世界,我总是写:

Pixel* p = new Pixel();
p->x = 2;
p->y = 5;

他们基本上做同样的事情,对不对?一个在堆栈上,另一个在堆栈上,因此以后我必须将其删除。两者之间有根本区别吗?为什么我要优先选择另一个?

Answers:


188

是的,一个在堆栈上,另一个在堆栈上。有两个重要区别:

  • 首先,显而易见的,次要的是:堆分配很慢。堆栈分配很快。
  • 其次,RAII更重要。由于堆栈分配的版本会自动清除,因此很有用。它的析构函数会自动调用,这使您可以保证清除由该类分配的所有资源。这是避免C ++中内存泄漏的基本方法。通过从不调用delete自己来避免它们,而是将其包装在delete内部通常在其析构函数中调用的堆栈分配对象中。如果您尝试手动跟踪所有分配并delete在正确的时间进行调用,我保证您每100行代码至少会发生一次内存泄漏。

作为一个小示例,请考虑以下代码:

class Pixel {
public:
  Pixel(){ x=0; y=0;};
  int x;
  int y;
};

void foo() {
  Pixel* p = new Pixel();
  p->x = 2;
  p->y = 5;

  bar();

  delete p;
}

很纯真的代码,对不对?我们创建一个像素,然后调用一些不相关的函数,然后删除该像素。有内存泄漏吗?

答案是“可能”。如果bar抛出异常怎么办?delete永远不会被调用,永远不会删除像素,并且我们会泄漏内存。现在考虑一下:

void foo() {
  Pixel p;
  p.x = 2;
  p.y = 5;

  bar();
}

这不会泄漏内存。当然,在这种简单情况下,所有内容都在堆栈上,因此会自动清除它,但是即使Pixel类在内部进行了动态分配,也不会泄漏。该Pixel班将简单地因为其删除析构函数,而这个析构函数将不管我们怎么离开这个所谓的foo功能。即使我们因为bar抛出异常而离开它。以下略作设计的示例显示了这一点:

class Pixel {
public:
  Pixel(){ x=new int(0); y=new int(0);};
  int* x;
  int* y;

  ~Pixel() {
    delete x;
    delete y;
  }
};

void foo() {
  Pixel p;
  *p.x = 2;
  *p.y = 5;

  bar();
}

Pixel类现在在内部分配了一些堆内存,但是它的析构函数负责清理它,因此在使用该类时,我们不必担心。(我可能应该提到,为了展示一般原理,这里的最后一个示例已简化了很多。如果我们实际上要使用此类,则它也包含几个可能的错误。如果y的分配失败,则x永远不会释放,如果像素被复制,我们最终将导致两个实例都试图删除相同的数据。因此,在这里以最后的例子来说明一下。实际代码有点棘手,但它显示了一般想法)

当然,可以将相同的技术扩展到内存分配以外的其他资源。例如,它可以用来确保文件或数据库连接在使用后关闭,或释放线程代码的同步锁。


5
+1。虽然,1leak / 100loc太多了。也许每1000行代码1个。
米兰巴布斯科夫2009年

10
@米兰:面对例外,我想说100可能比1000
还要

5
是的,您可能可以写出前500行而不会泄漏。然后再添加100行,其中包含6种不同的方法来泄漏相同的数据,所有方法都在同一函数中。当然,我还没有测量,但是听起来不错。:)
杰夫

3
@Matt:哦,真的吗?如果您不使用异常,就不必担心内存管理吗?这对我来说是个新闻。我想象大量的C程序员希望他们也知道这一点。我相信,只要用C编写的许多大型软件项目就可以充分理解这一点小知识:只要没有例外,就不需要管理内存。
jalf

3
@马特:我不是。我故意解释它们。没有“错误”。看着你在我所有答案上留下的评论串,很显然它们值得多少。无论如何,我在帖子中看不到任何“强迫症”。我也看不到任何旨在保护功能的内容。我看到一个非常简单的习惯用法被用来编写非常简单易用的代码。没有它,客户端代码将变得更加复杂和脆弱,而类本身的实现可能会节省几行代码。
jalf

30

在添加删除之前,它们是不一样的。
您的示例过于琐碎,但析构函数实际上可能包含执行某些实际工作的代码。这称为RAII。

因此,添加删除。即使发生异常,也要确保它发生。

Pixel* p = NULL; // Must do this. Otherwise new may throw and then
                 // you would be attempting to delete an invalid pointer.
try
{
    p = new Pixel(); 
    p->x = 2;
    p->y = 5;

    // Do Work
    delete p;
}
catch(...)
{
    delete p;
    throw;
}

如果您选择了一些更有趣的东西,例如文件(该资源需要关闭)。然后使用所需的指针在Java中正确执行此操作。

File file;
try
{
    file = new File("Plop");
    // Do work with file.
}
finally
{
    try
    {
        file.close();     // Make sure the file handle is closed.
                          // Oherwise the resource will be leaked until
                          // eventual Garbage collection.
    }
    catch(Exception e) {};// Need the extra try catch to catch and discard
                          // Irrelevant exceptions. 

    // Note it is bad practice to allow exceptions to escape a finally block.
    // If they do and there is already an exception propagating you loose the
    // the original exception, which probably has more relevant information
    // about the problem.
}

C ++中的相同代码

std::fstream  file("Plop");
// Do work with file.

// Destructor automatically closes file and discards irrelevant exceptions.

尽管人们提到了速度(因为在堆上查找/分配内存)。就我个人而言,这不是决定因素(分配器非常快,并且针对不断创建/销毁的小对象的C ++使用进行了优化)。

对我来说,主要原因是对象的生存时间。局部定义的对象具有非常特定且定义明确的生存期,并且可以确保在最后调用析构函数(因此可能具有特定的副作用)。另一方面,指针控制具有动态寿命的资源。

C ++和Java之间的主要区别是:

谁拥有指针的概念。所有者有责任在适当的时候删除对象。这就是为什么您很少在实际程序中看到像这样的原始指针的原因(因为没有所有权信息与原始指针相关联)。相反,指针通常包装在智能指针中。智能指针定义谁拥有内存以及谁负责清理内存的语义。

例如:

 std::auto_ptr<Pixel>   p(new Pixel);
 // An auto_ptr has move semantics.
 // When you pass an auto_ptr to a method you are saying here take this. You own it.
 // Delete it when you are finished. If the receiver takes ownership it usually saves
 // it in another auto_ptr and the destructor does the actual dirty work of the delete.
 // If the receiver does not take ownership it is usually deleted.

 std::tr1::shared_ptr<Pixel> p(new Pixel); // aka boost::shared_ptr
 // A shared ptr has shared ownership.
 // This means it can have multiple owners each using the object simultaneously.
 // As each owner finished with it the shared_ptr decrements the ref count and 
 // when it reaches zero the objects is destroyed.

 boost::scoped_ptr<Pixel>  p(new Pixel);
 // Makes it act like a normal stack variable.
 // Ownership is not transferable.

还有其他


9
我喜欢将C ++文件的使用与Java进行比较(让我微笑)。
马丁·约克

2
同意 还有加分,因为它表明RAII被用于管理其他类型的资源,而不仅仅是内存分配。
jalf

25

从逻辑上讲,他们做同样的事情-除了清理。在指针情况下,只是您编写的示例代码会发生内存泄漏,因为该内存没有释放。

来自Java的背景,您可能还不完全了解多少C ++围绕着跟踪已分配的内容以及谁负责释放它。

通过在适当的时候使用堆栈变量,您不必担心释放该变量,它随堆栈框架而消失。

显然,如果您非常谨慎,则始终可以在堆上进行分配并手动进行释放,但是好的软件工程的一部分是,以一种不会破坏的方式构建事物,而不是信任您的超人程序员,福永远不会犯错。


24

只要有机会,我宁愿使用第一种方法,因为:

  • 更快
  • 我不必担心内存释放
  • p将是整个当前范围的有效对象

14

“为什么不对C ++中的所有内容使用指针”

一个简单的答案-因为分配内存成为一个巨大的问题-分配和删除/释放。

自动/堆栈对象消除了其中的一些繁琐工作。

那只是我要问的第一件事。


11

一个好的通用经验法则是,除非绝对必要,否则不要使用new。如果您不使用new程序,那么您的程序将更易于维护,并且不易出错,因为您不必担心在哪里清理程序。


11

编码:

Pixel p;
p.x = 2;
p.y = 5;

没有动态分配内存-没有搜索可用内存,没有更新内存使用情况,什么也没有。它是完全免费的。编译器在编译时为变量保留了堆栈上的空间-它计算出要保留的空间很大,并创建了一个操作码来将堆栈指针移动所需的数量。

使用new需要所有内存管理开销。

问题就变成了-您要使用堆栈空间还是堆空间存储数据。像“ p”这样的堆栈(或局部)变量不需要取消引用,而使用new则增加了间接层。


10

是的,起初有道理,来自Java或C#背景。不必记住释放分配的内存似乎没什么大不了的。但是,当您第一次遇到内存泄漏时,就会抓挠头,因为您通过SWORE释放了所有内容。然后第二次发生,第三次您会更加沮丧。最终,在由于内存问题导致六个月的头痛之后,您将开始感到厌倦,并且堆栈分配的内存将开始变得越来越有吸引力。多么漂亮和干净-只需将其放在堆栈上就可以了。很快,您将可以随时使用堆栈。

但是,这是无可替代的。我的建议?暂时尝试一下。你会看到的。


6
您忘记提及它的邪恶双胞胎,双倍释放。:)只是当您认为已释放所有内存时,由于在释放内存后正在使用内存,或者尝试释放已经释放的内存,您会开始收到错误消息。
jalf

6

我的直觉是告诉您,这可能会导致严重的内存泄漏。在某些情况下,您可能正在使用指针,这可能导致有关谁应该负责删除指针的困惑。在诸如示例之类的简单情况下,很容易看到应该在何时何地调用delete,但是当您开始在类之间传递指针时,事情会变得有些困难。

我建议您查看boost智能指针库中的指针。


6

不更新所有内容的最好原因是,当堆栈中的所有内容都可以确定性地进行清理。在Pixel的情况下,这不是很明显,但是在说一个文件的情况下,这变得很有优势:

  {   // block of code that uses file
      File aFile("file.txt");
      ...
  }    // File destructor fires when file goes out of scope, closing the file
  aFile // can't access outside of scope (compiler error)

如果要更新文件,则必须记住将其删除才能得到相同的行为。在上述情况下,这似乎是一个简单的问题。但是,请考虑更复杂的代码,例如将指针存储到数据结构中。如果将该数据结构传递给另一段代码怎么办?谁负责清理。谁将关闭您的所有文件?

当您不更新所有内容时,只要变量超出范围,析构函数就会清理资源。因此,您可以对成功清除资源有更大的信心。

这个概念称为RAII-资源分配即初始化,它可以大大提高您处理资源获取和处置的能力。


6

第一种情况并不总是堆栈分配。如果它是对象的一部分,则将分配该对象所在的任何位置。例如:

class Rectangle {
    Pixel top_left;
    Pixel bottom_right;
}

Rectangle r1; // Pixel is allocated on the stack
Rectangle *r2 = new Rectangle(); // Pixel is allocated on the heap

堆栈变量的主要优点是:

  • 您可以使用RAII模式来管理对象。一旦对象超出范围,它的析构函数就会被调用。有点像C#中的“使用”模式,但是是自动的。
  • 不可能有空引用。
  • 您无需担心手动管理对象的内存。
  • 它导致较少的内存分配。C ++中的内存分配(尤其是较小的内存分配)可能比Java慢。

创建对象后,在堆上分配的对象与在堆栈上分配的对象(或任何位置)之间没有性能差异。

但是,除非使用指针,否则不能使用任何类型的多态性-对象具有完全静态的类型,该类型在编译时确定。


4

对象生存期。当您希望对象的生存期超过当前作用域的生存期时,必须使用堆。

另一方面,如果您不需要超出当前范围的变量,则在堆栈上声明它。超出范围时,它将自动销毁。请小心传递其地址。


4

我会说很多关于口味的问题。如果创建一个允许方法采用指针而不是引用的接口,则允许调用者传入nil。由于您允许用户传递nil,因此用户传递nil。

由于您必须问自己“如果此参数为nil,会发生什么?”,因此您必须更加防御性地进行编码,并始终注意执行null检查。这说明了使用引用。

但是,有时您确实希望能够传递nil,然后引用就成为不可能了:)指针为您提供了更大的灵活性,并使您更加懒惰,这真的很好。在知道必须分配之前,不要分配!


他不是在指函数参数,而是在谈论事物的分配位置(堆与栈)。他指出Java只是堆上的所有对象(我听说过现代版本中的一些巧妙技巧,可以自动将某些对象放在堆栈上)。
Evan Teran

我认为您在回答有关指针与引用的不同问题;而不是OP的有关基于堆栈或基于堆的对象的问题。
看到

4

问题不在于指针本身(除了引入NULL指针之外),而是手动进行内存管理。

当然,有趣的部分是,我见过的每个Java教程都提到垃圾收集器是如此酷,因为您不必记住调用delete,而在实践中C ++仅delete在调用时才需要new(以及delete[]在调用时)new[])。


2

仅在必须时使用指针和动态分配的对象。尽可能使用静态分配的(全局或堆栈)对象。

  • 静态对象更快(没有新的/删除的对象,没有间接访问它们的对象)
  • 无需担心对象寿命
  • 更少的击键更易读
  • 更强大。每个“->”都可能访问NIL或无效的内存

为了澄清,在本文中,“静态”是指非动态分配。IOW,任何不在堆上的东西。是的,它们也可能存在对象生存期问题-就单例销毁顺序而言-但将它们粘贴在堆上通常无法解决任何问题。


我不能说我喜欢“静态”建议。首先,它不能解决问题(因为无法在运行时分配静态对象),其次,它们有很多自身的问题(例如,线程安全性)。就是说,我没有-1你。
jalf

您还应该注意,静态变量同时具有开始和终止生存期问题(Google表示“静态初始化顺序失败”)。就是说,我也没有-1。所以,请不要对我做任何事!:)
Johannes Schaub-litb

1
@Roddy-您的意思是“自动”(堆栈分配)而不是“静态”吗?(而且我也没有-1。)
Fred Larson

@ jalf-也许“静态”不是最好的词。您是否正在考虑从多个线程锁定单例构造的问题?
罗迪

我正在考虑使用“ static”关键字声明的所有变量。如果这不是您的意思,则应避免使用该词。:)就像弗雷德所说的那样,堆栈上的对象具有“自动”存储类。如果这就是您的意思,那么您的答案就更有意义了。
jalf

2

为什么不对所有东西都使用指针呢?

他们比较慢。

编译器优化对指针访问的支持将不那么有效,您可以在任意数量的网站上阅读有关它的内容,但这是Intel的不错的pdf。

检查页13,14,17,28,32,36;

在循环符号中检测不必要的内存引用:

for (i = j + 1; i <= *n; ++i) { 
X(i) -= temp * AP(k); } 

循环边界的表示法包含指针或内存引用。编译器没有任何手段来预测指针n引用的值是否通过其他某些赋值的循环迭代而更改。这将使用循环为每次迭代重新加载n引用的值。当发现潜在的指针混叠时,代码生成器引擎还可以拒绝调度软件流水线循环。由于指针n所引用的值在循环内没有老化,并且对于循环索引而言是不变的,因此* ns的加载要在循环边界之外进行,以简化调度并消除指针的歧义。

在这个主题上有很多变化

复杂的内存引用。换句话说,分析诸如复杂指针计算之类的引用会限制编译器生成高效代码的能力。在代码中,编译器或硬件将执行复杂计算以确定数据所在的位置应成为关注的焦点。指针别名和代码简化可以帮助编译器识别内存访问模式,从而使编译器可以将内存访问与数据操作重叠。减少不必要的内存引用可能会使编译器拥有对软件进行流水线处理的能力。如果使存储器引用计算保持简单,则很容易识别许多其他数据位置属性,例如混叠或对齐。


链接误入歧途。:-(
derM '17

1

从另一个角度看问题...

在C ++中,您可以使用指针(Foo *)和引用(Foo &)来引用对象。只要有可能,我都会使用引用而不是指针。例如,当通过引用传递给函数/方法时,使用引用可使代码(希望)进行以下假设:

  • 引用的对象不属于函数/方法,因此不应该属于delete该对象。就像说,“在这里,使用这些数据,但在完成后将其返回”。
  • NULL指针引用的可能性较小。可以传递一个NULL引用,但至少不会是函数/方法的问题。无法将引用重新分配给新的指针地址,因此您的代码不可能意外地将其重新分配为NULL或其他无效指针地址,从而导致页面错误。


0

我没有看到的是增加的内存使用率。假设4个字节的整数和指针

Pixel p;

将使用8个字节,并且

Pixel* p = new Pixel();

将使用12个字节,增加50%。直到您为512x512的图像分配足够的空间之前,这听起来并不多。然后您说的是2MB,而不是3MB。这忽略了管理所有具有这些对象的堆的开销。


0

在堆栈上创建的对象比分配的对象创建的速度更快。

为什么?

因为分配内存(使用默认内存管理器)需要一些时间(找到一些空块甚至分配该块)。

另外,您也没有内存管理问题,因为超出范围时,堆栈对象会自动销毁自身。

不使用指针时,代码更简单。如果您的设计允许您使用堆栈对象,则建议您这样做。

我本人不会使用智能指针使问题复杂化。

OTOH我在嵌入式领域做了一些工作,并且在堆栈上创建对象不是很聪明(因为分配给每个任务/线程的堆栈不是很大-您必须小心)。

因此,这是一个选择和限制的问题,没有适合他们的响应。

而且,一如既往,不要忘了使其尽可能简单


0

基本上,当您使用原始指针时,您没有RAII。


0

当我是一名新的C ++程序员时(这是我的第一语言),这使我非常困惑。有很多非常糟糕的C ++教程通常似乎可以归为以下两类之一:“ C / C ++”教程,这实际上意味着它是C教程(可能带有类),以及认为C ++是带删除功能的Java的C ++教程。 。

我认为我花了大约1-1.5年(至少)的时间在代码中的任何地方键入“ new”。我经常使用像vector这样的STL容器,这对我来说很重要。

我认为很多答案似乎要么被忽略,要么只是避免直接说出如何避免这种情况。通常,您不需要在构造函数中使用new进行分配,而在析构函数中使用delete进行清理。相反,您可以直接将对象本身粘贴在类中(而不是指向它的指针),然后在构造函数中初始化对象本身。然后,默认构造函数将完成大多数情况下所需的一切。

对于几乎无法解决问题的任何情况(例如,如果您冒着堆栈空间用尽的风险),无论如何您都应该使用以下标准容器之一:std :: string,std :: vector和std ::: map是我最常使用的三个,但是std :: deque和std :: list也很常见。其他(诸如std :: set和非标准rope之类的东西)使用不多,但表现相似。它们都从免费存储区中分配(C ++用“其他语言”表示“堆”),请参阅:C ++ STL问题:分配器


-2

除非将更多成员添加到Pixel类,否则第一种情况最好。随着越来越多的成员被添加,有可能发生堆栈溢出异常


我的意思是成员意味着成员变量。不是方法。对不起,如果我不清楚。
Uday
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.