【问题标题】:Missed Optimization: std::vector<T>::pop_back() not qualifying destructor call?错过优化:std::vector<T>::pop_back() 没有限定析构函数调用?
【发布时间】:2022-08-19 03:13:07
【问题描述】:

std::vector&lt;T&gt; 中,向量拥有分配的存储空间,它构造Ts 并破坏Ts。不管T\ 的类层次结构如何,std::vector&lt;T&gt; 知道它只创建了一个T,因此当调用.pop_back() 时它只需要销毁一个T(不是T 的某个派生类)。采取以下代码:

#include <vector>

struct Bar {
    virtual ~Bar() noexcept = default;
};

struct FooOpen : Bar {
    int a;
};

struct FooFinal final : Bar {
    int a;
};

void popEm(std::vector<FooOpen>& v) {
    v.pop_back();
}

void popEm(std::vector<FooFinal>& v) {
    v.pop_back();
}

https://godbolt.org/z/G5ceGe6rq

FooFinalPopEm 只是将向量的大小减少 1(元素)。这是有道理的。但是PopEm for FooOpen 调用了类通过扩展Bar 得到的虚拟析构函数。鉴于FooOpen 不是最终的,如果在FooOpen* 指针上调用普通delete fooOpen,则需要执行虚拟析构函数,但对于std::vector,它知道它只生成了@987654341 @ 并且没有构造它的派生类。因此,std::vector&lt;FooOpen&gt; 不能将类视为 final 并省略对pop_back() 上的虚拟析构函数的调用吗?

  • 不,编译器不知道向量只会包含 FooOpen。也许稍后它将与其他一些确实插入从 FooOpen 派生的类的组件链接。所以你的假设只对这个 sn-p 的代码有效。使用 FooFinal 可以完成优化。
  • @PepijnKramer 如何插入派生自FooOpen 的类?我能看到的唯一可能性是用户可以将派生对象放置到FooOpen 元素的存储中,这至少取决于许多未指定的行为,但我觉得应该是库未定义的行为首先。
  • @PepijnKramer 我认为关键是即使我们有一百万个继承 fooopen 的类,它们中的任何一个都不能存储在向量中。向量总是构造和破坏 fooopens,仅此而已。对这个问题进行猜测:向量知道可以进行这种优化,但这并不意味着编译器知道。编译器必须进行一些复杂的代码分析才能弄清楚这一点。我对正在使用的优化技术知之甚少,但我想你需要对向量进行一些特殊处理才能实现这一点。
  • @Wutz 不需要复杂的分析。规范说std::allocator_traits::destroy 用于销毁元素,对于std::allocator 作为分配器,这仅意味着(不合格的)析构函数调用。如果使用std::allocator,标准库可以检测容器并对其进行特殊处理(他们已经这样做以优化可复制的类型),然后始终使用合格的析构函数调用而不是allocator_traits::destroy,这将强制执行静态调度,即使该类有一个虚拟析构函数。
  • @UriRaz 不,如果您尝试将派生类插入向量中,您将只存储它的切片副本,因此也只会调用 FooOpen\ 的析构函数。这就是重点。不可能在向量中存储除 FooOpen 之外的任何其他类型。矢量接口根本不允许其他任何事情。

标签: c++ vector destructor final


【解决方案1】:

长话短说 - 编译器没有足够的上下文信息来推断它https://godbolt.org/z/roq7sYdvT

无聊的部分:

所有 3 个的结果都相似:msvc、clang 和 gcc,所以我猜这个问题很普遍。 我分析了 libstdc++ 代码只是为了发现 pop_back() 运行如下:

void pop_back() // a bit more convoluted but boils-down to this
{
    --back;
    back->~T();
}

一点也不意外。就像在 C++ 教科书中一样。但它显示了问题 - 从指针虚拟调用析构函数。 我们正在寻找的是此处描述的“去虚拟化”技术:Is final used for optimisation in C++ - 它指出去虚拟化是“假设”行为,因此如果编译器有足够的信息来执行它,它看起来可以进行优化。

我的意见:

