C++ 给人的印象通常是特性众多,使用复杂,性能突出。当然,它也有不怎么给力的时候。
1 问题
C++ 中有没有不能被子类继承的父类成员?(私有成员除外)
2 答案
有。而且至少有两种情况:隐藏 和 非依赖名字
3 名字隐藏
C++ 中,子类中跟父类同名的方法,分为 2 种情况
- 子类和父类同名方法的参数签名相同, 就叫做覆盖(override)
- 子类和父类方法的 参数签名不同 ,不管该方法是否为虚函数,父类的同名方法都被隐藏了(hide)
被隐藏起来的父类方法,默认是不被“继承”的:
|
上面的代码会引发编译错误。原因就是 int Base::Foo(int) 没有被 Derived 默认“继承”。
如果要使用父类里被隐藏的方法,需要加入显示的限定符:
|
这样虽然可以编译,但使用上面太麻烦,可用性很成问题。还有一个显为人知的解是在子类中显示声明使用父类的同名方法:
|
看起来只要使用 using 把父类同名方法显示引入就可以了,问题解决了!不过,如果再想一想,就会发现新的问题:为什么要让使用者多写一个 using ? 为什么要引入隐藏机制?为什么不是默认把同名的父类方法继承过来? 一句话,为什么 C++ 被设计成这样?要回答这些问题,先得说明白 C++ 众多的功能中的 3 个:
- 强类型:编译器会在编译期检查参数类型,不允许类型不匹配的调用。这是对 C 语言的一个重大改进。强类型检查使得大部分编程错误在编译期就被发现。
- 函数重载:同名函数可以有不同的定义。编译器会根据调用方的入参数目类型来选择一个合适的函数实现。它简化了编程,也是最基本的一种“多态”(同一个名字在不同上下文中对应不同的东西)
- 隐式类型转换:在调用函数时,如果形参和实参类型不一制,C++ 会先尝试将实参类型转换成形参类型再调用。它主要作用是为了兼容 C 语言。没有隐式类型转换,大部份的 C 代码就不可能不经修改就通过 C++ 的编译。
这三个功能互相配合,互相影响。函数重载是以强类型检查为基础的,没有强类型检查,编译器就不能根据形参类型区分同名函数。而隐式类型转换却跟强类型是一对矛盾。这三个功能相互影响会产生什么问题呢?来看下面的程序
|
上面的程序先用隐式类型转换将 3u (类型为 unsigned int) 转换成 3 (类型为 signed int) 然后再调用到 void Foo(int n),没有任何问题。但如果某天加入了另外一个函数:
|
这下强类型和重载判断会认定 void Foo(unsigned int n) 是比 void Foo(int n) 更优的一个选择,于是调用新加入的这个函数。到现在为止都还好。但如果把类和继承加入进来呢?同名但签名不同的父类和子类方法不正是属于一个重载集合吗?如果默认父类的同名方法属于子类的重载集合会发生什么事呢?
|
这段代码工作的很好,可是,某天,第三方的 Base 类有了一个升级:
|
原来正常工作的代码,这时候就抛出了异常(不用惊讶,NULL 更加匹配 int 类型而不是 void*,可恶的 C++, 可爱的 nullptr …)。只是父类中增加了一个同名的重载方法,所有原来使用子类中同名方法的代码都受到了影响。而且,这个父类和子类并不要求直接继承。也就是说,不管离你多远的父类中的一个同名方法的增删,都会影响子类代码的使用,这实在是危险!这个危险就是上面列出来的强类型,函数重载,隐式类型转换三个功能在继承时带给我们的。可这三个功能都不能去掉,那怎么办?C++ 之父给了个折中:如果默认就把父类的同名函数加入子类的重载集确实很危险,所以 C++ 不会这么做,除非用户显示的告诉编译器要这么做(using Base::Foo)。这,应该就是故事的来龙去脉了。可以说,C++ 为了保持和 C 语言的兼容性(隐式类型转换就是 C 兼容性的要求),让语言的复杂性大大增加。但与 C 兼容也是 C++ 成功的基石。真是成也是 C 败也是 C 。我仿佛看见了 C++ 之父无可奈何的表情。
4 非依赖名字
另一个“不能”继承的情景是和非依赖名字(non-dependent name)相关的,下面的代码编译会出错:
|
这里,Foo 虽然是 TDerived<Base> 的方法,但却会引发编译错误。原因是 C++ 在引入模板时,为了要尽可能早的发现代码错误,将模板代码分为依赖名字和非依赖名字。编译时,编译器会执行二阶段查找(2 phase lookup)。所谓的依赖名字,就是所有跟模板参数类型有关的名字。非依赖名字是跟模板参数类型无关的名字。在第一阶段查找时,编译器会检查所有跟参数类型无关的名字,这时如果发现错误,就可以不用等模板被实例化(通常这会比较耗费资源)就给出诊断信息了。而所有的依赖名字必须要等到模板实例化时,有了具体的类型才能进行检查。在我们上面的例子里,Foo 这个名字是非依赖名字,它不依赖于模板参数 T,于是,在模板实例化之前,它就被检查。这时候,编译器没有发现任何 Foo 的定义,于是报错。如果要修正这个问题,只要将 Foo 改为依赖名字就行了
|
或者:
|
这两种改法都将 Foo 变成了依赖名字,会在 TDerived<Base> 实例化时才进行检查,这时,Base::Foo 就能被编译器找到了。
5 总结
C++ 恐怕是最复杂的编程语言了,甚至没有“之一”。这些复杂性很大一部分来源于 C 语言的包袱。除了这里列出来的两种情况,我相信肯定还能找出不能被“继承”的情况,甚至用这些偶然发现的技巧去实现让一个类整体不能被继承的功能(所谓的 final或 sealed 类)也不是没有可能。这,恐怕也算得 C++ 的“魅力”之一了。
文章信息
- 原创文章,转载请注明:转载自 道可道 | C++ 不给力之不可继承 http://zhuoqiang.me/a/ungelivable-cpp-those-who-cannot-inherit
- 版权声明 License:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0