内存分配和构造函数

5
抱歉,如果这已经在标准中明确说明过了,但我无法找到有关自动存储对象的内存是在封闭块的开头分配还是在执行构造函数之前立即分配的信息。我询问这个问题是因为 https://en.cppreference.com/w/cpp/language/storage_duration 上说到:

存储期 程序中的所有对象都具有以下存储期之一:

自动存储期。对象的存储空间在封闭代码块的开始时分配,在结束时释放。除了那些声明为 static、extern 或 thread_local 的对象外,所有局部对象都具有此存储期。

现在,这是否意味着即使由于某种原因没有调用构造函数,也会分配存储空间?
例如,我有类似于这样的东西。
{
     if(somecondition1) throw something;
     MyHugeObject o{};
     /// do something
}

因此,可能存在MyHugeObject不需要构建的情况,但根据我引用的源代码,即使该对象可能永远不会被构建,仍然为其分配了内存。这是事实还是基于实现的?


如果 MyHugeObject 的构造函数可能会引发异常,那该怎么办?因此,编译器不构造对象直到它实际需要使用才是有意义的。 - PaulMcKenzie
4
请记住,你引用的来源受到“似乎”规则的限制,因此只要没有副作用发生,编译器仍然可以在存储期间玩得快而不拘束。请注意,这段话已经被翻译成了中文。 - user4442671
太具有个人色彩,不足以成为答案,但根据我的经验,大多数编译器都会预先分配所有堆栈空间(反正这只是一条指令),无论是否会使用。请参见编译器浏览器 - Botje
如果编译器发现 somecondition 对于 MyHugeObject 的构造没有影响,即使构造函数可能会 throw,内存仍然可以预先分配。最好将 throw 后面的代码放在自己的 { } 块中,以确保安全。 - PaulMcKenzie
我认为这完全是实现定义的。在典型的基于堆栈的实现中,这种“分配”仅由程序汇编中硬编码的一些偏移量表示。 - Daniel Langr
请注意,在C语言中,存储期确实与封闭块相关联(在C语言中,存储期和生命期直接相关)。但是在C++中,存储期与生命期并不直接相关 - 它可以更长。也许这就是混淆的根源所在。 - Sander De Dycker
3个回答

6
首先,从语言标准的角度来看,您不能在对象的生命周期之外访问对象的存储。在对象创建之前,您不知道对象位于哪里,在它被销毁后,访问存储将产生未定义行为。简而言之,符合C++程序不能观察分配存储的时间差异。
自动存储通常意味着“在调用堆栈上”。即通过减少堆栈指针来进行分配,并且通过重新增加堆栈指针来进行取消分配。编译器可以发出代码,以便在对象生命周期开始/结束的确切位置进行堆栈指针调整,但这是低效的:每个使用的对象都会使生成的代码多两个额外的指令。这特别是一个问题,对于在循环中创建的对象:堆栈指针将不断地在两个或多个位置之间跳转。
为了提高效率,编译器会将所有可能的对象分配汇集到单个“堆栈帧分配”中:编译器为函数内的每个变量分配偏移量,确定存储所有存在于函数内的变量所需的最大大小,并在函数执行开始时使用单个堆栈指针递减指令分配所有内存。清理是相应的堆栈指针递增。这将从循环中删除任何分配/取消分配开销,因为下一次迭代中的变量将简单地重用与上一次迭代中使用的相同位置在堆栈帧内。这是一个重要的优化,因为许多循环至少声明一个变量。
C++标准不关心此事。由于在对象生命周期之外使用存储是UB,编译器可以自由地对存储执行任何它想要做的操作。程序员也不应该关心,但他们确实关心其程序的执行时间。这就是大多数编译器通过使用堆栈帧分配来进行优化的原因。

