【C++】继承(详解)
文章目录
- 上文链接
- 一、继承的概念及定义
- 1. 继承的概念
- 2. 继承的定义格式
- 3. 继承方式
- 4. 类模板的继承
- 二、基类和派生类的转换
- 三、隐藏
- 四、派生类的默认成员函数
- 1. 四个常见的默认成员函数
- 2. 实现一个不能被继承的类
- 五、继承与友元
- 六、继承与静态成员
- 七、多继承及其菱形继承
- 1. 继承模型
- 2. 虚继承
- 3. IO 库中的菱形虚拟继承
- 4. 多继承的指针偏移问题
- 八、继承与组合
上文链接
- 【C++】模板(进阶)
一、继承的概念及定义
1. 继承的概念
继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法 (成员函数) 和属性 (成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
下面我们看到没有继承之前我们设计了两个类 Student 和 Teacher,Student 和 Teacher 都有姓名/地址/ 电话/年龄等成员变量,都有 identity 身份认证这样的成员函数,设计到两个类里面就是冗余的。当然他们也有一些不同的成员变量和函数,比如老师独有成员变量是职称,学生的独有成员变量是学号;学生的独有成员函数是学习,老师的独有成员函数是授课。
#include<iostream>using namespace std;class Student
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 void identity(){// ...}// 学习 void study(){// ...}protected:string _name = "peter"; // 姓名 string _address; // 地址 string _tel; // 电话 int _age = 18; // 年龄 int _stuid; // 学号
};class Teacher
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 void identity(){// ...}// 授课 void teaching(){//...}protected:string _name = "张三"; // 姓名 int _age = 18; // 年龄 string _address; // 地址 string _tel; // 电话 string _title; // 职称
};int main()
{return 0;
}
下面我们公共的成员都放到 Person 类中,Student 和 Teacher 都继承 Person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦。
#include<iostream>using namespace std;// 公共部分
class Person
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证 void identity(){cout << "void identity()" << _name << endl;}protected:string _name = "张三"; // 姓名 string _address; // 地址 string _tel; // 电话 int _age = 18; // 年龄
};class Student : public Person // 表示继承了 Person 的成员函数和成员变量
{
public:// 学习 void study(){// ...}protected:int _stuid; // 学号
};class Teacher : public Person
{
public:// 授课 void teaching(){//...}protected:string title; // 职称
};int main()
{Student s;Teacher t;s.identity();t.identity();return 0;
}
- 输出
void identity()张三
void identity()张三
2. 继承的定义格式
上面的例子中,Person 是被继承的一个类,我们可以叫它基类,也称作父类。Student 则是派生类,也称作子类。
在需要继承时,需使用下面的格式:
3. 继承方式
继承方式和访问限定符的类型都有三种:public、protected、private。不同的继承方式会产生不同的效果:
对于基类中的私有 (private) 成员,无论是哪种类型的继承方式,在派生类中都是不可见的。这里的不可见是指基类的私有成员虽然被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
虽然基类 private 成员在派生类中是不能被访问,但是如果基类成员不想在类外直接被访问,而需要在派生类中被访问,那么该成员的访问限定符就定义为 protected。可以看出保护成员限定符 (protected) 是因继承才出现的。在没有用到继承的情况下,private 和 protected 的功能一样。
基类的私有成员在派生类都是不可见,基类的其他成员在派生类中的访问方式 === Min(成员在基类的访问限定符,继承方式),public > protected > private。
比如说,原本在基类中的 public 成员,在被 protected 继承后,派生类中的该变量就变成了 Min(public, protected) = protected 成员。
所以根据上述性质我们可以总结出以下的表格:
类成员 / 继承方式 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
基类的 public 成员 | 变成派生类的 public 成员 | 变成派生类的 public 成员 | 变成派生类的 private 成员 |
基类的 protected 成员 | 变成派生类的 protected 成员 | 变成派生类的 protected 成员 | 变成派生类的 private 成员 |
基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
除此之外,还有以下两点需要注意:
在继承时可以不写继承方式,只在冒号右侧写基类,此时采用默认的继承方式。使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显式地写出继承方式。
在实践中,我们 99% 的情况用到的都是 public 继承,因为这样的话原来在基类中的成员是什么属性在派生类中就是什么属性。用 protected / private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
4. 类模板的继承
注意在类模板的继承时,调用基类的成员函数需要指定其类域。
#include<iostream>
#include<vector>using namespace std;namespace mine
{template<class T>class stack : public vector<T>{public:void push(const T& x){// 基类是类模板时,需要指定⼀下类域, // 否则编译报错:error C3861: “push_back”: 找不到标识符 // 因为stack<int>实例化时,也实例化vector<int>了 // 但是模版是按需实例化的,调用哪个函数才实例化哪个// 也就是说push_back等成员函数还未被实例化,所以找不到 vector<T>::push_back(x);//push_back(x);}void pop(){vector<T>::pop_back();}const T& top(){return vector<T>::back();}bool empty(){return vector<T>::empty();}};
}int main()
{mine::stack<int> st;st.push(1);st.push(2);st.push(3);while (!st.empty()){cout << st.top() << " ";st.pop();}return 0;
}
- 输出
3 2 1
二、基类和派生类的转换
- 在 public 继承中,派生类对象可以赋值给基类的指针/基类的引用。
Student sobj;
// 派⽣类对象可以赋值给基类的指针/引⽤
Person* pp = &sobj;
Person& rp = sobj;
这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
注意我们之前学习引用的时候提到过类型转换的时候会产生临时对象,而临时对象具有常性,对其进行引用时必须加 const。
int a = 1;
const double b = a; // 类型转换, a会产生一个临时对象, b引用的是这个临时对象(具有常性)const string& s = "1111"; // 类型转换
但是在基类与派生类的引用中有所不同,看起来像是一个类型转换,由 Student
转换成 Person
。实则不然,它实际上是一种赋值兼容转换,是直接引用的派生类中“切割”出来的父类的那一部分,中间不会产生临时对象。
- 派生类对象可以赋值给基类的对象。
Person pobj = sobj;
这是通过拷贝构造完成的。
- **基类对象不能赋值给派生类对象,会编译报错 **
sobj = pobj; // ERROR
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI (Run-TimeType Information) 的 dynamic_cast 来进行识别后进行安全转换。(简单提一下,具体后面章节再讲)
三、隐藏
- 在继承体系中基类和派生类都有独立的作用域。
- 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。 (在派生类成员函数中,可以使用基类
::
基类成员显式访问基类的同名成员)。
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 s1;s1.Print();return 0;
};
- 输出
姓名:小李子
身份证号:111
学号:999
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
注意在实际中在继承体系里面最好不要定义同名的成员。
下面我们来看一到例题:
#include<iostream>using namespace std;class A
{
public:void fun(){cout << "func()" << endl;}
};class B : public A
{
public:void fun(int i){cout << "func(int i)" << i << endl;}
};int main()
{B b;b.fun(10);b.fun();return 0;
};
- A 和 B 类中的两个 func 函数构成什么关系?
A. 重载 B. 隐藏 C. 没关系
- 程序的编译运行结果是什么?
A. 编译报错 B. 运行报错 C. 正常运行
分析:
函数重载要满足的条件是函数名相同参数不同,并且要在同一个作用域。现在它们一个在派生类的作用域一个在基类的作用域,因此是隐藏关系,选 B。
调用 b.fun();
的时候编译器会先在 B
类中寻找,而 B
类中恰好有一个名为 func
的函数,但是此时没有传参,编译器就会认为所传的参数的个数过少,因此会编译报错,不会再去 A
类中找。选 A。
四、派生类的默认成员函数
1. 四个常见的默认成员函数
常见的默认成员函数有构造函数、拷贝构造函数、赋值重载函数、析构函数。那么在派生类中,这几个默认成员函数是如何生成以及使用的呢?
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
class Person
{
public:// 可以但看 Person 类中不存在默认构造Person(const char* name): _name(name){cout << "name:" << name << endl;}protected:string _name;
};class Student : public Person
{
public:Student(const char* name, const char* address, int num): Person(name) // 显式调用, _address(address), _num(num){cout << "address:" << address << endl;cout << "num:" << num << endl;}protected:string _address = "xxx";int _num = 1;
};int main()
{Student stu("李四", "xxxx", 2);return 0;
}
- 输出
name:李四
address:xxxx
num:2
- 派生类构造函数会先调用基类的构造函数,再执行派生类的构造函数。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
Student(const Student& s): Person(s) // 调用基类的拷贝构造, _address(s._address), _num(s._num)
{cout << "Student(const Student& s)" << endl;
}
注意这里的 Person(s)
中的 s 是切片,虽然它是 Student
的引用,但是它实际引用的是 Student
中基类的那一部分。
- 派生类的
operator=
必须要调用基类的operator=
完成基类的复制。需要注意的是派生类的operator=
隐藏了基类的operator=
,所以显式调用基类的operator=
,需要指定基类作用域。
Student& operator=(const Student& s)
{if (this != &s){// 构成隐藏,所以需要显式调用Person::operator=(s);_address = s.address;_num = s._num;}return *this;
}
- 由于一些场景下析构函数构成多态的原因,需要基类和派生类析构函数名称相同,于是编译后,底层统一将基类和派生类析构函数名称处理成统一的
destructor
。- 派生类构造函数的调用顺序是 ”先父后子“,而析构函数则是 ”先子后父“。也就是说析构函数会先调用派生类的析构函数,在这个析构函数结束时自动调用基类的析构函数。
2. 实现一个不能被继承的类
如果不希望一个类被继承,那么如何实现?
【方法一】C++98 中,可以采用构造函数私有化的方式。
class Base
{
public:void func5() { cout << "Base::func5" << endl; }protected:int a = 1;private:// C++98 的方法Base(){}
};
【方法二】C++11 新增了一个 final
关键字,final
修饰基类,就不能被继承了。
// C++11的⽅法
class Base final // 使用 final 关键字
{
public:void func5() { cout << "Base::func5" << endl; }protected:int a = 1;private:// C++98的⽅法 /*Base(){}*/
};
五、继承与友元
友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员。
// 前置声明
// 如果没有前置声明,那么 Person 中的友元 Display 函数就找不到 Student 类
class Student;class Person
{
public:friend void Display(const Person& p, const Student& s); // 基类的友元protected:string _name; // 姓名
};class Student : public Person
{// friend void Display(const Person& p, const Student& s);
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;// 编译报错:error C2248: “Student::_stuNum”: ⽆法访问 protected 成员 // 解决⽅案:Display也变成Student 的友元即可 Display(p, s);return 0;
}
六、继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个 static 成员实例。
class Person
{
public:string _name;static int _count;
};int Person::_count = 0;class Student : public Person
{
protected:int _stuNum;
};int main()
{Person p;Student s;// 这里的运行结果可以看到非静态成员_name的地址是不一样的 // 说明派生类继承下来了,父派生类对象各有一份 cout << &p._name << endl;cout << &s._name << endl;// 这里的运行结果可以看到静态成员_count的地址是一样的 // 说明派生类和基类共用同一份静态成员 cout << &p._count << endl;cout << &s._count << endl;// 公有的情况下,父派生类指定类域都可以访问静态成员 cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}
- 输出
000000DC1E6FF7B8
000000DC1E6FF7F8
00007FF707D834C4
00007FF707D834C4
0
0
七、多继承及其菱形继承
1. 继承模型
【单继承】 一个派生类只有一个直接基类时称这个继承关系为单继承。
【多继承】 一个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后继承的基类在后面,派生类成员在放到最后面。
【菱形继承】 菱形继承是多继承的一种特殊情况,如下图。
菱形继承的问题,从上面的对象成员模型构造可以看出。菱形继承有数据冗余和二义性的问题,在 Assistant 的对象中 Person 的成员会有两份。支持多继承就一定会有菱形继承,像 Java 就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。
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"; // 编译报错:error C2385: 对“_name”的访问不明确// 需要显式指定访问哪个基类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "张三";a.Teacher::_name = "张老师";return 0;
}
2. 虚继承
很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,为了解决菱形继承的问题就有了菱形虚拟继承,底层实现就很复杂,性能也会有一些损失。
虚继承只需在继承时冒号右侧加上一个 virtual
关键字即可,可以解决数据冗余和二义性的问题。
class Person
{
public:string _name; // 姓名 // ...
};// 使用虚继承 Person 类
class Student : virtual public Person
{
protected:int _num; //学号
};// 使用虚继承 Person 类
class Teacher : virtual public Person
{
protected:int _id; // 职工编号
};// 教授助理
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};int main()
{// 使用虚继承,可以解决数据冗余和二义性 Assistant a;a._name = "peter"; // OKreturn 0;
}
我们可以设计出多继承,但是不建议设计出菱形继承,因为如果用菱形虚拟继承以后,无论是使用还是底层都会复杂很多。比如说下面这个例子:
class Person
{
public:Person(const char* name):_name(name){}string _name; // 姓名};
class Student : virtual public Person
{
public:Student(const char* name, int num):Person(name), _num(num){}
protected:int _num; //学号};
class Teacher : virtual public Person
{
public:Teacher(const char* name, int id):Person(name), _id(id){}
protected:int _id; // 职工编号};class Assistant : public Student, public Teacher
{
public:Assistant(const char* name1, const char* name2, const char* name3): Person(name3), Student(name1, 1), Teacher(name2, 2){}
protected:string _majorCourse; // 主修课程};int main()
{// 思考一下这里a对象中_name是"张三", "李四", "王五"中的哪一个?Assistant a("张三", "李四", "王五");return 0;
}
在 Assistant
的构造函数的初始化列表中,”调用” 了三个构造函数,但是我们会发现 Student
和 Teacher
的构造函数中也有 Person
的构造函数,这是不是意味着 Person
的构造函数会被执行三次?实际上不是,它只会在 Assistant
中被执行,在其他两个基类中不会被执行。因此 a
中的 _name
应该是王五。
再补充一点,下面这种情况也属于菱形继承,此时如果要使用虚继承的话应该加在 B 和 D 上。
3. IO 库中的菱形虚拟继承
在实际应用中很少会有菱形继承,但是也会有一些例外,比如 IO库。
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};
4. 多继承的指针偏移问题
- 下列说法正确的是:
A. p1 == p2 == p3
B. p1 > p2 > p3
C. p1 == p3 != p2
D. p1 != p2 != p3
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
由于多继承对象在内存中的模型是先继承的基类在前面,后继承的基类在后面,派生类成员在放到最后面。再结合切片的知识,可得三个指针的位置情况如下图所示:
因此选 C。
八、继承与组合
-
public 继承是⼀种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
-
组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
-
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复⽤通常被称为白箱复用 (white-boxreuse)。术语 “白箱” 是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
-
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-boxreuse), 因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于保持每个类被封装。
-
优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不那么绝对,类之间的关系就适合继承 (is-a) 那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承 (is-a) 也适合组合 (has-a),就用组合。
// Tire(轮胎)和Car(⻋)更符合has-a的关系
class Tire
{
protected:string _brand = "Michelin"; // 品牌 size_t _size = 17; // 尺⼨
};class Car
{
protected:string _colour = "⽩⾊"; // 颜⾊ string _num = "陕ABIT00"; // ⻋牌号 Tire _t1; // 轮胎 Tire _t2; // 轮胎 Tire _t3; // 轮胎 Tire _t4; // 轮胎
};class BMW : public Car
{
public:void Drive() { cout << "好开-操控" << endl; }
};// Car和BMW/Benz更符合is-a的关系
class Benz : public Car
{
public:void Drive() { cout << "好坐-舒适" << endl; }
};template<class T>
class vector
{};// stack和vector的关系,既符合is-a,也符合has-a
template<class T>
class stack : public vector<T>
{};template<class T>
class stack
{
public:vector<T> _v;
};int main()
{return 0;
}