一、引言
代码的复用对于代码的质量以及程序员的代码设计上都是非常重要的,C++中的许多特性都体现了这一点,从函数复用、模板的引入到今天我们将一起学习的:继承
二、什么是继承?
1、继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象 程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继 承是类设计层次的复用、
注:接下来我们将多次用到下面的继承场景,即人、老师与学生三者之间的关系:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "zhao"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
protected:
int _stuid; //学号
};
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
2、继承的定义
(1).定义继承的格式
注:括号内为对于其前元素的说明
class Student(派生类/子类) : public(继承方式) Person
{
public:
//子类的成员函数列表
//子类的成员变量列表
};
(2).继承关系与访问限定符
继承方式有三种,分别对应了三种访问限定符,它们分别是:

不同的继承方式与不同的访问限定符组合会产生九种不同的效果:
可以发现,基类的私有成员在派生类中一定不可见(在类外与类内都不可访问,可以认为没有这个成员),对于其他情况来说,一个成员的权限是访问限定符与继承方式中权限更小的那一个,所以在可能用到继承的类中,我们更倾向于使用保护作为基类的访问限定符
三、父子类之间的对象赋值转换
父子类之间支持子类对象、指针和引用向父类进行赋值转换,这时会发生切片赋值,也就是会将子类中属于父类成员的那一部分赋值给父类对象,同时切掉独属于子类的那一部分,对于引用和指针也是类似的,引用赋值时,父类的引用类型是子类中属于父类对象的别名;指针赋值时,父类的指针类型直接指向了子类中属于父类对象的那一部分
需要注意的是,父类对象不能,转换赋值给子类对象,这点是很好理解的,因为父类对象中不包含子类对象的内容
上面几点可以通过下面的代码及其运行结果说明,为了方便演示,我将上面提供过的类中的protected成员换成了public成员:
代码:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
string _name = "zhao"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
public:
int _stuid; //学号
};
class Teacher : public Person
{
public:
int _jobid; // 工号
};
int main()
{
Student s;
s._age = 10;
s._name = "mei";
Teacher t;
Person p = s;
Person* pp = &s;
Person& rp = s;
p.Print();
pp->Print();
rp.Print();
}
运行结果:

四、继承中的作用域
1、在继承体系中基类和派生类都有独立的作用域
2、子类和父类中如果有同名成员,那么子类的成员将会屏蔽掉父类中与它同名的成员,使其不能直接访问,这种情况叫做隐藏,也叫做重定义
下面的代码和运行结果可以说明2中的问题:
代码:
class Person
{
protected:
string _name = "zhao"; // 姓名
int _num = 1010; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "num:" << _num << endl;
}
protected:
int _num = 10; //学号
};
int main()
{
Student s;
s.Print();
}
运行结果:

3、需要注意的是,函数要构成隐藏,只需要函数名相同就构成隐藏
下面的代码及运行结果可以说明3中的问题:
代码:
class Person
{
void Print(int x = 10)
{
cout << "Person" << endl;
}
protected:
string _name = "zhao"; // 姓名
int _num = 1010; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << "Student" << endl;
}
protected:
int _num = 10; //学号
};
int main()
{
Student s;
s.Print();
}
运行结果:

4、注意在实际应用中最好不要在继承体系中定义同名成员,很容易混淆
五、派生类中的默认成员函数
每一个类中都有6个默认成员函数,"默认"是指我们不写,编译器会自动生成的函数,在这里对普通类中的默认成员函数不多做赘述,如果想要了解关于默认成员函数的详细内容可以跳转到以下链接:
C++?类和对象(中)!!!-CSDN博客https://blog.csdn.net/2501_90507065/article/details/147402717?spm=1001.2014.3001.5501 接下来我们将主要讨论一个继承体系中派生类的默认成员函数都有哪些特点
1、构造函数
构造函数用于初始化类中的成员变量,对于派生类的构造函数,在初始化列表部分会自动调用基类的默认构造函数,如果基类没有提供默认构造函数,我们需要主动调用它的构造函数,函数的调用类似于定义一个基类匿名对象,这是很合理的一种写法,显然,我们不可以在初始化列表部分初始化基类的成员,但是可以进行函数体内赋值,两者并不冲突
2、拷贝构造函数
派生类的拷贝构造函数必须调用基类的拷贝构造函数完成对基类成员的拷贝构造,对于拷贝构造函数传入的派生类对象引用,我们可以直接以类似定义基类匿名对象的形式将派生类对象的引用传入,这匹配了上文讲过的引用类型的切片赋值,与构造函数相同,上面的限制都是在初始化列表,对于函数体内赋值我们不做限制
3、operator=赋值运算符重载
派生类的operator=函数体中必须调用基类的operator=完成对于基类成员的赋值,需要注意的是,由于派生类与基类中的operator=函数名一致,这时候会造成函数名相同时派生类对于基类同名成员的隐藏,这时候我们需要指定类域,否则会在派生类的operator=中调用自己,形成死递归,最终造成程序崩溃
4、析构函数
由于继承体系的特殊性,派生类中一定会先定义基类部分,这就意味着初始化列表中一定会先初始化基类部分,再初始化其他部分,那么在析构函数中我们就必须保证先释放其它部分再释放基类部分,很明显这是确定的,所以编译器接管了这一部分任务,我们需要在析构函数中完成对于其它部分的释放,在析构函数的末尾,编译器会自动调用基类的析构函数完成对于基类的析构
需要注意的是,由于多态部分的一些情况,析构函数需要构成重写,一个条件是函数名必须相同(以后会讨论到),所以编译器对这个部分做了特殊处理,析构函数名被统一处理成了destrutor,所以基类与派生类的析构函数构成了隐藏,与operator=类似,如果要在派生类的析构函数中调用基类的析构函数需要指定类域,但是这种情况一般不会出现,在这里只是做一个特殊说明
5、注意
还有两个默认成员函数分别是取地址运算符重载和const修饰的取地址运算符重载,但是这两个函数编译器自动生成的完全够用,所以在这里不做过多赘述
了解了上面几个默认成员函数的相关定义以及它们分别的注意点,下面利用Person类与Student类的做演示:
//基类
class Person
{
public:
//构造函数
Person(int x)//只是为了演示构造函数有传参的情况
{}
//拷贝构造函数
Person(Person& rp)
{
_name = rp._name;
_code = rp._code;
}
//赋值运算符重载
Person& operator=(Person& rp)
{
_name = rp._name;
_code = rp._code;
return *this;
}
//析构函数
~Person()//没有空间释放
{}
protected:
string _name = "zhao"; // 姓名
int _code = 1010; // 身份证号
};
//派生类
class Student : public Person
{
public:
//构造函数
Student(int x)
:Person(x){}//其余成员给缺省值
//拷贝构造函数
Student(Student& rs)
:Person(rs)//调用基类的拷贝构造,引用类型进行切片赋值
{
_num = rs._num;
}
//赋值运算符重载
Student& operator=(Student& rs)
{
Person::operator=(rs);
_num = rs._num;
return *this;
}
~Student()
{
_num = 0;
}
protected:
int _num = 10; //学号
};
六、继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
所以对于如下情况,Display函数并不能访问stuNum
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
void main()
{
Person p;
Student s;
Display(p, s);
}
七、静态成员变量与友元
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子 类,都只有一个static成员实例
下面的代码及其运行结果可以说明这个问题:
代码:
class Person
{
public:
Person() { ++_count; }
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
}
int main()
{
TestPerson();
return 0;
}
运行结果:

