Effective C++条款34:区分接口继承和实现继承(Differentiate between inheritance of interface and inheritance of implementation)
- 条款34:区分接口继承和实现继承
- 1、纯虚函数
- 2、虚函数(非纯)
- 2.1 将默认实现分离成单独函数
- 2.2 利用纯虚函数提供默认实现
- 3、普通成员函数(非虚)
- 4、class设计者常犯的两个错误
- 4.1 第一个错误
- 4.2 第二个错误
- 5、牢记
- 总结
《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:
第6章:继承与面向对象设计
条款34:区分接口继承和实现继承
public继承由两部分组成:函数接口继承和函数实现继承。当我们设计类时,对于基类的成员函数可以大致做下面三种方式的处理:
-
①纯虚函数:基类定义一个纯虚函数,然后让派生类去实现。
-
②非纯虚的virtual虚函数:基类定义一个非纯虚的virtual虚函数,然后让派生类去重写覆盖(override)。
-
③普通的成员函数:基类定义一个普通的成员函数,并且不希望派生类去隐藏。
为了对这些不同的选择有一个更好的理解,考虑表示几何图形的类继承体:系:
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
1、纯虚函数
首先考虑纯虚函数draw:
class Shape {
public:
virtual void draw() const = 0;
...
};
纯虚函数的两个最具特色的特征是:它们必须被继承它们的任何具象类重新声明;在抽象类中它们通常情况下没有定义。将这两个特征放在一起,你就会发现:
- 声明纯虚函数的目的是让派生类只继承函数接口
这对Shape::draw函数是再合理不过的事了,因为所有的Shape对象来说都是能够画出的,这是一个合理的需要,但是Shape类不能为这个函数提供合理的缺省实现,比如,画一个椭圆的算法和画一个矩形的算法是不一样的。Shape::draw的声明对派生具现类的设计者说,“你必须提供一个draw函数,但是我并不知道你该如何实现它。”
我们可以为一个纯虚函数提供一个定义。也就是你可以为Shape::draw提供一个实现,C++不会发出抱怨,但是调用它的唯一方式是在函数名前加上类名限定符:
Shape *ps = new Shape; // error! Shape是抽象的
Shape *ps1 = new Rectangle; // 没问题
ps1->draw(); // 调用Rectangle::draw
Shape *ps2 = new Ellipse; // 没问题
ps2->draw(); // 调用Ellipse::draw
ps1->Shape::draw(); // 调用Shape::draw
ps2->Shape::draw(); // 调用Shape::draw
这项性质除了能给别人留下一个深刻的印象外,用途有限。
2、虚函数(非纯)
简单虚函数背后的故事同纯虚函数有些不太一样。通常,派生类继承函数接口,但是虚函数会提供一份实现代码,派生类可能覆写它。
- 声明一个简单虚函数的目的是让派生类继承一个函数接口和缺省实现。
考虑Shape::error这个例子:
class Shape {
public:
virtual void error(const std::string& msg);
...
};
这个接口表示,每个class都必须支持一个“当遇到错误时可调用的函数”,但是每个类对错误如何进行自由的处理。如果一个类不想做任何特殊的事情,那么调用基类Shape中error的默认实现就可以了。也就是Shape::error的声明对派生类的设计者说,“你可以支持error函数,但如果你不想自己实现,你可以使用Shape类中的默认版本。”
先来看一个虚函数的演示案例,假设某航天公司设计一个飞机继承体系,该公司现在只有A型和B型两种飞机,代码如下:
class Airport {...}; //机场
class Airplane { //飞机的基类
public:
virtual void fly(const Airport& destination);
...
};
void Airplane::fly(const Airport& destination) {
//缺省代码,飞机飞往指定的目的地
}
class ModelA :public Airplane {};
class ModelB :public Airplane {};
为了表示所有的飞机必须支持fly函数,还有不同型号的飞机可能需要fly的不同实现,因此Airplane::fly被声明为virtual。然而,为了避免在ModelA和ModelB中实现同一份代码,我们为Airplane::fly提供了默认实现,ModelA和ModelB可以同时继承。
这是典型的面向对象设计。两个类共享同一个特征(实现fly的方式),所以一般的特征都会移到基类中,然后被派生类继承。这种设计使得类的普通特性比较清晰,防止代码重复,可以促进将来的增强实现,使长期维护更加容易——这是面向对象如此受欢迎的原因。
现在假设XYZ公司界定购入新式C型飞机,型号C和型号A和B不一样,具体说是,它的飞行方式变了。
XYZ的程序员为Model C在继承体系中添加了新类,但是他们如此匆忙的添加新类,以至于忘了重新定义fly函数:
class ModelC: public Airplane {
... // 未声明 fly 函数
然后代码中有这些动作:
Airport PDX(...); // PDX是我家附近的机场
Airplane *pa = new ModelC;
...
pa->fly(PDX); // 调用Airplane::fly
这会是一个灾难:型号C的飞机尝试用型号A或者型号B的飞行方式去飞行。这不是增加旅客信心的行为。
2.1 将默认实现分离成单独函数
问题不在于Airplane::fly有默认的行为,而在于允许 Model C在没有明确说明它需要基类行为的情况下继承了基类的行为。幸运的是,很容易为派生类提供只有在它们需要的情况下才为其提供的默认行为,这种技术在于切断“virtual函数”和其“默认实现”之间的连接。代码如下:
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination) {
//飞机飞往指定的目的地(默认行为)
}
注意,在A和B的类的fly()函数中,对defaultFly()函数做了一个inline调用(见条款30,inline和virtual函数之间的交互关系)
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);
}
...
};
现在C型飞机,或者别的添加的飞机就不会意外继承默认的飞行行为了(因为我们将默认的飞行行为封装到一个defualtFly函数中了),自己可以在fly中定义飞行行为了
class ModelC :public Airplane {
public:
virtual void fly(const Airport& destination);
};
void ModelC::fly(const Airport& destination) {
//将C型飞机飞至指定的目的地
}
Airplane::defaultFly是一个非虚函数同样重要。因为没有派生类可以重定义这个函数,如果defaultFly是虚函数,就会有一个循环问题:万一某些派生类忘记重新定义defaultFly,会怎样?
2.2 利用纯虚函数提供默认实现
有人反对以不同的函数分别提供接口和缺省实现,像上面我们将fly()接口和实现(defaultFly()函数)分开来实现,有些人可能会反对这样做,因为这样会因过度雷同的函数名称而引起class命名空间污染。
如果不想将上述两个行为分开,那么可以为纯虚函数进行定义,在其中给出defaultFly()函数的相关内容。例如:
class Airplane {
public:
//实现纯虚函数
virtual void fly(const Airport& destination) = 0;
...
};
void Airplane::fly(const Airport& destination) {// 纯虚函数实现
//缺省(默认)行为,将飞机飞至指定的目的地
}
class ModelA :public Airplane {
public:
virtual void fly(const Airport& destination) {
Airplane::fly(destination);
}
...
};
class ModelB :public Airplane {
public:
virtual void fly(const Airport& destination) {
Airplane::fly(destination);
}
...
};
class ModelC :public Airplane {
public:
virtual void fly(const Airport& destination);
};
void ModelC::fly(const Airport& destination) {
//将C型飞机飞到指定目的地
}
这几乎和前一个设计一模一样,只不过在派生类的fly()函数中用纯虚函数Airplane::fly替换了独立函数Airplane::defaultFly。这种合并行为丧失了“让两个函数享有不同保护级别”的机会:例如上面的defaultFly()函数从protected变为了public(因为它在fly之中)。
3、普通成员函数(非虚)
最后,看看 Shape 的非虚函数objectID:
class Shape {
public:
int objectID()const; //普通成员函数,不希望派生类隐藏
};
class Rectangle :public Shape {};
class Ellipse :public Shape {};
如果成员函数是个非虚函数:
-
意味是它并不打算在派生类中有不同的行为。
-
实际上一个普通的成员函数所表现的不变性凌驾其特异性,因为它表示不论派生类变得多特特异化,它的行为都不可以改变。
声明一个非虚函数的目的在于让派生类继承一个函数接口,并且有一个强制的实现,
你可以把Shape::objectID的声明想做是:
-
每个Shape对象都有一个用来产生对象识别码的函数,此识别码总是采用相同计算方法,该方法有Shape::objectID的定义式决定,任何派生类都不应该尝试改变其行为
-
由于非虚函数代表的意义是不变性凌驾特异性,所以它绝不该在派生类中被重新定义(这也是条款36所讨论的一个重点)
4、class设计者常犯的两个错误
“纯虚函数、非纯虚的virtual虚函数、非虚函数”之间的差异,使得指定你想要派生类继承的东西:只继承接口,或是继承接口和一份缺省实现,或是继承接口和一份强制实现。针对于不同的函数,经验不足的class设计者最常犯的两个错误如:
4.1 第一个错误
第一个错误是将所有函数声明为“non-virtual”,这使得派生类没有多余空间进行特化工作。
non-virtual析构函数尤其会带来问题(见条款7)。
当然,如果一个类不打算作为基类,那么将所有函数声明为“non-virtual”是可以的。但是如果该类会作为基类,那么可以适当的声明一些virtual函数(见条款7)。
如果你当心virtual函数的成本,那么可以参阅80-20法则(也可参阅条款30):
-
这个法则为:一个典型的程序有80%的执行时间花费在20%的代码身上。
-
这个法则意味着,平均而言你的函数调用中可以有80%是virtual而不冲击程序的大体效率。所以当你担心virtual函数的成本之前,先将精力放在那举足轻重的20%代码上,它才是真正的关键。
4.2 第二个错误
第二个错误是将所有成员函数声明为virtual。
有时候这样做是正确的,例如条款31的Interface classes。然而某些函数就是不该在派生类中被重新定义,因此你应该将那些函数声明为non-virtual的。
5、牢记
-
接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
-
pure virtual函数只具体指定接口继承。
-
简朴的(非纯)impure virtaul函数具体指定接口继承及缺省实现继承。
-
non-virtual函数具体指定接口继承以及强制性实现继承。
总结
期待大家和我交流,留言或者私信,一起学习,一起进步!