我稍微干预了代码,我认为优化不会发生,因为编译器无法推断出“back”指向的唯一对象是 FooOpen 实例。我们——人类——知道这一点是因为我们分析了整个类,并看到了将元素存储在向量中的整体概念。我们知道指针必须只指向 FooOpen 实例,但编译器看不到它 - 它只看到一个可以指向任何地方的指针(向量分配未初始化的内存块,它的解释是向量逻辑的一部分,指针也在外部修改pop_back()) 的范围。在不了解 vector<> 的整个概念的情况下,我不知道如何推断(不分析整个类)它不会指向可以在其他翻译单元中定义的 FooOpen 的任何后代。

FooFinal 没有这个问题,因为它已经保证没有其他类可以从它继承,所以去虚拟化对于 FooFinal* 或 FooFinal& 指向的对象是安全的。

更新我做了几个可能有用的发现:

【讨论】:

  • 好分析。这看起来有点傻,因为此时T是一个完整的类型,所以在FooFinal的情况下应用了优化,但另一方面,即使std::vector的整个实现对编译器也是可见的,它不理解只构造 T 类型的对象(而不是从 T 派生的)。
【解决方案2】:

是的,这是一个错过的优化。

请记住,编译器是一个软件项目,必须编写功能才能存在。在这种情况下,虚拟破坏的相对开销可能足够低,以至于到目前为止,添加它并不是 gcc 团队的优先事项。

这是一个开源项目,所以你可以提交一个补丁来添加它。

【讨论】:

  • 此外,低优先级也可能是他们没有看到足够的用例来证明它的合理性。在向量中直接拥有一个带有虚拟析构函数的对象似乎是一种代码异味——在这种情况下,我通常会期望某种指针包装器,或者析构函数不应该是虚拟的。显然可能有例外,但可能数量不够多。
  • @HansOlsson将继承层次结构的底部放在向量中是一种气味,但是该层次结构中的每个类都有一个虚拟析构函数。除非您想尽可能多地执行标记final,否则vector&lt;FooOpen&gt; 的存在似乎很好
  • @Caleth您愿意解释一下为什么在向量中具有层次结构的底部是一种气味吗?
  • @KrzysiekKarbowiak 因为 Hans 建议的原因:你有一些打算是多态的,插入向量中会切片。
  • @Caleth 如果是底部层次结构它不能切片。在切片的情况下,我会称之为严重的程序员错误,而不是代码异味。
【解决方案3】:

感觉很像 § 11.4.7 (14) 对此提供了一些见解。截至最新的工作草案(N4910 Post-Winter 2022 C++ 工作草案,2022 年 3 月):

在执行析构函数的主体并销毁任何具有自动存储持续时间的对象后 在主体内分配,类 X 的析构函数调用 X 的直接非变量非静态数据的析构函数 成员,X 的非虚拟直接基类的析构函数,如果 X 是最派生的类 (11.9.3),它的 析构函数调用 X 的虚拟基类的析构函数。所有的析构函数都像被引用一样被调用 使用限定名称,即忽略更多派生类中任何可能的虚拟重写析构函数。 基和成员按照其构造函数完成的相反顺序被销毁(见 11.9.3)。[注 4:析构函数中的 return 语句 (8.7.4) 可能不会直接返回给调用者;在转移控制权之前 对于调用者,成员和基的析构函数被调用。 ——尾注]数组元素的析构函数按其构造的相反顺序调用(见 11.9)。

这个主题也很有趣,§ 11.4.6, (17):

在显式析构函数调用中,析构函数由 ~ 指定,后跟类型名称或 decltype 说明符 表示析构函数的类类型。析构函数的调用遵循通常的规则 成员函数(11.4.2);也就是说,如果对象不是析构函数的类类型,也不是派生类 从析构函数的类类型(包括通过空指针值调用析构函数时),程序具有未定义的行为。

因此,就标准而言,析构函数的调用受制于成员函数的通常规则。

对我来说,这听起来很像析构函数调用做了很多编译器可能无法确定的事情,在编译时,析构函数调用“什么也不做”——因为它也调用成员的析构函数,而 std::vector 不知道这一点。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-05-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2022-01-19
    • 1970-01-01
    • 2021-05-05
    相关资源
    最近更新 更多