继承
文章目录
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
以前我们写函数的时候,可以使用一个函数去实现另一个函数,这就是函数层面的复用
而继承就是一种类层面的复用!——像是我们设置一个管理系统——管理系统里面就有很多的角色,每个角色都有各自的基本信息
但是我们发现里面其实每个类都有很多重复的信息!每个类都要进行修改,写各自的构造
这样子太麻烦了!
所以我们可以把这些共有的信息都抽离出来写成一个新的类用来复用,然后其他的特有的信息就各自写在各自的类里面
那么我们如何复用这个类呢?——这时候就得使用继承了!所以继承就是类设计定义层次的复用
#include<iostream> #include <string> using namespace std; class Person { public: Person(string name = "小明", int age = 18, string address = "北京") :_name(name), _age(age), _address(address) { } void print() { cout << "_name:" << _name << endl; cout << "_age" << _age << endl; cout << "_address:" << _address<< endl; } protected: string _name; int _age; string _address; }; class Student :public Person { protected: int _stuid; }; class teacher :public Person { protected: int _jobid; }; int main() { teacher t; Student s; t.print(); s.print(); return 0; }
我们没有在teacher/student类里面定义print函数,但是我们依旧可以使用!
继承不仅仅是继承成员,而且也继承函数!
继承格式
也可以叫父类与子类
继承关系与访问限定符
这就衍生出了9中关系
继承基类成员的访问关系的变化
类成员/继承方式 public继承 protected继承 private继承 基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员 基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员 基类的private成员 派生类中不可见 派生类中不可见 派生类中不可见 基类private成员在派生类中无论以什么方式继承都是不可见的——即不可以使用但是本身却依旧存在于派生类中
#include<iostream> using namespace std; class Person { public: Person(string name = "小明", int age = 18, string address = "北京") :_name(name), _age(age), _address(address) { } void print() { cout << "_name:" << _name << endl; cout << "_age" << _age << endl; cout << "_address:" << _address<< endl; } protected: string _name; string _address; private: int _age;//我们将这个改成私有 }; class Student :public Person { protected: int _stuid; }; class teacher :public Person { protected: int _jobid; }; int main() { teacher t; Student s; t.print(); s.print(); return 0; }
我们发现依旧可以访问这是为什么呢?——记住私有是不允许在派生类里面去继承使用!但是在父类里面的函数任然是可以调用的!我们调用的函数是存在于父类里面的!
class Student :public Person { public: void stu_print() { cout << "_age" << _age << endl; } protected: int _stuid; };
如果我们在子类的函数中使用!那就是无法访问!但是_age依旧是在派生类里面的!
继承方式也可以不用写!——struct默认共有,class默认私有——但是最好还是显示的写出来!
class Student :Person//默认私有 { protected: int _stuid; }; struct teacher:Person// { protected: int _jobid; }; int main() { teacher t; Student s; t.print(); s.print(); return 0; }
print在父类是共有的函数!在私有继承后就变成了private!所以无法外部访问!,但是共有继承后任然是共有所以可以外部使用!
总结
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它 ——基类中的私有本质就是不想让子类继承!但是仍然存在于派生类里面但是无法使用
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private ——可以认为是一种权限的缩小,权限不能放大但是可以缩小
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
- 一般父类里面的通常很少使用私有成员!因为设计出来但却不让使用的情况太少了!一般都是保护!
可以记住父类私有成员继承无法使用!
其他类型的成员遇到权限更小的继承就取里面的权限小的那个!
或者也可以说私有继承后都是私有,保护继承后都是保护,public不变
基类域派生类的对象赋值转换——也叫向上转换
- 派生类对象是可以赋值给基类的对象/基类的指针/基类的引用——又被叫做切片或者切割意思就是将派生类中父类的那一部分赋值过去!——而且之间是不发生类型转换的!
- 基类对象不能赋值给派生类对象!——因为派生类对象一般是比基类对象的大小更大!
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
int main() { Person p; Student s; p = s; //中间不存在类型转换——意味着不存在临时变量! int i = 0; double d = 2.2; i = d; return 0; }
这样也就意味着
int main() { Person p; Student s; Person& rp = s;//这是可以的! int i = 0; double d = 2.2; int& ri =d;//这是不可以的! //const int& ri = d //这样就行! return 0; }
将double类型的变量赋给int类型的引用发生隐式类型转换,会产生一个临时变量!而临时变量是一个常量!发生了权限的放大所以会赋值失败!
但是派生类变量赋值给基类的引用则不会!因为它是直接切割过去!没有发生隐式类型转换!
可以简单的认为派生类是一个特殊的基类!——因为基类里面所含有的派生类都有!
这个就变成了派生类中基类那一部分的别名!可以将这一部分看做是基类使用!
还可以赋值给基类的指针
int main() { Person p; Student s; Person* ptrp = &s; ptrp->_age = 100; return 0; }
被基类指针指向的派生类变量可以将其当初一个父类的变量使用!
这也叫向上转换!是天然可以的!
继承中的作用域
- 在继承的体系中基类和派生类都有独立的作用域
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 **基类::基类成员 **显示访问 )
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
在同一个作用域内是不可以定义同名的变量的!
函数隐藏和函数重载要区分!
同名成员变量
class Person { public: int _num = 1000; }; class Student :public Person { void print() { cout << _num << endl;//在派生类里面的默认是派生类自己的变量! cout << Person::_num << endl;//可以用访问限定符来在子类函数里面的去访问父类的变量! } public: int _num = 100; }; int main() { Person p; Student s; cout << "Person:" << p._num << endl; cout << "student:" << s._num << endl; s.print(); return 0; }
同名成员函数——面试
class A { public: void fun(int i) { cout << "A::fun(int i)" << endl; } }; class B :public A { public: void fun(int i) { cout << "B::fun(int i)" << endl; } }; void test() { B b; b.fun(10);//这个调用的是B自己的变量! } //这个两个fun必然构成隐藏/重定义 class A { public: void fun() { cout << "A::fun()" << endl; } }; class B :public A { public: void fun(int i) { cout << "B::fun(int i)" << i << endl; } }; //这个不构成函数重载!这个构成的是隐藏/重定义 void test() { B b; b.fun(10);//这个调用的是B自己的变量! b.fun();//这个会报错!虽然类内部有个fun(),但是依旧默认调用的是B自己的!因为没有参数所以会编译报错! b.A::fun();//如果想要访问就必须指定作用域 }
函数重载是在同一个作用域下面的!
函数隐藏是在不同作用域下面的!
只要函数名相同那么就是隐藏/重定义!
派生类的默认成员函数
基类的成员函数和以往的正常的成员函数是一样的!
但是派生类的默认成员函数相比正常的默认成员函数更加的复杂!
class Person { public: Person(const string& 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 << " operator=" << endl; if(this != &p) _name = p._name; } ~Person()//析构 { cout << "~Person" << endl; } protected: string _name; }; //派生类 class student :public Person { public: student(int id = 0)//构造 :_id(id) { cout << "student(int id)" << endl; } student(const student& s)//拷贝构造 :_id(s._id) { cout << "student(const student& s)" << endl; } student& operator=(const student& s)//赋值重载 { _id = s._id; } ~student()//析构 { cout << "~student()" << endl; } protected: int _id; };
构造函数
class Person { public: Person(const string& name = "peter")//构造 :_name(name) { cout << "Person()" << endl; } protected: string _name; }; class student :public Person { public: student(int id = 0)//构造 :_id(id) { cout << "student(int id)" << endl; } protected: int _id; }; int main() { student s1; }
我们可以发现是s1不仅调用了自己的构造函数,而且还去调用的基类的构造函数
为什么呢?因为派生类的成员可以分为两个部分一份是基类的,一份是派生类自己的——而基类的成员是通过调用基类的构造函数来进行初始化的!剩下的派生类的成员才是使用派生类自己的构造函数进行初始化!
假如基类没有默认构造那么我们是不是就应该在派生类里面的去初始化呢?
class Person { public: Person(const string& name)//构造 :_name(name) { cout << "Person()" << endl; } protected: string _name; }; class student :public Person { public: student(int id,const string& name)//构造 :_id(id), _name(name) { cout << "student(int id)" << endl; } protected: int _id; }; int main() { student s1(1,"peter"); }
这样子是不行的!基类的成员就必须使用基类的构造函数进行初始!
所以必须显示的去调用基类的构造函数!
class student :public Person { public: student(int id,const string& name)//构造 :_id(id), Person(name)//显示的调用构造函数 { cout << "student(int id)" << endl; } protected: int _id; }; int main() { student s1(1,"peter"); }
拷贝构造
class Person { public: Person(const string& name = "peter")//构造 :_name(name) { cout << "Person()" << endl; } Person(const Person& p)//拷贝构造 :_name(p._name) { cout << "Person(const Person& p)" << endl; } protected: string _name; }; //派生类 class student :public Person { public: student(int id,const string name)//构造 :_id(id), Person(name) { cout << "student(int id)" << endl; } student(const student& s)//拷贝构造 :_id(s._id), Person(s) { cout << "student(const student& s)" << endl; } protected: int _id; }; int main() { student s1(1, "小明"); student s2(s1); return 0; }
当我们不写拷贝构造的时候
生成的默认拷贝构造,父类的那一部分会自动的去调用基类的拷贝构造!剩下的部分都是进行按字节拷贝!
如何我们显示的去写了拷贝构造我们就要去显示的调用基类的拷贝构造!不能去自己去处理!
如果不去显示的调用的话,只会处理派生类自己的成员变量!
student(const student& s)//拷贝构造 :_id(s._id), Person(s)//发生了切割 { cout << "student(const student& s)" << endl; }
我们发现了 Person的拷贝构造类型是==Person&==但是传过去的s类型是 studen& 此时就发生了切割!
赋值重载
class Person { public: Person(const string& 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=" << endl; if(this != &p) _name = p._name; return *this; } protected: string _name; }; //派生类 class student :public Person { public: student(int id, const string name)//构造 :_id(id), Person(name) { cout << "student(int id)" << endl; } student(const student& s)//拷贝构造 :_id(s._id), Person(s) { cout << "student(const student& s)" << endl; } student& operator=(const student& s)//赋值重载 { Person::operator=(s);//这里同样的发生切割 cout << "student:operator=()" <<endl; if(this != &p) _id = s._id; return *this; } protected: int _id; }; int main() { student s1(1, "小明"); student s2(s1); return 0; }
和拷贝构造一样如果不写赋值运算符重载,编译器生成的就会去自动的调用基类的赋值运算符去给基类的那一部分赋值!剩下的派生类的那一部分就按字节拷贝
如果我们显式的写了这个赋值运算符重载我们同样的要去进行显示的调用
student& operator=(const student& s)//赋值重载 { //operator=(s);//不可以怎么写因为同名函数发生了隐藏!我们怎么调用是在调用这个函数本身!会发生无限递归! //要使用作用域指定! Person::operator=(s);//这里同样的发生切割 cout << "student:operator=()" <<endl; if(this != &p) _id = s._id; return *this; }
析构函数
class Person { public: Person(const string& 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=" << endl; if(this != &p) _name = p._name; return *this; } ~Person() { cout << "~Person()" << endl; } protected: string _name; }; //派生类 class student :public Person { public: student(int id, const string name)//构造 :_id(id), Person(name) { cout << "student(int id)" << endl; } student(const student& s)//拷贝构造 :_id(s._id), Person(s) { cout << "student(const student& s)" << endl; } student& operator=(const student& s)//赋值重载 { Person::operator=(s);//这里同样的发生切割 cout << "student:operator=()" <<endl; if(this != &p) _id = s._id; return *this; } ~student() { //~Person();//这样写是错误的! //Person::~Person(); cout << "~student()" << endl; } protected: int _id; }; int main() { student s1(1, "小明"); student s2(s1); return 0; }
按照上面的经验我们调用派生类的析构去处理派生类的部分,显示的调用基类的析构去处理基类的部分!——但是这样写其实是错误的!
为什么?因为子类的析构函数和父类的析构函数默认构成隐藏关系!(由于多态关系的需求,所有的析构函数都会被特殊处理变成destructor的函数名!所以构成隐藏关系)
想要调动Person的析构函数必须使用作用域
我们发现调了两次Person的析构函数!——调用了两次就会出现多次释放!
这是为什么?因为我们自己写的时候,基类的析构函数会自己去调用而不用我们显示的去调用!——在派生类的析构函数调用完毕后就去自动调用基类的析构函数!
从汇编我们也可以看出来!最后编译器自己去调用了一次Person的析构函数!
那为什么是这样子的呢?
class A { private: int _num; } int main() { A a; A aa; }
上面的代码我们可以知道a先构造然后aa再构造!aa先析构 a再析构!
而类也是一样的!类分为两部分 基类的部分先构造,派生类的部分后构造!派生类的部分先析构!基类的部分后析构
因为如果我们去显示的调用析构的话就无法保持这个顺序了!所以就不让我们去显示的调用,让编译器在派生类析构结束后自己去调用!
总结
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能 保证派生类对象先清理派生类成员再清理基类成员的顺序
- 派生类对象初始化先调用基类构造再调派生类构造
- 派生类对象析构清理先调用派生类析构再调基类的析构
- 因为多态场景析构函数需要构成重写,重写的条件之一是函数名相同那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系
继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成元
class B; class A { friend void test(A a,B b); private: int _num = 10; }; class B :public A { private: int _id = 100; }; void test(A a,B b) { cout << "i am A's friend!" << endl; cout << a._num <<endl; //cout << b._id << endl;//是不能访问子类的私有成员! cout << b._num <<endl;// _num 是属于父类的!所以可以访问! } int main() { test(A(), B()); return 0; }
想要使用子了的成员函数就要将函数也变成子类的友元!
继承与静态成员
class Person { public: Person() { _count++; } void fun() { cout << this << endl; } string _name; public: static int _count; }; int Person::_count = 0; class Student :public Person { protected: int _id; }; int Studen::_count = 0;//也可以这样初始化但是没有必要,最好还是父类初始化! int main() { Person p; Student s; p._name = "小明"; s._name = "小红"; cout << &p._count << endl; cout << &s._count << endl; Person* ptr = nullptr; //这是可行的!因为静态成员不在对象里面!其实没有发生解引用 cout << ptr->_count << endl; //这也是可行的!因为函数也不再对象里面!其实没有发生解引用 ptr->fun(); //这是不行的!因为_name是在对象里面发生了解引用 ptr->_name; (*ptr)._count; (*ptr).fun(); //这两个也都不会报错!因为这个和上面的-> 等价的!-> 与* 不一定会发生解引用! return 0; }
像是上面的p与s,里面都有一个_name 但是不是同一份的 _name 但是,子类与父类都自己的对象模型!
但是静态成员变量不一样!
我们可以发现两个对象的_count地址一样的!
为什么呢?——因为存储的区域是不一样的!普通成员变量都是存在对象里面的!
但是静态成员是存储在静态区(全局区)里面的!
静态成员变量是属于整个类的所有对象!同时也属于所有的派生类及其对象!
下面的代码我们也可以看出来静态成员变量与函数都是不存在与对象里面的!
class Person { public: void fun() { cout << this << endl; cout << _name <<endl;//在nullptr里面这个会报错!因为发生了解引用在类里面的调用成员变量本质就是this -> _name cout << _count <<endl;//这个不会报错! } }
ptr->_count; ptr->fun(); //都是两两等价的! (*ptr)._count; (*ptr).fun();
从汇编上我们也可以看出来并没有什么差别!都不会去解引用去对象里面找,都是在代码段里面找这个函数的地址然后call,这个 -> 的作用仅仅只是用来传递this指针(一般通过压栈来进行传递)——主要看要不要到对象里面找东西!
多继承
单继承: 一个子类只有一个直接父类的时候!就叫做单继承——如下
多继承: 一个子类有两个或者以上的直接父类时称这个关系为多继承!
多继承的方式就是在后面加个逗号就可以继续写要继承的父类!
菱形继承:菱形继承是多继承的一种特殊的形式——这是一个很大的坑!
菱形继承的问题
我们可以看一下菱形继承后的类的结构
发现我们Person类在assistant类里面是有两份的!出现了数据冗余和二义性!
class Person { public: int _id; }; class Student :public Person { protected: int _num; }; class Teacher :public Person { protected: int _jobid; }; class assistant :public Student, public Teacher { public: string _major; }; int main() { assistant a; //a._id;//出现了二义性! a.Student::_id;//可以通过指定作用域的防止二义性! a.Teacher::_id; return 0; }
访问对象不明确:
通过作用域里解决二义性:
但是这个方式依旧无法解决数据冗余的问题!
所以应该要怎么解决?——虚继承!
虚继承
C++引入的关键字virtual来实现虚继承!
谁引发了数据冗余就谁进行虚继承!
class Person { public: int _id; }; class Student :virtual public Person//要在腰部进行虚继承! { public: int _num; }; class Teacher :virtual public Person { protected: int _jobid; }; class assistant :public Student, public Teacher { public: string _major; }; int main() { assistant a; a._id = 100;//二义性问题得到了解决! a.Student::_id;//仍然可以这样访问! a.Teacher::_id; return 0; }
vs此时的监视窗口其实已经不准确了!因为虽然看上去有三个_id但是其实此时类里面只有一个 _id存在!
虚继承是如何实现
class A { public: int _a; }; class B :virtual public A { public: int _b; }; class C :virtual public A { public: int _c; }; class D :public B, public C { public: int _d; }; int main() { D d; d._b = 1; d._c = 2; d._d = 3; d.B::_a = 4; d.C::_a = 5; d._a = 6;//虚拟继承情况下 //虚继承下对象的模型也会发生改变! B b; b._a = 1; b._b=2; B* ptr = &b; ptr->_a = 10; ptr = &d; ptr->_a = 20; return 0; }
此时因为vs的监视窗口已经不准确了!所以我们要通过内存窗口看这个真实的对象模型!
在不在virtual的菱形继承的情况下——其内存结构是十分简单的!
![]()
这是菱形虚拟继承后的对象模型
我们可以看到原来的B,C的_a的位置的值变成了两个奇怪的数值,然后A类被放在了最下面!
虚继承下的类型B的对象的模型
我么发现对象b在虚继承后向量_a也是保存在最下面
同时也保存了一个地址!那个地址里面也存着一个偏移量!
我们可以发现ptr既可以指向切片也可以指向一个正常的对象!但是虚基类对象在一般对象里面和切片里的位置其实都是不一样的!正常的类对象是在最下面没有错,但是如果是一个切片就不清楚位置在哪里了!可能是在指针能看到的空间的最下面,也可能隔了好几个位置!
所以有了这个偏移量,就可以无论是切片还是正常的情况,都是使用这个偏移量去找虚基类对象就没有问题了!
这个存偏移量的空间我们称之为虚基表(和虚表要区分)!那个指向虚基表的指针就是虚基表指针!
因为多了一步用偏移量找,所以虚继承会导致一定的性能损失!
从汇编我们可以看到访问a比访问b更加的复杂
上面的看上去虚继承后,比起原先占用的内存空间似乎更大了?这是为什么,虚继承不是为了解决内存冗余吗?——其实是因为我们的虚基类太小了!才4个字节,导致了看上去更加的浪费了!当有一个很大的虚基类对象的时候,例如100 字节,那么如果不是虚继承就多浪费了100字节,但是如果是虚继承,我们就可以在B,C类里面存一个指针来指向同一个虚基类对象!用8个字节的消耗节省了92字节,但是那么节省空间的效果就显示出来了!
iostream就是一个典型的菱形继承!
虚继承如何解决数据冗余和二义性——要从两个点出发,一个是对象模型,是从腰部继承,然后将虚基类放在最下面,为了方便的找这个虚基类就有了一个虚基表里面存量偏移量,案例就是上面的指针指向正常的对象和指针指向一个切片,无论是指向切片还是正常类型,都可以通过偏移量来找到!
继承和组合
- public 继承是一个is-a的关系——也就是说每一个派生类对象都是一个基类对象!
- 组合是一种has-a——例如B组合了A,就是说每一个B对象都有一个对象A
//继承 class X { int _x; }; class Y :public X { int _y; }; //组合 class M { int _m; }; class N { M _m; int _n; };
继承和组合都是完成了复用,但是继承相比访问有更大的权限!
组合只能使用public成员,但是继承可以访问public成员和protected成员!
所以继承又被叫做——白箱复用!
白盒是指知道底层实现,根据底层实现来进行设置!
组合又被叫做——黑盒复用!
黑盒是不知道底层实现,从功能角度来进行设置!
如果有一个场景既能用组合也可以用继承那么我们倾向于使用组合!
因为组合耦合度低!而继承的耦合度高!
这个更符合我们高内聚,低耦合的概念!
耦合度高的缺点就是类和类的关联性高!一旦修改了基类那么派生类就有很大概率会被影响到!耦合度越高影响越大!
继承就像是跟团团游——任何所有人一起行动任何一个人都会影响到整体
组合就像是跟团自由行——只有一开始的时候是在一起的,后面的就是自己自由行动!就像是组合只有一部分耦合在一起,剩下的大部分是解耦的!
但是也不是组合一定比继承好!只是说组合和继承都可以使用的时候我们更加倾向于使用组合!——要看情况是is-a关系还是has-a关系!
例如Person类和Student类,就是is-a关系!
has-a 关系就像是头和眼睛,头上有眼睛,但是头不是眼睛!