C++面向对象程序设计的核心思想是封装(数据抽象)、继承和多态(动态绑定)。
通过数据抽象,我们可以将类的接口与实现分离,可以将数据和方法封装在一起;
使用继承,可以定义相似的类型并对其相似关系建模,以及虚函数的重写;
使用动态绑定,可以在一定程度上忽略相似类型的区别,而用同一方式使用它们的对象;
继承和多态是为了代码的复用,和形成多种形态,以实现多种功能。
一、继承 |
1、概念:为了代码的重用,保留基类的原本结构,并新增派生类的部分,同时可能覆盖(overide)基类的某些成员。
目的:继承是为了扩展原有的类,增加新的功能,
继承的格式:子类名 : 继承方式 父类名
继承的方式: public(公有继承) 、protected(保护继承)、private(私有继承)
public:基类的所有成员的访问权限在继承至子类后都不发生改变,基类的私有成员在派生类中是不可访问的。
protected:基类的公有成员在被继承变成保护的,基类的保护成员和私有成员访问权限不变,基类的私有成员仍然不可以被派生类访问。
private:基类的共有成员和保护成员的访问权限在被继承后全部变为私有的。
保护成员跟私有成员,只能在类内部访问,不管是基类的保护成员或者自己的保护成员,都不能用对象访问。
既然不能用派生类的对象访问基类的保护成员,派生类可以做什么?它可以在类内访问基类的保护成员。
声明一个派生类对象,即在构造派生类对象时,遵循基类的接口,先构造基类子对象,再构造派生类增加的部分。
2、继承中构造函数和析构函数的调用 |
当基类显示定义构造函数,派生类中也显示定义了构造函数:
构造函数的调用顺序为:先调用派生类的构造函数,在派生类的初始化列表中调基类的构造函数;
析构函数的调用顺序:先调派生类的析构函数(将派生类的析构函数体的内容执行完毕,在将要结束派生类的析构函数时调用基类的析构函数,将基类的析构函数调用完成后会返回到派生类析构函数体的右花括号之前,继而结束派生类的析构函数)。
3、同名隐藏 |
当派生类中特有的成员和从基类所继承的成员同名(包括成员变量和成员函数)时,在派生类中会隐藏基类中的成员,即使用派生类的对象直接只能访问派生类中的(和基类同名)成员,但是有时候也需要可以访问基类中的成员(同名),这时在派生类的成员函数可以使用形如 : 基类类名::成员名来访问基类中的同名成员。
①这里需注意:当父类和子类成员函数同名时,这时只需关注函数名,与参数列表和函数的返回值都无关;当参数列表不同时,可能会有同学认为它们可以构成重载,切记:它们不能构成重载,可以构成重载函数的首要条件是必须在同一作用域内,这里的父类和子类很明显是两个作用域。
②一般在继承体系中最好不要使用同名成员。
4、赋值兼容规则 |
①子类对象可以赋值给父类对象(子类对象也可以看成一个父类对象);
②父类对象不能赋值给子类对象;
③子类对象的指针/引用不可以指向父类对象。(强制类型转换可以完成)。
5、小结 |
①基类使用系统合成的构造函数(只有在需要时系统才会合成),这时的派生类也可以使用系统合成的构造函数(也是在需要时才会合成)。
当基类显式定义了缺省的构造函数,这时的派生类可以使用系统合成的(系统会在此时合成派生类的构造函数,在派生类的初始化列表会去调基类的构造函数)。
②当基类中定义了非缺省的构造函数(需要参数),这时派生类中必须显式定义构造函数,且在派生类构造函数的初始化列表需要调用基类的构造函数(传参),这里应该很容易明白,因为系统并不知道该给基类的构造函数传什么参数,所以你必须自己进行传参。
③基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
④基类的私有成员在派生类中是不可访问的,如果基类不想在类外直接访问,但又想在派生类中可以访问时,需要定义为protected。可以看出,protected这个访问权限是为了继承才出现的。
⑤派生类会继承基类中的所有成员,只是访问权限有时会发生改变,导致基类的某些成员在派生类中不可见。
⑥每个类控制他自己的成员初始化过程。
⑦防止继承的发生:c++11新标准提供了一种防止继承发生的方法,即在类名后加上一个关键字final。
⑧使用关键字struct时—>默认公有继承 使用关键字class时—–>默认私有继承。
⑨公有继承是一个接口继承,每个子类对象都可以看成是一个父类对象。
6、友元与继承:
友元函数不能继承(因为友元函数不属于类,还记得友元函数不受类中访问权限的限制)。
7.静态成员变量可以继承。而且整个继承体系中只有一份静态成员变量,是所有类对象所共享的。
二、多态 |
静态多态:在编译时期就已经确定了的行为,例如带变量的宏,模板,函数重载,运算符重载,拷贝构造等。
动态多态:在运行时期才能确定调用的行为。例如虚函数调用机制。本部分主要讨论的是动态多态。虚函数是实现动态多态的机制,其核心理念就是通过基类指针来访问派生类定义的成员。成员函数在基类为虚函数时,在派生类同样也是虚函数。纯虚函数是指不希望基类对象调用的成员函数,需要派生类覆盖实现这样的纯虚函数。(注:如果某个成员函数在基类中没有用virtual关键字修饰,即普通函数,而在派生类中却又有完全相同的成员函数声明,两个函数即使有相同的名字和相同的参数类型与数量,这两个函数也是完全不同的函数,因为类的作用域不同)
虚函数表(vtable):每个类都拥有一个虚函数表,虚函数表中罗列了该类中所有虚函数的地址,排列顺序按声明顺序排列,例如这样两个类:
class Base
{
virtual void f() {}
virtual void g() {}
//其他成员
};
Base b;
class Derive : public Base
{
void f() {}
virtual void d() {}
//其他成员
};
Derive d;
继承之后Derive 的对象模型变为:
虚表指针(vptr):每个类有一个虚表指针,当利用一个基类的指针绑定基类或者派生类对象时,程序运行时调用某个虚函数成员,会根据对象的类型去初始化虚表指针,从而虚表指针会从正确的虚函数表中寻找对应的函数进行动态绑定,因此可以达到从基类指针调用派生类成员的效果。
那么为什么需要虚指针和虚函数表来实现动态多态呢?因为无论是什么函数,包括类内的虚函数和非虚函数,都会储存在内存中的代码段。但是当编译器在编译时,就可以确定普通函数和非虚函数的入口地址,以及其调用的信息,所以这指的是常量指针。当遇到动态多态时,虚函数真正的入口地址的指针要在运行时根据对象的类型才能确定,所以要通过虚指针从虚函数表中找虚函数对应的入口地址。
当然,用基类指针绑定的子类对象,只能通过这个基类指针调用基类中的成员,因为作用域仅限于基类的子对象,子类新增的部分是看不见的。
如下:
#include <iostream>
using std::cout;
using std::endl;
class Base
{
public:
void fun() { cout << "Base::fun()" << endl; }
virtual void vfun() { cout << "Base::virtual fun()" << endl; }
};
class Derive : public Base
{
public:
void fun() { cout << "Derive::fun()" << endl; }
virtual void vfun() { cout << "Derive::virtual fun()" << endl; }
void dfun() { cout << "Derive::dfun()" << endl; }
};
int main()
{
Base* bp = new Base();
Base* dp = new Derive();
bp->fun();
bp->vfun();
dp->fun();
dp->vfun();
//dp->dfun(); //编译错误:基类指针指向子类中基类的子对象
//不能看到子类的成员
delete bp;
delete dp;
return 0;
}
可以看出,bp绑定一个基类对象,调用自己的成员无异议;dp绑定的是一个子类对象,因此调用fun()时,由于dp是一个基类指针,作用域在于基类中,所以调用的是基类的fun(),而调用vfun()是通过动态绑定调用虚函数表中被子类覆盖的Derive::vfun(),而如果要调用dfun()时则会出现编译错误,因为子类独有成员基类指针不可见。
注:在解有关动态多态的题时,只要把握住一点:这个指针指向的到底是基类对象还是子类对象,如果是基类对象,则调用基类的成员函数,如果是子类对象,则要考虑到这个虚成员函数是否被子类中的成员覆盖掉,即是否产生了动态绑定。另外还有一点,从子类对象强制类型转换为基类对象是允许的,而相反地要从基类对象强制转换成子类对象是错误的(编译不通过)。
Base* dp1 = new Derive();
Derive* dp2 = (Derive*) dp1; //基类指针指向的是子类对象,可以强制转化为子类指针
Base* bp1 = new Base();
Derive* bp2 = (Base*) bp1; //错误,[Error] invalid conversion from 'Base*' to 'Derive*' [-fpermissive]
//基类指针指向的是基类对象,不能强制转化为子类指针