解锁C++第二大特性,代码也玩“父子”游戏——继承
文章目录
前言 —— 封装
经过之前的学习,我们学了C++的第一大特性 —— 封装。现在我们来进行总结和回顾。
封装可以理解为有两个层次的含义, 其中封装的第一层是,以前我们学习C语言,它的数据和方法是分离的,在C++中,我们把数据和方法放到一个类里,用访问限定符进行限制。 这是第一个层级的封装。
第二个层级的封装,我们可以参考一下反向迭代器,还有迭代器的定义。
vector<int>::iterator
list<int>::iterator
deque<int>::iterator
这里的封装是指的什么呢?就是有些东西封装了后,我们在上层看到是类似的,但是下层是弯弯绕绕的。这里的上下层就可以类比迭代器的使用和实现。
假设我们从未了解过底层的实现,那迭代器看起来就是一个东西,但是我们学了底层后才会发现,迭代器使用是非常的像,但是实际的实现是天差地别。
比如:vector可能是个原生指针,但是vector的底层不一定是用原生指针解决, vector可以是原生指针,也可以是用类似链表那样去实现。list,是一个自定义类型封装的原生指针,四个指针,指向buff的开始和结束,指向buff的某个位置,指向中控的位置。
封装的本质是什么?
封装的本质是层层往外套,反向迭代器我们看起来跟正向迭代器是一个东西。实际上并不相同。反向迭代器是对正向迭代器的封装。
vector<int>::reverse_iterator
封装的总结
所以对于封装来说我们可以总结以下两点:
- 数据和方法放到一起,把想给访问定义成公有,不想给你访问定义成私有和保护
- 一个类型放到另一个类型里面,通过typedef 成员函数调整,封装另一个全新的类型
封装,我们就可以联想成生活中的礼物包装,我们不知道包装的具体东西是什么,但是包装可以看起来都一模一样。
一、继承的基本概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
我们可以举个例子,比如:校园管理系统。设计一个校园管理系统。我们就会设计出很多类,比如学生,老师,宿管,保安等等。这四个类,我们都要定义每个人的名字,年龄,家庭住址……。
但在设计类的情况上,我们出现了冗余的情况,我们之前设计冗余或者内存冗余是可以用模板来解决,但不是所有场景都可以用模板来解决的,比如普通迭代器和 const 迭代器,它们就返回值不一样,没法用模板解决,因为模板主要解决的是类型不同。而这个地方类是重复的,类型是一样的,只是每人都有一份。那这个时候怎么办呢?
这种情况,我们就可以设计一个类,这个类就不叫学生老师,这个类叫人。这个类是每个人公共的,我们把它放到这个类里面。每个人都具备的信息都放到上面。但是他们还有一些个人的,独有的信息。比如说,学生有学号,宿舍,老师可能有自己的工号,办公室,宿管也有自己的,但它们都有公共的部分。所以我们把公共的都放到上面的person类里面。
我们把上面的类就叫做父类或者基类,下面的类叫做子类,或者派生类。
1.1 继承的定义
我们通过一段代码来理解继承的语法。在这段代码种,我们有两个类,一个是Person类,另一个是Student类。
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:
char _stuid;//学号
};
int main()
{
Student s;
s.Print();
return 0;
}
我们先看一下语法,这里在Student类后面要加一个冒号 : ,然后这里有一个继承方式,继承方式有三种,公有,私有,保护,我们现在先用公有继承。然后在后面再加一个父类(基类)的名字就可以了。
继承以后的效果:
继承以后有什么用呢?好像我的 Student 类里面只有我的学号_stuid。我的学生类好像是没定义名字的,但它实际有没有呢?
实际上是有的,因为父类有,也就是父类的成员在子类都会有一份。这就是被“继承下来了”。
所以如果我把父类的保护成员放成公有,或者能不能调用公有的print成员函数呢?答案是都可以。
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student :public Person
{
protected:
char _stuid;//学号
};
int main()
{
Student s;
s._name += 'x';
s._name += 'x';
s._name += 'x';
s.Print();
return 0;
}
我们没在子类中定义名字,打印函数,但也能用,就是因为父类有这些成员或者成员函数。当然,如果父类的成员是保护的话,就没法访问了。
那什么情况下我们会用继承呢?
就是说,比如说有几个类,它们有公共的部分,那我就可以把公共部分提取出来,放到一个类,那个类就叫父类,其他类叫子类。
二、继承的三种方式
访问限定符有三种,而我们的继承方式也有三种。
有三种继承方式,有三个类成员的访问限定符。两两组合起来就九种方式。
它们分别是公有成员三个继承方式,保护成员三个继承方式,私有成员三个继承方式。
2.1 私有不可见
如果是基类的私有成员,在派生类中无论按什么方式继承就均不可见。
这个不可见是啥意思呢?这个东西还不好理解,首先是不是它就没了呢?
并不是。
我们通过一个例子来理解这个不可见。
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
private:
int _age = 18; // 年龄
};
在 Person 类中三种访问限定成员都有,公有,保护,私有。
私有成员无论用哪种继承方式都不可见。这个语法不好理解。不可见是啥呢?
其实它是在派生类里有这个成员,但是它的界定是不是你的。 私有的成员就像爸爸藏的私房钱一样,存在,但没法用。父类的私有成员,父类自己可以用。爸爸自己的私房钱爸爸自己能用。但你能不能用?你不能用,因为爸爸的私房钱你知道有,但你没法用。
class Student : public Person
{
public:
void func()
{
cout << _name << endl;
// 不能直接访问父类私有成员,就像不能直接用爸爸私房钱一样
// cout << _age << endl;
// 可以间接使用
Print();
}
protected:
int _stuid; // 学号
};
所以不可见就是指存在,但不可用。但是有没有办法可以间接用?答案是可以的,我们调用父类的print成员函数,就是间接使用父类的私有成员。
所以我们总结一下,私有不可见就是指父类的私有成员,在派生类不可以直接使用,但是可以间接使用,因为父类是可以使用自己的成员,我可以调用父类的成员函数,间接使用。这个成员函数可以是父类的公有函数,也可以是保护函数。所以私有不可见也是相对的。
这样这最后一排的内容我们就解决了。
2.2 公有、保护的继承
我们还剩六个组合方式,对于这六个组合方式,我们只需要记住一个原则,父类的成员或者基类的成员在子类的访问方式是取访问限定符和继承方式小的那一个。取权限小的那一个。
那它的权限关系是什么?我们可以认为是公有>保护>私有。
这里的六个组合也就是公有成员遇到公有取公有, 保护成员遇到公有取保护;公有成员遇到保护取保护,遇到私有取私有;保护成员遇到保护取保护,遇到私有取私有。
所以在派生类的访问方式到底是什么就取决于我们的最小权限关系。
那保护有什么作用呢?
我们可以通过一段代码来分析保护的作用。
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter";
private:
int _age = 18;
};
class Student : protected Person
{
public:
void func()
{
cout << _name << endl;
//公有碰保护取保护
//保护的成员在派生类内部可以访问
//在派生类外部不能访问
Print();
}
protected:
int _stuid;
};
int main()
{
Student t;
//t.Print();//无法访问
t.func();
return 0;
}
通过上述例子,我们知道保护说的是**在类里面可以访问,在类外面不能访问。**注意:私有成员是不可见,私有继承对于派生类而言是在类内部的权限改为了私有。私有是从父类就对外不可见,保护是从派生类开始才对外不可见。
那protected和private有什么区别呢?
我们认为在类和对象阶段它们是一样的,在类里面可以访问,在类外面就不能访问。而接触了继承之后,它们会有一个权限的变化,保护说的是在类里面可以访问,在类外面不能访问,而私有对子类来说是不可见的。
所以基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
如果不是继承,保护限定符就没有意义了。一般情况下我们都使用公有继承。其实很少使用保护/私有继承。实践当中用的最多的方式还是以下两种:
继承的意义是什么?是想让父类的东西都能用。
那struct 能不能继承?
struct 也可以继承。因为struct也可以定义类。只是说struct默认定义的访问限定符是public的。
struct默认继承方式和访问限定符都是公有
class 默认继承方式和访问限定符都是私有
这里的访问限定符,不建议不写,最好还是要写出来,哪怕是私有的,就都写清楚。一般情况下不建议用默认的。
2.3 基类和派生类对象赋值转换
赋值兼容转换是什么呢?不同类型之间赋值的时候,如果可以支持的话,首先第一个前提,相近类型。
int main()
{
int i = 1;
double d = i;
string s = "11111";
return 0;
}
那什么是相近类型呢?就是它们的意义呢,有相似之处。比如说int 和 double。包括int 和 unsigned int ,char 这些。它们都是用来表示数据大小的,可能只是表示的范围不一样或者精度不一样。再比如说,内置类型和自定义类型也可以相互转换。单参数的构造函数,支持隐式类型转换。但是类型转换中间会产生临时变量。比如说在string这个位置会产生临时变量。
string s = "11111"
所以这里给引用是过不了的。因为引用的是临时变量。临时变量具有常性。
我们得加const。
const string& s = "11111"
但是没有关系之间的类型能不能转换?比如 i 能不能转给string?这是不能的。不相关的类型就不能转换。相关类型就那么几个。内置类型就整型,浮点数、整型和指针。
指针为什么和整型可以转换呢?因为指针本身也是一个整型。指针是地址的编号,编号还是整数。所以整数和指针也能转换。然后就是自定义类型。自定义类型就是支持构造函数的,比如支持node * 去构造一个迭代器。那node * 就可以转换成一个迭代器,就链表迭代器。比如说const char*可以去构造string,所以能转换成string。
但是这个不相关类型,无论是隐式转换还是强转都不可以。只是说有些支持隐式,有些支持显示。
那如果我们有父类和子类呢?
父类可以赋值给父类没问题,那父类和子类之间能不能相互转换呢?一个子类对象,能不能赋值给一个父类对象?一个子类对象能不能赋值给一个父类对象?按理来说我们之前是不可以的.因为它们是不相近的类型,但是这里子类对象可以赋值给父类对象。
Student st;
Person p = st;
但是这里也要注意有一些限制。限制是什么呢?它是前提是公有继承。如果是保护或者私有就不可以了。但是它这个地方和转换还不一样。在公有继承下,父类和子类有些地方认为它们有一个is-a的概念。就是每个子类对象都是一个特殊的父类对象。
你父类的成员我子类都有,那我把子类对象赋值给父类对象怎么给呢?因为对象里面只有成员变量,所以它把这个过程叫做切割或者切片。就是把子类那一部分切出来,依次赋值给父类,子类自己的成员就不给。
这个切割或者切片也会走拷贝构造。 如果是内置类型,它按值拷贝,自定义类型,也会去走它的拷贝构造。 比如说这里的sex和age是内置类型就直接赋值,这个name,如果是自定义类型,它是会去调用它自己的拷贝构造。并且这个地方还会提出一个赋值切割或者切片的赋值兼容规则。什么叫赋值兼容呢?就是它中间不会产生临时对象。
我们之前中间转换会产生临时对象,可以认为这个地方是编译器进行了特殊处理,按理来说是要产生临时对象的。但是它在这个部分不会产生临时对象
我们来证明一下:
int main()
{
int i = 1;
double d = i;
//这里不加const不行
const string& s = "11111";
// 切割/切片赋值兼容
Student st;
Person p = st;
//这里不加const可以
Person& ref = st;
Person* ptr = &st;
return 0;
}
编译器进行了特殊处理。
比如说第一个,如果你是一个对象,那我怎么赋值呢?就是把我子类那部分切了,拷贝过去,如果你是给一个引用。那我这个ref就变成,子类对象当中切出来的父类这一部分的别名。而不是那个临时对象的别名。如果是指针也一样。这个指针,就指向我子类对象当中切出来的那个父类对象。
如果在这个地方用引用,来对这个公有成员进行加等,能否改变这个子类中的成员?或者我用这个指针尝试来改变。能不能?
这里是可以改变的,所以也证明了这个引用是子类对象切割出来父类成员的那一部分。公有的这一部分就是is-a。
那父类对象能不能赋值给子类对象呢?结论是不能的,这里我们直接记结论即可。
总结
- 封装的第一层含义:数据和方法放到一起,把想给访问定义成公有,不想给你访问定义成私有和保护
- 封装的第二层含义:一个类型放到另一个类型里面,通过typedef 成员函数调整,封装另一个全新的类型
- 继承是面向对象程序设计使代码可以复用的最重要的手段,是类设计层次的复用。
- 模板是用来解决类型不同的代码冗余,而继承用来解决类设计层次的冗余。
- struct默认继承方式和访问限定符都是公有,class 默认继承方式和访问限定符都是私有
- 私有不可见就是指父类的私有成员,在派生类不可以直接使用,但是可以间接使用。
- 赋值兼容转换是什么呢?不同类型之间赋值的时候,相近类型可以转换,不相关类型不能转换,强转也不行。
- 基类和派生类对象赋值转换,过程中有个切割或者切片的概念,就是把子类那一部分切出来,依次赋值给父类,子类自己的成员就不给。对于内置类型会直接赋值给父类成员,而自定义类型成员会走对应的拷贝构造。
- 赋值切割或者切片的赋值兼容规则。什么叫赋值兼容呢?就是它中间不会产生临时对象。