一.继承
1.理解继承
C++中的继承是类与类之间的关系,是一个很简单很直观的概念,与现实世界中的继承类似,例如儿子继承父亲的财产。
继承可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。
在C++中,派生和继承是一个概念,只是站的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子。
被继承的类称为父类或基类,继承的类称为子类或派生类。“子类”和“父类”通常放在一起称呼,“基类”和“派生类”通常放在一起称呼。
派生类除了拥有基类的成员,还可以定义自己的新成员,以增强类的功能。
以下是两种典型的使用继承的场景:
-
当你创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有基类的所有功能。
-
当你需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义为基类,然后从基类继承,既可以节省代码,也方便后续修改成员。
继承的一般语法为:
class 派生类名:[继承方式] 基类名{派生类新增加的成员
};
继承方式包括 public(公有的)、private(私有的)和 protected(受保护的),此项是可选的,如果不写,那么默认为 private。
现在我们知道,public、protected、private 三个关键字除了可以修饰类的成员,还可以指定继承方式。
2.继承方式
不同的继承方式会影响基类成员在派生类中的访问权限。
1) public继承方式
- 基类中所有 public 成员在派生类中为 public 属性;
- 基类中所有 protected 成员在派生类中为 protected 属性;
- 基类中所有 private 成员在派生类中不能使用。
2) protected继承方式
- 基类中的所有 public 成员在派生类中为 protected 属性;
- 基类中的所有 protected 成员在派生类中为 protected 属性;
- 基类中的所有 private 成员在派生类中不能使用。
3) private继承方式
- 基类中的所有 public 成员在派生类中均为 private 属性;
- 基类中的所有 protected 成员在派生类中均为 private 属性;
- 基类中的所有 private 成员在派生类中不能使用。
注意,我们这里说的是基类的 private 成员不能在派生类中使用,并没有说基类的 private 成员不能被继承。实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性。
在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数,如果基类没有非 private 成员函数,那么该成员在派生类中将无法访问。
3.改变访问权限
使用 using 关键字可以改变基类成员在派生类中的访问权限,例如将 public 改为 private、将 protected 改为 public。
注意:using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用,所以基类中的 private 成员在派生类中无论如何都不能访问。
//基类People
class People {
public:void show();
protected:char *m_name;int m_age;
};
void People::show() {cout << m_name << "的年龄是" << m_age << endl;
}//派生类Student
class Student : public People {
public:void learning();
public:using People::m_name; //将protected改为publicusing People::m_age; //将protected改为publicfloat m_score;
private:using People::show; //将public改为private
};
我们可以看到在派生类中对应的权限下进行using的基类成员再声明,即可改变类成员在派生类中的访问权限。
二.同名遮蔽问题
1.遮蔽
如果派生类中的成员(包括成员变量和成员函数)和基类中的成员重名,那么就会遮蔽从基类继承过来的成员。所谓遮蔽,就是在派生类中使用该成员(包括在定义派生类时使用,也包括通过派生类对象访问该成员)时,实际上使用的是派生类新增的成员,而不是从基类继承来的。
#include<iostream>
using namespace std;class Base {
public:int m_a;void func() {cout << "Base的func()调用" << endl;}Base() {m_a = 100;}
};class Son :public Base {
public:int m_a; //同名成员属性void func() { //同名成员方法cout << "Son的func()调用" << endl;}Son() {m_a = 200;}
};//测试同名属性
void test01() {Son s;cout << "Son下的m_a为:" << s.m_a << endl;cout << "Base下的m_a为:" << s.Base::m_a << endl;
}//测试同名方法
void test02() {Son p;p.func();p.Base::func();
}
int main() {test01();test02();return 0;
}
还需要补充的是,即使你在基类中使用函数重载,而派生类只有同名函数而没有对应的重载,想要在派生类中调用即类的某个重载函数也是不可能的,因为派生类中只要出现同名,就会遮蔽即类中所有的同名函数,无论其是否构成重载。
总结:
- 1.子类对象可以直接访问到子类中同名成员
- 2.子类对象加作用域可以访问到父类同名成员
- 3.当子类与父类拥有同名的成员函数,子类会隐藏父类中同名成员函数,加作用域可以访问到父类中同名函数
2.同名静态成员处理
首先复习一下静态成员知识点:
- 静态成员变量
- 所有对象共享同一份数据
- 在编译阶段分配内存
- 类内声明,类外初始化
- 静态成员函数
- 所有对象共享同一个函数
- 静态成员函数只能访问静态成员变量
上面我们知道了一般成员的同名遮蔽问题和怎么处理?那么静态成员的处理是不是一样的呢?
其实静态成员和非静态成员出现同名,处理方式一致:
- 访问子类同名成员 直接访问即可
- 访问父类同名成员 需要加作用域
三.继承知识点
1.继承中的对象模型
第二部分就说过,有继承关系时,派生类的内存模型可以看成是基类非静态成员变量和新增成员变量的总和,而所有成员函数仍然存储在另外一个区域——代码区,由所有对象共享。
那么存在成员变量遮蔽时的内存分布模型是怎么样的呢?通过开发人员工具我们知道,假设A类有属性m_a,B有m_b,且遮蔽m_a,那么总共应该有m_a、m_b、A::m_a三份数据。
#include<iostream>
using namespace std;class Base {
public:int m1;static int m0; //静态变量
private:int m2;
};class Son :public Base {
public:int m1;//同名遮蔽int m3;//新增变量
};void test01() {cout << "基类的内存模型大小为:" << sizeof(Base) << endl;cout << "派生类的内存大小为:" << sizeof(Son) << endl;
}int main() {test01();return 0;
}
运行结果为:
基类的内存模型大小为:8
派生类的内存大小为:16
总结一下,计算类的大小时,首先排除静态变量,然后判断是否有继承,继承又是否有同名遮蔽问题。
2.构造和析构顺序
#include<iostream>
using namespace std;class Base {
public:Base() {cout << "Base的构造函数" << endl;}~Base() {cout << "Base的析构函数" << endl;}
};class Son :public Base {
public:Son() {cout << "Son的构造函数" << endl;}~Son() {cout << "Son的析构函数" << endl;}
};void test01() {Son s;
}int main() {test01();return 0;
}
运行结果:
Base的构造函数
Son的构造函数
Son的析构函数
Base的析构函数
可以很明显的看到当存在继承关系时,我们虽然自定义了一个派生类对象,但是它也调用了基类的构造函数与析构函数,构造函数的顺序是先调用基类,再调用派生类,而析构函数则与此相反。
注意:还记得我们之前的封闭类吗,注意区分二者调用构造与析构的顺序。
3.构造与析构
前面我们说基类的成员函数可以被继承,可以通过派生类的对象访问,但这仅仅指的是普通的成员函数,类的构造函数不能被继承。构造函数不能被继承是有道理的,因为即使继承了,它的名字和派生类的名字也不一样,不能成为派生类的构造函数,当然更不能成为普通的成员函数。
在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 private 属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。
这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数。
事实上,通过派生类创建对象时必须要调用基类的构造函数,这是语法规定。换句话说,定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数,那么编译失败。
和构造函数类似,析构函数也不能被继承。与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需程序员干涉。
另外析构函数的执行顺序和构造函数的执行顺序也刚好相反:
- 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数。
- 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数。
四.多继承
1.多继承
在前面的例子中,派生类都只有一个基类,称为单继承。除此之外,C++也支持多继承,即一个派生类可以有两个或多个基类。
多继承的语法也很简单,将多个基类用逗号隔开即可。例如已声明了类A、类B和类C,那么可以这样来声明派生类D:
class D: public A, private B, protected C{//类D新增加的成员
}
D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。D 根据不同的继承方式获取 A、B、C 中的成员,确定它们在派生类中的访问权限。
二义性问题:
当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符::
,以显式地指明到底使用哪个类的成员,消除二义性。
2.多继承的对象内存模型
、B 是基类,C 是派生类,假设 obj_c 的起始地址是 0X1000,那么 obj_c 的内存分布如下图所示:
基类对象的排列顺序和继承时声明的顺序相同。
五.虚继承
1.菱形继承
多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的是菱形继承,如下图所示:
类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,一份来自 A–>B–>D 这条路径,另一份来自 A–>C–>D 这条路径。
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B–>D 这条路径,还是来自 A–>C–>D 这条路径。
菱形继承,也叫钻石继承,也就是典型的钻石问题。
2.虚继承
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员。
在继承方式前面加上 virtual 关键字就是虚继承,
//间接基类A
class A{
protected:int m_a;
};
//直接基类B
class B: virtual public A{ //虚继承
protected:int m_b;
};
//直接基类C
class C: virtual public A{ //虚继承
protected:int m_c;
};
//派生类D
class D: public B, public C{
public:void seta(int a){ m_a = a; } //正确void setb(int b){ m_b = b; } //正确void setc(int c){ m_c = c; } //正确void setd(int d){ m_d = d; } //正确
private:int m_d;
};
int main(){D d;return 0;
}
这里B和C继承A的方式就叫虚继承,而A类称为虚基类。
这段代码使用虚继承重新实现了上图所示的菱形继承,这样在派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。
3.虚继承的构造函数
在虚继承中,虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
另外需要关注的是构造函数的执行顺序。虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数;而对于普通继承,就是按照构造函数出现的顺序依次调用的。
4.虚继承的内存模型
对于普通继承,基类子对象始终位于派生类对象的前面(也即基类成员变量始终在派生类成员变量的前面),而且不管继承层次有多深,它相对于派生类对象顶部的偏移量是固定的。
前面我们说过,编译器在知道对象首地址的情况下,通过计算偏移来存取成员变量。对于普通继承,基类成员变量的偏移是固定的,不会随着继承层级的增加而改变,存取起来非常方便。
而对于虚继承,恰恰和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面,这样随着继承层级的增加,基类成员变量的偏移就会改变,就得通过其他方案来计算偏移量。
六.向上转型
在 C/C++中经常会发生数据类型的转换,例如将 int 类型的数据赋值给 float 类型的变量时,编译器会先把 int 类型的数据转换为 float 类型再赋值;反过来,float 类型的数据在经过类型转换后也可以赋值给 int 类型的变量。
类其实也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型。相应地,将基类赋值给派生类称为向下转型。****
向上转型非常安全,可以由编译器自动完成;向下转型有风险,需要程序员手动干预。
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。运行结果也有力地证明了这一点,虽然有a=b;
这样的赋值过程,但是 a.show() 始终调用的都是 A 类的 display() 函数。换句话说,对象之间的赋值不会影响成员函数,也不会影响 this 指针。
派生类对象赋值给基类对象时,会舍弃派生类新增的成员,也就是“大材小用”,以发现,即使将派生类对象赋值给基类对象,基类对象也不会包含派生类的成员,所以依然不同通过基类对象来访问派生类的成员。
这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。