问题在于可能存在差异。如果我理解正确,if else可能比在return或throw语句之后编写相同代码需要更少的内存。 - Myrddin Krustowski
2
@RazielMagius 编译器可以自由地为不同的 if() {int i;} else {int j;} 子句分配变量在相同的内存位置:它们的生命周期不重叠,因此它们不能相互干扰。当然,除非程序存在未定义的行为。 - cmaster - reinstate monica
@RazielMagius 顺便说一下:我觉得担心throw情况下的内存消耗有点无意义:如果使用异常,应该只在特殊情况下使用。你应该编写函数,期望它在任何情况下都能创建对象。异常的成本太高了,不能在主代码路径中使用。这就像担心停车位的费用,当你担心汽车经销商在交付时损坏汽车,迫使他们退款而不是交付汽车。 - cmaster - reinstate monica
我的意思是,如果给定等效的{ if(condition) throw or return; do-something; }可能需要比逻辑上等效的{if (condition) throw/return; else do-something; }更多的内存,因为内存可能会在块的开头分配而不是跳过if语句后分配。 throw只是一个例子——即使我们永远无法到达构造函数,也可能分配内存。这是真的吗?还是我漏掉了什么? - Myrddin Krustowski
@RazielMagius 是的,没错。整个堆栈帧一次性分配,包括实际执行代码路径中可能不需要的变量的空间。尽管如此,这并不是问题:只有在整个堆栈帧的分配实际上导致堆栈溢出(无论是内存不足还是地址空间不足)时才会有影响。在所有其他情况下,堆栈指针会被某些量减少,一些内存会被成功使用,然后堆栈指针会被重新增加。无论增量或减量偏移量有多大都没有关系。 - cmaster - reinstate monica
顺便说一句:超过几千字节的大型对象不应该在堆栈上分配。 - cmaster - reinstate monica

2
内存从系统中回收的时刻取决于具体实现。标准规定的仅是构造函数调用时对象可以安全使用的时刻。
常见的实现方法是对于自动存储期对象使用堆栈,大多数情况下在块的开始分配整个框架并在块的末尾弹出它。即使堆栈操作很快,限制其数量也更简单,而简单就意味着更加健壮。
但是,即使对于自动存储期,使用堆栈也不是标准要求的,更不用说分配和从堆栈弹出框架的时刻了。

2
[basic.stc]中,C++标准对此有以下说明:
2 静态、线程和自动存储期与由声明(6.1)引入的对象以及由实现(6.6.7)隐式创建的对象相关联。
这个引用6.6.7是关于临时对象的[class.temporary]。临时对象不完全是相同的概念,但该部分有以下内容:
2 为了避免创建不必要的临时对象,通常会尽可能延迟临时对象的实例化。
我没有找到其他可以回答您问题的内容,因此标准似乎给了实现一些余地来决定何时为对象分配存储空间。
请注意,这不适用于对象初始化 - 这发生在声明语句执行时,根据[stmt.dcl]
2 具有自动存储期(6.6.5.3)的变量每次执行其声明语句时都会被初始化。在块中声明的具有自动存储期的变量在退出块时被销毁(8.6)。
您提到的cppreference链接可能讨论的是典型的实现,在这些实现中,具有自动存储期的对象在堆栈上分配。在这样的实现中,在封闭块的开头分配存储空间是有意义的(毕竟只是堆栈指针的简单(递增/递减),并且将它们分组是有益的)。
如果您想在不需要时避免为巨大对象分配存储空间,则可以重构代码。在某些实现中,引入附加块范围将实现这一点:
{
    if(somecondition1) throw something;
    {
        MyHugeObject o{};
        /// do something
    }
}

在其他实现中,可能需要采用其他方法。 @DanielLangr在下面的评论中指出了分配发生在封闭函数开始时而不是块开始时的实现方式。

1
我不确定添加块作用域是否会改变常见实现。例如,GCC和Clang都在函数开头为块作用域对象分配内存(通过降低rsp):在线演示 - Daniel Langr
@DanielLangr:公正的评论 - 我并不打算让这成为一个绝对可行的解决方案(标准并不保证它,因此实现可以自由选择做什么),而是作为一个可能有效的解决方案。我会澄清这一点。 - Sander De Dycker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接