“C++的数组不支持多态”?

“C++的数组不支持多态”?

先是在微博上看到了个微博和云风的评论,然后我回了“楼主对C的内存管理不了解”。

后来引发了很多人的讨论,大量的人又借机来黑C++,比如:

//@Baidu-ThursdayWang:这不就c++弱爆了的地方吗,需要记忆太多东西

//@编程浪子张发财:这个跟C关系真不大。不过我得验证一下,感觉真的不应该是这样的。如果基类的析构这种情况不能 调用,就太弱了。

//@程序元:现在看来,当初由于毅力不够而没有深入纠缠c++语言特性的各种犄角旮旯的坑爹细枝末节,实是幸事。为现在还沉浸于这些诡异特性并乐此不疲的同志们感到忧伤。

然后,也出现了一些乱七八糟的理解:

//@BA5BO: 数组是基于拷贝的,而多态是基于指针的,派生类赋值给基类数组只是拷贝复制了一个基类新对象,当然不需要派生类析构函数

//@编程浪子张发财:我突然理解是怎么回事了,这种情况下数组中各元素都是等长结构体,类型必须一致,的确没法多态。这跟C#和java不同。后两者对于引用类型存放的是对象指针。

等等,看来我必需要写一篇博客以正视听了。

因为没有看到上下文,我就猜测讨论的可能会是下面这两种情况之一:

1) 一个Base*[]的指针数组中,存放了一堆派生类的指针,这样,你delete [] pBase; 只是把指针数组给删除了,并没有删除指针所指向的对象。这个是最基础的C的问题。你先得for这个指针数组,把数据里的对象都delete掉,然后再删除数组。很明显,这和C++没有什么关系。

2)第二种可能是:Base *pBase = new Derived[n] 这样的情况。这种情况下,delete[] pBase 明显不会调用虚析构函数(当然,这并不一定,我后面会说) ,这就是上面云风回的微博。对此,我觉得如果是这个样子,这个程序员完全没有搞懂C语言中的指针和数组是怎么一回事,也没有搞清楚, 什么是对象,什么是对象的指针和引用,这完全就是C语言没有学好。

后来,在看到了 @GeniusVczh 的原文 《如何设计一门语言(一)——什么是坑(a)》最后时,才知道了说的是第二种情况。也就是下面的这个示例(我加了虚的析构函数这样方便编译):

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

class Derived : public Base
{
  public:
    virtual ~D() { cout <<"D::D~()"<<endl; }
};

Base* pBase = new Derived[10];
delete[] pBase;

C语言补课

我先不说这段C++的程序在什么情况下能正确调用派生类的析构函数,我还是先来说说C语言,这样我在后面说这段代码时你就明白了。

对于上面的:

Base* pBase = new Derived[10];

这个语言和下面的有什么不同吗?

Derived d[10];

Base* pBase = d;

一个是堆内存动态分配,一个是栈内存静态分配。只是内存的位置和类型不一样,在语法和使用上没有什么不一样的。(如果你把Base 和 Derived想成struct,把new想成malloc() ,你还觉得这和C++有什么关系吗?)

那么,你觉得pBase这个指针是指向对象的,是对象的引用,还是指向一个数组的,是数组的引用?

于是乎,你可以想像一下下面的场景:

int *pInt; char* pChar;

pInt = (int*)malloc(10*sizeof(int));

pChar = (char*)pInt;

对上面的pInt和pChar指针来说,pInt[3]和pChar[3]所指向的内容是否一样呢?当然不一样,因为int是4个字节,char是1个字节,步长不一样,所以当然不一样。

那么再回到那个把Derived[]数组的指针转成Base类型的指针pBase,那么pBase[3]是否会指向正确的Derrived[3]呢?

