Refresh

This website coolshell.cn/articles/9543.html is currently offline. Cloudflare's Always Online™ shows a snapshot of this web page from the Internet Archive's Wayback Machine. To check for the live version, click Refresh.

“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. shuiren :
    1, “很多说C++难用的人,其实是不懂C语言。” 不能支持这个观点更多
    2, 关于标准的undefined behavior, vc2010的实现无可厚非。 vc的实现和博主期望不一致而已。 既然是 undefined,那他自然可以和博主的实现不一致,反过来说g++对这个未定义的实现和博主的期望一致。其实博主后来的实验例子,也不过是在试图弄清g++的实现方式而已(注意,是g++的实现,不是iso c++的规范)。
    至于误导问题,c学得不好的人,编译器是应该实现出一个符合多数人直觉的行为,还是给用户一个难查的bug(也可能不表现出bug症状,编译也不会警告或报错——比如博主后面举的不出错的例子),这是个产品设计哲学的问题,不能据此就说ms做的东西坑人。 不懂c是不懂c的人的问题, 不是vc的问题。
    3, stroustrup 本人也曾表示过, 比可能要求所有的c++编译器实现都和标准完全兼容,更不用说各个编译器的实现彼此之间完全一致了。 vc对这个undefined behavior的实现方式, 可以认为是vc的一个方言, g++也有很多自己的方言,并且这些方言性质的特性, 很多后来都进入了c++标准,这也符合c++标准化过程中的一个原则 standardizing existing standard。 反过来,一些特性由于遭到几乎所有编译器的抵制,也会被标准移除,比如模板关键字export

    公道,同意!

  2. 微软那个编译器的行为倒有他的原因。是有客户(貌似来头还不小)一定要求这种行为。反正这种行为未定义,那我按客户的要求来也不会违反标准。这是纯粹的商业行为。

  3. 想向楼主及大家请教一个问题:
    当D采用虚拟继承的方式继承B时,代码”B *pb=new D[2]; delete [] pb;“出现错误。请高手指点一下,我一直没整明白。

  4. 这里可以这样解释,
    delete只会根据地址的类型(下面简称type)和内存地址(addr)来进行内存释放,
    delete是由编译器在编译阶段生成固定汇编代码的,而这种汇编代码的生成是不考虑什么父类、子类,虚函数、虚函数表的。
    示例代码:

    struct A{
    int unnamed;
    A(){printf(__FUNCTION__ “\n”);}
    ~A(){printf(__FUNCTION__ “\n”);}
    };

    struct B{
    int unnamed;
    B(){printf(__FUNCTION__ “\n”);}
    ~B(){printf(__FUNCTION__ “\n”);}
    };

    struct C{
    int unnamed;
    C(){printf(__FUNCTION__ “\n”);}
    ~C(){printf(__FUNCTION__ “\n”);}
    };
    // 执行以下代码
    delete((A*)new A());
    delete((B*)new A());
    delete((C*)new A());
    // 输出
    A::A
    A::~A
    A::A
    B::~B
    A::A
    C::~C
    ///看汇编代码就知道了,编译器直接生成针对于var的type的析构函数,而根本不会考虑var的type的父类、子类、虚函数、虚函数表///
    delete((B*)new A());
    00875C9D push 4
    00875C9F call operator new (4D47FDh)
    00875CA4 add esp,4
    00875CA7 mov dword ptr [ebp-140h],eax
    00875CAD mov dword ptr [ebp-4],1
    00875CB4 cmp dword ptr [ebp-140h],0
    00875CBB je XCFight::create+100h (875CD0h)
    00875CBD mov ecx,dword ptr [ebp-140h]
    00875CC3 call A::A (4D62CEh)
    00875CC8 mov dword ptr [ebp-19Ch],eax
    00875CCE jmp XCFight::create+10Ah (875CDAh)
    00875CD0 mov dword ptr [ebp-19Ch],0
    00875CDA mov eax,dword ptr [ebp-19Ch]
    00875CE0 mov dword ptr [ebp-14Ch],eax
    00875CE6 mov dword ptr [ebp-4],0FFFFFFFFh
    00875CED mov ecx,dword ptr [ebp-14Ch]
    00875CF3 mov dword ptr [ebp-158h],ecx
    00875CF9 mov edx,dword ptr [ebp-158h]
    00875CFF mov dword ptr [ebp-164h],edx
    00875D05 cmp dword ptr [ebp-164h],0
    00875D0C je XCFight::create+153h (875D23h)
    00875D0E push 1
    00875D10 mov ecx,dword ptr [ebp-164h]
    00875D16 call B::`scalar deleting destructor’ (4D62B5h)
    00875D1B mov dword ptr [ebp-1A0h],eax
    00875D21 jmp XCFight::create+15Dh (875D2Dh)
    00875D23 mov dword ptr [ebp-1A0h],0

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

    爬了一会评论看到的最有意义的回答,虽然我并不怀疑作者的水平,但是把这种不被标准支持的写法和所谓”没学好C”联系起来真是有点无聊。

    1. 顶, Base* pBase = new Derived[10];这种写法就是没有意义,
      这种写法是c语言和c++的残缺的”oo”的杂交出来的怪胎, c++的Base* pBase = new Derived这种写法”被认为”是oo的,但是仅限于一个pBase对应一个pDerived,而不能加上[],
      我觉得以上写法的语义非常混乱,这当然是c++的问题,它是一条腿的”oo”,只能单腿跳着走或者贞子似的在地上爬行, vc的设计我觉得可以说是完善了oo(先不说这种oo本身好不好)
      c++之父说学习c++之前不必学习c语言,因为”c++是一门全新的语言”,—对啊, 如果你不会c语言,就直接学c++,那你就会先入为主的认为c++是一门”优雅”强大”的语言,而看不出c++有丑陋恶心到让人想吐的设计,

  6. 同意lulie的说法,这种写法虽然编译上没问题,实际上是错误的,就如同:

    Base *pBase = new Base[10];
    delete pBase; // 正确的写法应该是 delete[] pBase;

    类似的写法在编译上都没问题,但是执行的时候的结果可能不确定,像上面的写法在有些平台上会crash,但是在有些平台上就不会,是否crash取决于new/delete的实现方式。

  7. 文中提到一句话:“C++的标准会把虚函数表的指针放在类实例的最前面”
    请教下博主,标准中真的有规定编译器对虚函数的实现方式吗? 从下面的Wiki页面来看,不但没有这方面的规定,甚至连用不用vtable实现都没有规定。
    http://en.wikipedia.org/wiki/Virtual_method_table

  8. cout << "虚函数表地址:" << (int*)(&b) << endl;
    刚读了您的《c++虚函数表解析》,发现上面的文字描述错了,(int *)(&b)其实只是虚函数指针的地址。

  9. 对的,如果B,D都定义int变量和一个虚函数的时候,在32位CentOS下也是段错误,因为数据对齐后B,D的sizeof不一样大,就是会报错的。@icylord

  10. 用g++编译的确如上述所说的出现一系列情况,但是,用clang编译却出现了奇怪的情况,就是不会调用派生类的析构函数,而且不会sf,gdb调试的时候,执行B *pb = new D[2]时会跳到main函数的结尾处,不知道是这么回事

  11. 这篇文章里面链接的文章《C++ 虚函数表解析》还是在您CSDN博客上的,但是文章里的图片都看不到了。您能方便时把这篇文章里的图片都恢复出来吗?

  12. 搞方言没什么。你为什么不把 VS 编译的东西理解成一门叫 Microsoft C++ 的方言呢?
    GCC 还在 C++ 里搞了 VLA 呢。

  13. 这么写的最大问题是代码不可移植!某些编译器编译后可以正常运行,某些编译器编译后就crash,然后你在数十万行代码中去找这个bug?或者对user说,我这里正常运行,你那里crash,这不是我的问题,是你机器的问题。

  14. 太强了!当我看到struct A struct B时,我知道我这么多年的C语言学的只是皮毛。。。惭愧。。。向耗子哥学习

发表回复

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