纯虚函数能为 private 吗?
-- zhuweisky 2003.04.18
我们把一个仅仅含有纯虚函数的类称为接口,我们也好像已经习惯了将这个接口中的所有纯虚函数全声明为 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 结合起来的语法背后隐藏的语义,挖掘出这些语义,对于我们以后在进行设计时作恰当的抉择无疑是大有帮助的。