这一条款是说的是公有继承的逻辑,如果使用继承,而且继承是公有继承的话,一定要确保子类是一种父类(is-a关系)。这种逻辑可能与生活中的常理不相符,比如企鹅是生蛋的,所有企鹅是鸟类的一种,直观来看,我们可以用公有继承描述:
class Bird
{
public:
virtual void fly(){cout << "it can fly." << endl;}
};
class Penguin: public Bird
{
// fly()被继承过来了,可以覆写一个企鹅的fly()方法,也可以直接用父类的
};
int main()
{
Penguin p;
p.fly(); // 问题是企鹅并不会飞!
}
但问题就来了,虽然企鹅是鸟,但鸟会飞的技能并不适用于企鹅,该怎么解决这个问题呢?方法一,在Penguin的fly()方法里面抛出异常,一旦调用了p.fly(),那么就会在运行时捕捉到这个异常。这个方法不怎么好,因为它要在运行时才发现问题。
方法二,去掉Bird的fly()方法,在中间加上一层FlyingBird类(有fly()方法)与NotFlyingBird类(没有fly()方法),然后让企鹅继承与NotFlyingBird类。这个方法也不好,因为会使注意力分散,继承的层次加深也会使代码难懂和难以维护。
方法三,保留所有Bird一定会有的共性(比如生蛋和孵化),去掉Bird的fly()方法,只在其他可以飞的鸟的子类里面单独写这个方法。这是一种比较好的选择,因为根本没有定义fly()方法,所以Penguin对象调用fly()会在编译期报错。
在艰难选择方法三之后,我们回过头来思考,就是在所有public继承的背后,一定要保证父类的所有特性子类都可以满足(父类能飞,子类一定可以飞),抽象起来说,就是在可以使用父类的地方,都一定可以使用子类去替换。
这正是Liskov替代原则告诉我们的:任何父类可以出现的地方,子类一定可以替代这个父类,只有当替换使软件功能不受影响时,父类才可以真正被复用。通俗地说,是“子类可以扩展父类的功能,但不能改变父类原有的功能”。
下面再来看一个数学上的例子,对于基础几何里的长方形和正方形,老师会说“正方形是特殊的长方形”,“特殊”体现在长宽是相等的,从字面上来看,我们可以表达成is-a关系,像这样:
class Rectangle
{
protected:
int length;
int width;
public:
void virtual IncreaseLength(int DeltaLength)
{
length += DeltaLength;
}
};
class Square: public Rectangle
{
public:
void virtual IncreaseLength(int DeltaLength)
{
length += DeltaLength;
width += DeltaLength;
}
};
为了与保持正方形长宽等值的特性,不得不将IncreaseLength里面也对width进行了操作,到这一步,恐怕成员函数名IncreaseLength已经变味了,明明是扩展长度,但“偷偷地”也把宽度给变了。这就与“子类可以扩展父类的功能,但不能改变父类原有的功能”相背离了,所以这个is-a关系在数学世界里也可这样说说,但在程序的世界里并不成立!
举上面两个例子,足可以看出public继承并不像口头上说的那么容易,要去理解两个类是否可以真正成为is-a关系,还需要好好斟酌。最后总结一下:
“public继承”意味着is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
名称的遮掩可以分成变量的遮掩与函数的遮掩两类,本质都是名字的查找方式导致的,当编译器要去查找一个名字时,它一旦找到一个相符的名字,就不会再往下去找了,因此遮掩本质上是优先查找哪个名字的问题。
而查找是分作用域的,虽然本条款的命名是打着“继承”的旗子来说的,但我觉得其实与继承并不是很有关系,关键是作用域。
举例子说明这个问题会比较好理解。
//例1:普通变量遮掩
int i = 3;
int main()
{
int i = 4;
cout << i << endl; // 输出4
}
这是一个局部变量遮掩全局变量的例子,编译器在查找名字时,优先查找的是局部变量名,找到了就不会再找,所以不会有warning,不会有error,只会是这个结果。
//例2:成员变量遮掩
class Base
{
public:
int x;
Base(int _x):x(_x){}
};
class Derived: public Base
{
public:
int x;
Derived(int _x):Base(_x),x(_x + 1){}
};
int main()
{
Derived d(3);
cout << d.x << endl; //输出4
}
因为定义的是子类的对象,所以会优先查找子类独有的作用域,这里已经找到了x,所以不会再查找父类的作用域,因此输出的是4,如果子类里没有另行声明x成员变量,那么才会去查找父类的作用域。那么这种情况下如果想要访问父类的x,怎么办呢?
可以在子类里面添加一个方法:
int GetBaseX() {return Base::x;}
利用Base::x,可以使查找指定为父类的作用域,这样就能返回父类的x的值了。
//例3:函数的遮掩
class Base
{
public:
void CommonFunction(){cout << "Base::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Base::VirturalFunction()" << endl;}
void virtual PureVirtualFunction() = 0;
};
class Derived: public Base
{
public:
void CommonFunction(){cout << "Derived::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
void virtual PureVirtualFunction(){cout << "Derived::PureVirtualFunction()" << endl;}
};
int main()
{
Derived d;
d.CommonFunction(); // Derived::CommonFunction()
d.VirtualFunction(); // Derived::VirtualFunction()
d.PureVirtualFunction(); // Derived::PureVirtualFunction()
return 0;
}
与变量遮掩类似,函数名的查找也是先从子类独有的作用域开始查找的,一旦找到,就不再继续找下去了。这里无论是普通函数,虚函数,还是纯虚函数,结果都是输出子类的函数调用。
下面我们来一个难点的例子。
//例4:重载函数的遮掩
class Base
{
public:
void CommonFunction(){cout << "Base::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Base::VirturalFunction()" << endl;}
void virtual VirtualFunction(int x){cout << "Base::VirtualFunction() With Parms" << endl;}
void virtual PureVirtualFunction() = 0;
};
class Derived: public Base
{
public:
void CommonFunction(){cout << "Derived::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
void virtual PureVirtualFunction(){cout << "Derived::PureVirtualFunction()" << endl;}
};
int main()
{
Derived d;
d.VirtualFunction(3); // ?
return 0;
}
很多人都会认为输出的是Base::VirtualFunction() With Parms,实际上这段代码却是编译不过的。因为编译器在查找名字时,并没有“为重载而走的很远”,C++的确是支持重载的,编译器在发现函数重载时,会去寻找相同函数名中最为匹配的一个函数(从形参个数,形参类型两个方面考虑,与返回值没有关系),如果大家的匹配程度都差不多,那么编译器会报歧义的错。
但以上法则成立的条件是这些函数位于相同的作用域中,而这里是不同的域!编译器先查找子类独有的域,一旦发现了完全相同的函数名,它就已经不再往父类中找了!在核查函数参数时,发现了没有带整型形参,所以直接报编译错了。
如果去掉子类的VirualFunction(),那么才会找到父类的VirtualFunction(int)。
提醒一下,千万不要被前面的Virtual关键字所误导,你可以试一个普通函数,结果是一样的,只要子类中有同名函数,不管形参是什么,编译器都不会再往父类作用域里面找了。
好,如果现在你非要访问父类里面的方法,那也可以,书上给出了两种作法,一种是采用using声明,另一种是定义转交函数。
//例5:采用using声明,使查找范围扩大至父类指定的函数:
class Base
{
public:
void CommonFunction(){cout << "Base::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Base::VirturalFunction()" << endl;}
void virtual VirtualFunction(int x){cout << "Base::VirtualFunction() With Parms" << endl;}
void virtual PureVirtualFunction() = 0;
};
class Derived: public Base
{
public:
using Base::VirtualFunction; // 第一级查找也要包括Base::VirtualFunction
void CommonFunction(){cout << "Derived::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
void virtual PureVirtualFunction(){cout << "Derived::PureVirtualFunction()" << endl;}
};
int main()
{
Derived d;
d.VirtualFunction(3); // 这样没问题了,编译器会把父类作用域里面的函数名VirtualFunciton也纳入第一批查找范围,这样就能发现其实是父类的函数与main中的调用匹配得更好(因为有一个形参),这样会输出Base::VirtualFunction() With Parms
return 0;
}
用了using,实际上是告诉编译器,把父类的那个函数也纳入第一批查找范围里面,这样就能发现匹配得更好的重载函数了。
//例6:使用转交函数强制指定父类的作用域
class Base
{
public:
void CommonFunction(){cout << "Base::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Base::VirturalFunction()" << endl;}
void virtual VirtualFunction(int x){cout << "Base::VirtualFunction() With Parms" << endl;}
void virtual PureVirtualFunction() = 0;
};
class Derived: public Base
{
public:
using Base::VirtualFunction;
void CommonFunction(){cout << "Derived::CommonFunction()" << endl;}
void virtual VirtualFunction(){cout << "Derived::VirturalFunction()" << endl;}
void virtual PureVirtualFunction(int x){cout << "Derived::PureVirtualFunction()" << endl;}
void virtual VirtualFunction(int x){Base::VirtualFunction(x)};
};
int main()
{
Derived d;
d.VirtualFunction(3); // 输出Base::VirtualFunction() With Parms
return 0;
}
采用这种做法,需要在子类中再定义一个带int参的同名函数,在这个函数里面用Base进行作用域的指定,从而调用到父类的同名函数。
快结尾了,这里还是声明一下,在不同作用域内定义相同的名字,无论是发生在变量还是函数身上,都是非常无聊也是不好的做法。除了考试试卷上,还是不要把名字遮掩问题带到任何地方,命个不同的名字真的有那么难吗?
最后总结一下:
1. derived classses内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
2. 为了让被遮掩的名称再见天日,可使用using声明式或转交函数(forwarding functions)。