“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. No,No,No,你对“会C++”的要求太高了。
    作为语言的设计者,你应该这样考虑你的用户:

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

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

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

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

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

  4. 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.

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

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

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

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

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

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

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

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

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

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

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

  14. 按照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()

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

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

    其他==》基本?

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

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

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

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

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

    这个地方肯定写错了·

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

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

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

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

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

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

  28. 看来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

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

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

  31. tree_star :

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

    这个地方肯定写错了·

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

Hadoken进行回复 取消回复

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