【问题标题】:Why does CLANG 3.5 on Linux cleans a "std::string" up twice when calling DTOR when throwing in CTOR?为什么在抛出 CTOR 时调用 DTOR 时,Linux 上的 CLANG 3.5 会清理两次“std::string”?
【发布时间】:2020-08-02 12:06:37
【问题描述】:

有一个项目专注于使用 C++ 98 没有额外的依赖,但它需要维护动态分配的内存。智能指针不可用,因此添加了手动清理的代码。该方法是在 CTOR 中将变量显式设置为NULL,读取一些可能动态分配内存的数据,捕获任何发生的异常并通过手动调用 DTOR 清理内存。无论如何,这都需要实现释放内存,以防万一一切成功,并且只是通过检查是否已分配内存的安全措施来增强。

以下是与此问题最相关的available code

default_endian_expr_exception_t::doc_t::doc_t(kaitai::kstream* p__io, default_endian_expr_exception_t* p__parent, default_endian_expr_exception_t* p__root) : kaitai::kstruct(p__io) {
    m__parent = p__parent;
    m__root = p__root;
    m_main = 0;

    try {
        _read();
    } catch(...) {
        this->~doc_t();
        throw;
    }
}

void default_endian_expr_exception_t::doc_t::_read() {
    m_indicator = m__io->read_bytes(2);
    m_main = new main_obj_t(m__io, this, m__root);
}

default_endian_expr_exception_t::doc_t::~doc_t() {
    if (m_main) {
        delete m_main; m_main = 0;
    }
}

标题中最相关的部分如下:

class doc_t : public kaitai::kstruct {
    public:
        doc_t(kaitai::kstream* p__io, default_endian_expr_exception_t* p__parent = 0, default_endian_expr_exception_t* p__root = 0);

    private:
        void _read();

    public:
        ~doc_t();

    private:
        std::string m_indicator;
        main_obj_t* m_main;
        default_endian_expr_exception_t* m__root;
        default_endian_expr_exception_t* m__parent;
    };

代码在three different environmentsclang3.5_linuxclang7.3_osxmsvc141_windows_x64 中进行了测试,以在读取数据时显式抛出异常以及在这些情况下是否泄漏内存。问题是这仅在 CLANG 3.5 for Linux 上触发 SIGABRT。最有趣的stack frames 如下:

<frame>
  <ip>0x577636E</ip>
  <obj>/usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.19</obj>
  <fn>std::basic_string&lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt;::~basic_string()</fn>
</frame>
<frame>
  <ip>0x5ECFB4</ip>
  <obj>/home/travis/build/kaitai-io/ci_targets/compiled/cpp_stl_98/bin/ks_tests</obj>
  <fn>default_endian_expr_exception_t::doc_t::doc_t(kaitai::kstream*, default_endian_expr_exception_t*, default_endian_expr_exception_t*)</fn>
  <dir>/home/travis/build/kaitai-io/ci_targets/tests/compiled/cpp_stl_98</dir>
  <file>default_endian_expr_exception.cpp</file>
  <line>51</line>
</frame>

[...]

<frame>
  <ip>0x577636E</ip>
  <obj>/usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.19</obj>
  <fn>std::basic_string&lt;char, std::char_traits&lt;char&gt;, std::allocator&lt;char&gt; &gt;::~basic_string()</fn>
</frame>
<frame>
  <ip>0x5ED17E</ip>
  <obj>/home/travis/build/kaitai-io/ci_targets/compiled/cpp_stl_98/bin/ks_tests</obj>
  <fn>default_endian_expr_exception_t::doc_t::~doc_t()</fn>
  <dir>/home/travis/build/kaitai-io/ci_targets/tests/compiled/cpp_stl_98</dir>
  <file>default_endian_expr_exception.cpp</file>
  <line>62</line>
</frame>

第 51 行和第 62 行是上面提供的 CTOR 和 DTOR 的最后一行,所以实际上是右括号。这看起来像是编译器添加的一些代码只是试图释放维护的std::string 两次,一次在 DTOR 中,另一次在 CTOR 中,很可能仅在引发异常时。

这个分析完全正确吗?

