【问题标题】:Pool allocators with virtual destructors具有虚拟析构函数的池分配器
【发布时间】:2017-01-13 03:56:48
【问题描述】:

我正在使用旧的 C++03 代码库。一个部分看起来像这样:

#include <cstddef>

struct Pool
{ char buf[256]; };

struct A
{ virtual ~A() { } };

struct B : A
{
  static void *operator new(std::size_t s, Pool &p) { return &p.buf[0]; }
  static void operator delete(void *m, Pool &p) { } // Line D1
  static void operator delete(void *m) { delete m; } // Line D2
};

Pool p;

B *doit() { return new(p) B; }

也就是说,B 派生自 A,但 B 的实例是从内存池中分配的。

(注意这个例子有点过于简单了......实际上,池分配器做了一些不平凡的事情,因此需要在 D1 行放置 operator delete。)

最近,我们在更多编译器上启用了更多警告,第 D2 行引发以下警告:

警告:删除‘void*’未定义[-Wdelete-incomplete]

嗯,是的,很明显。但由于这些对象总是从池中分配的,我认为不需要自定义(非放置)operator delete。所以我尝试删除 D2 行。但这导致编译失败:

new.cc:在析构函数“virtual B::~B()”中:new.cc:9:8: error: no 适合“B”结构 B 的“运算符删除”:A ^ new.cc:在全局范围内:new.cc:18:31:注意:这里首先需要合成方法‘virtual B::~B()’ B *doit1() { return 新的(p)B; }

一点研究确定问题出在 B 的虚拟析构函数上。它需要调用非放置B::operator delete,因为某个地方的某个人可能会尝试通过A *deleteB。由于名称隐藏,第 D1 行呈现默认的非放置 operator delete 不可访问。

我的问题是:处理此问题的最佳方法是什么?一个明显的解决方案:

static void operator delete(void *m) { std::terminate(); } // Line D2

但这感觉不对……我的意思是,我是谁坚持让你必须从池中分配这些东西?

另一个明显的解决方案(以及我目前使用的):

static void operator delete(void *m) { ::operator delete(m); } // Line D2

但这也感觉不对,因为我怎么知道我调用了正确的删除函数?

我想,我真正想要的是using A::operator delete;,但这无法编译(“没有匹配‘A::operator delete’的成员在‘struct A’中”)。

相关但不同的问题:

Why is delete operator required for virtual destructors

Clang complains "cannot override a deleted function" while no function is deleted

[更新,扩大一点]

我忘了提到A 的析构函数在我们当前的应用程序中并不需要是virtual。但是从具有非虚拟析构函数的类派生会导致一些编译器在您提高警告级别时抱怨,而练习的最初目的是消除此类警告。

另外,为了明确期望的行为......正常的用例如下所示:

Pool p;
B *b = new (p) B;
...
b->~B();
// worry about the pool later

也就是说,就像大多数使用placement new 一样,您可以直接调用析构函数。或者调用一个辅助函数来为你做这件事。

不会期望以下工作;事实上,我认为这是一个错误:

Pool p;
A *b_upcast = new (p) B;
delete b_upcast;

检测到这种错误使用并失败是可以的,但前提是它可以在不对非错误情况增加任何开销的情况下完成。 (我怀疑这是不可能的。)

最后,我确实希望这会起作用:

A *b_upcast = new B;
delete b_upcast;

换句话说,我想支持但不要求为这些对象使用池分配器。

我目前的解决方案大部分都有效,但我担心直接调用 ::operator delete 不一定是正确的。

如果您认为您有充分的理由证明我对应该或不应该起作用的期望是错误的,我也想听听。

【问题讨论】:

  • 删除操作符与析构函数是分开的。删除操作符必须释放内存然后调用析构函数。你有虚拟析构函数——你尝试过虚拟删除操作符吗?我想知道这是否可能。您可以尝试一个返回“对象是如何分配的”的虚函数,并使用它来决定如何在基本运算符 delete 中释放。
  • 如果有人从池中创建了一个B,然后被父A 指针删除,你想发生什么?
  • @johnnycrash:我知道删除运算符和析构函数之间的区别(和交互),并且我在措辞上尽量小心。删除运算符始终是“静态的”,即使您没有这样声明它们。 (虽然它们的行为有点像虚拟的,这是我问题的根源。)如果你想测试你的想法,你可以使用-c-S 自己编译我的示例。
  • @MarkB:这是个好问题。我可以想到不止一个合理的答案...为了争论起见,假设在这种情况下的答案是:那将是错误的用法,但A *a = new B; delete a; 不会。让我们进一步说,检测这种错误使用是可取的,但前提是它可以在不对非错误情况造成任何开销的情况下完成。
  • Pool p; 被销毁时会有未定义的行为,因为它的存储被重新使用了

标签: c++ language-lawyer