我们来看个纯C语言的例程,下面有两个结构体,就像继承一样,我还别有用心地加了一个void *vptr,好像虚函数表一样:

    struct A {
        void *vptr;
        int i;
    };

    struct B{
        void *vptr;
        int i;
        char c;
        int j;
    }b[2] ={
        {(void*)0x01, 100, 'a', -1},
        {(void*)0x02, 200, 'A', -2}
    };

注意:我用的是G++编译的,在64bits平台上编译的,其中的sizeof(void*)的值是8。

我们看一下栈上内存分配:

    struct A *pa1 = (struct A*)(b);

用gdb我们可以看到下面的情况:(pa1[1]的成员的值完全乱掉了)

(gdb) p b
$7 = {{vptr = 0x1, i = 100, c = 97 'a', j = -1}, {vptr = 0x2, i = 200, c = 65 'A', j = -2}}
(gdb) p pa1[0]
$8 = {vptr = 0x1, i = 100}
(gdb) p pa1[1]
$9 = {vptr = 0x7fffffffffff, i = 2}

我们再来看一下堆上的情况:(我们动态了struct B [2],然后转成struct A *,然后对其成员操作)

    struct A *pa = (struct A*)malloc(2*sizeof(struct B));
    struct B *pb = (struct B*)pa;

    pa[0].vptr = (void*) 0x01;
    pa[1].vptr = (void*) 0x02;

    pa[0].i = 100;
    pa[1].i = 200;

用gdb来查看一下变量,我们可以看到下面的情况:(pa没问题,但是pb[1]的内存乱掉了)

(gdb) p pa[0]
$1 = {vptr = 0x1, i = 100}
(gdb) p pa[1]
$2 = {vptr = 0x2, i = 200}
(gdb) p pb[0]
$3 = {vptr = 0x1, i = 100, c = 0 '\000', j = 2}
(gdb) p pb[1]
$4 = {vptr = 0xc8, i = 0, c = 0 '\000', j = 0}

可见,这完全就是C语言里乱转型造成了内存的混乱,这和C++一点关系都没有。而且,C++的任何一本书都说过,父类对象和子类对象的转型会带来严重的内存问题。

但是,如果在64bits平台下,如果把我们的structB改一下,改成如下(把struct B中的int j给注释掉):

    struct A {
        void *vptr;
        int i;
    };

    struct B{
        void *vptr;
        int i;
        char c;
        //int j; <---注释掉int j
    }b[2] ={
        {(void*)0x01, 100, 'a'},
        {(void*)0x02, 200, 'A'}
    };

你就会发现,上面的内存混乱的问题都没有了,因为struct A和struct B的size是一样的:

(gdb) p sizeof(struct A)
$6 = 16
(gdb) p sizeof(struct B)
$7 = 16

注:如果不注释int j,那么sizeof(struct B)的值是24。

这就是C语言中的内存对齐,内存对齐的原因就是为了更快的存取内存(详见《深入理解C语言》)

如果内存对齐了,而且struct A中的成员的顺序在struct B中是一样的而且在最前面话,那么就没有问题。

再来看C++的程序

如果你看过我5年前写的《C++虚函数表解析》以及《C++内存对象布局 上篇下篇》,你就知道C++的标准会把虚函数表的指针放在类实例的最前面,你也就知道为什么我别有用心地在struct A和struct B前加了一个 void *vptr。C++之所以要加在最前面就是为了转型后,不会找不到虚表了。

好了,到这里,我们再来看C++,看下面的代码:

#include
using namespace std;

class B
{
  int b;
  public:
    virtual ~B(){ cout <<"B::~B()"<<endl; }
};

class D: public B
{
  int i;
  public:
    virtual ~D() { cout <<"D::~D()"<<endl; }
};

int main(void)
{
    cout << "sizeB:" << sizeof(B) << " sizeD:"<< sizeof(D) <<endl;
    B *pb = new D[2];

    delete [] pb;

    return 0;
}

上面的代码可以正确执行,包括调用子类的虚函数!因为内存对齐了。在我的64bits的CentOS上——sizeof(B):16 ,sizeof(D):16

