继承与面向对象设计
OOP是C++中十分重要的一个课题,本文尝试着解释C++各种不同特性的真真意义,也就是当你当用某个特定构件你真正想要表达的意思。
1. 确定你的public继承塑模出is-a关系
以C++进行面向对象编程,最重要的一个规则是:public inheritance意味“is-a”的关系!
如果你令class D(“Derived”)以public继承class B(Base),你便告诉C++编译器,每一个类型为D的对象同时也是一个类型为B的对象。D是B更特殊化的概念,而B是D更一般化的概念。任何B能派上用场的地方,D都可以,反之不然。
就比如说:
class Person { ... }; // 更一般化的概念
class student : public Person { ... };
class teacher : public Person { ... }; // 更特殊化的概念
在C++领域中,任何函数如果期望获得一个类型为Person(或pinter或reference)的实参,都愿意接受一个student对象(也就是动态绑定,运行时多态实现的基础!)。
尽管讲起来十分的简单,但我们在建模时还是会出现许多的问题。例如,我们尝试着把鸟建模:
class Bird {
public:
virtual void fly();
...
};
class Penguin : public Bird {
...
};
企鹅是一种鸟吧?企鹅可以飞吗?
这就是建模时没有分析清楚的表现。更合适的做法是把bird类再细分。
class Bird {
...
};
class FlyingBird : public Bird {
public:
virtual void fly();
...
};
class Penguin : public Bird {
...
};
class Eagle : public FlyingBird {
virtual void fly();
...
};
我们应该相信,世界上并不存在一个“适用于所有软件”的完美设计。所谓最佳设计,取决于系统希望做什么事情,包括现在与未来。我们只能期望设计出一个暂时完美而有效的设计。
除了is-a关系以外,还有两个常见的关系has-a和is-implemented-in-terms-of(根据某物实现出)。在后文会涉及。
2. 避免遮掩继承而来的名称
由于作用域的不同,继承类的作用域的名称会遮掩基类中相同的名称。本节讨论不同的方法来避免。
作用域
当位于一个derived class成员函数内提及(refer to)base class内的某物时,编译器可以找出我们所提及的东西,因为dereived class继承了声明与base class内的所有东西。dereived class作用域总是被嵌套在base class作用域内。
class Base {
private:
int x;
public:
virtual void mf1() = 0; // pure virtual
virtual void mf2(); // impure virtual
void mf3(); // non-virtual
...
};
class Derived : public Base {
public:
virtual void mf1();
void mf4() {
...
mf2();
...
}
...
};
当编译器看到使用名称mf2,必须估算它指的是什么。编译器的做法是查找各作用域,查找相关mf2的声明式。首先查找local作用于,然后查找外围作用域,也就是dereived class覆盖的作用域,然后是base class作用域,如果再找不到就找内含base的那个namespace,最后往global作用域找。
如果有重载函数,怎么办?
上一个例子较为简单,并没有什么出现混淆之处。现在我们来给出了较难得例子,里面含有重载函数。虽然这个例子的代码写的并不好。
class Base {
private:
int x;
public:
virtual void mf1() = 0; // pure virtual
virtual void mf1(int);
virtual void mf2(); // impure virtual
void mf3(); // non-virtual
void mf3(double);
...
};
class Derived : public Base {
public:
virtual void mf1();
void mf3();
void mf4();
...
};
...
// in main:
Derived d;
int x;
...
d.mf1(x); // error! can't find mf1(int)!
因为以作用域为基础的“名称遮掩规则”并没有改变,因此base class内所有名为mf1和mf3的函数都会被dereived class内的mf1和mf3函数遮掩掉!!从名称查找观念来看,Base::mf1和Base:mf3不再被Derived继承了!于是,如果我们尝试着调用基类的mf3(double)函数就不再可能了。
这些行为背后的基本理由是为了防止你在程序库或应用框架内建立新的dereived class时附带地从疏远的base class继承重载函数。但问题是,你总是希望继承这些重载函数的。实际上,如果你使用public继承而又不继承那些重载函数,就是违反base和dereived class之间的is-a关系了。
解决方法1:使用using声明使。
class Base {
private:
int x;
public:
virtual void mf1() = 0; // pure virtual
virtual void mf1(int);
virtual void mf2(); // impure virtual
void mf3(); // non-virtual
void mf3(double);
...
};
class Derived : public Base {
public:
using Base::mf1;
using Base::mf3;
// 让base class内名为mf1和mf3的所有东西在dereived作用域内可见。
virtual void mf1();
void mf3();
void mf4();
...
};
....
// in main:
Derived d;
int x;
...
d.mf1(x); // ok!
这意味着如果你继承base class并加上重载函数,而你又希望重新定义或重写其中一部分,那么你就可以使用using声明式,否则某些你希望继承的名称会被遮掩!
解决方法2:使用转交函数
有时候你并不想继承base class的所有函数,这是可以理解的。但如果在public继承下,这是不可以的!因为他违反了public is-a建模规则!但对于private继承却是可以的。方法就是:
class Base {
public:
virtual void mf1() = 0;
virtual void mf1(int);
...
};
class Derived : private Base {
public:
virtual void mf1() {
Base::mf1(); // inline转换函数
}
...
};
3. 区分接口继承和实现继承
public继承概念实际上由两部分组成:函数接口继承和函数实现继承。本文讨论他们的不同之处,以及使用方法。
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectID() const;
...
};
class Rectangle : public Shape { ... };
class Ellipse : public Shape { ... };
成员函数的接口总是会被继承。
就像前文提到的。public是一种is-a模型,理应把所有base class的接口继承到dereived class中。
pure virtual函数
声明一个pure virtual函数的母的是为了让dereived class只继承函数的接口。
由于pure virtual函数一般情况下是没有实现的并且把他所属的class声明为abstract class,所以实际上pure virtual函数就是实现一个规范接口的作用。让dereived class必须定义该函数相应的实现。
其实,pure virtual函数也是可以有实现的。但必须要显示地调用。
Shape* ps = new Shape; // error!
Shape* ps1 = new Rectangle;
ps1->draw();
ps1->Shape::draw(); // 显示调用基类纯虚函数。
impure virtual函数
声明impure virtual函数的目的,是让dereived class继承该函数的接口和缺省实现。
其接口表示,每个子类都可以有自己关于这个函数的特定实现,也可以调用他们共同的缺省行为。这是典型的面向对象设计。由多个class共享一份相同性质,然后被多个class继承。这个设计突显共同性质,避免代码重复,并提升未来的强化能力,减缓长期维护所需的成本。
但这其实也有问题。因为它的缺省行为使得它的一个子类可以没有声明相关的实现,而直接调用缺省行为。
所以为了避免程序员可能会忘记在新增加的子类中给出相关的实现,更合理的做法是给出pure virtual函数,并给出相关的实现。从而就可以避免子类没有给出相关实现而且也给出了缺省行为。(例如给出draw的实现)
non-virtual函数
声明non-virtual函数的目的是为了令dereived class继承函数的接口以及一份强制实现。
就像此例中objectID,子类不需要有特殊的实现,只需要按照基类的实现就可以了。从而实现不变形凌驾特殊性!
4. 绝不要重新定义基层而来的non-virtual函数
non-virtual是给出函数接口和函数实现,所以无论如何我们都不应该重新定义从基类继承而来的non-virtual函数。
尽管这个问题非常显而易见,但还是能够给出相关的解释和推理方法。
class B {
public:
void mf();
...
};
class D : public B {
void mf();
};
...
// in main:
D x;
...
B* pb = &x;
pb->mf();
// 不同于:
D* pd = &x;
pd->mf();
non-virtua函数都是静态绑定的。这意思就是,由于pb被声明为一个B类型的pointer,它就会调用B类的函数,pd也是如此。
另一方面,virtual函数却是动态绑定的,所以他们不会导致这种问题。
5. 绝不重新定义继承而来的缺省参数值
为简化讨论,我们把讨论局限于“继承一个带有缺省参数值的virtual函数”。并且基于一个重要的原理:virtual函数是动态绑定的,而缺省参数值却是静态绑定的。
问题产生
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
...
};
class Rectangle : public Shape {
public:
virtual void draw(ShapeColor color = Green) const;
...
};
class Circle : public Shape {
public:
virtual void draw(ShapeColor color) const;
// 注意!客户在使用该对象时一定要指定参数值。
...
};
问题发生在哪呢?
一开始,我就提到,C++对于virtual函数是动态绑定的,但对缺省参数值却是静态绑定的!这也就意味着,同样是调用draw函数,却可能因为静态类型不同而导致不同的缺省参数。原则上来说,只要你细心一点,这并不到导致什么致命的错误。但是我们还是应该相信,程序员总是“懒惰的”,我们要避免一切可能犯错误的可能,特别是这种及其容易混淆的情况。
问题解决
解决这个问题其实很简单,只要让基类和子类的缺省值一样就可以了。这样就可以避免奇怪的行为。但问题仍然存在。
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
...
};
class Rectangle : public Shape {
public:
virtual void draw(ShapeColor color = Red) const;
...
};
class Circle : public Shape {
public:
virtual void draw(ShapeColor color = Red) const;
...
};
这种解决方法暂时来说可行,但不可维护!如果一旦我们需要改变缺省值,就得把所有的类都查一遍改掉所有的缺省值!多么浪费时间!这就是代码重复又带着相依性的缺陷!
于是,我们可以采取一种更具技巧的方法。让non-virtual函数指定缺省参数,而private virtual函数负责真正的工作:
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
void draw(ShapeColor Color = Red) const {
doDraw(color);
}
...
private:
virtual void doDraw(ShapeColor color) const = 0;
// 真正的工作!
};
class Rectangle : public Shape {
public:
...
private:
virtual void doDraw(ShapeColor color) const;
// 特化这种实现。
...
};
于是每个子类对象调用draw时,实际上都是调用基类的Draw,而draw中特化的实现又是由特定的子类完成,多么有趣的技巧啊!
6. 通过复合塑模出“has-a”或“根据某物实现出”
复合(composition)是类型之间的一种关系,当某种类型的对象内含它种类型对象,便是这种关系。意味你正在处理两个不同的领域(domains)
复合实际上也有两种函数:1)has-a,意味着你塑造属于应用域(application domain)的对象。)2)is-implemented-in-terms-of,意味着你在处理实现域(implementation domain)对象。
区别方法可以根据字面来理解,他们都有一个或以上的成员是其他类,has-a就是指这些成员只影响应用不影响实现。is-implemented-in-terms-of指这个类的实现是基于这些其他类的成员的,他们影响着类的实现!
其实要区别is=a和has-a是比较简单的。难的是如何区分is-a和is-implemented-in-terms-of。以下我们给出一个例子,实现一个基于list的set。
STL中的set通常是以平衡查找树实现而成的,每个元素耗用三个指针(self, right, left)。他们在查找、安插、移除元素时都保证对数时间效率。当速度比空间重要时,这是合理的设计,但如果空间比效率重要时,我们就需要自己设计一个set来体现空间更重要。
我们不妨用list来实现set(体现重用!)但应该用什么的模式呢?如果我们用is-a,问题就在于set中不允许两个相同的元素存在,而list中允许,并且两个class之间并非is-a关系,所以用is-a是不合理的。我们应该采用is-implemented-in-terms-of,set对象可以根据一个list对象实现出来:
template<typename T>
class Set {
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep; // 用来描述set数据
};
// 相应的实现:
template<typename T>
bool Set<T>::member(const T& item) const {
return std::find(rep.begin(), rep.end(), item) != rep.end();
}
template<typename T>
bool Set<T>::insert(const T& item) {
if (!member(item)) {
rep.push_back(item);
}
}
template<typename T>
bool Set<T>::remove(const T& item) {
typename std::list<T>::iterator it = std::find(rep.begin(), rep.end(), item);
if (it != rep.end())
rep.erase(it);
}
template<typename T>
std::size_t Set<T>::size() const {
return rep.size();
}
7. 谨慎使用private继承
private继承意味着implemented-in-terms-of!本节讨论private继承和上文提到的is-implemented-in-terms-of的区别。
首先,我们必须声明,1)编译器不会自动将一个derived class对象转换为一个base class对象。2)有private base class继承而来的所有成员,在derived class中都会变成private属性。所以实际上,private继承就是只继承实现,不继承接口!
尽可能不用private继承
我么下面这种类来表示定时器。
class Timer {
public:
explicit Timer(int tickFrequency);
virtual void onTick() const;
...
};
class Widget : private Timer {
private:
virtual void onTick const;
...
};
借由private继承,我们可以重新声明/定义onTick函数。这是个好设计,但是并没有什么用。因为只要采用以下方法就可以实现相同的功能。
class Widget {
private:
class WidgetTimer : public Timer {
public:
virtual void onTick() const;
};
WidgetTimer timer;
public:
...
};
这种方法虽然比较复杂,但实际上却有着更大的好处。
- 你或许会想设计Widget使得它得以拥有dereived class,但同时你可能会想阻止dereived class重新定义onTick。如果Widget继承自Timer,上面的想法就无法实现。但如果WidgetTimer是Widget内部一个private成员并继承Timer,Widget的dereived class将无法取用WidgetTimer,因此无法继承它或重新定义它的virtual函数。
- 可以使得编译依存性最小化。
使用private继承的特殊情况
事实上也存在着应该使用private继承的情况。这种情况涉及空间最优化。
问题提出:
class Empty { };
// 没有数据,所以其对象不应该使用任何内存。
// 但内部有typedef enum static成员变量等,non-virtual函数...
class HoldsAnInt {
private:
int x;
Empty e;
};
你会发现sizeof(HoldsAnInt)>sizeof(int)。因为面对“大小为零之独立(非附属)对象”,通常C++会默默安插一个char到空对象内,然而齐位需求可能造成编译器对class加上一些衬垫,所以可能HoldsAnInt不只获得一个char大小,也许实际上会被方法到足够存放一个int。(在Xcode中确实如此!)为了最优化空间,我们当然不希望一个Empty类被无端放大,那么该怎么办呢?
问题解决:
class Empty { };
// 没有数据,所以其对象不应该使用任何内存。
// 但内部有typedef enum static成员变量等,non-virtual函数...
class HoldsAnInt : private Empty {
private:
int x;
};
这几乎可以确定sizeof(HoldsAnInt) == sizeof(int)。这就是所谓EBO(empty base optimization:空白基类最优化)。这种使用方法在STL很常见。
8. 谨慎使用多重继承
多重继承(multiple inheritance, MI)有优势也有劣势,本节我们主要讨论多重继承的该如何被使用。
调用歧义
对一个类使用多重继承时,可能会在被继承多个类中有重名函数,于是调用这个名称的函数就会导致歧义。虽然这个问题并不致命,只要显示给出作用域符就可以解决,但不管怎么说,它都导致了一种可能错误的可能。
多重继承导致重复继承于同一个类。
class File { ... };
class InputFile : public File { ... };
class OutputFile : public File { ... };
class IOFile : public InputFile, public OutputFile {
...
};
通过以上方式建模,会导致IOFile内有两份File的成员(通过两条路径继承),但实际上IOFile应该只有一份File的成员。这个问题的解决方法也很简单,就是继承虚基类。
class File { ... };
class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... };
class IOFile : public InputFile, public OutputFile {
...
};
编译器为避免继承得来的成员变量重复,它必须提供许多的幕后操作,其结果就是:使用virtual继承的那些class所产生的对象往往比non-virtual继承的兄弟们体积大,访问virtual base class的成员变量,往往也更慢。
最为复杂的还是virtual base class初始化。virtual base的初始化责任是由继承体系中的最底层(most dereived)class负责。这意味着:1)class若派生自virtual base而需要初始化,必须认知其virtual base–不管那些base距离多远。2)当一个新的dereived class加入继承体系中,它必须承担起virtual base(不管是直接或间接继承的)的初始化责任。
实例程序:
class Person
{
public:
Person(string nam , char se , int a)
{
name = nam ;
sex = se ;
age = a ;
}
protected:
string name ;
char sex ;
int age ;
};
//声明Person的直接派生类Teacher
class Teacher : virtual public Person //声明Person为公共继承的虚基类
{
public:
Teacher(string nam , char se , int a , string ti) : Person(nam , se , a)
{
title = ti ;
}
protected:
string title ;
};
//声明Person的直接派生类Student
class Student : virtual public Person //声明Person为公共继承的虚基类
{
public:
Student(string nam , char se , int a , float sco) : Person(nam , se , a) , score(sco) {}
protected:
float score ;
};
//声明多重继承的派生类Graduate
class Graduate : public Teacher , public Student
{
public:
Graduate(string nam , char se , int a , string ti , float sco , float wa) : Person(nam , se , a) , Teacher(nam , se , a , ti) , Student(nam , se , a , sco) , wage(wa) {}
void show()
{
cout << "name :" << name << endl ;
cout << "sex :" << sex << endl ;
cout << "age :" << age << endl ;
cout << "title :" << title << endl ;
cout << "score :" << score << endl ;
cout << "wage :" << wage << endl ;
}
private:
float wage ;
};
所以,1)非必要不适用virtual base!2)不要在virtual base里发给置数据,这对后面继承的类提供太多的负担。
Interface + Implemention
多重继承有一种合理的应用:将“public继承自某接口”和“private继承自某实现”结合在一起。
class IPerson {
public:
virtual ~IPerson() {
}
virtual string name() const = 0;
virtual string birthDate() const = 0;
}; // abstract class be used to be interface
class DatabaseID { ... }; // 通过ID获得唯一对象。
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const {
// 宝轮缓冲区给返回值使用,由于缓冲区是static,因此会被自动初始化为0.
static char value[Max];
strcpy(value, valueDelimOpen());
....
strcat(value, valueDelimClose());
return value;
}
virtual const char* theBirthDate() const;
virtual const char* valueDelimOpen() const {
return "[";
}
virtual const char* valueDelimClose() const {
return "]";
}
...
}; // be implemention
class CPerson : public IPerson, private PersonInfo {
public:
explicit CPerson(DatabaseID pid) : PersonInfo(pid) { }
virtual string name() const {
return PersonInfo::theName();
}
virtual string birthDate() const {
return PersonInfo::theBirthDate();
}
private:
const char* valueDelimOpen() const {
return "";
}
const char* valueDelimClose() const {
return "";
}
// 用于规范化输出的方式
};
总结
多重继承只是OOP一个工具而已,相比于单一继承,它通常比较复杂。所以除非有非常明确的理由要使用多重继承,否则还是应该使用的单一继承。