六、继承与面向对象设计条款32-34
条款32:确定你的public继承塑模出is-a关系
is-a即“是一种”的关系,比如Drive继承自Base,那么我们有:
- 每一个Derive对象都是Base对象。Base对象可以派上用场的地方Derive照样可以。
- 但是Derive可以派上用场的地方,Base却无法效劳。因为Derive里面有Base的成分,反之没有。
例如,Student继承自一个Person类,那么:
void eat(Person &p)
{
...
}
void study(Student &s)
{
...
}
Person p;
Student s;
eat(s); // 正确调用,每个s都是p的对象
study(p); // 错误调用,p对象不能代表s!
可以明显看到,Person类不能传递给一个Student类。
public继承的错觉
一般来说,我们的基类都是比较广泛的一个类,子类是更具体化的类,它继承了基类的特性,并且丰富了自己特有的性质。
但是世上的事没有那么的绝对,比如我有一个鸟类作为基类,鸟会飞,于是我把飞行动作作为一个虚函数,让以后的子类重写它:
class Bird
{
public:
virtual void fly()
{
...
}
};
问题来了:企鹅是鸟类,但是企鹅会飞吗? 明显不能,那我们就不该将fly声明为虚函数!
基于此问题的一般解决方法如下有两种:
一、提供一个运行期的错误
class Penguin : public Bird
{
public:
virtual void fly
{
error("penguin can't fly");
}
};
我们重写必要的虚函数,但是实现的内部我们只用一个error提供错误信息,这样在运行的时候我们就可以明确知道这个pneguin;类是无法提供fly操作的。
二、在编译期就解决问题
这是作者比较推崇的方法,能在编译期解决的事情就不要留在运行期。
这时候我们就要修改我们class的设计了。既然鸟类也有不会飞的,那么我们继承鸟类的时候要提供一个会飞的鸟基类,在这个类中提供飞行动作,让会飞的去继承这个即可,其它的直接继承鸟类。
class Bird
{
...
};
// 提供飞行函数,会飞的鸟的基类
class FlyingBird : public Bird
{
public:
virtual void fly()
{
...
}
};
// 不会飞的鸟直接继承Bird
class Penguin : public Bird
{
public:
virtual void fly
{
error("penguin can't fly");
}
};
将我们的设计声明为如此形式就可以在编译期解决问题。
综合上面的情况,我们要明确一个思想,不要把其它领域(如数学)的直觉施加到程序上面来,这样有时候可能并不奏效。代码可以通过编译,但是不代表它的逻辑、结果都是正确的呀!
作者总结
“public继承”意味着is-a.适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derive class对象也是一个base class对象。
条款33:避免遮掩继承而来的名称
继承而来的函数如果被重写,那个Base类的重载函数也不可见了:
class Base
{
public:
virtual void f1() = 0;
virtual void f1(int);
virtual void f2();
void f3();
void f3(double);
...
};
class Derive : public Base
{
public:
//using Base::f1;
//using Base::f3;
virtual void f1();
void f3();
void f4();
};
进行如下调用:
Derive d;
int x;
d.f1(); // 正确。调用Derive::f1
d.f1(x); // 错误。被遮掩了。
d.f2(); // 正确。调用Base::f2
d.f3(); // 正确。调用Derive::f3
d.f3(x); // 错误。被遮掩了。
通过这几个调用可以看到,即便Base类中有多个重载函数,但是一旦Derive类重写了一个继承而来的同名函数,那么其他几个重载的也不会被继承了。如f3,Base类中有两个f3函数,但是Derive中重写了f3,那么带参数的f3在derive类中就不再可见了。
如果要让他们可见的话,把两行using Base::的注释去掉,就可以正常访问了。又或者使用转交函数的方法指定作用域。比如:
virtual void f1()
{
Base::mf1();
}
作者总结
derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
为了让被遮掩的名称再见天日,可使用using声明式或转交函数。
条款34:区分接口继承和实现继承
当我们继承的时候,public的成员函数总是会被继承,包括它们的实现。在继承体系中,我们一般声明为虚函数,这样才能为多态提供保证:
- pure virtual。 声明一个pure virtual函数的目的只是为了让Derived类继承此接口,而不包括实现。一般实现都是Derived classes自己编写。
- impure virtual. 是为了让derived classes继承该类的接口以及缺省的实现。
接下来运用书上的例子,来看看怎么恰当的使用impure/pure virtual函数。
情景
某航空公司有A和B型两种飞机,它们都采用一样的飞行方式,写成类:
// 目的地机场
class Airport
{
...
};
class Airplane
{
public:
virtual void fly(const Airport &destination)
{
...
}
...
};
class ModelA : public Airplane
{
...
};
class ModelB : public Airplane
{
...
};
基于当前情景,现在的设计还是一个好的设计。两种飞机都采用同一种飞行方式,那么我们就直接继承自基类的飞行方式,这样就不会A和B都另外写一份同样的代码,显得冗余,避免了代码重复。
现在该公司又生产了新型飞机C。采取不一样的飞行方式,但是我们却忘记在ModelC中给出我们新的fly函数,那么就会导致我们会调用基类的飞行方式,显然那不是我们想要的。
没错,是忘记。理论上我们只需要记得在ModelC里面重写这个虚函数就可以保证正确执行。但是实际上我们真的可能忘记。
所以作者在文中推荐的一种方式:不管你是不是调用默认的方式,都要自己写一下调用。
class Airplane
{
public:
virtual void fly(const Airport &destination) = 0;
protected:
void defaultFly(const Airport &destination)
{
... // 缺省的飞行行为
}
...
};
class ModelA : public Airplane
{
public:
virtual void fly(const Airport &destination)
{
defaultFly(destination);
}
...
};
class ModelB : public Airplane
{
public:
virtual void fly(const Airport &destination)
{
defaultFly(destination);
}
...
};
这个设计虽然不能完美解决问题,因为我们还是可能因为复制黏贴而调用错误,但至少我们手动再调用一次会比之前的设计更好。
总结如下:
(1) 将fly函数写成pure virtual,就是只提供接口。
(2) 写一个protected的默认飞行函数。如果是老式飞机就在继承的fly函数中调用此函数。新型飞机就自己写实现方式。
这样就提供了更好的一层保障。
该怎么声明取决于实际情况
声明non-virtual函数的目的是为了令derived classes继承函数的接口以及一份强制性实现。
pure virtual,impure virtual,non-virtual函数之间的差异在于你要精确指定你想要derived classes继承的东西:只继承接口还是继承接口和一份缺省实现?或是一份继承接口和一份强制性实现?
作者总结
接口继承是实现继承不同。在public继承下,derived classes总是继承base class的接口。
pure virtual函数只具体指定接口继承。
impure vitual函数具体指定接口继承及缺省实现继承。
non-virtual函数具体指定接口继承及强制性实现继承。