“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微信公众账号和微信小程序

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

——=== 访问 酷壳404页面 寻找遗失儿童。 ===——
好烂啊有点差凑合看看还不错很精彩 (44 人打了分,平均分: 4.55 )
Loading...

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

  1. @st8676746
    为了面包而放弃自由的人,会失去自由,最终也会失去面包
    程序员也要知道自由的价值
    写出undefined的代码,即使能正确工作,也不要这么做
    因为这是不负责任的行为

  2. 《C++虚函数表解析》这篇文章有不完善之处,既然是名为虚函数表解析,那么就不能只讲对应与多态实现的部分,对应于RTTI实现的部分也应该有所涉及。对于虚函数表这种东西,《Inside The C++ Object Model》这本书里写的是“将指针原类型相关信息存放于虚函数表中”,而现代的编译器(VC++,G++)都是将相关信息的表格入口地址放置于虚函数表之前且与之相临的内存区域中。然后这张表格中又存有指向其它存储具体信息的表格的地址,这样RTTI才得以实现。

  3. Pingback: Hongquan's Blog
  4. 我只知道一件事情
    公司的代码里最多只用【Base* pBase = new Derived();】
    其他东西都用【规范】两个字给规避掉了。。。

    PS:
    我还知道一件事情就是,很多公司用C++仅仅是在C的基础上用class封装一下而已,并不用太多的OO东西

  5. struct B{
    void *vptr;
    int i;
    char c;
    //int j; <—注释掉int j
    }
    不是很明白一点是:
    在六十四位系统中int 为什么不是 8 bytle,
    那么这个sizeof B应该也是24才对吧

  6. 标准中的“未定义”和“错误”是两个概念,我觉得cl编译器开发者这么做没有问题——倒不如说我比较支持这种做法,因为开发者理应尽最大可能考虑并处理用户的出乎意料的行为(对于编译器开发者来说,那些程序员就是用户)。

  7. Base* pBase = new Derived[10];
    delete[] pBase;
    本來我是不懂得寫這樣厲害的代碼的,看了這個討論,我的疑惑就多了,真頭疼。

    =====
    (我加了虚的析构函数这样方便编译):

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

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

  8. 那么是不是应该理解为?
    1.当父子类内存对齐时,(c 同理)
    2. 当析构函数放再数据前面的时候
    调用 delete[] pBase 才会对 内存进行争取释放对吗? 求个大神指点!

  9. 的确,我一直非常反对网上那些呼吁 不需要有C基础、“现代”的方式学习“现代”C++的家伙们!
    了解一个东西的最好方式就是了解它的历史,它是如何诞生又如何演变到现在的整条路;从这点上来看C是所有C++学习者不可绕开的必经之路;
    对各种历史的兴趣。。。这也是为什么我节假日只要一看wikipedia就会看到半夜的原因 – –

  10. 老师讲得太好了。C 中数组元素的步进是以数组元素的类型为标准的,而在 C++ 中也是一样,而且并没有因为「以基类指针指向派生类数组」而不支持多态,只是这是一种很奇葩的做法,一般在工程应该少有见到,更多的是作为探讨 C++ 语言特性。

    「但是,如果你在class D中再加一个int成员的问题」?
    如果此时将基类指针作强制类型转换为派生类类型后再调用同样没有问题,因为步进变为了派生类大小。

    值得庆幸:在《深度探索 C++ 对象模型》这本书中提到,当「以基类指针指向派生类数组」,然后调用 delete [] ,不是个好主意!但似乎在现在的编译器已经修复了这个问题。看看下面的代码;
    class A
    {
    public:
    A(){a = 1;}
    virtual ~A(){cout << a << " ";cout << "A destruction" << endl;}

    int a;

    void foo(){}
    virtual void funcA(){}
    virtual void func()
    {cout << "class A's func." << endl;}
    };

    class B : public A
    {
    public:
    B(){b = 2;c = 3;}
    virtual ~B(){cout << c << " ";cout << "B destruction" << endl;}

    int b;
    int c;

    void foo(){}
    virtual void funcB(){}
    virtual void func()
    {cout << "class B's func." << endl;}
    };

    cout << sizeof(A) << " " << sizeof(B) << endl;
    A *arr = new B[10];
    delete [] arr;

    类 A 和类 B 明显是不对齐,但是 delete 过程中,基类和派生类的析构函数成功调用了!但书中有一点建议还是值得程序员参考:避免以一个 base class 指针指向一个 derived class objects 所组成的数组——如果 derived class object 比其 base 大的话。

  11. 诶 这么说我之前一直有个疑问,其实这个问题就是地址对错了,那这个是否就是说无法用基类数组来保存派生类了?有没有别的解决办法呢?

  12. 佩服。把C++的东西用C来深化理解是避免很多错误、也是避免记忆很多规则的好方法,毕竟C++的本质是C。

  13. 如果再加入 C语言的等效代码就更直接了。
    Base *p = new Derived[N];
    delete [] p;
    C code:
    // 1:malloc memory and keep array info
    typedef struct ArrayInfo{
    int count;
    int oneSize;
    }ArrayInfo;
    void *p = malloc(ArrayInfo+sizeof(Derived)*N);
    ArrayInfo *pArrayInfo = (ArrayInfo*)p;
    pArrayInfo->count = N;
    pArrayInfo->oneSize = sizeof(Derived);
    Derived *pb = (Derived*)(pArrayInfo +1);
    for(int i=0; i)
    for(int i=0; isize; i++){
    // call dtor
    Derived_Finalize(((byte*)p)+i*pAI->oneSize);
    }
    free((void*)pAI);

    下面的函数都必须传进来,如果不传需要做成更复杂的C struct,其中包含了cpp的默认4哥们
    不过C style一般不干这些事情,如果干就带着函数指针数组了。也就是所谓的虚表指针了。
    Derived_Init
    Derived_Finalize
    可能有错误,评论筐里面打的

    “很多说C++难用的人,其实是不懂C语言”其实是一个都不懂,甚至都没想过要去懂

  14. @xiaodong
    忘了说了,这个是为什么VC对的原因
    因为VC记住了具体的类型。

    g++不对的原因是
    for(int i=0; isize; i++){
    // call dtor
    Base_Finalize(((byte*)p)+i*(sizeof(Base)));
    }
    因为用户给的是个Base,按照base的大小步进的。所以第一个元素可能被是放掉,第二个over(此处,我想应该是只记住了n,而没有去管oneSize)
    但是我想VC的那个可能在release 最优化的时候可能也会有问题的。去掉oneSize还是很大节约。
    因为身处mac,无法验证猜测。

    只是我的理解的示例代码,真正的还得看汇编那里的具体计算。

  15. 法官要看法律,更要知道立法原意;
    程序员要看需求,但更要看背景需求。
    c的确比c++更明确、不容易混淆,只是编码功能的便利性方面弱一点

  16. Stackoverflow上的第二个回答很简单,也很清晰:

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

    b[5] will use the size of B to calculate which memory location to access, and if B and D have different sizes, this will not lead to the intended results.

  17. stack overflow上面说得很清楚了,之所以下面的写法在C++中是“未定义”:
    Base* pBase = new Derived[10];
    delete[] pBase
    是因为这种写法根本就 没有意义

    没有意义的原因在于:你不能用pBase[0] pBase[1] …… 去访问Derived[10]这个数组中的成员。

  18. 分析的很透彻,非常棒,对内存分布也很清晰
    不过个人觉得数组不支持多态在down cast这种内存分布上让人更清楚一些,但应用性上确实存在这种隐患
    VC++这种没有数组类型的new/delete的方式或者java这种伪多态在应用的角度方便很多
    啥事情都有两面性,和历史一样,没法一下子就给出绝对的好与坏
    个人的想法,也许有不对处,仅供参考

  19. 谷歌首席软件工程师罗布派克(Rob Pike)说:我们之所以开发Go,是因为过去10多年间软件开发的难度令人沮丧。C++功不可没

  20. 我使用C++语言,我不会写这样的代码,我不会沉浸于这些诡异特性并乐此不疲
    我无法说服楼主,因为我们是两个世界的人
    我讨厌undfined,所以我使用微软家的编译器
    我希望enum能有个tostring方法,于是CLR给我提供了
    分析的非常好,但是又有什么用了?
    oh,你问我为什么要问有没有用
    我无法回答

  21. viho_he :
    stack overflow上面说得很清楚了,之所以下面的写法在C++中是“未定义”:
    Base* pBase = new Derived[10];
    delete[] pBase
    是因为这种写法根本就 没有意义
    没有意义的原因在于:你不能用pBase[0] pBase[1] …… 去访问Derived[10]这个数组中的成员。

    楼主洋洋洒洒写了好几千字,此楼几句话就说明白了。不错。

  22. @M

    如果这些东西大家都懂,也就不用费劲在这里叨叨了。正因为这些原本应该很基础的东西很多人不懂,所以才需要讲。

  23. 使用智能指针⑨没这么蛋疼了喵……或者知道如何实现和使用Pool和 Placement New,你的对象就能更好的自己管理了……顺带仰望微博里面一群奇葩,他们是我远离国内微博的主要动力喵……

  24. 楼主看到的是问题本质, 我补充几点表面上的吧。
    第一这也可以说是C++的问题, 因为这是delete导致的问题, 如果用free就不会有这个问题。
    其次,为何会这样? 当发生delete时编译器一般会调一个类似如下的函数进行处理:void* vec_delete(void* array, size_t size, in elm_count, void (*destructor)(void*, char)) 答案就在这里咯, count(数组元素个数)编译器有机制帮你记录, 对象大小size和destructor函数指针编译阶段看的就是你的指针类型咯。
    以上观点都来自于我看lippman的《inside the c++ object model》。 学得呆板欢迎指正。

  25. 为嘛像C++这样复杂的语言这么流行啊?
    为嘛很多富有表现力,不需要太关注细节的语言就流行不起来呢?
    为嘛要做桌面的GUI程序好像只能选C++啊?

    一个Delphi程序员和ruby fans路过
    Delphi已经没落了,期待Go语言的崛起,拯救我们这些程序员吧。

  26. 既然是undefined的东西,那编译器怎样实现都可以的。g++还允许void *p; p++这样的语句

suninrain进行回复 取消回复

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