我们把一个仅仅含有纯虚函数的类称为接口,我们也好像已经习惯了将这个接口中的所有纯虚函数全声明为public,而且按照这样的设计,一切都工作得不错。比如COM正是这样做的,它的接口中几乎不会存在private的纯虚函数。那么,让我们想一想,纯虚函数或者虚函数可以为private吗?如果这种方式是可行的,那么什么时候可以将(纯)虚函数设为private了?这些都是本文将要讨论的主题。一起来看看。
一.访问限定符与继承
如果基类隐式(间接)向子类暴露了私有成员,那么从某种意义上讲,该私有成员对于子类是可见的。
任何一本讲C++基础的课本上都详细地介绍了访问限定符与继承的关系,在这里就不重复了,但是,课本上的东西并不全,不信?那么请先看看下面的例子:
#include <string>
#include <iostream>
using namespace std ;
class Base
{
private:
string classID() const
{
return string("Base") ;
}
protected:
virtual void doWork() =0 ; //纯虚函数
public:
void work()
{
cout<<"this class id is "<<classID()<<endl ;
doWork() ;
}
virtual ~Base()
{
}
};
class DerivedA : public Base
{
private:
string classID() const
{
return string("DerivedA") ;
}
protected:
void doWork()
{
cout<<"this is DerivedA doWork !"<<endl ;
}
};
以上的代码声明了一个基类和一个子类,不过比较奇特的是基类的提供的公共接口是非虚的,而这个非虚的公共接口却调用了一个非虚的私有函数和一个虚拟的保护函数。接着,子类重定义了这两个函数。那么下面的调用会输出什么了?
Base* bp = new DerivedA() ;
bp->work() ;
delete bp ;
以下是输出的结果:
this class id is Base
this is DerivedA doWork !
怎么回事?为什么不是
this class id is DerivedA
this is DerivedA doWork !
子类的classID()不是将基类的classID()覆盖了么?我们来分析一下,基类中的公共的work()成员函数调用了私有的classID()成员函数,根据输出的结果来看,在子类中定义的classID方法并没有覆盖基类的同名方法,为什么呢?难道是因为classID是private导致的?那好,我们将classID函数改为public再次运行,我们期望的结果出现了吗?呵呵,很抱歉,没有,希望再次破灭了,为什么会这样?这主要涉及的原因是:普通函数的调用是在编译期确定的,当work函数一看到所调用的classID是非虚的,就会毫无疑问地去直接使用基类的classID。这一切与Base类是否会被继承没有任何关系,跟Base类被继承后子类会否再次定义classID就更没有关系了。
那么这种情况下,Base类将classID声明为private和public/protected有什么区别了?当将classID声明为private时,DerivedA看不到基类的classID的声明,所以不会发生重定义;当将classID声明为public/protected时,DerivedA将看到基类的classID声明,于是会发生重定义,即会覆盖调基类的classID的定义。讲到这里就要提一下,如果当将classID声明为public/protected,并且子类也定义同名的函数classID,但是子类的classID与基类的classID的函数签名不同,那么此时发生的将是函数重载而不是覆盖。
让我们更进一步,将基类和子类的classID声明都改为virtual public ,再次运行程序,会得到以下输出:
this class id is DerivedA
this is DerivedA doWork !
而这正是我们所期望的,不是吗?这其中的原因也很容易理解,因为classID是virtual ,并且是public的,所以会产生多态调用。
再往下走,将基类和子类的classID声明改为virtual private ,再次运行程序,看看输出了什么。
this class id is DerivedA
this is DerivedA doWork !
没有变化,将classID声明为virtual private和声明为 virtual public 得到的结果是一样的。“为什么会这样,classID是private啊?”你惊讶地叫出来。是,classID是private,但classID也是virtual,原因就在这里,用基类指针或引用进行虚函数调用采用的是动态绑定,看看编译器为调用classID产生的代码就知道了:
//c++伪码
(this->vptr[1])() ;
在运行时期,通过this指针将会找到正确的vtbl,即DerivedA类的vtbl,这样自然就会出现上面的结果了。那么将classID 声明为private限制了什么?和将非虚函数声明为private一样,这将使得在Base类外部无法调用多态函数classID,只能在Base内部调用,如通过work函数调用。
可见,多态性与将实现多态的函数的访问限定符没有任何关系,private 函数仍然可以实现多态,它的指针仍然位于vtbl中,只不过该函数的多态一般只能在基类的内部由其他非虚函数调用该函数的时候反映出来,访问限定符仅仅限制外部对类的成员的访问权限,它并没有破坏以下规则:
通过基类指针或引用调用成员函数时,如果该函数时非虚的,那么将采用静态绑定,即编译时绑定;如果该函数是虚拟的,则采用动态绑定,即运行时绑定。
二.virtual与访问限定符结合
上面我们通过分析,已经知道了多态的实现与访问限定符没有任何关系,访问限定符只是控制类的成员对外部的可见性,但不限制多态。正如上面提到的,将classID声明为virtual private和声明为 virtual public 后再次运行程序,得到的结果是一样的,上面我们简单的地分析了一下表面现象,但这个问题决不是这么简单,让我们挖掘更深层次的意义,我想这应该属于OOA、OOD的范畴了。好,让我们一步步看过来。
当我们将classID声明为非虚的 private时,子类将看不见它,当然也就无法覆盖或重载它,即在这中情况下,子类无法更改classID的实现,但是子类继承了公共接口work(),而这个接口调用了classID,所以,可以看作,子类间接地继承了classID的实现,并且这个实现是无法修改的。于是,我可以说,基类中声明一个普通私有成员函数,表示这是一个不可被更改的实现细节。
再来讨论将classID声明为virtual private的情况,声明为private表示基类不想让子类看到这个函数,但是又声明为virtual,表示基类想让这个函数实现多态。呵呵,基类既想实现多态,却又不让子类看见这个函数,这似乎有点自相矛盾,是吗?其实,这其中的意思是,子类既可以修改这个实现,也可以继承其基类默认的实现。所以可以这么说,如果基类中有一个虚拟私有成员函数,表示这是一个“可以”被派生类修改的实现细节。注意,当中的用词,是“可以”,而不是别的。
最后来看看将classID声明为virtual protected的情况。将classID声明为protected表示基类“需要”子类看见这个函数,注意,我使用“需要”这个动词,这个词表示了一定的“强制”意味。与将classID声明为virtual private的情况对比一下,我想你已经知道答案了,即是,如果基类中有一个虚拟保护成员函数,表示这是一个必须被派生类修改的实现细节。“必须”这个词表达了强制的意思。
关于“将virtual与访问限定符结合”的问题就讨论这么多,你也许说,还漏掉了将classID声明为virtual public的情况。是的,其实,我并不推荐将虚拟函数声明为public,尽管这种方式在现在很流行,我推荐将其使用virtual protected来替换,这就说明基类必须另外发布一个几乎不更改的非虚public接口,在这个接口中调用了virtual protected或virtual private函数,这样以来,我们就对类的内部实现作了进一步的隐藏,而这无论是对系统的可扩展性,还是可维护性都是大有帮助的。“虚拟函数应该和数据成员一样对待――让他们成为私有的,除非设计需求表明应该有较少的限制。提升它们到更高存取级别比把它们降到更私有的级别更容易些。”
最后,把上面所说的小结一下:
基类中的一个普通私有成员函数,表示这是一个不可被更改的实现细节。
基类中的一个虚拟私有成员函数,表示这是一个可以被派生类修改的实现细节。
基类中的一个虚拟保护成员函数,表示这是一个必须被派生类修改的实现细节。
最好不要将虚拟成员函数声明为public,而是用protected来替换。
三.模板方法模式
在理解了上面所述的内容的情况下,再来理解模板方法模式就非常easy了,模板方法是在GOF的经典大作《设计模式》中阐述了一种模式,该模式定义了一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中,模板方法使得派生类可以不改变一个算法的结构即可重定义算法的某些特定步骤。在这里,我不想再重复解释这个模式如何实现的,我仅仅举个例子,这个例子将体现出模板方法中最重要的思想。
假设基类定义的一个算法的骨架由3个步骤完成,其中第一个步骤是该继承体系中不可被改变的一个步骤,即所有的类对该步骤的实现都是一样的,那个这个步骤可以设置为非虚的private ;第二个步骤是一个可以被派生类改写也可以不被改写的步骤,通过上面的讨论知道,可以将其设为virtual private ;第三个步骤是针对每一个派生类的实现都不同,那么这个步骤可以被设为virtual protected ,而且,步骤三只能针对特定的派生类才有意义,所以将步骤三也设为纯虚函数。如下面的代码所示:
class BaseTemplate
{
private:
void step1(void) // 不可被更改的实现细节
{
}
virtual void step2(void ) // 可以被派生类修改的实现细节
{
}
protected:
virtual void step3(void ) =0; // 必须被派生类修改的实现细节
public:
void work(void) // 骨架函数,实现了骨架
{
step1() ;
step2() ;
step3() ;
}
};
注意,上例中根本没有暴露任何虚函数,所有的这一切都是通过work()这个非虚的public接口展现出来的,当我们用一个BaseTemplate指针调用work()时,表面上是一个非虚函数调用,采用静态绑定,事实上也正是这样,但是,这个调用的背后隐藏的却是多态调用,即step2和step3动态绑定了。看见,采用模板方法模式,不仅定义了一个算法的骨架,而且把这个骨架的实现的细节作了进一步的封装。我们可以在模板方法模式中可以这样设计:
(1) 如果一个函数作为算法骨架中不可变更的一部分,那么可以将此函数作为基类的私有函数,并且在基类的公共骨架函数中调用该函数,即该函数作为骨架的一个不可更改的实现细节。
(2) 如果一个函数提供了算法骨架某环节的一个缺省实现,那么可以考虑将该函数作为基类的私有虚函数,表示子类可以改写它,也可以不改写它。
(3) 如果作为算法骨架一部分某个函数要求在子类中拥有不同的实现,那么可以考虑将该函数作为基类的保护(纯)虚函数,表示子类必须改写它。
讲到这里,已经差不多了,在结束的时候,提一下语法与语义的联系。通常,语法是表象,语义是表象后面隐藏的东西,而这些隐藏的语义往往更具有价值。举个例子,public继承与private继承在语法方面似乎没有什么更多的东西值得探讨,它们的区别仅仅在于改变了继承得到的成员的可见性,但是从语义方面来分析,它们就相差太远了,private继承在语义上来讲是“通过基类来实现自己”,即是“实现继承”,在这种继承关系中,基类和子类的关系是很薄弱的;而public继承在语义上即是我们所熟知的“IS-A”关系,它体现了基类和子类之间的亲密性,也正是这种“IS-A”关系为多态性提供了基础。
所以,通过表面的语法来挖掘其背后的语义很有意义,就像这篇文章中提到的将访问限定符与virtual结合起来的语法背后隐藏的语义,挖掘出这些语义,对于我们以后在进行设计时作恰当的抉择无疑是大有帮助的。
--zhuweisky 2003.04.18