[深入理解C++(二)]理解接口继承规则
罗朝辉 ( http://www.cppblog.com/kesalin/ )
CC许可,转载请注明出处
一,前言
在前一篇《[深入理解C++(一)]类型转换(Type Casting)》中,我详细讲述了 C++ 中转型动作,以及使用规则。有网友说应该提及下《深度探索 C++ 对象模型》一书中的内容,其实他的意思是,要是对 C++ 对象的内存布局不甚了解,就想要彻悟C++中的类型转型,对象切割,虚函数调用等,犹如脱离了坚实的根基,想去建空中阁楼。理解 C++ 对象的内存布局对学会 C++来说至关重要,但我不打算写 C++ 对象的内存布局相关的文章,因为要站在前人的肩膀上,大牛陈皓 已经就这个主题写了三篇图文并茂的文章:
(一),C++ 虚函数表解析
(二),C++ 对象的内存布局(上)
(三),C++ 对象的内存布局(下)
在继续阅读本文之前,建议先阅读这三篇文章,以更好地理解本系列文章。在接下来的内容中,我将从重载,重写,屏蔽等概念入手,引入众多接口继承规则。
二,引子:重载(overload),重写(override),屏蔽(hide)
重载(overload):在相同作用域内,函数名称相同,参数或常量性(const)不同的相关函数称为重载。重载函数之间的区分主要在参数和常量性(const)的不同上,若仅仅是返回值或修饰符 virtual,public/protected/private的不同不被视为重载函数(无法通过编译)。不同参数是指参数的个数或类型不同,而类型不同是指各类型之间不能进行隐身类型转换或不多于一次的用户自定义类型转换(关于类型转换,请参考前文:类型转型(Type Casting))。当调用发生时,编译器在进行重载决议时根据调用所提供的参数来选择最佳匹配的函数。
重写(override):派生类重写基类中同名同参数同返回值的函数(通常是虚函数,这是推荐的做法)。同样重写的函数可以有不同的修饰符virtual,public/protected/private。
屏蔽(hide):一个内部作用域(派生类,嵌套类或名字空间)内提供一个同名但不同参数或不同常量性(const)的函数,使得外围作用域的同名函数在内部作用域不可见,编译器在进行名字查找时将在内部作用域找到该名字从而停止去外围作用域查找,因而屏蔽外围作用域的同名函数。
(注:编译器在决定哪一个函数应该被调用时,依次要做三件事:名字查找,重载决议,访问性检查。后续文章将详细介绍这个决定过程。)
下面来分析示例:
class Base
{
public:
virtual void f() { cout << "Base::f()" << endl; }
void f(int) { cout << "Base::f(int)" << endl; }
virtual void f(int) const { cout << "Base::f(int) const" << endl; }
virtual void f(int *) { cout << "Base::f(int *)" << endl; }
};
class Derived : public Base
{
public:
virtual void f() { cout << "Derived::f()" << endl; }
virtual void f(char) { cout << "Derived::f(char)" << endl; }
};
const Base b;
b.f(10);
Derived d;
int value = 10;
d.f();
d.f('A');
d.f(10);
//d.f(&value);//编译报错
在上面代码中,Base 中的一系列名为 f 的函数在同一作用域内,且同名不同参或不同常量性,故为重载函数;而 Derived 中的 f() 则是重写了基类同名同参的 f();而 Derived 中的 f(char) 则屏蔽了 Base 中所有的同名函数。
所以上面代码的执行结果是:
Base::f(int) const
Derived::f()
Derived::f(char)
Derived::f(char)
对 d.f(10); 这两个调用,看似基类 Base 中有更好的匹配,但实际上由于编译器在进行名字查找时,首先在 Derived 类作用域中进行查找,找到 f(char) 就停止去基类作用域中查找,因而基类的所有同名函数没有机会进入重载决议,因而被屏蔽了。因此编译器将 10 隐式转型为 char 调用 Derived 中的 f(char)。至此,聪明的你应该很容易明白为什么 d.f(&value); 无法通过编译了吧(VS编译器的提示信息很给力)。
三,函数继承规则:
鉴于继承基类的函数有如此隐晦的概念需要弄懂,再加上 virtual 函数,public/protected/private 继承等等,更是增加了理解一个类接口的难度(因为你不仅要看类自身的接口,还有向上追溯所有基类的接口,以及是以何种方式继承基类的接口等等)。因此,C++里面有很多针对类接口继承的惯用法:
1,优先使用组合而非继承。既然继承代价如此之大,那么最好的就是不继承呗。当然不是说完全不用继承,只有在存在明确的“IS-A”关系时,继承的好处才会显现出来(可以用多态-但要遵循 Liskov 替换原则);而其他情况下(”HAS-A”或“Is-implemented-in-terms-of”)应毫不犹豫地使用组合,而且要优先使用 PIMPL(Point to implementation) 手法(后续文章会介绍这个惯用法)来使用组合。
2,纯虚函数继承规则-声明纯虚函数的目的是让派生类来继承函数接口而非实现,使得纯虚函数就像Java或C#中的 interface 一样。唯一的例外就是需要纯析构函数提供实现(避免资源泄漏)。
3,非纯虚函数继承规则-声明非纯虚函数的目的是让派生类继承函数接口及默认实现。但这是一种欠佳的做法,因为默认实现能让新加入的没有重写该实现的派生类通过编译并运行,而默认实现有可能并不适用于新加入的派生类,对此编译器并不会提供任何信息(警告都没一个)。为了应对这一潜在的陷阱,诞生了另一个规则:”纯虚函数的声明提供接口,纯虚函数的实现提供默认实现;派生类必须重写该接口,但在实现时可以调用基类的默认实现。“
如下代码所示:
class Base
{
public:
virtual void f() = 0;
};
void Base::f()
{
cout << "Base::f() default implement." << endl;
}
class DerivedA : public Base
{
public:
virtual void f()
{
Base::f();
}
};
class DerivedB : public Base
{
public:
virtual void f()
{
cout << "DerivedB::f() override." << endl;
}
};
4,非虚函数继承规则-永远也不要重写基类中的非虚函数。非虚函数的目的就是为了让派生类继承基类的强制性实现,它并不希望被派生类改写。
5,尽量不要屏蔽外围作用域(包括继承而来的)名字。屏蔽所带来的隐晦难以理解等问题在前面已有描述。
如果没得选择(我还真没想到有什么场景会出现这种情况,通常换个名字都是可行的)必须重新定义或重写基类中同名函数,那么你应该为每一个原本会被隐藏的名字引入一个 using 声明或使用转交函数(派生类定义同名同参函数,在该函数内部调用基类的同名同参函数)来使这些名字在派生类的作用域中可见。(Effective C++ 条款33)。
该规则应用如下:
class Base
{
public:
virtual void f() { cout << "Base::f()" << endl; }
void f(int) { cout << "Base::f(int)" << endl; }
virtual void f(int) const { cout << "Base::f(int) const" << endl; }
virtual void f(int *) { cout << "Base::f(int *)" << endl; }
};
class Derived : public Base
{
public:
using Base::f;
virtual void f() { cout << "Derived::f()" << endl; }
//virtual void f(char) { cout << "Derived::f(char)" << endl; }
};
const Base b;
b.f(10);
Derived d;
int value = 10;
d.f();
d.f('A');
d.f(10);
d.f(&value);
运行得到的结果为:
Base::f(int) const
Derived::f()
Base::f(int)
Base::f(int)
Base::f(int *)
在这里,因为使用了 using Base::f; ,因此基类中的所有名字 f 对子类来说都是可见的,所有 d.f(&value); 等均可通过编译运行了。再次提醒:这是一种非常不好的做法。
6,基类的析构函数应当为虚函数,以避免资源泄漏。
假设有如下情况,带非虚析构函数的基类指针 pb 指向一个派生类对象 d,而派生类在其析构函数中释放了一些资源,如果我们 delete pb; 那么派生类对象的析构函数就不会被调用,从而导致资源泄漏发生。因此,应该声明基类的析构函数为虚函数。
7,避免 private 继承 – private 继承通常意味着根据某物实现出(Is-implemented-in-terms-of),此种情况下使用基类与派生类这样的术语并不太合适,因为它不满足 Liskov 替换原则,并且从基类继承而来的所有接口均为私有的,外部不可访问。private 继承可用 PIMPL 手法取代。
文中已经两次提到 PIMPL 利器,在这里就 private 继承先给出一个示例,以后再详述 PIMPL 的好处。
原先使用 private 继承:
class SomeClass
{
public:
void DoSomething(){}
};
class OtherClass : private SomeClass
{
private:
void DoSomething(){}
};
使用 PIMPL 手法替代:
class SomeClass
{
public:
void DoSomething(){}
};
class OtherClass
{
public:
OtherClass();
~OtherClass();
void DoSomething();
private:
SomeClass * pImpl;
};
OtherClass::OtherClass()
{
pImpl = new SomeClass();
}
OtherClass::~OtherClass()
{
delete pImpl;
}
void OtherClass::DoSomething()
{
pImpl->DoSomething();
}
8,不要改写继承而来的缺省参数值。前面已经说到非虚函数继承是种不好的做法,所以在这里的焦点就放在继承一个带有缺省参数值的虚函数上了。为什么改写继承而来的缺省参数值不好呢?因为虚函数是动态绑定的,而缺省参数值却是静态绑定的,这样你在进行多态调用时:函数是由动态类型决定的,而其缺省参数却是由静态类型决定的,违反直觉。
有代码有真相:
class Base
{
public:
// 前面的示例为了简化代码没有遵循虚析构函数规则,在这里说明下
virtual ~Base() {};
virtual void f(int defaultValue = 10)
{
cout << "Base::f() value = " << defaultValue << endl;
}
};
class Derived : public Base
{
public:
virtual void f(int defaultValue = 20)
{
cout << "Derived::f() value = " << defaultValue << endl;
}
};
这段代码的输出为:
Derived::f() value = 10
调用的是动态类型 d -派生类 Derived的函数接口,但缺省参数值却是由静态类型 pb-基类 Base 的函数接口决定的,这等隐晦的细节很可能会浪费你一下午来调试,所以还是早点预防为好。
9,还有一种流派认为不应公开(public)除虚析构函数之外的虚函数接口,而应公开一个非虚函数,在该非虚函数内 protected/private 的虚函数。这种做法是将接口何时被调用(非虚函数)与接口如何被实现(虚函数)分离开来,以达到更好的隔离效果。在设计模式上,这是一种策略模式。通常在非虚函数内内联调用(直接在头文件函数申明处实现就能达到此效果)虚函数,所以在效率上与直接调用虚函数相比不相上下。
譬如:
class Base
{
public:
virtual ~Base() {}
void DoSomething()
{
StepOne();
StepTwo();
}
private:
virtual void StepOne() = 0;
virtual void StepTwo() = 0;
};
class Derived : public Base
{
private:
virtual void StepOne()
{
cout << "Derived StepOne: do something." << endl;
}
virtual void StepTwo()
{
cout << "Derived StepTwo: do something." << endl;
}
};
四,后记
C++ 陷阱特别多,学好用好 C++ 不容易,但只要把 OO 设计原则牢记在心头,多见识些 C++ 惯用手法,C++ 的威力就能很好的展现出来。
五,引用
Effective C++ 条款 32 ~ 39
More Effective C++ 条款20 ~ 25