网上好像有人因为这个吵起来了,转载围观一下
转载
“C++ 的数组不支持多态”?
2013 年 04 月 29 日 陈皓 评论 177 条评论 59,678 人阅读
先是在微博上看到了个微博和云风的评论, 然后我回了 “楼主对 C 的内存管理不了解”.
后来引发了很多人的讨论,大量的人又借机来黑 C++, 比如:
//@Baidu-ThursdayWang: 这不就 c++ 弱爆了的地方吗, 需要记忆太多东西
//@编程浪子张发财:这个跟 C 关系真不大. 不过我得验证一下,感觉真的不应该是这样的。如果基类的析构这种情况不能 调用,就太弱了.
//@程序元:现在看来, 当初由于毅力不够而没有深入纠缠 c++ 语言特性的各种犄角旮旯的坑爹细枝末节, 实是幸事。为现在还沉浸于这些诡异特性并乐此不疲的同志们感到忧伤.
然后,也出现了一些乱七八糟的理解:
//@BA5BO: 数组是基于拷贝的,而多态是基于指针的, 派生类赋值给基类数组只是拷贝复制了一个基类新对象, 当然不需要派生类析构函数
//@编程浪子张发财:我突然理解是怎么回事了 , 这种情况下数组中各元素都是等长结构体,类型必须一致,的确没法多态. 这跟 C# 和 java 不同。后两者对于引用类型存放的是对象指针.
等等,看来我必需要写一篇博客以正视听了.
因为没有看到上下文,我就猜测讨论的可能会是下面这两种情况之一:
- 一个 Base*[] 的指针数组中,存放了一堆派生类的指针,这样,你 delete [] pBase; 只是把指针数组给删除了,并没有删除指针所指向的对象. 这个是最基础的 C 的问题。你先得 for 这个指针数组,把数据里的对象都 delete 掉, 然后再删除数组。很明显,这和 C++ 没有什么关系.
2)第二种可能是:Base *pBase = new Derived [n] 这样的情况。这种情况下, delete [] pBase 明显不会调用虚析构函数(当然,这并不一定,我后面会说) , 这就是上面云风回的微博。对此,我觉得如果是这个样子, 这个程序员完全没有搞懂 C 语言中的指针和数组是怎么一回事,也没有搞清楚, 什么是对象,什么是对象的指针和引用,这完全就是 C 语言没有学好.
后来,在看到了 @GeniusVczh 的原文 《如何设计一门语言(一)—— 什么是坑 (a)》最后时,才知道了说的是第二种情况. 也就是下面的这个示例(我加了虚的析构函数这样方便编译):
1 | class Base |
C 语言补课
我先不说这段 C++ 的程序在什么情况下能正确调用派生类的析构函数, 我还是先来说说 C 语言,这样我在后面说这段代码时你就明白了.
对于上面的:
1 | Base* pBase = new Derived[10]; |
这个语言和下面的有什么不同吗?
1 | Derived d[10]; |
一个是堆内存动态分配,一个是栈内存静态分配. 只是内存的位置和类型不一样,在语法和使用上没有什么不一样的. (如果你把 Base 和 Derived 想成 struct, 把 new 想成 malloc () , 你还觉得这和 C++ 有什么关系吗?)
那么,你觉得 pBase 这个指针是指向对象的,是对象的引用, 还是指向一个数组的,是数组的引用?
于是乎,你可以想像一下下面的场景:
1 | int *pInt; char* pChar; |
对上面的 pInt 和 pChar 指针来说, pInt [3] 和 pChar [3] 所指向的内容是否一样呢?当然不一样,因为 int 是 4 个字节, char 是 1 个字节,步长不一样,所以当然不一样.
那么再回到那个把 Derived [] 数组的指针转成 Base 类型的指针 pBase, 那么 pBase [3] 是否会指向正确的 Derrived [3] 呢?
我们来看个纯 C 语言的例程,下面有两个结构体,就像继承一样, 我还别有用心地加了一个 void *vptr, 好像虚函数表一样:
1 | struct A { |
注意:我用的是 G++ 编译的,在 64bits 平台上编译的, 其中的 sizeof (void*) 的值是 8.
我们看一下栈上内存分配:
1 | struct A *pa1 = (struct A*)(b); |
用 gdb 我们可以看到下面的情况:(pa1 [1] 的成员的值完全乱掉了)
1 | (gdb) p b |
我们再来看一下堆上的情况:(我们动态了 struct B [2], 然后转成 struct A *, 然后对其成员操作)
1 | struct A *pa = (struct A*)malloc(2*sizeof(struct B)); |
用 gdb 来查看一下变量,我们可以看到下面的情况:(pa 没问题, 但是 pb [1] 的内存乱掉了)
1 | (gdb) p pa[0] |
可见,这完全就是 C 语言里乱转型造成了内存的混乱,这和 C++ 一点关系都没有. 而且,C++ 的任何一本书都说过, 父类对象和子类对象的转型会带来严重的内存问题.
但是,如果在 64bits 平台下,如果把我们的 structB 改一下, 改成如下(把 struct B 中的 int j 给注释掉):
1 | struct A { |
你就会发现,上面的内存混乱的问题都没有了,因为 struct A 和 struct B 的 size 是一样的:
1 | (gdb) p sizeof(struct A) |
注:如果不注释 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++, 看下面的代码:
1 |
|
上面的代码可以正确执行,包括调用子类的虚函数!因为内存对齐了. 在我的 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++》中的条款三)
1 | Base* pBase = new Derived[10]; |
所以,微软 C++ 编程译器 define 这个事让我非常不解, 对微软的 C++ 编译器再度失望,看似默默地把其编译对了很漂亮, 实则误导了好多人把这种 undefined 的东西当成 defined 来用,还赞扬做得好, 真是令人无语. (就像微博上的这个贴一样,说 VC 多么牛,还说这是 OO 的特性. 我勒个去!)
现在,你终于知道 Base* pBase = new Derived [10]; 这个问题是 C 语言的转型的问题, 你也应该知道用于数组的指针是怎么回事了吧?这是一个很奇葩的代码!请你不要像那些人一样在微博上和这里的评论里高呼并和我理论到:“微软的 C++ 编译器支持这个事!”.
最后,我越来越发现,很多说 C++ 难用的人,其实是不懂 C 语言.
(全文完)