【解决方案1】:

有趣的问题。如果我理解正确,您要做的就是根据它是否通过池分配来选择正确的删除运算符。

您可以在池中分配的块的开头存储一些额外的信息。

由于不能在没有池的情况下分配 B,因此您只需使用有关池的一些额外信息将其转发到普通 delete(void*) 运算符中的放置删除器。

Operator new 会将该部分存储在分配块的开头。

更新: 感谢您的澄清。同样的技巧仍然适用于一些小的修改。更新了下面的代码。 如果那仍然不是您想要做的,那么请提供一些正面和负面的测试用例来定义什么应该起作用,什么不应该起作用。

struct Pool
{
    void* alloc(size_t s) {
        // do the magic... 
        // e.g. 
        //    return buf;
        return buf;
    }
    void dealloc(void* m) {
        // more magic ... 
    }
private:

    char buf[256];
};
struct PoolDescriptor {
    Pool* pool;
};


struct A
{
    virtual ~A() { }
};

struct B : A
{
    static void *operator new(std::size_t s){
        auto desc = static_cast<PoolDescriptor*>(::operator new(sizeof(PoolDescriptor) + s));
        desc->pool = nullptr;
        return desc + 1;
    }

    static void *operator new(std::size_t s, Pool &p){
        auto desc = static_cast<PoolDescriptor*>(p.alloc(sizeof(PoolDescriptor) + s));
        desc->pool = &p;
        return desc + 1;
    }
    static void operator delete(void *m, Pool &p) {
        auto desc = static_cast<PoolDescriptor*>(m) - 1;
        p.dealloc(desc);
    }
    static void operator delete(void *m) {
        auto desc = static_cast<PoolDescriptor*>(m) - 1;
        if (desc->pool != nullptr) {
            throw std::bad_alloc();
        }
        else {
            ::operator delete (desc);
        } // Line D2
    }
};


Pool p;
void shouldFail() { 
    A* a = new(p)B;
    delete a;
}
void shouldWork() { 
    A* a = new B;
    delete a;
}

int main()
{
    shouldWork();
    shouldFail();
    return 0;
}

【讨论】:

  • 不,我不想让delete downcastedToA 工作。从池中分配然后调用“删除”实际上是一个错误。 (另外,不应该将其称为“upcastedToA”吗?)但我想保持这个工作:A* a = new B; delete a;,您的解决方案无法做到这一点。我会更新我的问题。
  • 我相信我目前的解决方案唯一没有做的就是抓住错误的情况......但是这样做会增加开销(空间和时间),所以我不认为它是值得的。它还建立在我们想要使用::operator new / ::operator delete 的假设之上,这是我想要避免的假设。 (我想我可以通过提供一个简单地调用 ::operator new 的非放置 B::operator new 来强制这个假设成立。也许这是这里最干净的选择。)
【解决方案2】:

真的很难理解你打算用这段代码实现什么,因为你剥离了它的重要部分。

你知道static void operator delete(void *m, Pool &amp;p) { } 只有在 B 的构造函数抛出异常时才会被调用?

15) 如果已定义,则由自定义单对象放置调用 new 如果对象的构造函数具有匹配签名的表达式 抛出异常。如果定义了特定于类的版本 (25),它 优先于 (9) 调用。如果既没有提供 (25) 也没有提供 (15) 由用户调用,不调用释放函数。

这意味着在当前的例子中这个操作符 delete (D1) 永远不会被调用。

在我看来,有一个带有虚析构函数的基类 A 看起来很奇怪,并且坚持认为删除调用的语义是不同的,这取决于对象的创建方式。

如果您确实需要基类 A,并添加虚拟析构函数只是为了使警告静音,您可以将析构函数设置在 A 中,而不是使其成为虚拟。像这样-

struct A
{
protected:
  ~A() { }
};

struct B final : public A
{
  ~B() = default;

  static void *operator new(std::size_t s, Pool &p) { return &p.buf[0]; }
  static void operator delete(void *m, Pool &p) {} // Line D1

  static void operator delete(void *m) {} // Line D2

};

【讨论】:

  • 是的,我知道所有这些功能是如何工作的。您的 D2 行版本会导致像 B *b = new B; delete b; 这样简单的事情发生内存泄漏,所以我不认为这是书面的解决方案。也就是说,一个受保护的~A 并完全删除 D2 行确实解决了我的问题,假设我们从不直接实例化A,我必须检查一下。谢谢你的想法。
  • 我无法编辑我的评论 :) - 我想说以下代码无法编译 B *b = new B;
猜你喜欢
  • 2012-04-13
  • 2012-04-18
  • 2011-11-16
  • 2012-09-21
  • 2018-06-29
  • 2021-03-27
  • 2013-07-06
  • 2011-08-12
  • 2015-09-02
相关资源
最近更新 更多