但是,如果你在class D中再加一个int成员的问题,这个程序就Segmentation fault了。因为—— sizeof(B):16 ,sizeof(D):24。pb[1]的虚表找到了一个错误的内存上,内存乱掉了。

再注:我在Visual Studio 2010上做了一下测试,对于 struct 来说,其表现和gcc的是一样的,但对于class的代码来说,其可以“正确调用到虚函数”无论父类和子类有没有一样的size。

然而,在C++的标准中,下面这样的用法是undefined! 你可以看看StackOverflow上的相关问题讨论:《Why is it undefined behavior to delete[] an array of derived objects via a base pointer?》(同样,你也可以看看《More Effective C++》中的条款三)

Base* pBase = new Derived[10];

delete[] pBase;

所以,微软C++编程译器define这个事让我非常不解,对微软的C++编译器再度失望,看似默默地把其编译对了很漂亮,实则误导了好多人把这种undefined的东西当成defined来用,还赞扬做得好,真是令人无语。就像微博上的这个贴一样,说VC多么牛,还说这是OO的特性。我勒个去!

现在,你终于知道Base* pBase = new Derived[10];这个问题是C语言的转型的问题,你也应该知道用于数组的指针是怎么回事了吧?这是一个很奇葩的代码!请你不要像那些人一样在微博上和这里的评论里高呼并和我理论到:“微软的C++编译器支持这个事!”。

最后,我越来越发现,很多说C++难用的人,其实是不懂C语言

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

好烂啊有点差凑合看看还不错很精彩 (52 人打了分,平均分: 4.25 )
Loading...

