继承
继承是面向对象的三大特性之一。派生类使用继承基类的部分时,会把哪部分当作一个对象来处理,会产生一个隐藏的基类指针来实现访问基类部分的成员。
1.继承的概念
在现实生活中,如老师和学生都有同样的属性,姓名、电话、年龄、家庭住址等,但是也都有专属于各自的属性,学生的课程,学生的学生证编号,老师的工号等。将老师和学生这种身份抽象为类之后,这些同样的属性在继承特性出现之前就都得进行定义,不符合泛型编程的逻辑,十分的冗余。继承语法的出现解决了这样的问题。
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类(子类),原有类叫做基类(父类)。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
继承之后,派生类就可以之写专属于自己的属性和方法,不再写冗余代码,进行降低效率的事情。
2.继承的定义
2.1格式
2.2继承和组合的区别
子类继承父类,在子类的实例化过程中,子类对象的内部是包括子类和父类所有的属性,子类对象可以访问到子类和父类全部的方法,是并行的;而子类组合了父类就是,子类内部成员变量定义了父类对象,子类使用这种方式实例化之后的对象内部本质上是子类所有的属性,子类的属性之一(父类对象)有包含有父类属性,是对父类这些属性的封装再封装,访问权限也是封装再封装,是串行的;
2.3继承关系和访问限定符
子类继承了父类。类外访问时,可以访问子类的公有成员,但是不可以私有成员。如果在子类可以随意访问到父类的成员,这样就打破了封装,不符合面向对象的特性,所有继承这种并行的关系也需要对父类继承下来的成员进行权限限定。
c++对子类中那些通过父类继承下来的成员进行了如下权限限定:基类的其他成员在子类的访问方式=Min(成员在基类的访问限定符,继承方式)。
1.任意继承,对于私有成员和以前一样子类类外不可访问,但子类内也不可访问,子类的派生类不可访问;
2.保护和公有继承,对于保护成员和以前一样子类类外不可访问,但是子类内可以访问,子类的派生类不使用私有继承,允许类内访问;私有继承,子类的派生类不允许类内访问;
3.对于公有成员,公有继承,允许子类外访问和子类内访问,子类的派生类不使用私有继承,允许类内访问;保护继承,允许子类内访问,子类的派生类不使用私有继承,允许类内访问;私有继承,子类的派生类不允许类内访问;
总结,继承之前,保护和私有认为是一样的,继承之后,保护比私有少了一层就是允许派生类访问保护成员,私有不允许。
2.4默认继承方式
struct默认是公有继承,class默认是私有继承。最好显式写继承方式。
通常使用的是公有继承,基类成员为公有或保护。
3.基类和派生类的赋值兼容(切割或者切片)
原本的内置类型之间是有隐式类型转换的,之后c++支持了自定义类型用内置类型赋值产生隐式类型转换+拷贝构造,优化为构造。这些方式都涉及到了隐式/显式类型转换。
自定义类型之间父类对象允许用子类进行构造或者赋值(向上转换),但不允许子类对象用父类构造或者赋值(向下转换),因为子类独有的属性需要子类来完成。这种方式为赋值兼容转换,没有产生临时变量。
向上转换可以是父类对象/指针/引用来接收子类对象/指针/引用,能接收子类对象是因为可以发生赋值兼容转换;而向下转换需要先将父类对象的指针/引用,进行强制类型转换后,用子类对象/指针/引用来接收,但是父类对象本身不允许显式类型转换,因为student赋值就需要同样大小的空间直接拷贝,而父类对象空间不够会导致野指针访问,所以不能接收父类对象。
Student s;
Person &p=s;//p是s基类部分的别名
4.继承当中的作用域
语法上不同的作用域是可以有同名的变量的,不会产生命名冲突的问题,但是派生类继承了父类,如果有同名变量就相当于在同一个作用域中出现了同名变量,会产生命名冲突。但是c++为了防止这种情况,子类成员将屏蔽父类对同名成员的直接访问,直接隐藏。
编译器查找会先从花括号作用域找,然后子类域,,然后父类域,然后全局域。可以显式域名::变量的方式访问。
同一作用域同名函数可以重载,使用函数名修饰规则来区分不同函数,不同作用域直接用作用域来区分不同函数,允许同名,不通过函数名修饰规则来区分。
1.在继承体系中基类和派生类都有独立的作用域。
2.子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类 :: 基类成员 显示访问)
3.需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4.注意在实际中在继承体系里面最好不要定义同名的成员。
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" <<i<<endl;
}
};
void Test()
{
B b;
b.fun();//会报错,因为B中能找到void fun(int i),但没有传参数。
b.fun(10);
};
5.派生类当中的默认成员函数
5.1构造函数
1.编译器不允许派生类直接显式的在初始化列表初始化继承自基类的成员变量,可以在函数体内,因为每个成员变量最多只能初始化一次;
2. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,有默认构造会自动调用,没有默认构造就必须去派生类的初始化列表进行构造,初始化的是基类,一个整体,使用类似匿名对象的方式,否则就会报错。也和自定义类型成员一样,没有默认构造必须去初始化列表进行构造,但是由于是一个明确的对象整体,所以使用对象名+()初始化;
3.先初始化基类部分,然后初始化派生类部分;
5.2拷贝构造函数
1.派生类的拷贝构造函数必须调用基类的构造函数初始化基类的那一部分成员,有默认构造会自动调用,没有默认构造就必须去派生类的初始化列表进行构造,初始化的是基类,一个整体,使用类似匿名对象的方式,否则就会报错,要注意的是会自动调用默认构造,而不是默认拷贝,所以一般都得显式的在初始化列表调用基类的拷贝构造,否则靠被就会出错。
5.3赋值重载
同理,需要必须显式调用,否则不会执行基类的赋值重载,自定义类型成员也是如此;
5.4析构函数
1.由于多态的原因,析构函数被进行了特殊处理,所有析构函数的名字都被统一成destructor,所以也需要指定域名调用,但是要注意的是,不需要显式调用基类析构函数,编译器会自动调用,因为要保证析构顺序,基类后析构,即执行玩派生类的析构函数之后会执行基类的析构函数,不是派生类执行的一半跳转到基类;
6.友元和继承
友元关系不能继承,否则就可以大批量的突破封装。
7.继承与静态成员
可以说继承了,因为继承了使用权;也可以说没继承,因为静态成员本身就不是某一个对象专有的,而是共有的。
8.复杂的菱形继承及菱形虚拟继承
8.1继承分类
单继承:一个子类只有一个直接父类时称这个继承关系为单继承;
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承,格式如下;
class A:public B,public C
8.2菱形继承
库里面也使用过菱形继承,如:io流。
会产生数据冗余和二义性,比如名字,现实生活中人们可以有多重身份但是只有一个名字;也就是说这些不同身份的公共部分应该只要一份,比如一些唯一性标识,不同身份的专有部分各有一份,使用了多继承之后却是,公共部分两份,其他正常;既浪费了空间有产生了变量调用不明确;
二义性的解决:用类域::来指明访问,这样虽然访问明确了,但是访问到的内容不符合逻辑。
8.3虚继承
虚继承后公共数据变成一份,初始化只能有一次。初始化顺序先基类,后派生类,按照声明顺序,先初始化A。
比如老师和学生这两种身份要用虚继承,用关键字virtual修饰,这样公共部分如果出现了两份,实际上就变成了一份,解决了二义性问题;
对于数据冗余问题的解决,忽略内存对齐,首先最顶层基类只有一个4字节变量时,并没有明显的感到解决了问题,反而空间使用还比以前要多,但多出来的大小影响不大;对于多个变量时,假如是8个整型,虚继承之前是2*8*sizeof(int)=64字节,虚继承之后发生变化的部分仅仅是最顶层基类的成员变量,变成了2个虚基表指针,还有4个整型,还得加虚基表开辟的空间,每一个是8字节,也就是2*4+8*sizeof(int)+8*2=56<54,显然解决了数据冗余问题。
class student:virtual public person
class teacher:virtual public person
8.4虚继承后,对象模型在内存的布局与底层实现
class A
{
public:
int a;
};
class B :public A
{
public:
int b;
};
class C :public A
{
public:
int c;
};
class D :public B, public C
{
public:
int d;
};
int main()
{
D d;
d.B::a = 1;
d.b = 2;
d.C::a = 3;
d.c = 4;
d.d = 5;
return 0;
}
8.4.1普通的菱形继承内存布局
普通菱形继承场景,D对象分为B部分,C部分,D部分,B部分和C部分中都含有A部分。
对于D d对象先继承了B后继承了C,BC都继承了A,所以先实例化B中的a,B中的b,然后C中的a,C中的c,
最后是d。d的大小是20字节就是5个整型,就有两个a。
8.4.2菱形虚拟继承内存布局
菱形虚拟继承中,D对象分为B部分,C部分,D部分,A部分,B和C中有存放偏移量的虚基表指针变量;
bcd的位置都没有发生改变,而原来放a的地方变成了地址,并且d的下一个位置出现了a的值。
解析如上图片可知,0x00fafca8才是实际存放a的地址,0x00fafc94和0x00fafc9c存放了两个地址,这两个地址处的下一个位置,第二行是偏移量,如14,0c,0x00fafc94+14=0x00fafca8,0x00fafc9c+0c=0x00fafca8,最终指向的位置是同一个位置。算出来d的大小是24字节,在32位机器下指针大小是4字节,所以包括了bcda加两个指针变量。
8.4.3虚拟继承底层实现
虚拟继承的底层实现就是,使用指向存放偏移量位置的指针,通过偏移量计算可以访问到基类成员,被virtual修饰的基类成员最终只能存在一个,其他类都是使用偏移量计算来访问。
两个问题:
1.为什么要建立偏移量表(虚基表),而不是直接存放偏移量?
主要是因为偏移量表里面不只是有偏移量,还有其他值,为多态的虚表预留存偏移量的位置。
记住偏移量表的偏移量是对被虚继承修饰的基类的偏移量,而不是基类中的某个成员,将基类放做了一个整体。
其实存放偏移量也是可以的,但仔细观察,偏移量表的第一行目前是空的,因为这一行要为(多态使用)其他值预留空间,第二行存放偏移量。如果存放到对象内部,对象就得用两个指针变量来存放,会使得对象本身变大,所以还是使用建立偏移量表的方式来实现虚继承。对于实例化多个对象的场景,同为D类型的对象就只需要指向同一个偏移量表,通过复用就可以满足条件。
2.为什么建立偏移量表而不是A地址表?
主要是为了实现表的复用。
因为针对于实例化多个对象的场景,每个对象都最多只有一个a变量,但要保证对象之间是独立的,所以a是独立的。如果是A的地址,就无法实现表的复用,每创建一个D对象就得重新建立一个表,但是使用偏移量表就可以复用了。
偏移量的应用场景:
虚继承后的A的a成员在实际访问的过程中是不需要用偏移量去访问的,因为它直接就在D对象的最后存放,所以直接就能够找到并访问到此变量。真正需要用偏移量的地方是赋值兼容转换的支持,如果B的指针去对D切片,注意B只能看到D中的B因为B是虚继承了A,所以B指针会切片虚基表指针,B类部分,A类部分三个位置,虚基表指针复用B类的虚基表地址,B类部分的值直接拷贝D对象,A类部分的值因为看不到D对象的全貌,所以用偏移量找到之后再赋值给A类部分。
B* ptr = &b;//无论是指向B对象还是D对象,都是用偏移量这种方式去给A对象赋值。
ptr -> a++;
ptr = &d;
ptr -> a++;
9.继承的总结和反思
9.1继承和组合
继承是一种白箱复用,组合是一种黑箱复用。黑箱白箱常见于软件测试。
黑箱测试就是不清楚内部实现,根据功能进行测试,白箱测试就是清楚内部实现,根据实现去写测试用例;比如不断调试去了解某些语法的底层实现。
继承是is-a关系,并列的,组合是has-a的关系,包含的。 继承清楚基类的实现,破坏了对基类的封装,耦合度提高,受到基类影响更大;而组合仅仅只能使用公有,相对来说耦合度低,受到基类影响小,所以建议是使用组合。当然有些对象的特征为了更符合现实世界就需要用继承,而且多态的实现也需要用到继承。
软件工程中常说高内聚低耦合,尽可能每个类的内部要实现简单且单一的功能,这要就比较好维护,比如OSI网络模型,每一层的设计互不影响。耦合度过高,改一个就全都改,这样就会有很大的功夫作用于无意义的事情上。