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