如果是这样,这是 C++ 的一般预期行为还是仅这个具体的编译器?我想知道,因为其他编译器不SIGABRT,即使代码对所有人都是一样的。这是否意味着不同的编译器会以不同的方式清理像std::string 这样的非指针?如何知道每个编译器的行为方式?

看看C++-standard says 是什么,我原以为std::string 由于异常仅由CTOR 释放:

C++11 15.2 构造函数和析构函数(二)

初始化或销毁被异常终止的任何存储持续时间的对象都将为其所有完全构造的子对象(不包括类联合类的变体成员)执行析构函数,也就是说,对于那些主构造函数(12.6.2)已完成执行,而析构函数尚未开始执行。

在这种情况下,破坏不会被异常终止,只有构造。但是因为 DTOR 是一个 DTOR,它也被设计为自动清理东西?如果是这样,一般是所有编译器还是只有这个?

手动调用 DTOR 可靠吗?

根据我的研究,手动调用 DTOR 应该不会太糟糕。那是不是一个错误的表达方式,因为我现在看到的东西,这是一个很大的禁忌?我的印象是,如果手动调用 DTOR,它只需要兼容才能以这种方式调用。以上应该是我的理解。它只是因为我不知道的编译器自动生成的代码而失败。

如何解决这个问题?

与其手动调用 DTOR 并触发自动生成的代码,不如简单地使用自定义 cleanUp 函数来释放内存并设置指向 NULL 的指针?如果发生异常并且始终在 DTOR 中调用它应该是安全的,对吗?或者有什么方法可以让所有编译器都以兼容的方式调用 DTOR?

谢谢!

【问题讨论】:

  • 你有 m_indicator 被显式的析构函数调用 this-&gt;~doc_t() 销毁,然后在构造函数通过异常退出时再次销毁。这通过在对象的生命周期结束后访问对象来表现出未定义的行为。修复它的正确方法是让所有资源由 RAII 类管理,并允许异常自然传播。即使标准库中的智能指针不可用,您也可以实现自己的;也许花里胡哨的东西更少。
  • 您的程序行为未定义,因为它使用带有双下划线的标识符。这样的标识符是为实现保留的。
  • @IgorTandetnik 但我想知道为什么只有一个编译器会中断,如果所有编译器在理论上在 CTOR 和 DTOR 的相同位置应用相同的魔法释放等。
  • 未定义的行为是未定义的。 “似乎有效”是未定义行为的一种可能表现形式。无论如何,您可以通过编写一个简单的类来观察实际行为,该类在其构造函数和析构函数中打印一些内容,否则什么也不做;然后将此类的成员添加到doc_t。我希望它的析构函数在doc_t 构造函数抛出时被调用两次。
  • Here's a simple example 演示了该行为。观察一个构造函数和两个析构函数调用。上一个链接是针对clang的; here's gcc。两者的行为相同。

标签: c++ memory-management memory-leaks clang


【解决方案1】:

这是一个类似于您的案例的简化示例,并且使行为显而易见:

#include <iostream>

struct S {
    S() { std::cout << "S constructed\n";}
    ~S() { std::cout << "S destroyed\n";}
};

class Throws {
    S s;
public:
    Throws() {
        try {
            throw 42;
        } catch (int) {
            this->~Throws();
            throw;
        }
    }
};


int main() {
  try {
      Throws t;
  } catch (int) {}
}

输出:

S constructed
S destroyed
S destroyed

Demo with clang, demo with gcc.

该示例通过两次破坏相同的S 实例来展示未定义的行为。由于析构函数的作用不大,尤其是不访问this,因此未定义的行为通过实际成功运行析构函数两次来表现出来,因此可以很容易地在操作中观察到。


显然,OP 怀疑析构函数是否应该真正销毁对象及其所有成员和基类。为了消除这些疑虑,以下是标准中的相关引述:

[class.dtor]/14 在执行析构函数的主体并销毁主体内分配的自动存储持续时间的任何对象后,X 类的析构函数调用@987654329 的析构函数@ 的直接非变体非静态数据成员,X 的非虚拟直接基类的析构函数,如果 X 是派生度最高的类 (11.10.2),则其析构函数调用析构函数X 的虚拟基类...

