关于动态分配:使用在C ++中动态分配的数组有什么问题?

What is wrong with using arrays dynamically allocated in C++?

本问题已经有最佳答案,请猛点这里访问。

如下代码:

1
2
3
4
5
6
7
int size = myGetSize();
std::string* foo;
foo = new std::string[size];
//...
// using the table
//...
delete[] foo;

我听说这样的使用(不是这个代码,而是整个动态分配)在某些情况下是不安全的,应该只与raii一起使用。为什么?


我看到您的代码有三个主要问题:

  • 使用裸的,拥有指针的。

  • 使用裸露的new

  • 使用动态数组。

  • 每一种都因其自身的原因而不受欢迎。我会依次解释每个问题。

    (1)违反了我所说的子表达式正确性;(2)违反了语句正确性。这里的想法是,任何语句,甚至任何子表达式本身都不应该是错误的。我把"错误"一词粗略地理解为"可能是一个错误"。

    编写好代码的想法是,如果出错,那不是你的错。你的基本心态应该是一个偏执的懦夫。根本不编写代码是实现这一点的一种方法,但由于这很少满足需求,接下来最好的事情是确保无论您做什么,都不是您的错。唯一可以系统地证明这不是您的错误的方法是,如果代码中没有任何一部分是错误的根本原因。现在让我们再看一下代码:

    • new std::string[25]是一个错误,因为它创建了一个动态分配的对象,该对象被泄漏。只有当其他人,在其他地方,在任何情况下,记住清理时,此代码才有条件地成为非错误。

      首先,这要求将该表达式的值存储在某个地方。这种情况发生在您的案例中,但在更复杂的表达式中,可能很难证明它会发生在所有的案例中(未指明的评估顺序,我正在看您)。

    • foo = new std::string[125];是一个错误,因为foo再次泄漏了一个资源,除非星星排列整齐,并且有人在任何情况下和正确的时间记得要清理。

    到目前为止,编写此代码的正确方法是:

    1
    std::unique_ptr<std::string[]> foo(std::make_unique<std::string[]>(25));

    请注意,此语句中的每个子表达式都不是程序错误的根本原因。这不是你的错。

    最后,对于(3),动态数组是C++中的一个错误特性,并且基本上不应该被使用。有几个仅与动态阵列相关的标准缺陷(不值得修复)。简单的论点是,如果不知道数组的大小,就不能使用数组。您可能会说,您可以使用sentinel或tombstone值动态标记数组的结尾,但这使得程序值的正确性依赖于它,而不是依赖于类型,因此不能静态地检查它(即"不安全"的定义)。你不能静态地断言这不是你的错。

    所以不管怎样,您最终都必须为数组大小维护一个单独的存储。你猜怎么着,你的实现无论如何都必须复制这个知识,这样当你说delete[]时它可以调用析构函数,所以这是浪费的复制。相反,正确的方法不是使用动态数组,而是将内存分配(并通过分配程序使其可自定义,为什么我们要这样做)与元素导向的对象构造分开。将所有这些(分配器、存储、元素计数)打包成一个方便的类是C++方式。

    因此,代码的最终版本是:

    1
    std::vector<std::string> foo(25);


    您提出的代码并非异常安全,替代方案是:

    1
    2
    std::vector<std::string> foo( 125 );
    //  no delete necessary

    是。当然,vector后来才知道它的大小,而且可以在调试模式下执行边界检查;可以通过(通过引用)传递或者甚至是按值)到一个函数,然后该函数将能够使用它没有任何附加的参数。数组new遵循数组的C约定和C中的数组严重破坏。

    据我所知,从来没有一个新的数组是合适的。


    I heard that such use (not this code precisely, but dynamic allocation as a whole) can be unsafe in some cases, and should be used only with RAII. Why?

    以这个例子为例(类似于您的例子):

    1
    2
    3
    4
    5
    6
    7
    8
    int f()
    {
        char *local_buffer = new char[125];
        get_network_data(local_buffer);
        int x = make_computation(local_buffer);
        delete [] local_buffer;
        return x;
    }

    这是微不足道的。

    即使您正确地编写了上述代码,也有人可能在一年后来到您的函数中添加条件(或10或20):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    int f()
    {
        char *local_buffer = new char[125];
        get_network_data(local_buffer);
        int x = make_computation(local_buffer);
        if(x == 25)
        {
            delete[] local_buffer;  
            return 2;
        }
        if(x < 0)
        {
            delete[] local_buffer; // oops: duplicated code
            return -x;
        }
        if(x || 4)
        {
            return x/4; // oops: developer forgot to add the delete line
        }
        delete[] local_buffer; // triplicated code
        return x;
    }

    现在,确保代码没有内存泄漏要复杂得多:您有多个代码路径,每个路径都必须重复delete语句(我故意引入了一个内存泄漏,为您提供一个示例)。

    这仍然是一个很小的例子,只有一个资源(本地缓冲区),它(幼稚地)假设代码在分配和释放之间不抛出任何异常。这个问题导致无法维护的代码,当您的函数分配大约10个本地资源时,可以抛出,并且有多个返回路径。

    更重要的是,上面的进展(简单、简单的情况扩展到具有多个出口路径的更复杂的函数,扩展到多个资源等等)是大多数项目开发中代码的自然进展。不使用raii,会为开发人员创建一种自然的方法来更新代码,这种方法会在项目的整个生命周期中降低质量(这被称为cruft,是一件非常糟糕的事情)。

    TLDR:使用C++中的原始指针来进行内存管理是一个错误的做法(很难实现一个观察者角色,一个带有原始指针的实现,很好)。使用原始资源管理违反SRP和DRY原则)。


    原始指针很难正确处理,例如WRT。复制对象。

    使用经过良好测试的抽象(如std::vector)更简单、更安全。

    简言之,不要不必要地重新设计车轮,其他人已经创造了一些在质量或价格上不太可能匹配的卓越车轮。


    它有两大缺点-

  • new不保证您分配的内存是用0s或null初始化的。除非您初始化它们,否则它们将具有未定义的值。

  • 其次,内存是动态分配的,这意味着它驻留在heap中,而不是驻留在stack中。EDCOX1 4和EDCOX1 5之间的区别在于,当变量超出范围时,堆栈被清除,但EDCOX1、4、s不被自动清除,C++也不包含内置的垃圾收集器,这意味着如果EDCOX1调用0调用失败,则最终会出现内存泄漏。


  • 最后可以跳过delete。在最严格的意义上,所显示的代码不是"错误的",但是C++在变量的范围内提供变量的自动内存管理;在示例中使用指针是不必要的。


    如果分配的内存在不再需要时没有释放,则会导致内存泄漏。它没有指定泄漏的内存会发生什么,但是现代操作系统会在程序终止时收集它。内存泄漏可能非常危险,因为系统可能会耗尽内存。


    在一个try块中进行分配,catch块应该解除分配到目前为止所有分配的内存,并且在异常块外的正常出口上,catch块不应该通过正常的执行块,以避免重复删除。


    参见JPL编码标准。动态内存分配导致不可预知的执行。我在完全编码的系统中看到了动态内存分配的问题——随着时间的推移,内存碎片就像硬盘一样。从堆中分配内存块将花费越来越长的时间,直到无法分配请求的大小。此时,您开始返回空指针,整个程序崩溃,因为很少有人测试内存不足的情况。重要的是要注意,通过本书,您可能有足够的可用内存,但是内存的碎片化阻止了分配。这是在.NET CLI中解决的,它使用"句柄"而不是指针,运行时可以使用标记和扫描垃圾收集器进行垃圾收集,从而移动内存。在扫描过程中,它压缩内存以防止碎片化并更新句柄。但是指针(内存地址)无法更新。但这是一个问题,因为垃圾收集不再具有确定性。尽管,.NET添加了一些机制使其更具确定性。但是,如果您遵循JPL的建议(第2.5节),就不需要进行花哨的垃圾收集。您可以在初始化时动态地分配所需的全部内容,然后重用分配的内存,永远不释放它,这样就不会有碎片风险,并且仍然可以进行确定性垃圾收集。