面向对象程序设计的特性之一–继承(C++讲解)
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5kt6g9w8-1665377649319)(D\gitee仓库\博客使用的表情包\举个例子.jpg)]](https://i-blog.csdnimg.cn/blog_migrate/0cc8421a3d7cc1cc5b11b66529db8e64.png)
1.继承的概念🐣
顾名思义,就像现实生活中的财产继承一样,继承上一代的财产。那么编程里面的继承呢–是为了实现类级别的复用。
举个栗子:
学生类与老师类,他们存在着很多共同点
- 有姓名
- 有年龄
- 有学校
也存在着很多不同点:
- 学生-学号
- 老师-教师号
- 老师有薪资
- …………
在编程中,当相同的地方多次进行时,最喜欢设计一个通用的东西,然后实现复用,例如:一个地方有多次进行比大小,那么我们可以设计一个比大小的函数,然后多次调用此函数。类似的,当多个类有一些共同的属性时,我们可以设计一个类,作为其他类的基础,在此基础上完成其他功能的实现
稍微官方一点的概念就是:
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性(父类或者基类)的基础上进行扩展,增加功能,这样产生新的类,称派生类或者父类。继承呈现了面向对象程序设计的层次结构, 体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
代码演示:
#include<iostream>
using namespace std;
class Person//基类
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和
//Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。
//调用Print可以看到成员函数的复用。
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
类的print
成员函数,还继承了其私有成员,name,age
2.继承的方式👻
根据继承方式的不同,子类对父类的访问方式也不同。在上述代码中我们使用的是public
公有继承,当然了也有protected
–保护继承与private
–私有继承
根据继承方式的不同,以及基类中的访问权限的不同,子类对基类的访问会产生9种效果(3*3)。
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
最后在派生类中一共有4种情况。
- public–派生类可以直接访问
- protected–派生类不可以直接访问,但是可以通过派生类成员函数访问
- private–派生类不可以直接访问,但是可以通过派生类成员函数访问
- 不可见–父类的的成员已经继承过来了,但是语法上限制子类不能访问,只能通过父类的公有成员函数才能访问
事实上,平时用的最多的就是公有继承
当然了,在派生类中的访问方式是有规律的
- 遵循小权限,权限大小:public > protected > private
- 如果基类中的private成员,那么在派生类中是不可见的
这里我们也知道了,private与protected的区别了。
大家可以通过上述的代码,修改一下继承方式和基类的成员访问方式,通过调式–来看看4种情况下的效果。
3.赋值兼容规则🤖(切片/切割)
派生类与基类是可以进行赋值的,但是并不是相互的,其中派生类可以给父类进行赋值,而父类不能给子类进行赋值
ps:了解这个赋值兼容规则,对于学习后面的C++多态是必要的,也是必须的
#include<iostream>
using namespace std;
class Person
{
public:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Person p;
Student s;
s._name = "张三";
s._sex = "男";
s._age = 18;
p = s; // 父<-子 可以的 切割/切片
// s = p; // 子x-父 不行的
Person* ptr = &s;
Person& ref = s;
return 0;
}
由于咱们还没有介绍派生类的构造函数,为了方便设置派生类的成员的值,就将成员变量设置为public权限。
图解:
4.隐藏(重定义)🐱👤
首先我们回顾以下作用域的概念。
全局域里面有个num
的值是99,局部域里面有个同名的num
的值是100
全局域的作用域:程序源文件的的整个范围
局部域的作用域:局部的代码块里面
当我们在局部域里面访问num
,将由于访问局部域里面的num
,如果要访问全局域的num
,要指明其作用域。
成员变量的隐藏
#include<iostream>
using namespace std;
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份号:" << Person::_num << endl;
cout << _num << endl;
}
protected:
int _num = 999; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
运行上述代码,发现输出的_num
显示的是999–学号。
也可以给大家展示一下成员变量隐藏下的情况。
原理解释:
派生类域基类都有他们对应的作用域:
- 基类的作用域,基类的类区域
- 派生类的作用域,派生类的类区域
那么我们在派生类不指定作用域的情况下,即使基类的_num
被继承下来了,优先访问的肯定是派生类的同名成员。
同理我们要访问基类的同命成员变量–指定作用域。
ps:不建议写出成员变量构成隐藏的代码
#include<iostream>
using namespace std;
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
cout << " 身份证号:" << Person::_num << endl;
}
protected:
int _num = 999; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
成员函数的隐藏
成员函数构成隐藏的条件:继承下函数同名即构成隐藏
#include<iostream>
using namespace std;
class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void func(int i)
{
cout << "func(int i)->" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
return 0;
};
上述代码种,调用的是B类里面的func
函数,我们我们要调用A类里面的成员函数。指定作用域即可
b.A::func();
–将这句代码加进去即可。
ps:很多同学容易以为上述的两个func函数构成函数重载,这是错误的说法
函数重载条件:
- 同一作用域
- 函数名相同,函数参数不同
而隐藏是不同作用域下的(一个作用域是基类,另一个是子类)
5.派生类的默认成员函数🐱👓
class Person
{
public:
Person(const char* name)
: _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() // -> 因为后面多态的一些原因,任何类析构函数名都会被统一处理成destructor()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student :public Person
{
public:
//student的默认成员函数
//…………
private:
int _stuid;
};
派生类的构造函数
Student(const char* name = "Peter" , int id = 18)
:Person(name)//Person部分调用其构造
,_stuid(id)
{
cout << "Student()" << endl;
}
-
先调用基类的构造,完成基类的构造,由于初始化列表是成员变量定义的地方,我们要在初始化列表完成Person的定义。
-
再处理派生类自己的成员变量部分
派生类的拷贝构造函数
Student(const Student& s)
:Person(s)//Person部分调用其拷贝构造
,_stuid(s._stuid)
{
cout << "Student(const Student& s)" << endl;
}
同理:
- 要先完成基类的拷贝构造,问题是,如何将基类的部分取出来,交给基类取拷贝构造,赋值兼容规则有用了,直接将Student的对象传进去,基类使用Person类的引用接收,根据切片规则即可取出Student对象的基类部分。
- 再处理派生类的拷贝构造部分即可
派生类的赋值运算符重载
Student& operator=(const Student& s)
{
if (this != &s)
{
operator=(s);//Person部分调用其赋值
_stuid = s._stuid;
}
return *this;
}
同理:
- 要先完成基类的赋值
- 再完成子类的赋值
但是这个上述的赋值运算符重载写错了,当然逻辑上没问题,是先调用基类的,再派生类的。但是上述代码调用基类发生了错误,
派生类域基类的赋值运算符重载的函数名相同,就是perator=
,那么根据成员函数的隐藏条件(函数名相同即构成隐藏),那么上述我们想调用基类,可是调用的却是派生类的operator=
解决方法:指定作用域!
Student& operator=(const Student& s)
{
if (this != &s)
{
::operator=(s);//Person部分调用其赋值
_stuid = s._stuid;
}
return *this;
}
派生类的析构函数
同理:
- 先调用基类的析构函数
- 再设计派生类的析构函数
ps:C++为了实现多态的一些功能的完整性,将基类域派生类的析构函数名都变成了destructor()
所以如果我们直接调用的话就会构成隐藏,所以调用基类的析构函数时需要指定作用域
~Student()
{
Person::~Person();//析构可以显示调用
cout << "~Student()" << endl;
}
我们拿一段代码测试一下:
我们发现,多调用了一次析构函数,这其实时析构函数做的处理,为了保证,LIFO–后构造的先析构的规则,编译器会在子类的析构函数调用结束后,自动调用父类的析构函数
这样也使得调用顺序更加合理。
由于我们上述的代码没有做指针,内存的一些清理工作,所以没有报错,不然析构两次是会报错的。
所以咱们的析构函数这样写就够了:
~Student()
{
cout << "~Student()" << endl;
}
6.思考题(面试题):请设计一个无法被继承的类🙉
方式一:将基类的构造函数设置成private权限
#include<iostream>
using namespace std;
class A
{
private:
A()
{
cout << "A()" << endl;
}
protected:
int _a;
};
class B :public A
{
public:
B()//无法继承了,会报错
{
cout << "B()" << endl;
}
void func()
{
cout << _b << endl;
cout << _a << endl;
}
protected:
int _b;
};
int main()
{
B b;
b.func();
return 0;
}
我们已经知道基类中的private成员在派生类中是不可见(不可访问)
上述代码中,B继承了A,那么在B的构造函数中是会去自动调用A的构造函数的,而A的构造函数时不可见(不可被B访问),所以是会报错的。这是一个很巧的办法。
方式二:加上关键字final
当我们在基类设计时,在后面加上final关键字,那么此类就无法被继承。
代码如下:
#include<iostream>
using namespace std;
class A final
{
public:
A()
{
cout << "A()" << endl;
}
protected:
int _a;
};
class B :public A
{
public:
B()
{
cout << "B()" << endl;
}
void func()
{
cout << _b << endl;
cout << _a << endl;
}
protected:
int _b;
};
int main()
{
B b;
b.func();
return 0;
}
7.友元是不被继承的🐡
#include<iostream>
using namespace std;
class Student;//声明有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;
}
int main()
{
Person p;
Student s;
Display(p, s);
}
上述代码会报错,因为,虽然**Display
是基类的友元,但友元并不会继承下去,即Display
不会通过继承一下就不成派生类的友元**,要想成为派生类的友元,必须也在派生类里面再加一句friend void Display(const Person& p, const Student& s);
8.继承下的静态成员🎲
静态成员是共享的成员,不仅仅基类的多个对象共用这个成员,派生类的对象也和基类的对象一起共用这个成员
注意,静态成员函数中是无this指针的,多态会考,先打预防针
一个经典的统计人数题
#include<iostream>
using namespace std;
class Person
{
public:
Person() { ++_count; }
Person(const Person& p) { ++_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 func(Student s)
{}
int main()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
func(s1);//传参也会创建临时对象
cout << Person::_count << endl;
cout << Student::_count << endl;
cout << Graduate::_count << endl;//静态成员被基类与派生类所共享
return 0;
}
上述代码,Person
类是基类,Student,Graduate
是派生类,让我们统计Person,Student,Graduate
这些类一共创建了多少对象。思路:派生类创建对象都会调用基类的构造函数,所以我们在基类中定义一个静态成员变量来统计人数,然后在基类的构造函数中++count,count就是对象的个数了
9.C++的填坑日记–多继承🎠
生活中会出现这样的场景,一个助教,同时他也是一个学生(博士或者硕士),同时他也是一个老师
那么我们就可以这样设计主教的类了(具有两个类的性质):
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; // 主修课程
};
多继承下的隐患–数据冗余与二义性
就拿上述代码举例:
图解:
Assistant
同时继承了Student,Teacher
类,而Student,Teacher
类又都继承了Person
类
这样就形成了菱形继承,也就会产生数据冗余–_name
有两份,二义性–_name
同名,访问不明确
如果硬要去用也可以这样:
#include<iostream>
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 _majorCourse; // 主修课程
};
int main()
{
Assistant a;
a.Student::_name = "小张";
a.Teacher::_name = "张老师";
return 0;
}
为了修改数据的便捷,我们将Person
类的_name
设置为public,上述代码要指明作用域才能避免二义性,如果指定会报错访问不确定,而且此方式没有解决数据冗余
解放方法–虚继承
虚继承是可以解决数据冗余与二义性的。
语法,在腰部加上virtual关键字
#include<iostream>
using namespace std;
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._a = 3;//加了虚继承就不会访问不明确了
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
图解演示:
普通继承时:
d对象里面先放继承B的成员,再放继承C的成员,再放自己的成员
这个顺序(与构造顺序一致)根继承时的声明关系有关,与初始化列表无关
虚继承下:
他将虚继承下重复的部分放到了最后面,但是奇怪的是B,C部分的第一个位置放了一个奇怪的值。这个值起始看起来很像一个地址。
虚继承的底层原理
我们将这个地址输入进内存窗口看看里面到底是什么。
我们看到14(十六进制)–转换成十进制为20,0c(十六进制)–转换成十进制为12
这两个数字正好是,B和C举例A的偏移量。也就是说,编译器是通过这个偏移量找到这个A的。
通常来说,我们将虚继承的类叫做虚基类,上述的虚基类就是A。虚继承下,B和C虚继承了A,那么B和C里面就会放一个指针(叫做虚基表指针),指向的就是虚基表,虚基表里面有虚基类的偏移量。那么为什么虚基表的一个位置放的是0呢,这涉及到后面多态的内容,咱们多态文章再把这个讲明白。
10.继承与组合的区别🚀
未来程序的趋向:高内聚,低耦合
通俗易懂地讲就是:一群程序猿一起设计程序,如果其中一个程序猿的代码出bug了,那么他只需要自己改自己的就可以了,其他程序猿写的代码不受到他的影响。但是每个程序猿之间又是相互联系,复用的。
继承–is-a的关系
其实继承是很不符合高内聚,低耦合的,因为类与类之间的联系太大了,一个改变另一个也要接着改变。
组合–has-a的关系
一个类的成员里面有另一个类的对象。那个类是无法访问另一个类的私有成员的。这样他们之间的联系不会太大,保证了封装性。
#include<iostream>
using namespace std;
class A
{
public:
void func()
{}
protected:
int _a;
};
// B继承了A,可以复用A
class B : public A
{
protected:
int _b;
};
// C组合A,也可以复用A
class C
{
public:
void func()
{
_a.func();
}
private:
int _c;
A _a;
};
int main()
{
B b;
b.func();
C c;
c.func();
return 0;
}
大家可以结合上述代码理解继承与组合区别。
ps:区分方式is-a/has-a/,如果既可以是继承又可以是组合,建议用组合
11.继承面试题🐨
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承?如何解决数据冗余和二义性的
- 继承和组合的区别?什么时候用继承?什么时候用组合?
答案都在文章里面!