编译器能否将堆上分配优化为栈上分配?

65

在编译器优化方面,将堆分配更改为栈分配是否合法或可行?或者这会违反as-if rule规定?

例如,假设以下是原始代码的版本

{
    Foo* f = new Foo();
    f->do_something();
    delete f;
}

编译器能把这个改成下面的形式吗?

{
    Foo f{};
    f.do_something();
}

我不这么认为,因为如果原始版本依赖于诸如自定义分配器之类的东西,那么这将产生影响。标准是否对此有具体说明?

我认为不会,因为这可能会对使用自定义分配器等内容的原始版本产生影响。标准对此有明确规定吗?

70
不,那太过分了。堆栈使用量的增加是一件大事,他们甚至用它命名了一个流行的编程网站。 - Hans Passant
4
相关 - nwp
2
为什么这个未使用的变量没有被优化掉? - 463035818_is_not_a_number
9
只有在Clang能够内联调用的函数(加上一些可能存在于函数体中的条件)时,它才会进行优化。 https://godbolt.org/g/hnAMTZ - Baum mit Augen
5
根据tobi303提供的链接,从C++14开始就有所改变,详见[expr.new];自C++14起,只要编译器可以证明相同的行为(例如在do_something中没有抛出任何异常),它就可以将Foo存储在堆栈中。 - Massimiliano Janes
显示剩余5条评论
3个回答

54

是的,这是合法的。C++14 中的 expr.new/10 规定:

实现允许省略可替换的全局分配函数 (18.6.1.1, 18.6.1.2) 的调用。当这样做时, 实现提供存储或通过扩展另一个 new 表达式的分配来提供存储。

expr.delete/7:

如果 delete-expression 的操作数值不是空指针,那么:

— 如果对象的 new-expression 的分配调用没有被省略并且分配没有被扩展(5.3.4), 则 delete-expression 必须调用解分配函数(3.7.4.2)。 new-expression 的分配调用返回的值应作为解分配函数的第一个参数传递。

— 否则,如果分配已被扩展或者通过扩展另一个 new-expression 的分配提供,并且 已经评估了由扩展 new-expression 提供存储的每个其他指针值的 delete-expression, 则 delete-expression 必须调用解分配函数。 扩展 new-expression 的分配调用返回的值应作为解分配函数的第一个参数传递。

— 否则,delete-expression 将不调用解分配函数(3.7.4.2)。

因此,总之,将 newdelete 替换为一些实现定义的内容是合法的, 例如使用堆栈而不是堆。

注意:正如Massimiliano Janes所评论的那样,如果do_something抛出异常,编译器可能无法完全按照您的示例进行转换:在这种情况下,编译器应省略对f的析构函数调用(而您的转换示例在这种情况下会调用析构函数)。但除此之外,它可以将f放入堆栈中。

2
问题是,即使使用了 new,分配是否可以在堆栈上进行。如果我理解正确,它将始终是动态内存,而不是在堆栈上。给定的段落说,分配的大小可以扩展到更大的动态内存块,或者使用先前扩展的动态内存。 - SHR
2
@SHR:我强调了“存储是由实现提供的”。它可以是任何东西,甚至是栈。 - geza
1
这些变化的背景在我的回答编译器是否允许优化堆内存分配?中进行了讨论。 - Shafik Yaghmour

6

这两者并不等价。f.do_something()可能会抛出异常,在这种情况下第一个对象仍然存在于内存中,而第二个对象会被销毁。


8
值得注意的是,将函数声明为 noexcept 对于 gcc 和 clang 的优化器并没有帮助,但是向 clang 显示函数体则有用。可能还有更多细节需要考虑。 - Baum mit Augen
@BaummitAugen 如果你在问“为什么编译器不执行这个优化”,我认为这背后确实有更多的原因:当有人写一个新表达式时,他们想要动态分配。如果他们想要栈分配,他们会写 Foo f{}。这其中有合理的原因,编译器无法知道,例如,也许他们正在运行 valgrind 并希望跟踪所有堆使用情况,或者他们正在调试堆碎片问题。编译器必须在允许的优化和程序员真正想要的之间取得平衡。编译器应该是朋友,而不是敌人。 - M.M

3
我想指出其他答案中我认为没有强调的一些重点内容:
struct Foo {
    static void * operator new(std::size_t count) {
        std::cout << "Hey ho!" << std::endl;
        return ::operator new(count);
    }
};

通常情况下,无法替换分配new Foo(),因为:

实现允许省略对可替换的全局分配函数(18.6.1.1、18.6.1.2)的调用。当这样做时,存储将由实现提供或通过扩展另一个新表达式的分配来提供。

因此,就像上面的Foo示例一样,需要调用Foo::operator new。省略此调用会改变程序的可观察行为。

现实世界的例子:Foo可能需要驻留在某些特殊的内存区域(如内存映射IO)中才能正常工作。


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