八、复杂的菱形继承及菱形虚拟继承
1、引入
(1).单继承
一个子类只有一个父类的继承体系称为单继承,如下图所示:

(2).多继承
一个子类继承了多个父类的情况称为多继承,如下图所示:

(3).菱形继承
菱形继承是一种特殊的多继承,可以理解为一个多继承归根结底有一个(多个)
类被重复继承,最常见的情况如下图所示:

很明显,菱形继承是存在一些问题的,就是:在Assistant类对象中,Person类的成员被保存了两份,这导致了Person类的数据早成冗余,同时Assistant的Person属性会出现分歧,这两个问题被称为菱形继承的数据冗余及数据二义性,下图是Assistant类对象模型,可以很明显的感受到菱形继承的两个问题:

2、菱形继承产生问题的解决与虚拟继承
(1).探究如何解决菱形继承产生的问题
首先,对于二义性的问题是比较好解决的,我们可以对于两个Person类对象成员按域给值,可以暂时性的解决程序跑不通的问题,如下所示:
class Person
{
public :
string _name ; // 姓名
};
class Student : public Person
{
protected :
int _num ; //学号
};
class Teacher : public Person
{
protected :
int _id ; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected :
string _majorCourse ; // 主修课程
};
void Test ()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a ;
a._name = "peter";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
(2).虚拟继承
上面的方法只是暂时性的解决了菱形继承产生的问题,但是并没有从根本上解决这个问题,这时候我们引入了虚拟继承,虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用,这是由于虚拟继承改变了类对象模型,同时需要编译器做大量处理,如下就是经过虚拟继承处理后的代码:
class Person
{
public :
string _name ; // 姓名
};
class Student : virtual public Person
{
protected :
int _num ; //学号
};
class Teacher : virtual public Person
{
protected :
int _id ; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected :
string _majorCourse ; // 主修课程
};
void Test ()
{
Assistant a ;
a._name = "peter";
}
3、虚拟继承为什么可以解决数据冗余和二义性的问题
为了研究虚拟继承原理,我们给出了一个简化的菱形继承体系,再借助内存窗口观察对象成员的模型,如下:
class A
{
public:
int _a;
};
// class B : public A
class B : virtual public A
{
public:
int _b;
};
// class C : public A
class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
下图是内存中存储的d对象模型(未进行虚拟继承处理的):

下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下 面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指 向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量 可以找到下面的A :

在这里需要注意的是D类对象中的B、C都要分别去找属于它们自己的A成员,否则在平常的情况不会有问题,但是在跨类的切片赋值时会出问题
九、继承学习的总结与反思
1、尽量不使用多继承,不使用菱形继承,菱形继承改变了类对象模型,性能上存在问题
2、继承与组合
(1).继承是is-a的关系,描述的是A是B
(2).组合是has-a的关系,描述的是A拥有B
(3).能使用组合不使用继承
(4).继承是一种白箱复用,将基类细节暴露了出来,同时提高了类之间的耦合度
(5).组合是一种黑箱复用,对象内部更多的是封装的,类之间耦合度低,组合类之间没有很强的关系
十、结语
这就是本期关于继承的所有内容了,期待各位于晏、亦菲和我一起学习、进步!
·