目录
一、什么是继承
1.继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,产生的新类称子类或派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
举个例子,我们每一个人在都有着不同的属性,对于我而言我职业是学生,而对于给我上课的人而言,他们的职业是老师,对于给我军训的教官而言,他们的职业是解放军军人。
人们在拥有这些职业属性的前提一定是大家都有作为人的属性,比如姓名,年龄,家庭住址等,然后每个人根据不同的职业进行细分又各自具有不同的属性,比如说学生有班级和学号等,老师有教授的科目和班级等,军人有军衔和所在的编制等等。
如果我们想描述一个学生,那么其作为人的属性也要包含在学生的属性中,也就是说Student类型的结构体一定包含Person这个类型的结构体的所有成员变量,我并不想写重复的东西,那么我们就可以将Person类的内容继承给Student类型,继承后父类的Person的成员函数和成员变量都会变成子类的一部分。这里体现出Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。
#include <iostream>
#include <string>
using namespace std;
//基类
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里的变量同时person的成员函数也被继承下来。
这里的Person类可以理解为是父亲,而Student和Teacher是孩子。继承这个过程可以理解为:父亲将自己的财产传承给子女,同时也告诉他们怎样去挣钱。
2.继承的定义
(1)定义继承
继承的定义包含派生类(子类)、继承方式和基类(父类)三部分
基类可以看作父亲,派生类看作孩子,继承方式是确定对进程中细节的选项。
(2)继承方式与访问限定符
在继承中,继承方式和访问限定符共同决定了基类成员函数和成员变量的使用权限。继承方式有三种,同时访问限定符也是这三个,分别是public、private和protected。在使用种用得最多的是public继承。在以前,我们认为访问限定符protected和private作用是一样的,但是学到继承时它们就有了一定的区别了。
private继承,不管是语法上具有限制而且在类里面都是不能访问的。
protected继承则只是语法上被限制了父类中成员变量和函数的访问,基类的成员是被继承下来了的。
public就是最简单的随便访问。
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
void print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter";
int _age = 18;
};
//public继承
class Staff : public Person
{
protected:
int _staid;
};
//protected继承
class Student : protected Person
{
protected:
int _stuid;
};
//private继承
class Teacher : private Person
{
protected:
int _jobid;
};
int main()
{
Staff sta;
Student stu;
Teacher tea;
sta.print();//可访问
stu.print();//不可访问
tea.print();//不可访问
return 0;
}
类成员/继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的protected成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
继承在C++开发的求职中是一个十分重要的问题,这个表可以总结为以下:
- 基类的private成员在派生类中无论以什么方式继承都是不可见的。
- 不可见的意思是指基类的私有成员继承到派生类对象后,但是语法上限制派生类对象不管在派生类的里面还是外面都不能去访问它。
- 基类中的private成员是不能被继承的,而protected成员是可以被继承的,但是在派生类中这些protected成员也不能被访问。
- 三个访问限定符的权限由小到大为:public(继承且派生类中随意访问) > protected(继承,但在派生类中限制访问) > private(不继承也不可访问)
- 除了基类中的private成员都不可访问,剩下的public和protected成员在继承时,会选择两个访问限定符中权限最小的就是该被继承的成员变量的访问权限。(比如说,protected继承基类的public变量,在派生类中它的访问权限就是protected)
- 使用关键字class时默认的继承方式是private,而使用struct时默认的继承方式是public,我们还是最好显示写出继承方式。
- 在实际运用中一般使用的都是public继承,几乎很少使用protetced/private继承,也不提倡使用,因为C++是早期引入继承的语言之一,protetced/private继承更多地是为了提供更加细致的使用,但是在实际中这两种继承基本没有多少使用的场景。
二、继承中的类型转换
继承中的父子类型相关变量遵循以下规则:
- 派生类对象可以赋值给基类的对象(也包括基类的指针和引用),同时内部不涉及类型转换。这种现象也被称为切片(或者切割),相当于把派生类中父类那部分切割下来并赋值给父类对象。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但是必须是基类的指针是指向派生类对象,这个父类指针或引用均指向子类中父类的部分(也是一种切片)。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast来进行识别后进行安全转换。(多态会在以后讲解)
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 = 111;
};
int main()
{
double a = 1.0;
int b = a;
//a为double类型,用一个int类型接收时
//编译器内部会生成一个临时变量,然后再将这个变量赋值给int类型的变量
const int& c = a;
//这个临时变量具有常性,所以引用要加上const否则报错
Student s;
Person p1 = s;//这里的赋值就不存在上述操作
Person& p2 = s;//这里就不用加const
Person* ptr = &s;//同样不用加const
return 0;
}
三、继承的作用域
1.父类与子类的独立作用域
在继承中,基类和派生类都有自己独立的作用域。
比如说,我们在基类Person和派生类中都定义一个变量_num。类似于C语言中局部变量和全局变量同时存在时,编译器会访问局部变量,同样子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种现象成为隐藏(或重定义)。如果在子类中想指定访问基类的_num就可以采取指定作用域的方式(Person::)。
class Person
{
protected:
string _name = "gdg"; // 姓名
int _num = 111;
};
class Student : public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "身份证号:" << Person::_num << endl;
cout << "学号:" << _num << endl;
}
protected:
int _num = 2023; // 学号
};
void Test()
{
Student s1;
s1.Print();
};
//输出:
//姓名:gdg
//身份证号:111
//学号:2023
2.小练习
A类和B类的两个fun函数构成什么关系?
a.重载 b.隐藏 c.代码编译报错
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;
}
};
int main()
{
B b;
b.fun(10);
return 0;
};
a.重载函数必须满足函数名相同和参数不同两个条件,但是这两个函数不再一个作用域内,故两函数不构成重载,错误。
b.只要父子类中拥有同名的函数,子类的函数就会隐藏父类的函数,因此构成隐藏,正确。
c.代码正常运行,错误。
不过,我们在子类中最好还是不要定义同名的成员。
四、默认成员函数
1.复习默认成员函数
6个默认成员函数(也称6个大爷),包括构造函数、析构函数、拷贝构造函数、赋值运算符重载、取地址重载,const取地址重载。(虽然侧重理解是前面4个大爷),这六个函数我们不写编译器也会为我们自动生成,若自己已经定义编译器则不会生成。
默认成员函数对内置类型不处理,自定类型调用其默认构造函数;析构函数对内置类型不处理,自定义类型会调用对应的析构函数;默认生成的拷贝构造和赋值重载针对只能进行浅拷贝,深拷贝则需要自己定义。
2.默认成员函数的调用规则
(1)构造函数等
我们来看下面代码:
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
protected:
string _name = "peter";
int _age = 18;
};
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
protected:
int _stuid = 111;
};
int main()
{
Student s;
return 0;
}
执行结果:
我们发现,在Student类的构造函数中并没有调用Person的构造函数,但编译器却调用了它。
原来,派生类的变量在初始化时,继承类的默认成员函数会默认先调用基类的默认构造函数,然后再调用派生类的默认构造函数,不需要显式调用。
这也告诉我们在派生类中,构造函数只需要初始化派生类自己的资源即可,不需要管基类。
实际上构造函数、拷贝构造函数、赋值运算符重载这些存在变量初始化的过程都符合构造函数这样的调用规则。
(2)析构函数
四大爷中就有一个特立独行,它就是析构函数。
我们在上面的代码中加上析构函数:
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name = "peter";
int _age = 18;
};
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _stuid = 111;
};
int main()
{
Student s1;
return 0;
}
执行结果:
我们又发现,同样在Student类的构造函数中也没有调用Person的析构函数,但编译器也调用了它。
与三大爷相反,派生类的变量在被回收时,继承类的默认成员函数会默认先调用派生类的默认构造函数,然后再调用基类的默认构造函数,也不需要显式调用。
这也同样告诉我们在派生类中,析构函数只需要回收派生类自己的资源即可,不需要管基类。
(3)总结
- 派生类对象初始化先调用基类构造函数再调派生类构造函数。
- 如果基类没有默认构造函数,则必须在派生类构造函数的初始化列表中显示调用构造函数。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的赋值运算符重载必须要调用基类的赋值运算符重载完成基类的拷贝。
- 派生类对象回收,会先调用派生类析构函数再调用基类的析构函数。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员,以保证派生类对象回收资源时先清理派生类成员再清理基类成员的顺序。
- 因为后续一些场景的需要,C++编译器会对析构函数的函数名全部处理成destrutor(),函数名相同子类析构函数和父类析构函数就构成了隐藏关系。
五、继承与友元
一句话:友元关系不能继承,也就是说基类的友元类是不能访问子类私有和保护成员的。
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
protected:
int _stuNum; // 学号
};
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;
}
六、静态成员继承
1.静态成员
只要基类定义了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 _course; //
};
int main()
{
Student s1;
cout << "人数:" << Student::_count << ",地址:" << &Student::_count << endl;
Person p1;
cout << "人数:" << Person::_count << ",地址:" << &Person::_count << endl;
return 0;
}
执行结果:
2.空指针访问
我们看一段代码:
class Person
{
public:
Person() { ++_count; }
void Print()
{
cout << "这是Print函数" << endl;
}
protected:
string _name; // 姓名
public:
static int _count; // 人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student{
protected:
string _course; //
};
int main()
{
Person* ptr = nullptr;
ptr->Print();//(*ptr).Print();也可以
cout << ptr->_count << endl;
return 0;
}
执行结果:
这段代码很有意思,我看似解引用了空指针,实则并没有。
在类中_name这个成员变量应该存放在栈区,如果访问它,编译器会对ptr进行解引用,对空指针解引用可是会报错的。
所以,我也并没有去访问成员变量,而是调用函数和打印静态变量。
因为函数会存放在代码段里,而不是在堆栈中,对类中的函数访问不需要解引用,编译器就可以直接去代码段直接找到Print()函数,本质是this指针的传递,传递一个空指针不报错。不过被调用的函数中也不能涉及成员变量的操作,否则会出现this指针为空而引起空指针解引用的情况。
同样的_count是存放在静态区,静态区中根本就没有this指针的说法。
七、单继承和多继承
1.什么是单继承和多继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
2.菱形继承
菱形继承:菱形继承是多继承的一种特殊情况。(这种继承方式在以后的代码中不要使用)
菱形继承有一个巨大的问题:比如说Person中有一个_name变量,在Student和Teacher类中都继承了Person的_name变量,如果Assistant类多继承这两个类,那么Assistant对象中Person的_name变量会有两份。
代码实现:
#include <iostream>
#include <string>
using namespace std;
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 _task; // 助手帮助的任务
};
int main()
{
Assistant s;
return 0;
}
监视窗口查看:
如果我们直接访问Assistant类中的_name,那么编译器将不知道该访问哪一个_name。虽然可以通过Person::_name和Student::_name指定作用域去进行针对性的访问,所以菱形继承存在数据冗余和二义性的问题。
八、虚继承
对于普通的菱形继承而言,在派生类中会存放两个相同名称的变量。如果我们使用虚继承就可以保证派生类中只有一个同名变量,很好地解决上面菱形继承的问题。
1.virtual关键字
下面代码的A,B,C,D分别对应Person、Student、Teacher、Assistant。
virtual关键字表示虚继承,对于菱形继承virtual关键字一定是在源头就加上,比如说就像下面的继承过程,到B和C继承到D的时候才发生二义性的问题,但是方式是从根源上解决,也就是从A继承到B和A及成果到C这两个过程中就使用虚继承,而不是到将发生错误再进行虚继承。
#include <iostream>
#include <string>
using namespace std;
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 = 1;
d._c = 2;
d._d = 3;
d.B::_a = 4;
d.C::_a = 5;
return 0;
}
2.底层实现
如果成员变量使用虚继承,那么派生类中将建立一个虚基表,这个虚基表的地址储存在开头。
跳转到虚基表位置,虚基表以null开头,后面储存偏移量。
一旦出现了菱形继承的同名变量情况,派生类就会相比普通继承扩大一块空间储存这个重复的变量值。然后对这个值的访问就通过储存虚基表位置加上偏移量来实现。因为我们有时候也会指令类域去访问变量,所以开辟两个虚基表也是为了支持确定类域的访问。