如下代码:
1 2 3 4 5 6 7
| int size = myGetSize();
std::string* foo;
foo = new std::string[size];
//...
// using the table
//...
delete[] foo; |
我听说这样的使用(不是这个代码,而是整个动态分配)在某些情况下是不安全的,应该只与raii一起使用。为什么?
- 假设在调用delete[]之前引发了异常。那么你的行为就不明确了。另外,foo中不包含有关的信息。它指向什么(它是指向string的指针吗?到一个EDOCX1[3]数组?必须调用EDOCX1[4]吗?还是应该有人这样做?.
- 为什么行为不明确?这不是"只是"内存泄漏吗?
- 只要看看raii的含义和推理,你就会明白为什么你的代码被认为是不安全的。
- @不,这是不明确的行为。为什么?因为标准是这么说的。
- @阿兰:标准规定了不明确的行为,所以是这样的。在实践中,内存泄漏的后果最小,还有一个事实是析构函数不会被执行,这反过来会导致内存进一步泄漏、资源保持锁定……不管毁灭者要做什么。
- @不,不是乌布。这只是内存泄漏。例如,单例的一个常见(有时是必要的)实践是动态地分配它们,而不是销毁它们。
- @matthieum.:请参阅第章和第节
- @干杯和哦。-严格地说你是对的。它并不总是ub,只有当程序依赖于构造函数调用的副作用时。不过,我认为"记忆泄漏"就是其中一种情况。α167,3.8 p4。
- @ MatthieuM。它在对象生存期内,&167;3.8 p4。
- @Juanchopanza:谢谢,我自己刚找到它;我从delete expression开始,显然它不在那里(因为它指定了表达式的作用,而不是它的缺失……)
- @juanchopanza这一节指的是"程序可以通过重用对象所占用的存储来结束任何对象的生命周期,或者通过使用非平凡的析构函数为类类型的对象显式调用析构函数来结束任何对象的生命周期"——这两种情况都不会出现在代码中。那一段中的另一段是指这两种情况下的行为。
- 具体地说,new std::string[125];分配的内存不会再用于其他目的;因此不会触发此段(该内存只是丢失了,再也无法访问)。
- @马特麦克纳布:阅读不容易。本节详细介绍的不是生命周期结束后可以做什么,而是生命周期如何结束。对于具有普通析构函数的对象,重用存储将结束其生命周期,而对于具有非普通析构函数的对象,生命周期将在执行析构函数时结束。如果您阅读下一句话,它包含的程序不需要在重用或释放对象所占用的存储之前显式调用析构函数(emphasis mine),所以我们不只是讨论重用。
- @Matthieum的新弦永远不会结束。最后一句话说只有ub与析构函数产生的副作用有关。但不可能有任何依赖于这些字符串的代码被销毁。
- @Matthieum.:我很惊讶看到您将内存泄漏归类为未定义的行为。不,不是。虽然ub可能会导致内存泄漏(例如删除析构函数未标记为virtual的基类指针),但仅内存泄漏不会调用ub。
- @纳瓦兹:我不关心内存泄漏本身,更关心的是没有执行析构函数。这可以很容易地锁定一个资源或保持一个文件句柄不受任何原因的影响…不管毁灭者在做什么。
- @MattmcNabb:我不是在说std::string,而是关于new[]和delete[]的一般情况,因为OP并没有准确地询问这个代码,而是作为一个整体进行动态分配。
- @马提厄姆:那不是乌布。如果不释放资源,则不会调用ub。只是漏了。
- @Matthieum那么你是说操作代码实际上没有ub?不管怎样,我在这里开始了一个新问题-我们不应该在评论中进行这种讨论:)
- 我不知道程序的第一行的要点。获得整数size,但不用于后继。下面的两行125实际上是size吗?
- 是的!我已经改正了那个错误。
我看到您的代码有三个主要问题:
使用裸的,拥有指针的。
使用裸露的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); |
- 注:有一个提议的std::dynarray类(被搁置或拒绝)。一些人认为,std::vector存储了一个额外的容量成员,并且具有在许多情况下不需要的调整大小功能,并且应该存在一个缩小的版本(不调整大小)。
- @Matthieum.:如果您使用的是Itanium ABI,那么当您使用析构函数时,vector仍然比动态数组好。不过,我同意缺少一个漂亮的、动态的、固定大小的数组。dynarray不是很正确的事情(我认为它现在在实验性TS中)。Boost可能有一些合适的东西。
- 我知道最大的问题是0。使代码难以读取和维护。当您可以在一行或两行中完成相同的工作(实际上是更好的工作)时,为什么要做一些分散在文件(或多个文件)周围的几十行代码的事情?
- @Mattmcnab:当然-代码越少,故障就越少。我希望这在我的帖子里出现——)
- 请注意,EDCOX1〔5〕不是C++标准的一部分(如C++ 11)。
- "最后,至于(3),动态数组是C++中的一个错误,应该基本上永远不被使用",这是绝对的建议。在编程语言的世界里,有些人必须使用C++来创建其他用途。类似地,在C++中,有些人必须使用动态数组和放置新的和不可拖动的TMP编程等来创建其他人使用的东西。当允许的C++被简化成一个安全的子集,比如C ^,为什么不直接使用C?或者Java,不管怎样。但即使这些语言对许多程序员来说也是不安全的。等等。。。
- 阿尔夫:动态数组没有错,因为它们没有"足够像Java"。它们是错误的,因为它们强制您解包必须始终组合在一起的两条信息(数组及其大小)。(当然,如果你知道你必须做什么和你正在做什么,你可以做一些错误的事情,但这不是给出建议的目的。如果不适合,所有建议都可以忽略。)
- 我看不出你从哪里得到规则2。你所说的"裸体"是什么意思?
- @你能指出数组new的有效用法吗?(我想这就是他所说的"动态数组"的意思。)我已经写C++大约25年了,包括沿着字符串和向量行实现预先标准的容器,我从来没有找到过。
- @jameskanze:"裸new"是指出现在特别整理和审查的图书馆功能列表之外的任何new。如我所说,表达式new T是一个编程错误,除非您能够证明结果的所有权已得到处理,而且这个证明的表面应该被限制为尽可能小的代码空间。
- @Kerreksb在这种情况下,大多数new将是裸的。通常,作为一个用户,您将在两种情况下使用new:您自己实现某种动态结构(因为没有库类型对应于您需要的内容),或者您需要对象的特定生存期,这与编译器隐式理解的任何内容都不对应。在这种情况下,它几乎总是一个"裸新";您通常不在库代码中,而是在应用程序的核心。
- @jameskanze如果"您需要对象的特定生存期,而该生存期与编译器隐式理解的任何内容都不对应",那么使用唯一指针或共享指针(取决于用法)而不是原始指针可能更安全,除非您有真正严格的大小/性能要求。
- @如果一个物体需要一个特定的寿命,那么unique_ptr或shared_ptr将不起作用。总的来说,生命周期并不对应于作用域(也不对应于作用域的集合)。
- @jameskanze,但是您需要以某种方式跟踪指针,并且跟踪指针的任何内容都将存在于某个范围的某个地方(或者如果使用共享指针,则可能存在多个范围)。事实上,对于类似的东西,如果指针的某些用户不一定知道何时删除,那么weak_ptr就非常方便了(毕竟,只有在通过一个指针完成所有访问之后,才可以将原始指针设置为NULL,如果您创建了指向同一位置的其他指针,那么就无法进行删除。确定第一个指针为空的OSE指针)。
- @刺拳我不确定你所说的"你需要跟踪指针"是什么意思。很明显,物体可以以某种方式到达,这样它就可以从任何时候需要它存在的地方接收事件或其他东西。当一个事件发生而导致其删除时,同样明显,它将(在其析构函数中)通知所有相关方。至于weak_ptr,如果你需要的话,这就是你不应该使用shared_ptr的证据。
您提出的代码并非异常安全,替代方案是:
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调用失败,则最终会出现内存泄漏。
- 1。这里不是问题,并且可以通过零初始化内置类型的方式调用new。2。RAII以这种取消分配的"问题"为例。
- @Juanchopanza的问题是"不是这段代码很精确,而是整个动态分配",所以我回答了一个整体,不仅仅是这个案例。
- 那就没问题了。RAII允许您安全地进行动态分配。
最后可以跳过delete。在最严格的意义上,所显示的代码不是"错误的",但是C++在变量的范围内提供变量的自动内存管理;在示例中使用指针是不必要的。
如果分配的内存在不再需要时没有释放,则会导致内存泄漏。它没有指定泄漏的内存会发生什么,但是现代操作系统会在程序终止时收集它。内存泄漏可能非常危险,因为系统可能会耗尽内存。
- 一般来说,不仅仅是内存可以泄漏。它是各种资源(引用计数、文件句柄等)
在一个try块中进行分配,catch块应该解除分配到目前为止所有分配的内存,并且在异常块外的正常出口上,catch块不应该通过正常的执行块,以避免重复删除。
参见JPL编码标准。动态内存分配导致不可预知的执行。我在完全编码的系统中看到了动态内存分配的问题——随着时间的推移,内存碎片就像硬盘一样。从堆中分配内存块将花费越来越长的时间,直到无法分配请求的大小。此时,您开始返回空指针,整个程序崩溃,因为很少有人测试内存不足的情况。重要的是要注意,通过本书,您可能有足够的可用内存,但是内存的碎片化阻止了分配。这是在.NET CLI中解决的,它使用"句柄"而不是指针,运行时可以使用标记和扫描垃圾收集器进行垃圾收集,从而移动内存。在扫描过程中,它压缩内存以防止碎片化并更新句柄。但是指针(内存地址)无法更新。但这是一个问题,因为垃圾收集不再具有确定性。尽管,.NET添加了一些机制使其更具确定性。但是,如果您遵循JPL的建议(第2.5节),就不需要进行花哨的垃圾收集。您可以在初始化时动态地分配所需的全部内容,然后重用分配的内存,永远不释放它,这样就不会有碎片风险,并且仍然可以进行确定性垃圾收集。