C++ 不给力之不可继承

 

C++ 给人的印象通常是特性众多,使用复杂,性能突出。当然,它也有不怎么给力的时候。

1 问题

C++ 中有没有不能被子类继承的父类成员?(私有成员除外)

2 答案

有。而且至少有两种情况:隐藏 和 非依赖名字

3 名字隐藏

C++ 中,子类中跟父类同名的方法,分为 2 种情况

  • 子类和父类同名方法的参数签名相同, 就叫做覆盖(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++ 之父无可奈何的表情。

4 非依赖名字

另一个“不能”继承的情景是和非依赖名字(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,于是,在模板实例化之前,它就被检查。这时候,编译器没有发现任何 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 就能被编译器找到了。

5 总结

C++ 恐怕是最复杂的编程语言了,甚至没有“之一”。这些复杂性很大一部分来源于 C 语言的包袱。除了这里列出来的两种情况,我相信肯定还能找出不能被“继承”的情况,甚至用这些偶然发现的技巧去实现让一个类整体不能被继承的功能(所谓的 final或 sealed 类)也不是没有可能。这,恐怕也算得 C++ 的“魅力”之一了。

文章信息

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值