【问题标题】:Why not use pointers for everything in C++?为什么不在 C++ 中对所有内容都使用指针?
【发布时间】:2010-11-07 01:14:25
【问题描述】:

假设我定义了一些类:

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;

他们基本上做同样的事情,对吧? 一个在堆栈上,另一个在堆上,所以我必须稍后将其删除。两者有什么根本区别吗?为什么我应该更喜欢其中一个?

【问题讨论】:

    标签: c++ pointers heap-memory stack-memory


    【解决方案1】:

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

    • 首先,显而易见但不太重要的一点是:堆分配很慢。堆栈分配很快。
    • 其次,更重要的是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 永远不会被释放,如果 Pixel 被复制,我们最终会导致两个实例都试图删除相同的数据。因此,这里的最后一个例子有点不可信。实际代码有点棘手,但它显示了一般的想法)

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

    【讨论】:

    • +1。虽然,1leak/100loc 太多了。每 1000 行代码可能有 1 个。
    • @Milan:面对异常,我会说 100 可能比 1000 更接近。
    • 是的,您可能能够写出前 500 行而没有泄漏。然后再添加 100 行,其中包含 6 种不同的方式来泄漏相同的数据,都在同一个函数中。当然,我没有测量过这个,但听起来不错。 :)
    • @Matt:哦,真的吗?不使用异常就不用担心内存管理?这对我来说是个新闻。我想很多 C 程序员希望他们也知道这一点。我相信很多用 C 编写的大型软件项目只要知道这个小智慧,就可以大大简化:只要没有例外,就没有必要管理你的记忆。
    • @Matt:我不是。我故意解释它们。没有“错误”。看看你在我所有答案上留下的一串 cmets,很清楚它们值多少钱。无论如何,我的帖子中没有看到任何“强迫症样板”。我也没有看到任何旨在保护功能的东西。我看到一个非常简单的习语被用来编写非常简单的代码,使用起来非常简单。没有它,客户端代码会变得更加复杂和脆弱,而类本身的实现可能会节省几行代码。
    【解决方案2】:

    在您添加删除之前,它们是不同的。
    您的示例过于琐碎,但析构函数实际上可能包含执行某些实际工作的代码。这称为 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的主要区别在于:

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

    例如:

     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.
    

    还有其他的。

    【讨论】:

    • 我喜欢将 C++ 文件的使用情况与 Java 进行比较(让我微笑)。
    • 同意。还有加分,因为它表明 RAII 被用于管理其他类型的资源,而不仅仅是内存分配。
    【解决方案3】:

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

    来自 Java 背景,您可能还没有完全准备好 C++ 中有多少是围绕跟踪已分配的内容以及谁负责释放它。

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

    显然,如果您非常小心,您总是可以在堆上分配并手动释放,但好的软件工程的一部分是以不会破坏的方式构建事物,而不是信任您的超级-人类程序员永远不会犯错。

    【讨论】:

      【解决方案4】:

      只要有机会,我更喜欢使用第一种方法,因为:

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

      【讨论】:

        【解决方案5】:

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

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

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

        这只是我对这个问题要说的第一件事。

        【讨论】:

          【解决方案6】:

          一个好的一般经验法则是永远不要使用 new ,除非你绝对必须这样做。如果您不使用 new,您的程序将更易于维护且不易出错,因为您不必担心在哪里清理它。

          【讨论】:

            【解决方案7】:

            代码:

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

            不动态分配内存——不搜索空闲内存,不更新内存使用,什么都没有。它是完全免费的。编译器在编译时在堆栈上为变量保留空间 - 它可以保留很多空间并创建单个操作码来将堆栈指针移动所需的数量。

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

            那么问题就变成了——你想为你的数据使用堆栈空间还是堆空间。像 'p' 这样的堆栈(或局部)变量不需要解引用,而使用 new 会增加一层间接性。

            【讨论】:

              【解决方案8】:

              是的,起初这是有道理的,来自 Java 或 C# 背景。记住释放分配的内存似乎没什么大不了的。但是当你遇到第一次内存泄漏时,你会摸不着头脑,因为你发誓你释放了一切。然后第二次发生,第三次你会更加沮丧。最后,在因内存问题而头疼六个月之后,您将开始厌倦它,堆栈分配的内存将开始看起来越来越有吸引力。多么漂亮和干净——只要把它放在堆栈上就可以了。很快您就会随时使用堆栈。

              但是——这种体验是无可替代的。我的建议?试试你的方式,现在。你会看到的。

              【讨论】:

              • 你忘了提到它的邪恶双胞胎,双重释放。 :) 就在你认为你已经释放了所有内存的时候,你开始出现错误,因为你正在使用被释放的内存,或者你试图释放已经被释放的内存。
              【解决方案9】:

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

              我建议查看提升 smart pointers library for your pointers.

              【讨论】:

                【解决方案10】:

                不更新所有东西的最好理由是,当堆栈上的东西时,您可以非常确定地进行清理。在 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——资源分配即初始化,它可以极大地提高您处理资源获取和处置的能力。

                【讨论】:

                  【解决方案11】:

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

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

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

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

                  【讨论】:

                    【解决方案12】:

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

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

                    【讨论】:

                      【解决方案13】:

                      我想说这很大程度上取决于品味。如果您创建一个接口允许方法采用指针而不是引用,则您允许调用者传入 nil。由于您允许用户传入 nil,因此用户传入 nil。

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

                      但是,有时您真的希望能够传入 nil,然后引用就不可能了 :) 指针给您更大的灵活性,让您更懒惰,这真的很好。在知道必须分配之前永远不要分配!

                      【讨论】:

                      • 他指的不是函数参数,而是在谈论分配事物的位置(堆与堆栈)。他指出 java 只是堆上的所有对象(我听说现代版本中有一些巧妙的技巧可以自动将一些对象放入堆栈)。
                      • 我认为您正在回答关于指针与引用的不同问题;而不是 OP 关于基于堆栈或基于堆的对象的问题。
                      【解决方案14】:

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

                      当然,有趣的是,我看到的每个 Java 教程都提到垃圾收集器是如此酷热,因为你不必记得调用 delete,而实际上 C++ 只需要 @987654323 @ 当你打电话给new (和delete[] 当你打电话给new[])。

                      【讨论】:

                        【解决方案15】:

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

                        • 静态对象更快(没有新建/删除,没有间接访问它们)
                        • 无需担心对象的生命周期
                        • 更少的击键次数 更易读
                        • 更加健壮。每个“->”都是对 NIL 或无效内存的潜在访问

                        为了澄清,在这种情况下,“静态”是指非动态分配。 IOW,任何不在堆上的东西。是的,它们也可能存在对象生命周期问题——就单例销毁顺序而言——但将它们放在堆上通常不会解决任何问题。

                        【讨论】:

                        • 我不能说我喜欢“静态”建议。首先,它没有解决问题(因为静态对象不能在运行时分配),其次,它们本身有很多问题(例如线程安全)。也就是说,我没有 -1 你。
                        • 您还应该注意,静态变量同时存在启动和停止生命周期问题(谷歌搜索“静态初始化顺序惨败”)。也就是说,我也没有-1你。所以请不要对我做任何事! :)
                        • @Roddy - 您的意思是“自动”(堆栈分配)而不是“静态”吗? (我也没有 -1 你。)
                        • @jalf- 也许“静态”不是最好的词。有没有想过多线程单例构造锁的问题?
                        • 我正在考虑使用“static”关键字声明的所有变量。如果这不是你的意思,你可能应该避免这个词。 :) 就像 Fred 所说,堆栈上的对象具有“自动”存储类。如果这就是您的意思,那么您的回答就更有意义了。
                        【解决方案16】:

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

                        他们比较慢。

                        编译器优化不会像指针访问语义那样有效,您可以在任意数量的网站上阅读它,但这里有一个不错的pdf from Intel.

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

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

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

                        循环边界的符号 包含指针或内存 参考。编译器没有 任何方法来预测值是否 指针 n 所引用的正在 通过一些循环迭代改变 其他任务。这使用循环 重新加载 n 引用的值 对于每次迭代。代码生成器 引擎也可能拒绝调度一个 潜在的软件流水线循环 找到指针别名。由于 指针 n 引用的值不是 在循环内老化,它是 不变的循环索引, 装载 *n s 要进行 在循环边界之外 更简单的调度和指针 消除歧义。

                        ...这个主题的许多变体....

                        复杂的内存引用。或者在其他 单词,分析参考文献,例如 复杂的指针计算,应变 编译器生成的能力 高效的代码。代码中的地方 编译器或硬件在哪里 执行复杂的计算 为了确定数据在哪里 居住,应该是重点 注意力。指针别名和代码 简化帮助编译器 识别内存访问模式, 允许编译器重叠 内存访问与数据操作。 减少不必要的内存引用 可能会暴露给编译器 流水线软件的能力。许多 其他数据位置属性,例如 作为混叠或对齐,可以 如果内存引用很容易识别 计算保持简单。用于 强度降低或感应 简化内存引用的方法 对协助编译器至关重要。

                        【讨论】:

                        • 链接误入歧途。 :-(
                        【解决方案17】:

                        换个角度看问题……

                        在 C++ 中,您可以使用指针 (Foo *) 和引用 (Foo &amp;) 来引用对象。我尽可能使用引用而不是指针。例如,当通过引用传递给函数/方法时,使用引用允许代码(希望)做出以下假设:

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

                        【讨论】:

                          【解决方案18】:

                          问题是:为什么要对所有内容都使用指针?堆栈分配的对象不仅创建起来更安全、更快,而且打字更少,代码看起来更好。

                          【讨论】:

                            【解决方案19】:

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

                            Pixel p;
                            

                            将使用 8 个字节,并且

                            Pixel* p = new Pixel();
                            

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

                            【讨论】:

                              【解决方案20】:

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

                              为什么?

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

                              此外,您不会遇到内存管理问题,因为堆栈对象在超出范围时会自动销毁自己。

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

                              我自己不会使用智能指针使问题复杂化。

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

                              所以这是一个选择和限制的问题,没有任何回应可以适应所有这些。

                              并且,一如既往地不要忘记keep it simple,尽可能多。

                              【讨论】:

                                【解决方案21】:

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

                                【讨论】:

                                  【解决方案22】:

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

                                  我认为我花了大约 1 到 1.5 年(至少)在我的代码中的任何地方键入“新”。我经常使用像 vector 这样的 STL 容器,这帮我解决了这个问题。

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

                                  对于几乎所有这不起作用的情况(例如,如果您冒着用完堆栈空间的风险),您可能应该使用标准容器之一:std::string、std::vector 和std::map 是我最常用的三个,但 std::deque 和 std::list 也很常见。其他的(像 std::set 和非标准的 rope 之类的东西)使用得不多,但行为相似。它们都从免费存储中分配(C++ 用语在其他一些语言中表示“堆”),请参阅:C++ STL question: allocators

                                  【讨论】:

                                    【解决方案23】:

                                    第一种情况最好,除非将更多成员添加到 Pixel 类。 随着越来越多的成员加入,可能会出现堆栈溢出异常

                                    【讨论】:

                                    • 我的意思是 members 是 member variables 。不是方法。对不起,如果我不清楚。
                                    猜你喜欢
                                    • 1970-01-01
                                    • 2021-07-23
                                    • 1970-01-01
                                    • 2015-06-08
                                    • 2014-01-04
                                    • 1970-01-01
                                    • 1970-01-01
                                    • 2016-09-24
                                    • 1970-01-01
                                    相关资源
                                    最近更新 更多