为什么通过基类指针删除派生类对象的数组是未定义行为?

40

我在C++03标准的5.3.5 [expr.delete] p3章节中发现了以下代码片段:

在第一种情况(删除对象)中,如果要删除的对象的静态类型与其动态类型不同,则静态类型必须是操作数的动态类型的基类,且静态类型必须具有虚析构函数,否则行为未定义。 在第二种情况(删除数组)中,如果要删除的对象的动态类型与其静态类型不同,则行为未定义。


关于静态类型和动态类型的快速回顾:

struct B{ virtual ~B(){} };
struct D : B{};

B* p = new D();

变量 p 的静态类型为 B*,而指针 *p 的动态类型为 D,参考标准 1.3.7 [defns.dynamic.type]:

[示例:如果一个静态类型为“指向类 B 的指针”的指针 p 指向一个派生自 B 的类 D 的对象,则表达式 *p 的动态类型为 “D”。]


现在再次查看顶部的引用,这意味着如果我理解正确的话,以下代码将调用未定义的行为,无论是否存在 virtual 析构函数:

struct B{ virtual ~B(){} };
struct D : B{};

B* p = new D[20];
delete [] p; // undefined behaviour here

我是否误解了标准中的措辞?我有没有忽略什么?为什么标准将这种情况指定为未定义行为?


12
如果你有一个派生类元素的数组,并将其分配给一个指向基类数组的指针,那么你就不再有任何信息来告诉你元素的大小。 - Hot Licks
1
@Daniel:我知道我的推理有些问题,但我不知道它会这么糟糕...我不知怎么就把(vector<B*> v(N)) == (B* p = new B[N])联系在了一起。没有这个,这个问题现在完全没有意义。:| - Xeo
@Xeo:根据您的标题,我本来要回答“删除指针数组是有定义的,但它不会删除对象”。然而我看到您已经意识到了这一点。奇怪的是没有一个现有的答案注意到这一点。 - Ben Voigt
@Ben:我甚至不知道我怎么得到了7个赞。我正在考虑彻底删除这个问题。你有什么想法? - Xeo
3
通过你的问题中的代码和得到的答案,我认为它需要重新命名...“为什么使用基础指针删除派生对象数组是未定义行为?” - Ben Voigt
显示剩余4条评论
5个回答

35

Base* p = new Base[n] 创建一个大小为 nBase 元素数组,并使 p 指向第一个元素。而 Base* p = new Derived[n] 则创建一个大小为 nDerived 元素数组,p 指向第一个元素的 Base 子对象。然而,p 并不指向数组中的第一个元素,这是有效的 delete[] p 表达式所需要的。

当然,强制实现在这种情况下让 delete [] p 做正确的事情是可能的(并且随后实现)。但是需要什么呢?实现必须注意以某种方式检索数组的元素类型,然后在道义上将 p 强制转换为该类型。然后就只需要像我们已经做的一样进行普通的 delete[]

问题在于,这将在每次具有多态元素类型的数组上都需要,无论是否使用多态性。在我看来,这与 C++ 的哲学不符,即只为使用的东西付费。但更糟糕的是:启用多态的 delete[] p 根本没有用,因为在你的问题中 p 几乎是无用的。 p 是一个指向元素子对象的指针,除此之外与数组完全无关。你当然不能使用它进行 p[i](对于 i > 0)操作。因此,delete[] p 不起作用并不是不合理的。

总结:

  • 数组已经有很多合法的用途。不允许数组表现出多态行为(整个数组或仅在 delete[] 时)意味着具有多态元素类型的数组不会因这些合法用途而受到处罚,这符合 C++ 的哲学。

  • 另一方面,如果需要具有多态行为的数组,则可以在我们已有的基础上实现它。