“C++的数组不支持多态”?》的相关评论

  1. class D中再加一个int成员之后,在Mac下面用clang++编译的居然不Segmentation fault…

  2. No,No,No,你对“会C++”的要求太高了。
    作为语言的设计者,你应该这样考虑你的用户:

    我的用户全他妈都是流鼻涕的大!傻!逼!如果不是我大发慈悲告诉你又傻帽了,你就永远觉得自己很屌,其实只是很屌丝而已。

    所以如果单独的讨论这个Feature,那就是类型转换规则上的缺陷,傻逼们会不经意的从T[]转换到T*而犯错。
    只不过因为C++要兼容C,这才是个迫不得已的选择。

    P.S.,这段文字是源出对VCZH讨论坑的文章的评论,我想你也看了。在原文中,以这个做例子其实是非常合适的。至于说,那个叫丑x的哥们儿要喷VC结果被轮了,那只是一个悲伤的意外而已。

  3. 原文:我在Visual Studio 2010上做了一下测试,对于 struct 来说,其表现和gcc的是一样的,但对于class的代码来说,其可以“正确调用到虚函数”无论父类和子类有没有一样的size,这让我非常不解,对微软的C++编译器再度失望。
    这正是微软的C++对这种情况进行了改进!因为调用者显然是希望能够正确调用到虚析构函数的。在这一点上,G++仅仅遵守了C++的规则,没有加以改进。我基本一直在Linux环境下编程,对微软编译器不感冒,但是具体到某个特征来说,哪个特征更加体贴了用户,还是要实事求是。

  4. 不熟悉C++,但是这样子做…还是不好吧…这么搞代码根被没有可维护性啊,这么写代码就是给自己挖坑…
    也就出一下笔试面试题了…确实能考考懂不懂C++。

  5. VC的delete[]自己包装了一次(第20页):
    http://www.openrce.org/articles/files/jangrayhood.pdf

    Although, strictly speaking, polymorphic array delete is undefined behavior, we had several
    customer requests to implement it anyway. Therefore, in MSC++, this is implemented by
    yet another synthesized virtual destructor helper function, the so-called “vector delete
    destructor,” which (since it is customized for a particular class, such as WW) has no difficulty
    iterating through the array elements (in reverse order), calling the appropriate destructor
    for each.

  6. struct B注释掉int j后还比struct A多一个char c成员,这两个怎么可能都是16个字节呢?楼主写错了吧

  7. 最后一段代码 在32bit ubuntu编译
    sizeB:8 sizeD:12
    Segmentation fault
    第一行代码改成#include

  8. @稀饭
    int 在64位的机子上还是4字节的,但由于要对齐,所以占用多了4个字节,而Struct B中int + char 一共占用5字节,加上前面的指针8字节,一共都是16字节。

    另外,楼主,在第二次gdb的时候,你忘了给pb赋值了 :)

  9. ”堆上的情况:(我们动态了struct B [2],然后转成struct A *,然后对其成员操作),struct A *pa = (struct A*)malloc(2*sizeof(struct B)); 用gdb来查看一下变量,我们可以看到下面的情况:(pa没问题,但是pb[1]的内存乱掉了)。“
    pb数组是怎么得到的?

  10. 在32位的Ubuntu12.04系统下,最后的代码会出现段错误!!!
    sizeB:8 sizeD:12
    Segmentation fault (core dumped)

  11. xiaogang :
    在32位的Ubuntu12.04系统下,最后的代码会出现段错误!!!
    sizeB:8 sizeD:12
    Segmentation fault (core dumped)

    将D里面的变量i注释掉就OK了

  12. cout << "sizeB:" << sizeof(B) << " sizeD:"<< sizeof(D) <<endl;
    这个在我电脑VS2010上运行结果是8和12….

  13. 最后一段代码在64位mac os上面运行,用clang++ 3.3运行,调不到子类的虚函数,不管子类父类的大小是否一样。
    g++ 4.2的测试情况和楼主一样,这个还是跟编译器实现有关啊。

  14. “因为delete[]需要调用析构函数,但是Base*类型的指针式不能正确计算出Derived数组的10个析构函数需要的this指针的位置的,所以在这个时候,代码就完蛋了(如果没完蛋,那只是巧合)。”
    那段代码我VS2010和GCC测试都没问题呀,求指教

  15. 我要是精通了C/C++,其他的就跟切菜似的。这感觉就像杜甫写的一样:会当凌绝顶,一览众山小。

  16. 按照LZ最后的那个说法,在class D里面加了个int变量,win7的32位系统VS2010环境
    没有出现段错误,也很好的调用了子类的虚构函数,结果没有任何问题,谢谢
    ———————————————————–
    // test.cpp : 定义控制台应用程序的入口点。
    //
    #include “stdafx.h”
    #include
    using namespace std;
    class B
    {
    int b;
    public:
    virtual ~B(){ cout <<"B::~B()"<<endl; }
    };
    class D: public B
    {
    int i;
    int d;
    public:
    virtual ~D() { cout <<"D::~D()"<<endl; }
    };
    int _tmain(int argc, _TCHAR* argv[])
    { cout << "sizeB:" << sizeof(B) << " sizeD:"<< sizeof(D) <<endl;
    B *pb = new D[2];
    delete [] pb;
    return 0;
    }

    结果:
    sizeB:8 sizeD:16
    D::~D()
    B::~B()
    D::~D()
    B::~B()

  17. 如果是标准之外的问题就没有对错之分了。各个实现爱怎么搞就怎么搞。

  18. 基类和子类的size不一样,
    在Base *pb = new Derived[10]中,由于delete pb[i]其实时delete pb+i*sizeof(Base),所以size不一样,那么偏移的时候就会错误的把一个值认为时vptr,然后企图通过这个错误的vptr指针去查找vtbl中析构函数所以,所以段错误了,
    是这样理解吧,皓哥。

  19. 1) 一个Base*[]的指针数组中,存放了一堆派生类的指针,这样,你delete [] pBase; 只是把指针数组给删除了,并没有删除指针所指向的对象。这个是最其它的C的问题。

    其他==》基本?

  20. B和D一样大的时候在g++和clang的行为是不一样的,后者只会调用基类析构函数。这两个编译器都声称是standard-conforming的,所以我觉得可以确定C++标准认为这个问题是undefined的。我觉得楼主不应该因为MS用另一种方式支持了undefined的行为而鄙视它。:)

  21. C++ Standard 1998 195页给了个例子,指出B* bp = new D[i]; delete[] bp;是undefined的。
    我觉得皓哥这样说不大好。如果说标准规定,delete[]用于上述例子必须出错,那么MSVC的实现确实不好;但既然是undefined了,编译器就应该尽可能做到最好(在这个例子中,最好的做法显然是正确释放内存)。
    @陈皓

  22. C++ Standard(1998版)就是undefined(正文195页,整书221页)。我也觉得undefined的编译器就应该尽量做到最好,而不是直接把程序搞挂了事(毕竟正确释放内存总比seg fault要好)。此外,标准从来没说delete[]的实现就是用指针运算或下标运算迭代调用各个对象的虚构函数(标准只是说要释放各个对象,没说必须怎么做),所以从使用指针运算会有问题推知使用delete[]必须有问题,有点奇怪。@dospeng

  23. 《More effective C++》 中条款三—“绝对不要以多台方式处理数组”中说得很明白了吧。

  24. 稀饭 :
    struct B注释掉int j后还比struct A多一个char c成员,这两个怎么可能都是16个字节呢?楼主写错了吧

    这个地方肯定写错了·

  25. 如果把delete[]看做一種寫操作,這個問題還可以這麽看:non-const pointer應該是invariant的,但是語言把它視作了covariant的(A是B的subtype->A*是B*的subtype),于是產生了delete[] (B*)aptr出錯的問題,另一個問題則是 *(B*)aptr=…

  26. class Base
    {
    public:
    virtual ~B(){ cout <<"B::~B()"<<endl; }
    };
    耗子叔,这里析构函数名字错了…下面那个类也是…

  27. Base* pBase = new Derived[10];
    delete[] pBase;
    为啥会有人这样用,为啥不直接用Derived * pDerived, C++的多态也不是这么用的把。。。

  28. @kokobar 呵呵,默默允许一个错误行为,直到代码膨胀了数十倍之后再回过头来调试??你要这么认为的话反而见得微软对脑残程序员真体贴啊。

  29. @gouchaoer 那只是因为你两个析构函数根本没有读写内存的必要。如果你的对象里有指向别处的指针需要进一步释放,那几乎就是崩溃了(万一没崩溃,那也只是内存访问侥幸没有违例,可能引起诡异的问题)。

  30. 关于对微软的吐槽,想起今年微软实习生招聘的笔试题~问了x=x++的值…
    微软的编译器用多了,总是会感觉良好,把微软define了的undefined behavior当成是标准解了…

  31. 看来MS又自作聪明的实现了C++标准未明确要求的特性。。
    跟踪了汇编,发现VC编译器对delete[]的解释挺奇葩的。。
    直觉的认为delete[]对每个元素的定位应该是静态的,但MS的实现确实是运行时判定的
    VC对有以vector来操作的类会生成vector deleting destructor的析构函数,vector deleting destructor是个thunk版本的析构函数,这样就能实现runtime dynamic了
    Raymond Chen写过这个话题:
    http://blogs.msdn.com/b/oldnewthing/archive/2004/02/03/66660.aspx

  32. C++标准有明确说内存布局里面,虚函数表的指针应该放在哪儿么?博主能进一步解释下么

  33. C++标准并没有说“虚函数表的指针放在类实例的最前面”吧?这个是编译器来决定的。

  34. tree_star :

    稀饭 :
    struct B注释掉int j后还比struct A多一个char c成员,这两个怎么可能都是16个字节呢?楼主写错了吧

    这个地方肯定写错了·

    64位Linux下指针大小是8个字节,int大小还是4字节,内存对齐后,int成员后跟个1个字节的char成员不碍事儿,呵呵~

回复 xiaogang 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注