C++面向对象设计的访问性问题

  最近在看Scott Meyers大神的《Effective C++》和《More Effective C++》,虽然这两本书都是古董级的教参了(当然针对C++11/C++14作者所更新的《Modern Effective C++》英文已经发售了,不过还没中文翻译版本),但是现在看来仍然收益匪浅,而且随着对这个复杂语言了解的深入和实践项目经验的增加,很多东西和作者产生了一种共鸣,以前种种疑惑突然有种拨云雾而见天日、豁然开朗的感觉,也难怪被列为合格C++程序员之必读书目。其实C++确实是个可怕的语言,于是市面上针对这个语言的教参也是聆郎满目层出不穷,当然水平也是参差不齐,像上面所说的Meyers三部曲能够历久弥新,也凸显了这些经典教参的真正价值。
  至于最近回归C++本质,主要是觉得现在后台开发的RPC、MQ、分布式系统虽然被称的神乎其神的,但是作为成熟的组件绝大多数公司都可以是直接拿来主义,当然也不可否认其使用经验的可贵,因为最近线上使用这些组件还是遇到或多或少不少问题的,以后可以少走些坑,然而这种东西也是可遇难求的;反而C++语言本身的使用占用了程序员绝大多数的工作内容,从而直接影响到项目的质量和后续的可维护性。在此,侯捷老师的 勿在浮沙筑高台 仍如警世名言响彻在耳,一个合格的程序员其扎实的基本功是多么重要。
  C++面向对象的东西太多了:public、protected、private访问和继承,virtual和多态、多继承,外加const、缺省参数、名字查找等,光这些元素的排列组合就可以导出很多种情况,看似灵活多变,但不是每种情况都值得去尝试的。

一、public继承

  public继承意味着是”is-a”的关系,每个派生类型对象也是一个基类类型对象,基类支持的操作派生类都支持,只不过派生类比基类更具体化一些而已,否则的话应该将派生类不支持的特性给踢出去,比如:

1
2
3
4
5
6
class Bird { ... };
class FlyingBird: public Bird {
public:
virtual void fly(); ...
};
class Penguin: public Bird { ... };

  所以,总体来说public继承是相对比较严格的契约关系。当然public继承是一个比较笼统的概念,细分下来还包括接口继承实现继承接口和实现继承
  如果基类声明了一个pure virtual函数,则其目的是让派生类只继承该函数接口;如果基类声明了一个impure virtual函数,就是让派生类继承该函数的接口和其缺省实现;如果某个成员函数是non-virtual函数,则意味着它不打算在派生类中有不同的行为,即派生类继承该函数接口及一份强制性实现。
  对于pure virtual函数的接口声明,基本没有什么意义,而non-virtual成员也显而易见。不过对于impure virtual虚函数,看似提供了缺省实现使用起来会比较方便,而且派生类可以覆盖其实现也比较灵活,但是如果直接使用这种方式,那么如果基类产生了新的派生类,但是恰好派生类忘记对这个impure virtual函数进行override,而其缺省实现又不满足新派生类的行为,那么新派生类对象的调用将会引发问题。所以如果想继承接口,同时又提供缺省实现,那么比较好的方式是将这两个功能进行分离,用一个pure virtual函数提供接口,再用一个non-virtual protected函数提供缺省实现,而让派生类手动确认是否使用该默认行为。

1
2
3
4
class Airplane {
public: virtual void fly(const Airport& dest) = 0;
protected: void defaultFly(const Airport& dest){ ... }
};

  除了上面的方式处理impure virtual的缺省实现,其实也可以将其转换为:仍然使用pure virtual函数声明接口,不同同时也提供其缺省定义,这样派生类在override这个pure virtual接口的时候既可以完全重新定义fly的行为,也可以直接一条语句用基类名字直接调用基类的缺省实现(Airplane::fly),其好处是不用引入一个新的函数名字,缺点是缺省实现成了public的了。
  说到此处,应该对C++中接口继承的行为得以了解了。

二、虚函数外的其他选择

  前面我们说到了《C++之virtual函数访问性》中谈及了NVI手法,算是对public virtual的一个强有力的替换工具,不过我们知道其本身也用到了虚函数。虚函数具有运行时开销,而且其实现也是编译时间确定运行时候选择,在有些情况下其灵活性还是受限。
  相比于虚函数依据派生类型进行行为的定制化之外,Strategy策略模式显得更为的灵活。通过在对象内部保存函数指针(或者更泛化的boost::function函数对象),其行为可以依据具体对象差异化而非派生类型差异化,甚至通过Set接口其行为还可以在运行时候进行变更。虽然Meyers说明如果使用非成员函数,默认将不能访问类的私有成员,否则就需要对封装性进行一定程度的妥协松懈,但是通过boost::function+boost::bind这个强有力的工具,使用继承体系中的成员函数也是十分方便的。
  此处本人感觉,虽然设计模式被奉为C++开发的经典,但是随着Modern C++在标准上引入更多的特性和功能,C++的开发将必定变的更加友好直观,也不被过于墨守那古典23式了,毕竟绝大多数的设计模式都通过继承来实现的,不可避免的增加了程序开发和维护的复杂度。