1
不需要存储类型,也不需要动态类型检查。只需将步幅与元素数量(标准已经要求实现记住)一起存储即可找到每个元素的指针,虚拟析构函数会处理其余部分。 - Ben Voigt
@BenVoigt 因此,在“道德等同”中,“道德”一词的含义!我说的是语义而不是实现。(如果不提及要强制转换的类型,我就不能提及dynamic_cast。) - Luc Danton
@Ben 我想用常规的 delete[] 表达这个假设性的 delete[],这意味着即使在多重继承的情况下也要找到数组的起始位置。事实上,它与 delete 的不同之处在于我似乎回避了基类析构函数的虚拟性。当然,我可以进行向下转换。但我认为对于这个问题没有更正确的答案:你是从动态类型还是从交给 delete[] 处理的子对象销毁多态数组的元素? - Luc Danton
@Luc:new[] 使用动态类型,并存储元素计数以供 delete[] 以后使用。它还可以存储元素大小(相同的大小)。这样你就可以计算每个元素的(基本)指针,在这个指针处你可以像在单个派生对象上使用 delete ptr_base 时编译器所做的那样,虚拟调用析构函数。错误就像 @sth 所说的那样:对基类指针进行指针算术运算使用 sizeof(Base) 来计算随后元素的地址。通过存储步幅,这个问题将被解决。当然,通过基本指针进行下标操作仍然有问题。 - Ben Voigt
@Luc: 类似这样的代码:template<typename T> array_delete_helper(T* target, size_t count, size_t stride) { while (count-- > 0) reinterpret_cast<T*>(reinterpret_cast<intptr_t>(target) + count * stride)->~T(); } 其中 T 是静态(基本)类型。 - Ben Voigt
显示剩余8条评论

16

把派生类数组当作基类数组对待是错误的,即使仅仅是访问元素也往往会引发灾难:

B *b = new D[10];
b[5].foo();

b[5] 将使用 B 的大小来计算要访问的内存位置,如果 BD 的大小不同,这将不会导致预期的结果。

就像 std::vector<D> 无法转换为 std::vector<B> 一样,指向 D[] 的指针也不应该能被转换为 B*。但出于历史原因,它仍然可以编译通过。如果使用 std::vector,则会产生编译时错误。

这个问题也在C++ FAQ Lite中有解释。

所以,在这种情况下,delete 会导致未定义的行为,因为这种方式处理数组本身就是错误的,尽管类型系统无法捕获此错误。


1

依我的看法,这与数组的限制有关,无法处理构造函数和析构函数。请注意,当调用new[]时,编译器强制只实例化默认构造函数。同样,当调用delete[]时,编译器可能仅查找调用指针的静态类型的析构函数。

现在,在虚拟析构函数的情况下,派生类析构函数应该先于基类被调用。由于对于数组,编译器可能会看到调用对象的静态类型(即Base),因此它可能最终只调用Base析构函数,这是未定义行为。

话虽如此,并非所有编译器都会导致未定义行为。例如,gcc会按正确顺序调用析构函数。


1

补充一下sth的优秀答案 - 我写了一个简短的示例来说明不同偏移量的问题。

请注意,如果您注释掉Derived类的m_c成员,delete操作将正常工作。

干杯,

Guy。

#include <iostream>
using namespace std;

class Base 
{

    public:
        Base(int a, int b)
        : m_a(a)
        , m_b(b)    
        {
           cout << "Base::Base - setting m_a:" << m_a << " m_b:" << m_b << endl;
        }

        virtual ~Base()
        {
            cout << "Base::~Base" << endl;
        }

        protected:
            int m_a;
            int m_b;
};


class Derived : public Base
{
    public:
    Derived() 
    : Base(1, 2) , m_c(3)   
    {

    }

    virtual ~Derived()
    {
        cout << "Derived::Derived" << endl;
    }

    private:    
    int m_c;
};

int main(int argc, char** argv)
{
    // create an array of Derived object and point them with a Base pointer
    Base* pArr = new Derived [3];

    // now go ahead and delete the array using the "usual" delete notation for an array
    delete [] pArr;

    return 0;
}

0

我认为这一切都归结于零开销原则。也就是说,该语言不允许存储有关数组元素动态类型的信息。


实际上是允许的。标准部分 [expr.new] 表示:“new-expression 将请求的空间量作为类型为 std::size_t 的第一个参数传递给分配函数。该参数不得小于正在创建的对象的大小;仅当对象是数组时,它可以大于正在创建的对象的大小。” 这旨在存储元素计数,但没有特定的禁止额外存储某种类型信息,例如元素大小。 - Ben Voigt
不允许存储有关数组每个元素类型的信息。标准允许标准实现者请求超过N * sizeof(T)字节,因为某些实现将数组大小存储为分配的内存的一部分。必须在某处存储数组大小,因为系统必须知道在调用“delete []”时需要销毁多少个对象。 - David Hammen

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