C++ 继承
封装,继承,多态,是面向对象的三大特性,所有面向对象的语言都具备着三大特征,在前面的文章中已经讲过封装的相关知识了,本文要介绍的是继承,也就是在父类的基础上,来构建各种子类
1. 继承的概念
继承机制是面向对象设计程序使得代码可以复用,它允许程序员在保持原有类特性的基础上进程扩展,增加功能,这样产生的新的类叫做派生类(子类)。简单来说继承是类设计层次的复用。
- 被继承的对象:父类/基类(base)
- 继承方:子类/派生类(derived)
1.1 本质
==继承的本质就是复用代码==
举个例子:假如需要你写一个学校教务系统,如果将每一个角色都设计一个类,难免太麻烦了,为了提高效率,可以选取各个角色的共同点来组成基类,复用代码。比如每个人都有姓名、性别、年龄等基本信息来创建基类,而教职工和学生则可以在基类的基础上加上各自的教职工编号和学生编号来进行区分。这样就可以通过复用基类的代码来划分出各种子类了,这就是继承。
1.2 作用
子类在继承父类后,可以继承父类中所有的公开/保护的属性
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
//学生
class Student : public Person
{
protected:
int _stuid; // 学号
};
//教职工
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
int main()
{
Student s;
Teacher t;
s.Print();
t.Print();
return 0;
}
通过监视,我们可以看出Student类和Teacher类都复用了Person类。此时,这里的Student类和Teacher类就是子类,而Person类就是父类
2. 继承的定义
2.1 定义格式
子类 : 继承方式 父类
继承的格式很简单,比如 class Student : public Person
,就表示Student
继承了Person
,这里的public
就表示共有继承
2.2 继承基类成员访问方式的变化
前面我们介绍过类中的访问权限,分别是public
(公有)、protected
(保护)、private
(私有),类的继承权限也是用这些限定符表示
任何继承方式下,父类中的私有成员都是不可被访问的,当子类成员可访问父类成员时,最终权限将会变为访问权限与继承权限中的较小者(公有 > 保护 > 私有)
继承权限规则:
- 基类
private
成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。 - 基类
private
成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected
。可以看出保护成员限定符是因继承才出现的。 - 基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),
public > protected > private
。 - 使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显示的写出继承方式。在实际运用中一般使用都是public
继承,几乎很少使用protetced/private
继承,也不提倡使用protetced/private
继承,因为protetced/private
继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
3. 基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。基类对象不能赋值给派生类对象。
double a = 8.8;
int i = a; //这里会产生临时变量,引用赋值必须带上const
const int& ri = a;
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _id; // 学号
};
int main()
{
Person p;
Student s;
p = s;
Person& rp = s;
Person* pp = &s;
return 0;
}
先来看看将派生类赋值给基类对象的原理 p = s
这里派生类直接赋值给基类,按常理来说也会有中间变量的产生,但是这里不会,这里是天然支持的,Student类实例化后继承Person类的成员,但是Student中有特有的成员:
再来看看将派生类赋值给基类引用的原理 &rp = s
最后来看看将派生类赋值给基类指针的原理 *pp = s
总结:
切片的发生是因为父类无法满足子类的需求,将子类对象中多余的部分去除,留下父类对象可接收的成员,最后再将 对象的指向进行改变就完成了 切片
4. 继承中的作用域
在继承体系中基类和派生类都有独立的作用域
class Person
{
protected:
string _name = "张麻子"; // 姓名
int _num = 888; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 666; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
4.1 隐藏
子类和父类中有同名成员时,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类
成员 显示访问)。 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
class A
{
public:
int a = 6, b = 8;
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
int a = 1, b = 2;
void fun(int i) //隐藏关系,并不构成函数重载
{
A::fun(); //显示访问基类中的func()
cout << "func(int i)->" << i << endl;
cout << "B-a = " << a << " " << "A-b = " << A::b << endl; //默认访问B类中的a,显示访问A类中的b
}
};
int main()
{
B b;
b.fun(10);
return 0;
}
注意:
隐藏会干扰调用者的意图,在实际中在继承体系里面最好不要定义同名的成员
5. 派生类的默认成员函数
子类在未定义的情况下同样会生成六个默认成员函数,由于子类是建立在父类的基础上的,所以在进行相关操作时也要为父类考虑
5.1 隐式调用
子类在继承父类后,子类对象构建前,需要创建父类对象,会自动调用父类的默认构造函数,子类对象销毁后,还会自动调用父类的析构函数,来销毁父类
class Person
{
public:
Person() {
cout << "Person()" << endl; }
~Person() {
cout << "~Person()" << endl; }
};
class Student : public Person
{
public:
Student() {
cout << "Student()" << endl; }
~Student() {
cout << "~Student()" << endl; }
};
int main()
{
Student s;
return 0;
}
注意:自动调用是由编译器完成的,前提是父类存在对应的默认成员函数;如果不存在则会报错
5.2 显示调用
上面介绍了隐藏的现象,当父子类中的函数重名时,子类无法调用父类的默认成员函数,此时就会引发浅拷贝的问题
class Person
{
public:
Person() {
cout << "Person()" << endl; }
void operator=(const Person& P) {
cout << "Person::operator=()" << endl; }
~Person() {
cout << "~Person()" << endl; }
};
class Student : public Person
{
public:
Student() {
cout << "Student()" << endl; }
void operator=(const Student&) {
cout << "Student::operator=()" << endl; }
~Student() {
cout << "~Student()" << endl; }
};
int main()
{
Student s1;
cout << endl;
Student s2;
s1 = s2;
return 0;
}
这里我们可以看到同名函数构成隐藏,子类无法调用父类的赋值重载函数,此时可以用域作用限定符 ::
来显 示调用父类中的函数
这里可以显示调用带参的构造函数,也可以显示调用被隐藏的析构函数
最后再结合其他几个成员函数整体看一下
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
Person(const Person& p)
: _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
Person& operator=(const Person& p)
{
cout << "Person operator=(const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator = (const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s); //Person::operator=() --> 防止隐藏
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
int main()
{
Student s1("jack", 17);
Student s2(s1);
Student s3("rose", 18);
s1 = s3;
return 0;
}
总结:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 不能显式的调用父类的析构函数,因为这不符合栈区的规则,父子类析构函数为同名函数 ,构成隐藏,如果想要满足我们的析构需求,就需要将其变为虚函数,构成重写
6. 继承与友元
友元关系不能被继承,也就是说基类友元不能访问子类私有和保护成员
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s); //无法访问Student类中的_stuNum
protected:
string _name = "zhangsan"; // 姓名
};
class Student : public Person
{
protected:
int _stuNum = 888; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
int main()
{
Person p;
Student s;
Display(p, s);
return 0;
}
如果想让 Display 函数也能访问子类中的私有成员,需要将其也声明为子类的友元函数
7. 继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个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; // 研究科目
};
int main()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数: " << Person::_count << endl;
cout << " 人数: " << Student::_count << endl;
cout << " 人数: " << Graduate::_count << endl;
Student::_count = 0;
cout << " 人数: " << Person::_count << endl;
return 0;
}
8. 菱形继承和菱形虚拟继承
单继承:一个子类只能继承一个父类
多继承:一个子类可以继承多个父类
- 在多继承中,哪个父类先被声明,它就会先初始化,与继承顺序无关
C++
的多继承在带来便捷性的同时,也出现了一个很大的缺陷,那就是菱形继承问题
D同时继承了B和C从A那里继承来的相同的属性,此时D是不知到该使用从谁那里继承来的A的属性的,即对于编译器来说,这是一个无法处理的操作!
通过代码来看看菱形继承的现象
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; // 主修课程
};
int main()
{
Assistant a;
a._name = "peter"; //这样会有二义性无法明确知道访问的是哪一个
return 0;
}
以上就是典型的菱形继承关系,这里Assistant
同时继承了Student
和Teacher
中来自Person
的_name
成员,使用时无法区分
菱形继承会造成两个问题:数据冗余(空间浪费)和二义性
解决二义性很简单,通过域限定符 ::
限制访问域即可
Assistant a;
a.Student::_name = "zhangsan"; //需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
cout << a.Student::_name << endl;
return 0;
这样只解决了二义性的问题,但是还没有解决数据冗余的问题
这时就要用到虚继承了,它是专门用来解决菱形继承问题的
虚继承需要在菱形继承的腰部继承父类时,使用 virtual
关键字来修饰被继承的父类
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; //主修课程
};
int main()
{
Assistant a;
a._name = "zhangsan";
a.Student::_name = "lisi";
a.Teacher::_name = "wangwu";
cout << a._name << endl; //最终输出wnagwu
return 0;
}
虚继承是如何解决数据冗余问题的呢?
- 利用虚基表将冗余的数据存储起来,此时冗余的数据合并为一份
- 原来存储冗余数 的位置,现在用来存储虚基表指针
- 此时无论这个冗余的数据存储在何处,都能通过基地址 + 偏移量的方式进行访问
class A {
public:int _a; };
class B : virtual public A {
public: int _b; };
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;
}
先来看看非虚拟继承
通过观察发现,B和C类中都有_a,此时出现了二义性和数据冗余的问题。D这个类中只有 _d,其他的都是继承下来的。
虚继承
这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
这里就是通过基地址 + 偏移量来解决了二义性和数据冗余的问题
再来看B类
上述中的假设B* pB = &b
; pB->_a
; 其实和上面D类
中的图中的B* pB = &d
; pB-> _a
是一样的,都是通过偏移量找到这里的 _a
验证一下:
这里通过观察可以发现上面我们使用偏移量的方式导致使用空间更大了,当有很多对象的时候,这里使用虚继承的话类中的数据也只有一份,相比非虚继承就会空间使用很少
注意:
虚继承较好的解决了菱形继承问题,但在实际使用中,要尽量避免出现菱形继承的情况
9. 继承和组合
不仅可以通过继承的方式来使用父类的成员,还可以通过组合
public
继承是一种is-a
的关系。也就是说每个派生类对象都是一个基类对象(高耦合)- 组合是一种
has-a
的关系。假设B
组合了A
,每个B
对象中都有一个A
对象(低耦合)
实际项目中,比较推荐使用组合的方式,这样可以做到解耦,避免因父类的改动而直接影响到子类
当然,还是需要具体问题具体分析
//组合
class A {
};
class B
{
private:
A _aa; //创建A对象
};
//继承
class C {
};
class D : public C
{
private:
C _cc; //直接继承使用
};
这里就有人会问了,为什么有耦合度更低的组合我们还要学习继承呢?这是因为,后面要学习到的多态的实现是离不开继承的
C++ 继承进阶到这里就介绍结束了,本篇文章对你由帮助的话,期待大佬们的三连,你们的支持是我最大的动力!
文章有写的不足或是错误的地方,欢迎评论或私信指出,我会在第一时间改正