时间:2023-01-26 09:45:32 | 栏目:C代码 | 点击:次
C++的继承机制相对其他语言是比较复杂的一种,不同于java只支持单继承,C++不仅支持单继承,也支持多继承,对于多继承中的菱形问题会引发一系列的麻烦,C++的两个重要缺陷,一个是多继承,一个是垃圾回收器。本文将详细讲解C++的单继承和多继承,以及菱形继承的解决方法及原理。
继承是面向对象设计使代码可以复用的重要手段,它允许程序员在保持原有类的基础上进行扩展。被扩展的类称为基类或者父类,扩展生成的类叫做子类或者派生类,继承是类设计层次的复用。
继承的作用是使得子类中既包含父类的成员,也可以包含自己的成员。
class Person { private: string _name; int _age; }; class Student :public Person { private: int _id; };
看这一段代码,其中子类Student继承了父类Person,Student后的public表示的是继承方式。
继承方式和父类的成员属性共同决定了子类中的成员属性。我们用一张表来表示三者之间的关系。
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
我们只需要两点来记忆这个表格:
1.基类的private成员在派生类中无论以什么方式继承都是不可见的。
2.子类中的成员属性取继承方式和父类成员属性中权限小的那个: public>protected>private
表格的说明:
1.不可见的意思不是没有被继承,而是不能使用,在底层继承下来比没有继承下来更方便。
2.在父类中private和protected没有区别,但是在子类中,protected成员可以在类内访问,而private不能,因此可以说protected是为了继承而存在的。
3.如果不写继承方式,如果子类是class定义的,那么默认为private继承,是struct定义的,默认是public继承。
4.不可见与private成员区别:不可见指的是在类内与类外都不能使用,private成员在类内可以使用,在类外不可以使用。
5.不想给子类访问的成员我们设成private。
我们定义了一个父类person和它的派生类student,以上是它们各自的成员。
当我们将一个派生类的对象赋值给基类的对象时,发生的过程我们称之为切片。即只将子类中父类成员赋值过去。当父类中有private成员时,同样会进行切片,只是不显示而已,因此继承中尽量不要定义私有成员。
注意,这种赋值兼容方式仅限于公有继承。
私有继承不支持切片,这是因为对于父类中的public成员,私有或保护继承之后会转变成private/protected类型,而赋值时会发生将派生类对象中的private/protected成员赋值给父类对象中的public成员的现象,但是private/protected成员在类外是不能被访问的,因此不支持私有继承。
Person b; Student a; b = a; Person* ptr = &a; Person& ref = a;
注意一个细节,我们可以使用引用赋值,说明这里并不存在类型转换的行为,因为类型转换中间会产生临时变量,需要使用const引用。
double d; const int& r=d;//发生了类型转换。
先说结论:
父类对象不可以直接赋值给子类对象。
这是因为子类对象中有父类不存在的类型,无法进行赋值。也不能通过所谓的强制类型转换进行赋值。
但是C++支持指针和引用的赋值:
Person b; Student a; a = (Student)b;//不正确 Student* ptr = (Student*)&b;//支持 Student& ref = (Student&)b;//支持
虽然指针和引用可以,但是当指针向下访问的时候超过父类对象的时候会出现问题。
会出现指向空的情况。
基类和派生类都有各自独立的作用域。
如果不同的域内有同名的成员,我们根据就近原则或者指定作用域的方式来指定成员的位置。
隐藏:子类与父类中出现同名成员,子类成员将屏蔽父类成员对同名成员进行直接访问,这种情况叫隐藏,也叫重定向。
注意如果是成员函数的隐藏,只要函数名相同就会构成隐藏,与参数无关。
举一个例子:
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(); }
在这段代码中,Person和Student分别定义了_num,当子类对象中的成员函数直接访问_num时,根据的是就近原则,访问的是子类中的_num,当要访问父类中的_num时,需要使用::来指定类域,就可以进行访问。父类中的_num与子类中的_num构成隐藏。
这段代码打印的结果是:
这里有一道小小的题目,是关于函数隐藏的:
class A { public: void func() { cout << "func" << endl; } }; class B :public A { public: void func(int i) { A::func(); cout << "func(int i)->" << i << endl; } }; void Test() { B b; b.func(10); b.func(); }
提问在Test中的两个函数能否调用成功?
b.func(10)可以调用成功,因为构成了隐藏。
b.func()不能调用成功,会发生变异报错,因为隐藏了调不动。
对于六大默认成员函数我们这里暂时先讨论4种重要的,即:构造函数,析构函数,拷贝构造,赋值运算符重载。
当我们不在子类中书写时,编译器会默认生成。这里只需要记住一句话:
继承下来的成员调用父类的来处理,自己的按基本规则来处理。
以构造函数举例:派生类中的父类成员调用父类中的构造函数,自己的成员按照构造函数自动生成的规则来。
自己写的情况
1.父类没有默认构造函数,需要我们自己写构造函数。
2.子类有资源需要释放,需要我们自己写析构函数。
3.如果子类涉及浅拷贝问题,需要自己写拷贝构造和赋值重载。
构造函数
父类成员调用对应的父类构造函数处理。子类成员按普通类处理。
举一个例子:
class Person { public: Person(string name , int num=2) :_name(name) ,_num(num) {} protected: string _name ; int _num ; }; class Student :public Person { public: Student(int num,string _name,int _num) :_num(num) ,Person(_name,_num) {} protected: int _num; }; int main() { Student s1(2,"zhangsan",2); }
看这一段代码,父类中没有默认构造函数(注意与默认成员函数区分),因此要初始化父类中的对象需要我们自己书写子类中的构造函数。在书写构造函数时,父类对象成员初始化使用父类中的构造函数,子类成员的初始化按正常方式书写即可。
拷贝构造和运算符重载函数
Student(const Student& s) :Person(s) ,_num(s._num) {} Student& operator=(const Student& s) { if (this != &s) { Person::operator=(s);//不指明类域的话会发生自己调自己的情况 _num = s._num; return *this; } } int main() { Student s1(2,"zhangsan",2); Student s2(s1); Student s3 = s2; }
我们可以通过调试来查看结果:
析构函数
析构函数比较特殊,对于父类中的析构函数,我们不需要指定去书写,就像下面这种情况:
//父类中的析构 ~Person() { cout << "~Person" << endl; } //子类中的析构 ~Student() { Person::~Person(); }
注意,析构函数的名字在最后会被统一处理成destructor(),如果不指定类域的话,父类析构函数和子类析构函数会构成隐藏,因此需要指定类域。
对于上述int中的代码,需要析构三个子类对象,打印出的结果是:
我们发现调用了六次父类中的析构函数。这说明每个对象的父类成员都被析构了两次。如果需要释放空间,则一定会报错。
先说结论:我们自己实现子类构造函数时,不需要显示调用父类析构函数,我们显示调用一次,它还会自动调用一次。
下面简单说明一下,为什么程序需要自动调用:
我们知道变量的定义是发生在栈中的,因此就存在构造和析构的顺序问题,栈满足先入后出原则,因此先构造的需要后析构。
在构造的过程中,我们会先初始化父类成员,再初始化子类成员。因此我们需要先析构子类成员,再析构父类成员。
如果先析构父类会打乱栈的顺序,因此编译器会自动调用父类的析构函数。
这个只需要记住两点:
1.友元关系不能继承。
2.静态成员会被继承下来,无论继承多少,静态成员只有一个。
一个类有两个及以上父类时称这个继承关系为多继承。
class Student { public: protected: int _id; }; class Teacher { public: protected: int _course; }; class Assistant:public Student,public Teacher { public: protected: protected: };
我们使用逗号表示分隔,即继承多个父类。可以通过调试来观察子类Assitant的内容:
菱形继承是多继承的一种情况:
具有这样的继承关系的称为菱形继承。
菱形继承出现的问题:从对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
数据冗余指的是类Assistant中会有两份Person的成员,二义性指的是这两份成员每一次调用不知道调用的的是哪一个,需要指定类域。
这段代码表示的就是菱形继承的关系:
class Person { public: string _name; }; class Student:public Person { public: protected: int _num; }; class Teacher:public Person { public: protected: int _id; }; class Assistant:public Student,public Teacher { public: protected: protected: int _course; }; int main() { Assistant a; }
我们通过调试可以观测a中的内容,发现会存在两份Person中的成员:
如果要对这两个Person成员赋值时,需要指定类域。
a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; }
这就是所谓的二义性,在实际中一个人不能有两个名字,对于冗余性来说,如果Person中有一个很大的数组浪费的空间会很多。
虚继承可以解决菱形继承的二义性和数据冗余问题。如上面的继承关系,在Student和Teacher的继承Person时使用的虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
class Student:virtual public Person { public: protected: int _num; }; class Teacher:virtual public Person { public: protected: int _id; };
只需要在菱形的腰部两个父类加入virtual关键词即可。
注意要在菱形的腰部。
当加完之后,在Assistant的对象中,Person类的_name成员就只有一个了。无论是否指定类域,更改的变量都只有一个:
内存演示
要研究虚继承的原理,我们给出一个简化的菱形继承结构,再借助内存窗口窗口观察对象成员的模型。
class A { public: int _a; }; class B:public A { public: int _b; }; class C:public A { public: int _c; }; class D :public B, public C { public: int _d; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
当没使用虚继承(即没有使用virtual时)
我们使用内存窗口来观察内容:
通过观察内存中的布局,我们发现d中的B父类对象和C父类对象中的内容分别是连续存放的,B中有父类A中成员_a的值是1,其自己成员_b的值是3,两者的内存是挨着的,C同理,对于D类中自己的成员_d,放在了内存的最后。
确定d中B类对象和C类对象的存储顺序是根据继承顺序决定的。由于上述代码是class D :public B, public C,因此B类的对象会存在C类的前面。
而当我们给腰部加上virtual构成虚继承之后:
class B:virtual public A { public: int _b; }; class C:virtual public A { public: int _c; };
使用virtual之后,我们发现已经将A中对象_a放入在了最后,因此无论指定不指定类域,改变的都是同一个_a的值。
但同时我们发现内存中多了两行,那么这两行是干什么的呢?
虚基表
从格式来看,这两行显然是都是地址。
我们再开辟一个内存2,向其中输入上面地址,我们发现地址中存储的内容是00 00 00 00,C类对象中同理,这里就不演示了。
这里00 00 00 00的意义在后面多态中会学习到,注意看它的下一个位置存放的是00 00 00 14
这里是十六进制,因此表示的是20这个数字。
再来看内存1:
两者的地址之差刚刚好是20个字节。
因此我们可以知道:在虚继承中,B类对象和C类对象的内存中新加入的是一个地址,分别用于寻找两者与A类型变量的偏移量。B类对象与A类对象的偏移量是20,同理可验证C类对象的偏移量是12。而内存2也有一个专有名词:虚基表
总结:A一般叫做虚基类,在D里面,A类成员放在一个公共的位置,有时B要找A,C要找A,就要通过虚基表中的偏移量进行计算。
比如,当我们再用B类和C类建立两个变量:
B b = d; C c = d;
此时会发生切片处理,需要将d中的A类对象赋值到b和c中,此时就需要使用到虚基表来寻找。
再比如:
B* pb = &d; pb->_a = 10;
pb指向了d的首地址,要更改d中的_a的值,指针pb也需要使用虚基表来进行寻找。
首先我们要对继承和组合进行区分:
继承表示的是子类继承父类,组合表示的是在一个类中定义了另一个类的成员变量。
//继承 class A { public: int _a; }; class B:public A { public: int _b; }; //组合 class C { public: int _c; }; class D { public: int _d; C _obj; };
我们需要明确一点:类之间,模块之间最好是低耦合,高内聚的,因为方便维护。
低耦合:类之间依赖关系越弱越好。
高内聚:内部成员关系紧密。
1.继承对应于白盒:B可以直接使用A中的公有和保护成员,破坏了封装性。
2.组合对应于黑盒:D只能使用C的公有,不能直接使用保护成员。
举一个例子:
如果A中有5个public,5个protected
对于组合来说,非基类只能使用这5个public,基类中的其他成员随便修改都不会影响该非基类。
对于继承来说,基类中一切的改变都会影响子类。
那可以抛弃继承的语法吗?当然是不行的。
多态是建立在继承的基础上的。
1.如果B就是一个A,比如Student是一个Person,我们称这种关系为is-a关系,此时适合使用继承。
2.如果D被包含于C,比如head包含eyes,我们称这种关系为has-a关系,此时适合使用组合。
3.当遇到特殊情况,is-a和has-a都可以讲通时,优先使用组合。
C++的语法复杂在于C++是第一个吃螃蟹的人,很多地方会考虑太多,拿多继承举例,有了多继承就有了菱形继承,有了菱形继承,就有了菱形虚拟继承,底层实现就更为复杂了,所以一般不建议设计多继承,设计了多继承也不建议设计菱形继承。