【讨论】:

  • 那个例子没有解释S的DTOR是因为什么原因被调用的,但这就是我有兴趣知道的。它似乎只在异常情况下在 DTOR 和 CTOR 中被调用。但是,对于 C++ 来说,这是在某个地方标准化的,还是编译器只是因为它有意义并且所有这些行为都以相同的方式表现?我找不到任何明确说明该行为的地方,并且当 CTOR 成功时显然没有调用 DTOR。所以需要有一些“魔法”。
  • 您自己在原始问题中提供了需要此行为的标准引用。我进一步证明了两个主要的编译器确实实现了这种必需的行为。他们如何实现它是一个内部实现细节——标准通常规定了程序应该具有的可观察行为,而不是实现它的方法。如果您对如何操作感到好奇,可以检查生成的程序集。
  • A few more traces 证明~Throws() 只被调用一次。 ~S()~Throws() 内部被调用一次,然后在构造函数抛出后展开堆栈时再次在外部调用。这能回答你的问题吗?
  • 我从标准中引用的内容仅解释了为什么在 CTOR 中调用 ~S,因为异常终止了构造,它没有解释为什么也从 DTOR 中调用它。引用明确提到终止的构造或破坏,在这种情况下,破坏不会终止,但无论如何都会调用不相关的 DTOR。那么所有 DTOR 是否都包含所有编译器的那种类型的清理代码,因为标准要求这样做?或者这是一个实现细节并且在大多数情况下只是有意义的,因为这是 DTOR 的预期行为?
  • 我没想到你会怀疑破坏者应该,嗯,摧毁对象,连同它的成员。 Anyhoo: "[class.dtor]/14 在执行析构函数的主体并销毁主体内分配的自动存储持续时间的任何对象后,X 类的析构函数调用@987654339 的析构函数@ 的直接非变体非静态数据成员、X 的非虚拟直接基类的析构函数,如果 X 是派生度最高的类 (11.10.2),则其析构函数调用析构函数X 的虚拟基类..."
【解决方案2】:

一旦调用析构函数,对象就不再存在(留下未初始化的内存)。这意味着析构函数可能会忽略“终结”内存写入,例如将指针设置为零(对象不再存在,因此无法读取其值)。这也意味着基本上任何对该对象的进一步操作都是UB。

如果this 指针不再以任何方式使用,人们认为销毁*this 有一些余地。在您的示例中情况并非如此,因为析构函数被调用了两次。

我知道手动调用析构函数的一种情况是正确的,而大多数情况下是正确的:当对象是用placement new创建的(在这种情况下,不会有自动调用析构函数的操作) .最正确的情况是在销毁对象后立即通过在同一位置调用placement-new 重新初始化对象。

关于你的第二个问题:你为什么要显式调用析构函数?据我所见,您的代码应该可以正常工作,没有所有的扭曲:

default_endian_expr_exception_t::doc_t::doc_t(kaitai::kstream* p__io, default_endian_expr_exception_t* p__parent, default_endian_expr_exception_t* p__root)
  : kaitai::kstruct(p__io), m__parent(p__parent), m__root(p__root), m_main() {
    _read();
}

在运行用户提供的构造函数之前,对象被初始化为有效状态。如果_read 抛出一个仍然应该是这种情况的异常(否则修复_read!),因此隐式析构函数调用应该很好地清理所有内容。

【讨论】:

  • DTOR 不会被调用两次 AFAIK,自动清理首先在 DTOR 中发生,然后在 CTOR 中发生。当 CTOR 抛出时,不会自动调用 DTOR。这就是为什么我要问在 DTOR 和 CTOR 中释放魔法是否对所有编译器都很常见,因为它只是一个测试的问题。除此之外,代码只是生成的一个示例,不能在任何地方轻易更改。给定的实现是目前可能的最佳折衷方案。
  • 为每个在该点初始化的元素调用 dtor。这意味着所有成员,因为我们在元素初始化阶段发生后抛出。请注意,(超级)对象的初始化尚未完成,因此它不会通过自己的 dtor 销毁。
猜你喜欢
  • 2018-11-06
  • 2013-08-10
  • 1970-01-01
  • 2016-10-10
  • 1970-01-01
  • 2021-03-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多