C++进阶(1)——继承
目录
继承的相关概念
继承的定义
定义的格式
继承和访问限定符的分类
继承基类成员访问方式的变化
默认的继承方式
基类和派生类之间的转换
继承中的作用域
派生类的默认成员函数
继承和友元
继承和静态成员
继承的方式
菱形虚拟继承
菱形虚拟继承的原理
继承和组合
继承的相关概念
继承机制是我们面向对象程序设计使得代码可以复用的最重要的手段,它允许我们在保持原有的类的特性的基础上进行扩展,增加了方法(成员函数)和属性(成员变量),这样做产生的新的类,我们就称之为派生类。
继承呈现了面向对象程序设计的层次结构,这也体现了我们的代码由简单到复杂的认知过程,以前我们所接触的复用都是函数层次,而我们的继承是类设计层次的复用。
下面我们还是来举一个学生和老师继承的栗子来理解:
#include <iostream>
using namespace std;
class Person {public:void identity() {cout << _name << endl;}protected:string _name = "张三";int _age = 18;
};
class Student : public Person {public:void study() {cout << "study" << endl;}protected: int _stuid;
};
class Teacher : public Person {public:void teaching() {cout << "teaching()" << endl;}protected:string title; // 职称
};
int main() {Student s;Teacher t;s.identity();t.identity();return 0;
}
在这个栗子中我们的继承关系如图:
继承之后我们的父类Person的成员包括了成员函数和成员变量,都会变成子类的一部分(权限允许的情况下,下面就会讲)。
继承的定义
定义的格式
我们这里的Person就是我们的基类,也叫做是父类;我们的Student和Teacher都是派生类,常被称之为子类。
图示如下:
继承和访问限定符的分类
图示:
继承基类成员访问方式的变化
我们实际上在继承的时候,我们的基类不同限定符和不同继承方式继承到派生类后,我们的派生类的访问方式是有变化的。
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
我们这里观察发现了了一个比较明显的访问限定符的权限的大小关系:public > protected > private,规则如下:
1、在我们的基类中的是public或是prtoected修饰的成员的时候,在派生类总的访问的方式就是在基类访问方式和继承方式中权限最小的那个。2、在我们的基类中的是private修饰的成员的时候,我们的派生类不管是什么继承方式都是不可见的。
敲黑板:
基类的private成员在我们的派生类中不可见说的是:我们的派生类是不可以访问我们基类的parivate成员的。
默认的继承方式
我们在使用继承的时候,我们如果不指定继承的方式的话,我们是会有默认的继承方式的,对于我们的class来说,我们的默认继承方式就是我们的private,使用struct时我们的默认继承方式就是我们的public。(这里其实很好记,就是我们这两种结构默认的成员的限定符)。
下面我们来举一个栗子:
对于我们的class:
class Person {public:string _name = "LiSi";
};
// 派生类
class Student : Person {public:void print() {cout << _name << endl;}private:int _stuid = 0;
};
int main() {Student a;a.print();cout << a._name << endl;return 0;
}
测试效果:
对于类里面:
对于类外面:
对于我们的struct:
// 基类
class Person {public:string _name = "LiSi";
};
// 派生类
struct Teacher : Person {void print() {cout << _name << endl;}private:string _title = "教师";
};
int main() {Teacher a;a.print();cout << a._name << endl;return 0;
}
测试效果如图:
基类和派生类之间的转换
我们的派生类对象实际上是可以复制给我们的基类对象、基类指针或是基类应用的,在这个过程中会发生基类和派生类之间的赋值转换。
比如下面的栗子:
// 基类
class Person {protected:string _name;string _sex;int _age;
};
// 派生类
class Student : public Person {protected:int _stuid;
};int main() {Student s;Person p = s;Person* ptr = &s;Person& ref = s;return 0;
}
我们称这种现象叫做切片,寓意就是把我们的派生类的基类部分切割出来(三种情况都是切割了之后的赋值)。下面是三种情况的图示:
派生类对象赋值个了基类对象:
派生类对象赋值给了基类指针:
派生类对象赋值给了基类的引用:
敲黑板:
我们这里基类对象不能赋值给派生类对象,基类的指针可以通过我们的强制类型的转换给派生类指针,但是这个时候我们的基类指针就必须是指向派生类对象才是安全的。
继承中的作用域
隐藏的规则:
1、在继承的体系中我们的基类和派生类是有自己独立的作用域。
2、派生类和基类有同名成员,派生类的成员将会屏蔽基类对于同名成员的直接访问,这种行为叫做隐藏。
3、需要注意的是,如果是我们的成员函数进行的隐藏,只要我们的函数名一样就可以构成隐藏了。
4、实际应用的时候,我们在继承体系中最好是不要定义同名的成员。
下面我们还是来举一个栗子:
// 父类
class Person {protected:string _name = "LiSi";int _age = 18;
};
// 子类
class Student : public Person {public: void Print() {cout << "age: " << _age << endl;}protected:int _age = 20;
};int main() {Student s;s.Print();return 0;
}
测试效果如图:
我们可以看到这个时候,我们访问的是我们的子类中的_age成员。
如果我们想要访问我们的父类中的_age成员,我们就要使用我们的作用域限定符了。
代码如下:
// 子类
class Student : public Person {public: void Print() {// cout << "age: " << _age << endl;cout << Person::_age << endl;}protected:int _age = 20;
};
测试效果如图:
下面就是我们对于我们的成员函数的隐藏,也就是使用我们的同名函数。
示例代码如下:
// 父类
class Person {public:void Print(int x) {cout << "person: " << x << endl;}
};
// 子类
class Student : public Person {public: void Print(int x) {cout << "student: " << x << endl;}
};int main() {Student s;s.Print(666);s.Person::Print(888);return 0;
}
测试效果如图:
我们这里来有一个关于继承作用域的选择题:
代码如下:
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
类中的fun
函数构成的关系
A. 重载 B. 隐藏 C.没关系
答案:B
第二问:下面程序的编译运行结果是什么?
A.编译报错 B.运行报错 C.正常运行
答案:A
派生类的默认成员函数
我们不写编译器会自动生成的函数就是我们的默认成员函数了,类里面的默认成员函数如下:
那么我们的派生类的成员函数有什么不一样的呢?
我们下面来对比一下:
对于我们的基类:
// 基类
class Person {public:// 构造函数Person(const string& name = "LiSi"): _name(name) {cout << "Person(const string& name = 'LiSi')" << endl;}// 拷贝构造函数Person(const Person& p) :_name(p._name){cout << "Person(const Person& p)" << endl;}// 赋值运算符重载函数Person& operator=(const Person& p) {cout << "operator=(const Person& p)" << endl;if(this != &p) {_name = p._name;}return *this;}// 析构函数~Person() {cout << "~Person" << endl;}private:string _name;
};
对于我们的子类:
// 子类
class Student : public Person {public:// 构造函数Student(const string& name, int stuid):Person(name),_stuid(stuid){cout << "Student(const string& name, int stuid)" << endl;}// 拷贝构造函数Student(const Student& s) :Person(s) // 使用了切片, _stuid(s._stuid){cout << "Student(const Student& s)" << endl;}// 赋值运算符重载函数Student& operator=(const Student& s) {cout << "operator=(const Student& s)" << endl;if(this != &s) {Person::operator=(s);_stuid = s._stuid;}return *this;}// 析构函数~Student() {cout << "~Student()" << endl;}private:int _stuid;
};
我们总结一下:
1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2、派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3、派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域。
4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5、派生类对象初始化先调用基类构造再调派生类构造。
6、派生类对象析构清理先调用派生类析构再调基类的析构。
7、因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
如何实现一个不可以被继承的类呢?
这个思想经常会被用来考察选择题,事实上我们有两种常见的方案:
方案1:我们可以将我们的基类的构造函数私有化(private),那么我们的基类就构造不了对象了,我们的派生类的构成又是必须要调用我们的基类构造函数的,但是这个时候我们基类的构造函数私有了,派生类看不见了,这个时候我们的派生类就不能实例化对象了。
方案2:C++11里面新增了一个关键字叫final,这个关键字可以用来修饰我们的基类,派生类就不能继承了。
继承和友元
我们的友元关系是不可以被继承的,也就是说我们的基类的友元是不可以访问我们派生类的私有和保护成员的
比如下面的这个代码:
class Student; // 前置声明
class Person {public:friend void Display(const Person& p, const Student& s);protected:string _name;
};
class Student : public Person {protected:int _stuid;
};
void Display(const Person& p, const Student& s) {cout << p._name << endl; // 可以访问cout << s._name << endl; // 可以访问cout << s._stuid << endl; // 不可以访问
}
int main() {Person p;Student s;Display(p, s);return 0;
}
测试效果:
这个时候如果我们想要Display函数能够访问成功,我们只要在Student中也写一个友元声明即可了:
class Student : public Person {public:friend void Display(const Person& p, const Student& s);protected:int _stuid;
};
测试效果:
继承和静态成员
基类中如果定义了一个static静态成员,那么整个的继承体系中就只有一个这样的成员,无论派生除了多少个派生类,我们都是只有一个static成员的实例。
我们这里可以写个代码来验证一下:
#include <iostream>
using namespace std;
class Person {public:Person() {_count++;}public:string _name;static int _count;
};int Person::_count = 0;class Student : public Person {protected:int _stuid;
};int main() {Person p1;Person p2;Student s1;Student s2;cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}
测试效果:
我们这里也是可以对比一下它们的地址的差异来验证的,代码如下:
#include <iostream>
using namespace std;
class Person {public:Person() {_count++;}public:string _name;static int _count;
};int Person::_count = 0;class Student : public Person {protected:int _stuid;
};int main() {Person p1;Person p2;Student s1;Student s2;// cout << Person::_count << endl;// cout << Student::_count << endl;cout << &p1._name << endl;cout << &s1._name << endl;cout << &p1._count << endl;cout << &s1._count << endl;return 0;
}
测试效果如图:
继承的方式
单继承:一个子类对应一个父类
多继承:一个子类有两个及以上的父类
菱形继承:多继承的一种特殊情况
我们这里重点还是讨论一下这个菱形继承的问题,其实这个问题在Java中是不存在的,这种继承的方式存在明显的数据的冗余和二义性问题。
比如下面这个栗子:
#include <iostream>
using namespace std;
class Person {public:string _name;
};
class Student : public Person {protected:int _stuid;
};
class Teacher : public Person {protected:string title;
};
class Assistant : public Student, public Teacher {protected:string _assid;
};int main() {Assistant a;a._name = "LiSi";return 0;
}
测试效果:
其实我们的语法检查也发现了问题所在,那就是我们我们的Sudent和Teacher当中都是继承了Person的,所以我们的Student和我们的Teacher都是有_name成员的,所以我们直接访问我们的Assistant对象的_name成员的时候会有二义性在。
我们这里比较好的解决方案就是在我们的代码中显示地指定我们的Assistant是哪一个父类的_name成员。
代码如下:
int main() {Assistant a;a.Student::_name = "LiSi";a.Teacher::_name = "WangWu";return 0;
}
但是还是不能消除我们的数据冗余,因为在我们的Assistant的对象的Person成员还是会有两份。
菱形虚拟继承
我们为了解决我们的菱形继承的二义性和数据冗余的问题就出现了虚拟继承,根据上面的这个栗子,我们只需要在Student和Teacher继承Person的时候使用虚拟继承即可解决问题了。
代码如下:
#include <iostream>
using namespace std;
class Person {public:string _name;
};
class Student : virtual public Person {protected:int _stuid;
};
class Teacher : virtual public Person {protected:string title;
};
class Assistant : public Student, public Teacher {protected:string _assid;
};int main() {Assistant a;a._name = "LiSi";return 0;
}
测试效果如下:
这个时候我们的代码没有报错了。
这个时候我们的二义性就解决了,我们我们这个时候去访问Assistant的父类Student的_name成员和父类Teacher的_name成员的时候得到的是同一个结果。
int main() {Assistant a;a._name = "LiSi";cout << a.Student::_name << endl;cout << a.Teacher::_name << endl;return 0;
}
那么这个时候有没有解决我们之前所提到的数据冗余的问题呢?
我们这里可以通过变量的指针来看看是不是一样的,我们通过下面的代码可以知道解决了数据冗余的问题。
代码如下:
菱形虚拟继承的原理
我们这里可以看一看我们的菱形继承中各个类的成员在内存中的分布情况。
代码如下:
#include <iostream>
using namespace std;class A {
public:int a; // 类 A 的成员变量
};class B : public A {
public:int b; // 类 B 的成员变量
};class C : public A {
public:int c; // 类 C 的成员变量
};class D : public B, public C {
public:int d; // 类 D 的成员变量
};int main() {D d;d.B::a = 1;d.C::a = 2;d.b = 3;d.c = 4;d.d = 5;return 0;
}
这也就说明了我们的D类中的各个成员在内存中的分布情况是下面这种情况:
那么对于我们的菱形虚拟继承是什么情况呢?
测试代码如下:
#include <iostream>
using namespace std;class A {
public:int a; // 类 A 的成员变量
};class B : virtual public A {
public:int b; // 类 B 的成员变量
};class C : virtual public A {
public:int c; // 类 C 的成员变量
};class D : public B, public C {
public:int d; // 类 D 的成员变量
};int main() {D d;d.B::a = 1;d.C::a = 2;d.b = 3;d.c = 4;d.d = 5;return 0;
}
我们可以通过我们的内存窗口看到我们D类中各个成员在内存中的分布情况:
我们这里发现我们D类对象里面的a成员被放在了内存的最后面而且时候2被存了下来(第一次是1但是我们第二次赋值给覆盖了,只能有一个a),原来存放两个a成员的地方变成了两个指针,这里的两个指针被称之为虚机表指针,它们分别指向了一个虚机表。这里可以看到虚机表里面有两个值(第一个值是我们为多态的虚表预留的存放偏移量的位置,第二个值是我们当前类对象位置距离公共虚基类的偏移量。)
我们这里进行切片操作之后我们的成员分布还是会保持上面的分布:
示例:
int main() {D d;d.B::a = 1;d.C::a = 2;d.b = 3;d.c = 4;d.d = 5;B b = d;return 0;
}
分布情况
继承和组合
我们这里简单地理解就是我们的public继承是一种is-a的关系,也就是我们的每一个派生类对象都是一个基类对象;组合就是一种has-a的关系了,如果是B组合了A,那么每个B对象中都会有一个A对象。
我们这里还是来举出两个栗子:
第一个就是我们的is-a的关系了,也就是我们的继承。
代码如下:
这个代码我们不难理解,就是我们的宝马和我们的车是is-a的关系,所以这里用了继承。
class Car {protected:string _colour;string _num;
};
class BWM : public Car {public:void Drive() {cout << "BWM" << endl;}
};
第二个就是我们has-a的关系了,也就是我们的组合关系。
代码如下:
这个代码也不难理解,我们的轮胎和我们的车是有has-a的关系的,并且我们的车一般是有四个轮子的。
class Tire {protected:string _brand;string _size;
};
class Car {protected:string _colour;string _num;Tire _t1;Tire _t2;Tire _t3;Tire _t4;
};
敲黑板:
我们一般的两个类既可以是is-a的关系也可以是has-a的关系,我们一般优先使用组合。
原因如下:
1、继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
2、对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-boxreuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
3、优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合。