C++的继承和多态特性十分多,十分复杂。访问级别有public\protected\private之分,继承方式有public\protected\private之分,函数属性有non-virtual\impure-virtual\pure-virtual之分,对对象的访问又有直接访问\指针\引用之分。所有的这些在继承和多态这里交叉影响,得出了很多各异的特性。可能这也是说C++难学的一个很重要的方面吧。
这段时间分别看了《C++Primer》《Thinking in C++》《Effective C++》三本书关于继承和多态的内容,算是有了一个大概的把握,本想总结的精炼一些,不过现在觉得有点难,暂且记录下学习笔记,待有时间和有能力时,希望自己能把这一块知识好好提炼总结一下,已达到豁然开朗的程度。
学习笔记内容:
面向对象的编程思想有三个基础:
1.封装性:用类实现
2.继承性:用继承实现
3.动态绑定:用虚函数实现
继承性的思想是用旧类创建新类,新类和旧类之间有很大的相似,实现这个思想的一个比较山寨的办法是“组合”。即将旧类的一个对象作为新类的一个成员。
当然比较官方的方法还是“继承”。
《继承》
通过继承我们能够定义这样的类,它们对类型之间的关系建模,共享公共的东西,仅仅特化本质上不同的东西。派生类(derived class)能够继承基类(base class)定义的成员,派生类可以无须改变而使用那些与派生类型具体特性不相关的操作,派生类可以重定义那些与派生类型相关的成员函数,将函数特化,考虑派生类型的特性。最后,除了从基类继承的成员之外,派生类还可以定义更多的成员。
C++中,基类用virtual关键字指明那些它希望派生类重新定义的函数,希望派生类继承的函数不能定义为virtual。除了构造函数以外,任意non-static函数都可以定义为virtual。virtual关键字只用在声明处,不用在定义处。
《动态绑定》
动态绑定允许我们编写这样的程序,对于继承层次中的任意类型的对象都可以使用,无需关系对象的具体类型,无需区分函数是在基类还是在派生类中定义的。
在C++中,通过基类的引用(或指针)调用虚函数时,发生动态绑定。实现动态绑定的关键是:基类的引用和指针既可以指向基类对象也可以指向派生类对象。究竟调用哪个类的虚函数,取决于运行时该引用(或指针)指向的对象的类型。
《如何定义基类》
基类的析构函数一般应定义为virtual。
《?》基类通常应该将派生类需要重定义的任意函数定义为虚函数,why?
对于public和private成员来说,派生类访问基类成员的权限和程序其它部分一样:可以访问public成员,不能访问private成员。
注意,甚至连派生类对象本身也不能访问基类的private成员,在这一点上,派生类和基类的普通用户没有区别。派生类的优势仅体现在protected上。对于基类希望派生类可以访问,但不希望其它代码访问的成员,应定义为protected,protected成员可以被派生类对象访问而不能被普通用户访问。
《protected成员》
派生类可以访问基类的protected对象的意思是,对于派生类对象,其从基类继承来的protected对象是可用的,而对于基类对象的protected对象,派生类仍然没有访问权。例程:
#include <iostream>
using namespace std;
class base_class
{
private:
?int i;
protected:
?int j;
};
class derived_class : base_class
{
public:
?void fun(base_class &bc,derived_class &dc)
?{
??cout<<i<<endl;??//error!base_class::i is private
??cout<<j<<endl;
??cout<<dc.j<<endl;
??cout<<bc.j<<endl;??//error!base_class::j is protected
?}
};
如果没有继承机制,类的用户就只有两种:类本身和类的客户,public和private两种访问限制体现了这两种用户之间的分隔。有了继承机制后,类有了第三种用户――派生类。应该将派生类实现操作所需要访问而不希望普通用户访问的成员定义为protected。
基类的public成员相当于派生类的public成员;基类的protected成员相当于派生类的private成员;基类的private成员对于派生类来说相当于一个与自己无关的类的private,可以使用基类提供的public接口访问它,除此别无他法。
《派生类》
定义派生类的语法为:
class classname: access-label base-class
access-label可以为public,protected,private。表示三种派生方式。有什么区别呢?
《派生方式》
从基类继承来的成员的访问级别由基类的访问标号和派生方式共同控制。派生类可以通过派生方式进一步控制但不能放松继承来的成员的访问级别。
对于基类的private成员,派生类无法访问,因此派生标号控制的是基类的public和protected成员的访问级别。又,对于派生类来说,从基类继承来的public成员和protected成员都是可以访问的,因此,实际控制的是这些继承来的成员作为派生类自己的成员时在派生类中的访问级别属性,即实际控制的是派生类的用户(包括派生类的子类)。
若是public继承,则基类成员在派生类中保持原有访问级别。public还是public,protected还是prptected。
若是protected继承,则基类中的public和protected都是protected。
若是private继承,则基类中的public和protected都是private。
public继承称为接口继承,派生类继承了基类的接口,因此任何使用基类对象的地方,理论上都可以用派生类对象代替。
protected和private继承称为实现继承,它们继承基类的接口不作为自己的接口,而是用来作为自己的实现。
类是使用接口继承还是实现继承对派生类的用户具有重要意义,迄今为止最常见的继承形式是public。
这种限制也是可以改变的,方法是在派生类的定义中在不同的访问控制标号后面使用using声明。
如果在继承的时候不指明派生方式,那么struct默认为public,class默认为private。
一种形象的说明:
public继承相当于昭告天下,D类是从B类派生而来,编译器是知道的。
protected继承是只有自己和自己的派生类知道,即只有D类和D类的派生类知道D类是从B类派生而来。
private继承则只有自己知道。
派生类继承基类的成员并且可以定义自己的附加成员。派生类对象包含两个部分:从基类继承的成员和自己定义的成员。派生类只重定义那些与基类不同或对基类进行扩展的方面。
《重定义成员函数》
实际上派生类可以重定义基类的任何成员,但是只有重定义virtual函数才能实现动态绑定。
《动态绑定》
动态绑定就是父类接口可以接受子类对象,执行哪个版本取决于父类接口(指针和引用)在运行时接受了父类对象还是派生类对象。只有virtual成员可以做到这点。
例程:
base_class bc;
derived_class dc;
base_class* pbc;
int i;
cin>>i;
if(i==0)
?pbc = &bc;
else
?pbc = &dc;
pbc->f();
如果f()是基类的虚函数,而且派生类中重定义了这个函数,那么pbc->f()执行哪个函数就取决于i的输入了。如果f()不是基类的虚函数,即使派生类中重定义了这个函数,pbc->f()也只可能执行基类的版本。这就是virtual关键字对于动态绑定的意义。因为非虚函数的调用是在编译期确定的,pbc是基类指针,只能调用基类的非虚成员函数。
动态绑定可以显式阻止,利用作用域说明符限定虚函数的版本,可以让虚函数的调用在编译期就确定下来。这一技术只能在成员函数的代码中使用。这一技术的一个用途是:可能派生类重定义的基类虚函数需要完成和基类虚函数一样的工作,然后再完成其它工作,这是可以的显式调用基类的虚函数来完成这些工作而避免将基类虚函数的代码拷贝过来。
基类的指针和引用可以指向派生类的对象,但是只能通过此指针或引用调用基类有的成员。因此,使用基类指针和引用时不能确定所引用的对象类型是基类还是派生类,编译器一致当做基类对象处理,将派生类对象作为基类对象处理是安全的,因为派生类对象含有基类对象的子对象。
动态绑定的实现依仗虚指针和虚函数表。
《派生类中虚函数的声明》
派生类中虚函数的声明必须与基类中的定义方式完全相同,除了一个特例:基类中的虚函数如果返回基类对象的引用和指针,派生类中的声明可以返回基类对象的引用或指针,也可以返回派生类对象的引用和指针。
《基类类型和派生类类型之间的转换》
1.概述
对于指针和引用来说,可以进行派生类到基类的自动转换,而没有基类到派生类之间的自动转换。但对于对象来说,编译器不会自动将派生类对象转换为基类类型的对象,虽然我们可以用一个派生类的对象给一个基类对象初始化或赋值。
2.引用转换不同于对象转换
设想一个接受基类引用的函数,如果传递给它一个派生类的对象,这里发生的仅仅是一个绑定,将一个基类引用绑定到一个派生类对象上,派生类对象仍然是派生类类型的,没有改变。
如果这个函数接受的是基类对象而非引用,再传递给它一个派生类对象,则该派生类的基类部分会被拷贝给形参。
后者是用派生类对象给基类对象初始化或赋值,实际上是调用函数――构造函数和赋值操作符。因为基类的拷贝构造函数和赋值操作符都以基类对象的引用为参数,而派生类对象的引用是可以自动转换为基类对象的引用的,因此,用派生类对象给基类对象初始化和赋值是可以实现的。
也就是说,当需要将一个派生类对象转换为一个基类对象时,实际发生的是:
将此派生类对象转换成一个基类对象的引用用来调用拷贝构造函数和赋值操作符,生成一个基类对象。一旦生成,得到的就是基类对象。相当于在初始化和赋值过程中,派生类对象的派生类部分被切掉了。
派生类到基类对象的转换能被那些有访问基类public成员权限的客户访问。派生类本身和其友元肯定可以,至于派生类的普通用户和下层派生类能否访问就要看派生类继承基类时的派生方式了。
3.基类到派生类的转换
没有从基类到派生类自动转换的办法,即使这个基类对象或引用实际绑定了一个派生类对象也不行。如果实际情况中知道这种转换是安全的,可以使用static_cast或者dynamic_cast进行。
《不会自动继承的成员函数》
构造函数、析构函数和operator=不会被自动继承。如果我们自己不在派生类中定义这些成员函数的话,编译器就会用将基类和各成员变量对应的成员函数组合的方式为我们提供这些函数,当然对于构造函数,编译器只能提供缺省构造函数和拷贝构造函数,对于基类中的其它版本,编译器无能为力。同样,编译器也只能通过组合的方式提供作用于相同类型的operator=,而对于基类中的其它版本无能为力。
《构造函数与复制控制》
受继承关系的影响,每个派生类构造函数除了要初始化自己的数据成员外,还要初始化从基类继承来的数据成员。
如果不提供构造函数,编译器同样会提供缺省的构造函数给派生类,与非派生类缺省构造函数不同的是,此缺省构造函数会调用基类的缺省构造函数来初始化基类部分的数据成员。
如果自定义派生类的缺省构造函数,并且在构造函数定义内没有对基类部分的对象进行初始化动作,那么调用此构造函数仍然会自动调用基类的构造函数来初始化基类部分的数据成员。
可以在派生类构造函数的定义中为基类构造函数传递实参,做这项工作需要在初始化列表中完成,而不能直接在函数体内完成。
如果派生类要定义自己的拷贝构造函数,它最好(在初始化列表中)显式地调用基类的拷贝构造函数。可以为基类的拷贝构造函数传递派生类拷贝构造函数收到的派生类对象引用作为参数,它会自动被转换成一个基类对象引用用以调用基类的拷贝构造函数。如果不显式调用基类的拷贝构造函数,基类的缺省构造函数将被调用,产生奇怪的效果:新构造出的派生类对象的基类部分是缺省初始化的,而其它部分却是拷贝初始化的。
同样,派生类的operator=也应如此定义。
析构函数则只需要负责自己的部分,编译器会保证所有层次的析构函数被调用。
《为什么基类的析构函数应该是虚函数?》
因为一个基类指针可以指向基类对象也可以指向派生类对象。而销毁这个指针的动作会导致该指针静态类型的析构函数被调用。如果该指针的静态类型与动态类型不符,那么就会导致未定义行为。即,销毁一个指向派生类对象的基类指针,导致调用基类析构函数去析构一个派生类对象这样的未定义行为。
因此,基类的析构函数应该定义为虚函数,这样在销毁基类指针时,调用哪个析构函数可以根据指针的动态类型来确定。
《函数调用所遵循的四个步骤》
1.首先确定进行函数调用的对象、引用或指针的静态类型。
2.在该类中查找函数,如果找不到,就在直接基类中查找,如此循着类的继承链往上找,直到找到该函数或者查找完最后一个类。如果不能在类或其相关基类中找到该名字,则调用是错误的。
3.一旦找到了该名字,就进行常规类型检查,查看根据找到的定义,该函数调用是否合法。
4.假定函数调用合法,编译器就生成代码。如果函数是虚函数且通过引用或指针调用,则编译器生成代码以确定根据对象的动态类型运行哪个函数版本,否则,编译器生成代码直接调用函数。
《纯虚函数》
可能我们会想定义这么一个类,只希望其它类从它继承,而不希望实例化该类的任何对象,那么应该将它定义为一个抽象基类。
含有或继承(即没有重新定义)一个或多个纯虚函数的类叫做抽象基类,抽象基类无法实例化。
纯虚函数的定义方式是在参数列表后面直接写“=0”:
double net_price(std::size_t) const = 0;
单纯继承抽象基类得到的仍然是抽象基类,只有将所有纯虚函数重新定义后才不是抽象基类。
当定义一个纯虚函数时,相当于告诉编译器在虚函数表中为该函数留一个位置而不保存任何地址,这样就得到一个有不完整虚函数表的类,这种类就是抽象基类。对于虚函数表不完整的类,编译器阻止它实例化。同样还会阻止可能发生的对象切片使抽象积累的对象被按值传递到函数内部。
纯虚函数也是可以给出定义的,直接给出函数体即可。允许这样做的目的是,也许各个派生类中对该纯虚函数的覆盖都会用到同一段代码,这样可以将这段代码作为基类中该纯虚函数的定义,然后在各个覆盖版本中显式调用它即可。
《向上映射》
可以将基类的指针和引用绑定到派生类对象上,但是要求派生方式为public,否则无法绑定,例程:
class A{};
class B:public A{};
class C:protected A{};
class D:private A{};
void fun(A&){}
int main()
{
?B b;
?C c;
?D d;
?fun(b);??//ok
?//fun(c);?//!error
?//fun(d);?//!error
?A* pa;
?pa = &b;?//ok
?//pa = &c;?//!error
?//pa = &d;?//!error
}
为什么?
《C++如何实现晚捆绑》
对于每一个有虚函数的类,编译器为它维护一个单独的虚函数表,称为VTABLE,不管该类实例化了多少个对象,虚函数表都只有一个,虚函数表里存放的是该类所有的虚函数的地址。对于有虚函数的类的对象,编译器在对象结构中(通常是开头)秘密插入一个指针,称为虚指针――VPTR,因此其它结构两个相同的类,一个含有虚函数,一个不含虚函数,那么后者的sizeof比前者大4个字节――一个void*指针的大小。此虚指针存储的是该对象对应的类的虚函数表。当利用基类的地址或引用绑定某基类或派生类对象而调用虚函数时,编译器查看该对象的虚指针,从而得到该对象的类型(实际是得到虚函数表的位置),从而在函数调用的地方插入虚函数表和相对位置来定位要调用的函数(非虚函数的调用是直接插入对函数的汇编CALL的),完成函数调用的晚绑定。
在一个派生体系中,各个类的虚函数表中各函数指针的相对顺序是固定的,父类虚函数在前,子类新增的虚函数在后。
在调用任何虚函数之前安装虚函数表指针是很重要的,安装VPTR的工作在构造函数中进行。
《构造函数和虚指针》
构造函数是安装虚指针的地方,而对于派生体系中的一个对象而言,它的初始化会导致该体系中从根到它所在层次的所有相关构造函数都被调用,而每个构造函数都会做一次初始化虚指针的工作,将该对象虚指针指向该构造函数所在类的虚函数表,而下一层次的构造函数会再次给虚指针赋值以覆盖它为当前类的虚函数表,因此,该对象的VPTR状态是由最后被调用的构造函数所确定的。
在一个个构造函数被依次调用的过程中,VPTR始终指向的是此构造函数对应类的VTABLE,因此在构造函数中调用虚函数,将屏蔽虚机制而直接调用本地版本。
实际上,析构函数中也屏蔽虚机制,调用虚函数的本地版本。
构造函数和析构函数中,虚机制被屏蔽的另一个容易理解的原因是,在构造函数和析构函数被调用的过程中,对象是不完整的,可能虚机制会导致虚函数操作还未产生的成员。
《对象切片》
按传值的方式将派生类对象传递给基类对象的形参,会发生对象切片,对象切片是安全的,得到的是地地道道的基类对象,无论数据成员还是成员函数都是基类的。因为按值传递时会调用拷贝构造函数,所以切片得到的基类对象有机会在拷贝构造函数中将自己的VPTR设置为正确地指向基类的VTABLE。
《Notes》
1.基类必须是定义过的类,而不能是只声明过的类。
2.派生类可以做基类继续被其它派生类继承。
3.如果只声明派生类而不定义它,那么不能写出派生列表。
4.虚函数也可以有默认实参,但是需要注意的是,如果使用指针或引用来调用虚函数,默认实参所取的值与指针和引用类型相关,而不取决于动态绑定时实际绑定的对象类型。
5.友元属性不能被继承,基类的友元不是派生类的友元;基类是友元,派生类也不会成为友元。
6.即使被继承,在继承层次中,static成员仍然只有一份实例。访问控制同普通成员。
7.一旦你开始定义派生类的拷贝构造函数和operator=,编译器就假定你知道自己在做什么,就不会再为你自动调用基类版本的拷贝构造函数和operator=了,而如果你不定义派生类的拷贝构造函数和operator=的话,编译器是会在提供组合版本时为你调用的。因此,定义派生类的拷贝构造函数和operator=时,应显式地调用基类的拷贝构造函数和operator=,否则将默认调用基类的缺省构造函数,而基类的operator=将不会被调用。
8.static成员函数不能为virtual。
9.派生类的构造函数只能初始化自己的直接基类。
10.派生类构造函数不能初始化基类的成员且不应该对基类成员赋值。如果那些成员为 public 或 protected,派生构造函数可以在构造函数函数体中给基类成员赋值,但是,这样做会违反基类的接口。派生类应通过使用基类构造函数尊重基类的初始化意图,而不是在派生类构造函数函数体中对这些成员赋值。
11.对于一个指向派生类对象的基类指针:
B* pb = &d;
来说,
pb->f();
会导致多态调用D::f(),然而,如果指定类名:
pb->B::f();
便可以屏蔽多态,调用B::f()。
12.如果D是从B以private方式派生,那么就不能用D对象给B指针或引用赋值,因为D对象中的B部分对于D对象是不可访问的,而如此赋值的目的就是访问这些部分,所以编译器阻止,但如果如下方式赋值:
B* pb = (B*) &d;
就可以,就像B是从D以public方式派生来的一样。
13.由于构造和析构过程中对象类型的不确定性,编译器认为在构造和析构的过程中对象的类型是变化的。如果在构造函数和析构函数内调用虚函数,则虚函数的版本依构造函数和析构函数的类型而定,而不再依据对象的具体类型。
14.基类指针引用虽然可以指向派生类对象,但只能通过它们访问对象的基类部分。对象指针和引用的静态类型决定了对象的行为。
15.派生类的成员名字将屏蔽基类中的成员名字,因为派生类的名字空间嵌套在基类中,编译器先在派生类中查找该名字,一旦找到,就不再继续寻找,如果没找到,再到基类中找。因此,如果基类和派生类都定义了相同名字的成员函数,但是参数表不同。通过派生类对象调用该函数,但是传递符合基类版本的参数的话,会造成错误,因为编译器在派生类中找到同名函数后就不再继续查找了,而这个函数是不能接收基类版本函数的参数的。相似情况也适用于局部作用于中声明的函数和全局作用域中定义的函数之间的关系。
16.如果基类中的成员函数有多个重载版本,那么要想通过派生类类型调用所有这些版本,要么一个都不能重定义,要么就要全部重定义。如果只有一个版本需要重定义,而其它版本想要直接使用基类版本的话,则应该使用using声明说明基类该成员函数所有版本可用(因为using声明不说明参数表),然后重定义所需版本。
17.protected继承只是为了语言的完整性,private继承还有些许功能,最常用的应该是public继承。
18.选择继承还是组合技术的一个重要依据是两者关系是is-a还是has-a,另外,是否需要向上映射。
19.编译器会在构造函数的头部秘密插入能够设置VPTR的代码。
《Effective C++》
《条款32――确定你的public塑模出is-a关系》
public继承应该用在派生类和基类之间存在“is-a”关系的情况。适用于base-classes身上的每一件事情一定也适用于derived-classes身上,因为每个derived class对象也都是一个base class对象。编译器可以完成从派生类对象到基类对象的各种转换(指针,引用,值传递)。
《条款33――避免遮掩继承而来的名称》
通常,在public继承下,应该继承基类的所有成员函数,而不能重定义以对其遮掩,因为这违反public塑模出的is-a关系。但是,在private继承下这是有意义的。这时重新定义基类函数会造成基类中所有版本的函数都被遮掩,要让基类函数在派生类中重见天日,可以使用using声明或者转交函数,在转交函数内部显示调用基类的函数。
《条款34――区分接口继承和实现继承》
public继承表面是is-a的关系,所以在public继承下,派生类总是继承基类的接口,但是基类的成员函数属性的不同,代表了它所希望派生类去继承它的方式。
基类的函数可以有三种属性:
1.non-virtual函数――希望派生类以实现继承的方式继承,即强制性指定了实现方式,不希望派生类对其重新定义――条款33。
2.common-virtual函数――希望派生类继承此接口,并且具体实现,但是提供了一份缺省实现供不需具体实现的派生类使用。
3.pure-virtual函数――只希望派生类继承此接口,具体实现希望派生类自己定义,这类函数显示的是派生类的特性超越继承自基类的共性。
《条款36――绝不重新定义继承而来的non-virtual函数》
原因见条款32、33、34。
《条款37――绝不重新定义继承而来的缺省参数值》
因为我们已经达成共识,不会重新定义继承来的non-virtual函数――条款36,因此这里讨论的就是不要重新定义继承来的virtual函数的缺省参数值。原因很明白,virtual函数实行动态绑定,而缺省参数值却是静态绑定。所以如果重新定义了继承来的virtual函数的缺省参数值的话就可能导致这样的奇怪行为――用基类版本的缺省参数值调用派生类版本的函数。
《条款38――通过复合塑模出has-a或“根据某物实现出”》
复合――即将某类的一个对象作为新类的一个成员。复合技术和继承有些许相似之处,都是用旧类生成新类。前文提到,public继承代表的派生类和基类关系是is-a,而复合代表的新类和旧类关系有两种――has-a(应用域)和“根据某物实现出”(实现域)。比如一个Person有一个PhoneNumber,一个set用一个list实现。