C++进阶——继承 (1)
ʕ • ᴥ • ʔ
づ♡ど
🎉 欢迎点赞支持🎉
个人主页:励志不掉头发的内向程序员;
专栏主页:C++语言;
文章目录
前言
一、继承的概念及定义
1.1、继承的概念
1.2、继承定义
(1)定义格式
(2)继承基类成员访问方式的变化
1.3、继承类模板
二、基类和派生类的转换
三、继承中的作用域
3.1、隐藏规则
四、派生类的默认成员函数
4.1、4个常见默认成员函数
4.2、实现一个不能被继承的类
总结
前言
本章节我们就来讲解我们C++进阶部分的第一章节的语法—继承。知识点比较多,我们得分两章讲解,继承的作用主要就是使我们的代码设计的更有层次,能够减少我们一些重复冗余的内容,同时也为我们的多态打下基础。我们一起来看看吧。
一、继承的概念及定义
1.1、继承的概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
我们先来尝试设计两个类,一个是学生的类 Student,另外一个是老师的类 Teacher。这两个身份的人都应该有姓名/地址/电话/年龄等成员变量,都有 identity 身份认证的成员函数。当然,他们也有一些不同的成员变量和函数,比如老师独有的成员变量是职称,而学生则是学号;学生独有的成员函数是学习,而老师的则是授课。
class Student
{
public:// 身份认证void identity(){}//学习void study(){// ...}private:string _name; // 名字string _address; // 地址string _tel; // 电话int _age; // 年龄int _stuid; // 学号
};class Teacher
{
public:// 身份认证void identity(){}// 授课void teaching(){// ...}private:string _name; // 名字string _address; // 地址string _tel; // 电话int _age; // 年龄int _title; // 职称
};
大家有学的好的吭哧吭哧就把代码写出来了,一点问题都没有。但是我们发现这两个类高度相似,看上去只有一点点区别。这样写太冗余了。确实如此,于是我们决定把他们公共成员放到 Person 类中,Student 和 Teacher 都继承 Person,这样就可以复用这些成员了。这样就不用重复定义了,省去了很多麻烦。
class Person
{
public:// ⾝份认证void identity(){cout << "void identity()" << _name << endl;}
protected:string _name; // 姓名string _address; // 地址string _tel; // 电话int _age; // 年龄
};class Student : public Person
{
public:// 学习void study(){// ...}protected:int _stuid; // 学号
};class Teacher : public Person
{
public:// 授课void teaching(){//...}protected:string title; // 职称
};
此时如果我们还想有更多的身份,都可以直接继承 Person 类,这样就可以把同样的成员函数和成员变量继承下来,减少冗余和重复定义。
1.2、继承定义
(1)定义格式
下面我们看到 Person 是基类,也称作父类。Student 是派生类,也称作子类(由于翻译的原因,所以既叫基类/派生类,也叫父类/子类)。
继承的格式就是如此,可以看到,这里的继承方式和我们之前的访问限定符是一样的。
(2)继承基类成员访问方式的变化
想要了解清楚继承的派生方式,就得先看懂下面的表格。
这个表的含义是我们父类不同访问限定符的成员,在子类的不同继承方式后会变成什么成员。
- 父类如果是 private 的成员,无论子类是如何继承的,都不会被子类所看到。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法上的限制子类对象不管在类里面还是类外面都不能去访问它。
- 父类 private 成员在子类中是不能被访问的,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结就会发现一个规律,父类的私有成员在子类都是不可见的。父类的其他成员在子类的访问方式就是选取在这两个类中保密程度最高的那个,保密程度public < protected < private(例如:父类的成员是 protected,而子类的继承方式是 public,那那个成员就是 protected)。
- 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
class Person
{
public:void Print(){cout << _name << endl;}
protected:string _name = "****"; // 姓名
private:int _age; // 年龄
};
//class Student : protected Person
//class Student : private Person
class Student : public Person
{
public:void changeName(){_name = "zhangsan";Print();}protected:int _stunum; // 学号
};
当我们 public 继承时:
int main()
{Student s;s.Print();s.changeName();return 0;
}
我们可以在子类中访问父类的 public/protected 限定符限定的成员,但是无法访问父类 private 限定的成员。
我们运行程序就可以看到我们未修改和已修改的成员了。
当我们 protected/private 继承时:
同样的代码,因为我们变成 protected/private 类型导致外界无法访问 Print 函数了,只能访问 changeName 函数。
我们运行程序就只能看到我们已修改的成员了。
在实际运用中一般使用的都是 public 继承,几乎很少使用 protected/private 继承,也不提倡使用 protected/private 继承,因为 protected/private 继承下来的成员都只能在子类的类里面使用,实际中扩展维护性不强。
1.3、继承类模板
之前栈的实现是使用封装容器的方式实现的,其实在这里我们可以尝试用继承的方式来实现一个栈。
template<class T>
class Stack : public std::vector<T>
{
public:void push(const T& x){vector<T>::push_back(x);}void pop(){vector<T>::pop_back();}const T& top(){return vector<T>::back();}bool empty(){return vector<T>::empty();}
};
可以使用这样的方式实现继承类模板。我们如果直接 push_back、pop_back 等,这样我们调用时会通不过,因为模板会按需实例化,我们调用时,vector <T> 会按需实例化成 vector <int>,但是由于它的成员函数和成员变量由于没有调用,所以就没有实例化,此时我们编译器在 push_back 时就会由于找不到而报错。所以我们应该指定一下要使用的类域。
二、基类和派生类的转换
- public 继承的派生类对象可以赋值给基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类的那部分切出来,基类指针或者引用指向的是派生类中切出来的基类那部分。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
class Person
{
public:string _name;string _sex;int _age;
};class Student : public Person
{
public:int _No;
};int main()
{Student sobj;// 1 子类对象可以赋值给父类对象/指针/引用Person pobj = sobj;Person* pp = &sobj;Person& rp = sobj;rp._name = "张三";return 0;
}
此时 Person 类就把 Student 类进行切割,留下的都是自己所拥有的类型了。
引用修改的就是 Student 中 Person 所拥有的那一部分了。
当然,反过来父类是不能赋值给子类的。
int main()
{Person pobj;Student sobj = pobj;return 0;
}
虽然我们父类不能赋值给子类。但是我们父类的指针和引用是可以的,但是得强制类型转换一下。
int main()
{Person* pobj;Student* sobj = (Student*)pobj;return 0;
}
之所以支持这样操作是因为我们的父类可能之前就是指向我们子类的对象,此时把它转换成子类就是它本来的类型,所以是支持怎么整的。但是我们无法确认父类原本是否指向子类,所以我们后面会讲解可以使用RTTI(Run-Time Type Information)的dynamic_cast来进行识别后安全转换。
三、继承中的作用域
3.1、隐藏规则
- 在继承体系中父类和子类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏。(在子类成员函数中,可以使用 父类 :: 父类成员 显示访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
当代码中的父类和子类同时拥有一个同名的成员,我们在子类中会访问哪一个成员呢?
class Person
{
protected:string _name = "小李子";int _num = 111;int _age;
};class Student : public Person
{
public:void Print(){cout << _num << endl;}protected:int _num = 999;
};int main()
{Student s;s.Print();return 0;
}
在这里父类和子类都有 _num 变量,此时我们在子类中输出 _num 其实是子类的 _num,因为父类的 _num 被隐藏了。
如果我们一定要访问父类的成员,那就得指定父类的类域才行。
class Student : public Person
{
public:void Print(){cout << Person::_num << endl;}protected:int _num = 999;
};
四、派生类的默认成员函数
4.1、4个常见默认成员函数
6个默认成员函数,默认的意思就是指我们不写,编译器会给我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的 operator= 必须要调用基类的 operator= 完成基类的复制。需要注意的是派生类的 operator= 隐藏了基类的 operator=,所以显示调用基类的 operator=,需要指定基类作用域。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类的成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调用派生类构造
- 派生类对象析构清理先调用派生类析构再调用基类析构。
- 因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系。
我们子类默认生成的构造函数的行为是在我们原来的基础上(内置类型不知道会不会初始化,自定义类型会调用它的默认构造),增加了一个继承的父类成员。我们可以将继承的父类成员当成一个整体,去要求调用父类的默认构造。
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:protected:int _num; //学号string _address;
};int main()
{Student s;return 0;
}
此时我们可以看到,我们子类中的父类对象是依靠父类的默认构造去完成初始化的。
当我们父类不提供默认构造了,我们就没办法自动生成了。
子类也没有办法去直接初始化父类的成员。
我们在派生类显示初始化父类应该这样做。
Student(const char* name, int num, const char* address): Person(name),_num(num),_address(address)
{}
就像定义一个匿名对象一样。
拷贝构造和我们的构造也是差不多,对于父类则是调用父类的拷贝构造。我们只需要考虑独属于子类的那一部分的成员是否需要实现拷贝构造即可,想这里的 Student 独属于它的成员没有在堆上开辟的空间,所以就没有必要实现拷贝构造。
那如果我们一定得写该怎么写呢。
Student(const Student& s): _num(s._num),_address(s._address),Person(s)
{ }
这里就用到了我们上面讲的基类和派生类转换。我们把子类对象传递给父类的引用,而我们父类引用的部分就是子类切出来的那一部分。
我们赋值重载和拷贝构造是同理的。这里也没有必要实现赋值重载,系统自动生成的就已经是足够使用的了。
Student& operator=(const Student& s)
{if (this != &s){// 构成了隐藏,需要指定调用Person::operator=(s);_num = s._num;_address = s._address;}return *this;
}
我们析构也是要有自己的资源才需要去写,不然系统自动生成的就足够了。我们这里尝试去显示的写一下父类的析构看看。
~Student()
{~Person();
}
我们发现这里会报错,原因是因为子类的析构和父类的析构在这里构成隐藏关系。此时,析构函数的函数名会被特殊处理,处理成 destructor()。
此时指定作用域即可。
~Student()
{Person::~Person();
}
我们的父类的析构函数不需要去显示调用,我们子类会自动去调用父类的析构。这样还可以保证析构顺序:先子后父。
4.2、实现一个不能被继承的类
方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
class Base
{
public:void func(){cout << "Base::func" << endl;}
protected:int a = 1;
private:Base(){}
};class Derive : public Base
{void func(){cout << "Derive::func" << endl;}
};
此时我们把基类的构造函数私有化了,这样它的构造函数在派生类就看不到了。但是我们想要创建子类对象就必须去调用父类的构造,这样就实现了我们不能被继承的功能。
这个方法有个不足的地方就在于它不够明显,如果你不去定义就不会报错。
方法2:C++11新增了一个 final 关键字,final 修改基类,派生类就不能继承了。
class Base final
{
public:Base(){}void func(){cout << "Base::func" << endl;}protected:int a = 1;
};class Derive : public Base
{void func(){cout << "Derive::func" << endl;}
};
这种不用定义都会报错。
总结
以上内容便是我们继承的一部分内容啦,虽然还没有学完,但是相信大家已经基本上了解到了继承的魅力了,大家下去好好吸收,我们下一章节再见。
🎇坚持到这里已经很厉害啦,辛苦啦🎇
ʕ • ᴥ • ʔ
づ♡ど