三、继承体系来的其他问题

  好了,轻松愉快的东西结束了,下面是C++史上的黑暗时刻了。

3.1 继承而来名字的可见性

  C++具有一套名字查找的规则,总体来说就是从局部到外围,从派生类到基类,从内层名字空间到全局名字空间的查找顺序。
  由于到此为止我们没有说明函数重载的情况,所以你此时仍然安之若素:对于public non-virtual函数我们不去重写,对于virtual函数我们可以override,这一切安好,但是一旦考虑到相同函数名的重载问题,C++有一套理论就会让你晕乎了:C++防止在应用程序库或者应用框架中建立的新的派生类被附带从疏远的基类中继承而来的重载函数,所以在继承的时候C++不会将基类的名字自动导入到派生类中。
  好了,这就说明,之前继承而来的接口,其实也是在使用的时候在派生类作用域中没有找到该符号,而在基类中找到该符号后满足调用的,而如果你在基类中定义了其某个重载版本(无论是virtual还是non-virtual)的时候,C++在名字查找的时候就在你的派生类作用域中找到该名字了,然后进行类型检查和重载,但是重载的版本只限于在派生类中出现的版本,基类的版本不参与重载!!!
  所以,在派生类中想增加还是改写无论virtual还是non-virtual函数的重载版本,第一件事是使用using声明将基类符号的所有版本声明到派生的名字作用域中,然后再干其他的。

3.2 绝不重新定义继承而来的non-virtual函数

  C++的non-virtual函数都是编译期静态绑定的,其名字查找从其指针的静态类型开始。
  任何情况下,都不要重新定义一个继承而来的non-virtual函数,否则其调用的版本决定于其指针静态类型,这与public继承is-a的一致性关系相互违背。

3.3 绝不重新定义继承来的缺省参数值

  因为上面说到我们不应该重新定义一个继承而来的non-virtual函数,所以到这里我们可以说:绝对不要重新定义一个继承而来带缺省参数值的virtual函数的参数默认值。其原因是:virtual函数是动态绑定的,而缺省参数是静态绑定的。
  所以如果基类和派生类的参数默认值不一致,则使用引用、指针调用发生参数默认值静态绑定和调用函数体动态绑定将会非常的诡异,所以需要避免这种情况。还有就是如果虚函数参数再基类指定的参数缺省值,而派生类override的时候没有指明参数缺省值,此时如果客户端以派生类对象方式调用该函数,则发生的是静态绑定,需要显示指定参数值;而如果客户端以指针、引用的新式调用该函数,则发生的是动态绑定,可以不指定其带有缺省值的参数。

1
2
3
4
5
6
7
class Shape {
public: virtual void draw(ShapeColor color = Red) const = 0; ...
};
class Circle: public Shape {
// 如果以对象模式调用draw,必须指定color参数而不能使用缺省参数
public: virtual void draw(ShapeColor color) const; ...
};

  解决这个问题的一个方式是使用NVI手法,其public non-virtual接口提供默认默认值(且不会被派生类重写),而private virtual不使用默认默认的特性以规避这种可能的不一致性。

3.4 private继承

  private继承没有”is-a”的契约关系了,在使用上一个巨大的差异是:编译器不再会自动将一个派生类对象转换为一个基类对象了,这意味着原本接收基类对象的函数参数将不再能够为其传递派生类对象作为实参了(对象、引用、指针类型都不允许,编译器会报基类S是派生类T不可访问的基类);同时由基类继承而来的所有成员,在派生类中都会变成private的访问权限。
  private继承意味着只有实现部分被继承,接口部分被全部略去了,所以private继承应当是采用基类的某些功能帮助派生类完善其功能,从某种情况下说具有”has-a”的符合类型,所以除了考虑到派生类需要访问基类protected成员和virtual的因素被牵扯进来,否则应该尽量使用组合类型来代替private继承,而且即使如此,也可以使用下面的手法瞒天过海:

1
2
3
4
5
6
7
8
9
10
class Timer {
public: virtual void onTick() const; ...
};
class Widget {
private:
class WidgetTimer: public Timer {
public: virtual void OnTick() const; ...
};
WidgetTimer timer;
};

  关于protected继承,连Meyers大神都没用过,那么我又何必废脑经去考虑他……

本文完!

参考

Effective C++