继承概念及定义
- 继承机制是面向对象程序设计,使代码可以服用的最重要的手段,它允许程序员在保持原有类的特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
- 讲的通俗一点就是说,我们实际应用中许多不同的对象有着共同的特征,例如动物中狗,猫,猪它们都有年龄,体重,肤色,但是这些对象又同时有着自己独特的特征,例如动物中狗忠诚,猫高傲,猪可爱。那么当我们用计算机的语言区描述它们的时候很多的特征就会重复,如果我们一类动物写一个代码那么代码的复用性无疑是很差的,这里我们可以用类继承的特性来继承那些类中公共的部分。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。我们对于某一种特定类型动物的描述的认知,再到这种动物的具体特征,具体到一个真正的个体这一个过程就体现了我们对一个个体由简单到复杂的认知过程。
继承的定义
Person是父类,也称作基类。Student是子类,也称作派生类。
子类以public方式继承父类
- public继承方式下基类中public和protected修饰的成员在子类中的权限没有发生变化
- 基类中private修饰的成员在子类中不能直接被访问(不可见)
- 那么我们在设计类的时候访问权限的设置就可以用如下的考虑方式:
- 成员如果想要在类外被访问,将其设置为public
- 如果成员不想在类外直接被访问,但是要在子类中直接被访问,可以将其设置为protected
- 如果成员不想在类外直接被访问,也不想在子类中被直接访问,可以将其设置为private
子类以protect方式继承
protected继承方式下:
- 基类中public修饰的成员变量在子类中的权限为protected
- 基类中protected修饰的成员变量在子类中的权限为protected,也就是说权限没有变化
- 基类中private修饰的成员变量在子类中不可见,及不能被直接访问。
子类以private方式继承
- private继承方式:父类中public修饰的成员在子类的访问权限是
- private可以在子类中访问子类中访问,但是它的子类不可以直接访问
- private继承方式:父类中protected修饰的成员在子类的访问权限是
- private可以在子类中访问子类中访问,但是它的子类不可以直接访问
- 父类中private成员变量在子类中不可以被访问
class关键字没有给出继承方式默认的基础方式是私有的继承方式
struct关键字没有给出继承方式默认的继承方式是公共的继承方式
class和struct的区别
- struct和class都可以定义类,但是struct成员的默认权限是共有的为了兼容C语言,二class的默认权限是私有的。
- 模板参数列表中只能用class来定义模板参数例如template<class T>而不可以用struct来定义。
- 在继承中class默认的继承方式是private而struct默认的继承方式是public。
关于继承方式
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
基类和派生类对象复制转换
派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用
这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。基类对象不能赋值给派生类对象。
可以使用基类在指针指向子类的对象,但是不可以反过来(不能直接使用子类的指针指向基类对象)如果一定要指向必须进行强转
可以使用基类的引用去引用子类的对象,但是反过来不可以
- 从public继承方式来看:可以将子类看作是一个基类对象,例如狗一定是个动物,但是动物不一定是狗。
- 从对象模型上来看:对象中成员变量在内存中的布局形式将其称为对象模型。
赋值理解了那我们就可以理解为什么指针不可以用基类的指针指向子类而子类的指针可以指向基类了,当基类的指针指向子类时,用基类的指针访问基类中的成员函数和对象都是可以访问的,因为子类对象中都有这些东西,但是如果可以用子类的指针指向基类的对象的活,那么要想用子类的指针访问子类中特有的成员,但是此时指针指向的是一个基类的对象,那么此时因为基类中没有子类中特有的成员,此时就可能导致程序崩溃。那么引用也是同样的道理。
继承中的作用域
在继承体系中基类和派生类都有独立的作用域
在继承体系中基类和派生类都有独立的作用域。例如我们在基类中定义一个函数,在子类中定义一个相同名字的函数但是参数不同,这里这两个函数是不构成函数重载的,因为函数重载两个函数必须在同一个作用域当中。
隐藏(重定义)
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问(也就是说子类对象访问同名的成员变量时只能访问到自己的),这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用基类::基类成员显示访问)。
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。也就是说如果成员函数同名和函数原型是否相同无关,子类对象直接访问自己的同名成员函数。基类的同名成员函数无法通过子类对象直接访问。因为子类中存在和基类中一样名称的成员函数因此通过子类对象直接访问基类同名成员函数时,编译器会禁止,如果就想要访问,可以在成员函数强加基类的作用域限定符。
派生类的默认成员函数
构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。(这是因为我们子类对象在构造时分两步去构造,第一步将从基类继承下来的成员初始化完成----需要调用基类的构造函数,第二步再将自己新增的成员初始化完成。而所有的初始化都是在初始化列表位置完成的,那么我们就需要在子类的初始化列表中调用基类的构造函数,而当我们不定义基类的构造函数时,会生成默认的构造函数,子类在调用时会自动调用默认的构造函数,但是当我们定义了构造函数之后就需要我们手动调用,因为编译器只会调用无参的构造函数)
如果基类没有显示定义任何构造函数,则子类可以定义也可以不用定义
注意不可以在子类的初始化列表中,初始化基类中的成员对象
如果此时基类定义了构造函数,但是基类的构造函数时无参的或者是全缺省的构造函数此时子类的构造函数可以定义也可以不用定义。
如果基类显示定义了构造函数,但是基类的构造函数不是无参的或者全缺省的构造函数,此时子类必须要定义自己的构造函数,并且需要在其子类的构造函数初始化列表位置显示调用基类的构造函数,目的就是为了完成从基类中继承下来的成员的初始化。
当然我们如果在基类中将无参的构造函数也定义出来,那么在子类中不调用基类的构造函数也是可以的,这时子类会调用基类的无参构造函数。
拷贝构造
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
operator=重载
派生类的operator=必须要调用基类的operator=完成基类的复制。
析构函数
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
构造和析构函数的调用顺序
- 派生类对象初始化先调用基类构造再调派生类构造。//这里在描述时候应该说明,创建那个类的对象编译器就要调用那个类的构造函数,是先执行基类对象的构造函数因为在执行基类的函数体时候,执行它的初始化列表时,会调用基类的析构函数,然后再执行子类对象的构造函数。
- 派生类对象析构清理先调用派生类析构再调基类的析构。//这里完整的原因是,因为在析构子类的对象时调用子类的析构函数,当执行完子类的析构函数之后在子类的析构函数的最后编译器会添加一条汇编指令去调用基类的析构函数。
继承与友元
基类当中的友元函数,友元类是不可以被子类继承的,也就是说例如友元函数可以访问基类当中的成员变量(这里的访问可以通过基类的对象访问基类的成员变量,也可以通过子类的对象访问基类的成员变量,但是不可以通过子类的对象访问子类的成员变量),但是不可以访问子类当中的成员变量。
继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
class Person
{
public:
Person()
{
++_count;
}
public:
static int _count;//通过static成员变量统计人的个数
protected:
string _name;
};
int Person::_count = 0;//初始化count
class Student :public Person
{
protected:
int _id;
};
int main()
{
Student s1;
Student s2;
Student s3;
cout << Student::_count << endl;//最后的输出数值为3,也就是说我们创建了三个对象
cout << &Person::_count << endl;
cout << &Student::_count << endl;
return 0;
}
通过对上面的输出结果我们可以看出在这里实现了对成员变量的累计,也就知道我们创建了几(3)个对象,同时通过最后的对两个类中的_count地址的输出,我们知道子类与父类中的是同一个static变量。
C++中不同的继承体系
单继承
一个子类只有一个父类。(基类部分在低地址而子类部分在高地址)
多继承
一个子类有多个父类。(观察下图我们看到基类部分在也在低地址并且基类部分在内存中的分配与继承列表中的基类的出现次序一致。先出现的在低地址,后出现的在高地址)
注意在多继承当中每个基类前必须要加访问权限,否则其访问权限默认位private。例如这里,A1前面没有加访问权限,那么他默认的访问权限就是private我们在类中无法使用其public成员。
在前面加上继承权限即可
菱形继承:单继承+多继承
单继承:一个子类只有一个直接父类时成这个继承关系为单继承
多继承:一个子类有两个或两个以上直接父类时成这个继承为多继承
菱形继承中存在的问题
二义性
最顶层的基类B中成员在最下面的子类D中的存在两份,一份从C1中继承一份从C2中继承就导致D类将最顶层B中的成员出现了两份,如果直接通过D的对象去访问最顶层基类中的成员时,会出现访问不明确,即二义性。即会出现入下的错误:
菱形继承的二义性的解决方式
让访问明确化(无法根本解决问题,仍然存在代码冗余问题,浪费空间,而且仍然存在二义性问题。只是让编译器知道访问那个基类中的成员)
这里如果作用域限定符加的是B他仍然会提示报错:说基类B不明确
但是这里可以运行成功我们可以看到他默认访问的是D类继承表的第一个也就是C1类中的"_b"
我们可以将D类中继承的值都赋一个值从而验证我们上面的菱形继承模型
采用菱形虚拟继承(采用菱形虚拟继承达到根本解决问题:让最顶层基类中成员在D中只存在一份)
什么是虚继承
观察上图也可以看出虚拟继承和普通继承的区别:
- 对象模型是倒立的
- 对象多了四个字节
- 如果我们的D中没有显示定义构造函数,则编译器一定会生成,如果 定义了构造函数,则编译器一定会对构造函数进行修改,修改的目的:往对象的前四个字节中填充数据
这里多出四个字节需要转到汇编一探究竟
第一条指令的作用:取对象前四个字节当中的内容,当作地址使用
第二条指令的作用:取刚刚取出来的地址空间中往后偏移4字节之后的内容---->这里将取出来的内容当作偏移量使用
第三条指令:取d的地址结合刚刚的偏移量给_d赋值
这里我们看看d对象的前四个字节的指针指向的地址中存放的是什么内容:
知道了虚拟继承原理之后来看一下菱形继承的解决
这里我们将C1和C2的继承方式都选为虚拟继承,这里我们可以大致推断出类的模型,如下。大小为24个字节。基类B不能放在C1中也不能放在C2中,因为如果放在C1中而不放在C2中就说不过去了,凭什么你C1中有,我C2中没有。所以我们推断只能放在D中
接下来我们用代码验证一下,可以发现和模型完全正确
我们再来看一下C1和C2中虚基表的值
继承的总结和反思
1.很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设
计出菱形继承。否则在复杂度及性能上都有问题。
2.多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
3.继承和组合
(1)public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
(2)组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
(3)优先使用对象组合,而不是类继承 。
(4)继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
(5)对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
(6)实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
什么是组合
class A
{
//....
};
class B
{
//...
protected:
A _a;
};
像上面的这种定义形式就是组合,即在B类中会有A类的对象,并且会使用A类内的部分成员函数或成员变量(前提必须是公有的),这样不需要关心A类内的其他成员函数或变量,只需要拿自己用的就可以了,A的封装性也没有被破坏。