他日若得脱身法,生吃黄莲苦也甜。
一、继承的概念及定义(类设计层次的代码复用)
1.继承概念
1.
继承是面向对象语言进行代码复用的一种手段,以前我们所接触的代码复用都是函数复用,譬如模拟实现vector的时候,尾插尾删都是复用了insert和erase接口。而继承提供的是一种类设计层次的代码复用,在原有类中增加扩展并实现新的功能,这样所产生的类叫做派生类或子类,原有类被称为基类或父类。
2.
例如下面代码中的student和teacher都可以继承person类,老师和学生不同的是学生是学号_stuid,老师是工号_jobid,但是相同的是老师和学生都有年龄和姓名,所以可以在原有person类的基础上增加新特性继承person类。
3.
继承过后,基类的成员函数和变量都变为子类的一部分,在子类中可以访问到基类的成员函数或变量。
#include <iostream> #include <string> using namespace std; class Person { public: void Print() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; } protected: string _name = "peter"; // 姓名 int _age = 18; // 年龄 }; // 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。 //下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。 class Student : public Person { protected: int _stuid; // 学号 }; class Teacher :public Person { protected: int _jobid; // 工号 }; int main() { //Person叫做父类或基类,Student和Teacher叫做子类或派生类 Student s; Teacher t; s.Print(); t.Print(); //基类中的私有就是不想给派生类继承。 //继承的目的就是让子类去使用基类的成员,所以一般情况下基类不会将成员设置为私有,设置为保护更常见, //class默认访问限定符和继承方式都是私有,struct默认访问限定符和继承方式都是公有。 //实际当中很少使用保护继承和私有继承,公有继承最为常见。 return 0; }
2.继承关系和访问限定符
1.
继承的格式就是在子类类名后面增加冒号和继承方式以及基类名,其中继承方式可分为公有保护私有三种,基类内部的访问限定符也可分为公有保护私有三种,所以组合后子类中对于基类成员访问方式的变化就有9种。
2.
基类的private成员无论以任何方式继承到子类,子类都是无法访问到基类的private成员的,能够访问到基类的private成员的只有基类内部才可以。
如果基类的某些成员不想被类外面访问到,但是允许子类访问,那就将基类成员设置为protected。从这里就可以看出protected限定符是为了继承的特性才被设计出的,否则如果没有继承这种特性,公有和私有两种其实就已经够用了。
除基类的私有成员无论以任何方式被继承都无法被访问外,其他成员和继承方式都遵循最小权限的原则,公有>保护>私有,在两种权限中找出最小权限,则基类的成员访问限定符被继承到子类后的访问限定符为此最小权限。
3.
如果不显示写出继承方式,则class定义的类默认是私有继承,struct定义的类默认是公有继承,不过最好还是显示的写出继承方式。
4.
实际运用中,公有继承最为常见,私有继承和保护继承并不常见,因为你继承的目的就是想让子类能够访问到基类的某些成员,并且保护继承下来的成员只能在派生类中进行使用,派生类外都无法访问到基类的公有成员函数,所以实际中扩展维护性不强,采用公有继承最为常见,几乎很少使用私有和保护继承。
二、公有继承中的基类和派生类对象的赋值转换
1.
派生类对象可以直接赋值给基类对象/基类指针/基类引用,基类的对象可直接得到派生类对象中基类成员的那一部分,而指针或引用是直接指向或者引用到派生类对象中基类成员那一部分。
2.
赋值的过程并不会产生临时变量,这里的赋值是一个天然的过程,有一个形象的说法叫做切割或切片赋值,形容将子类中基类成员进行切割赋值给基类。
注意只能向上赋值,而不能向下赋值,基类对象不能赋值给子类对象,这个也很好理解,子类中的某些成员父类没有,那还怎么进行赋值呢?
3.
有一个例子可以证明赋值过程是天然的,比如下面代码中的对临时变量的常引用问题,如果有临时变量产生,则子类对象给基类对象赋值引用时,必须用常引用,但是可以看到,不需要用常引用,那就说明不会有临时变量产生,赋值过程是天然的。
class Person { protected: string _name; // 姓名 string _sex; // 性别 public: int _age; // 年龄 }; class Student : public Person { public: int _No; // 学号 }; int main() { Person p; Student s; // 中间不存在类型转换,不产生临时变量 p = s; Person& rp = s;//如果有临时变量,这里普通引用一定会报错。子类可以认为是特殊的父类 // rp变为子类中父类那一部分的别名,所以这里有人喜欢叫做切割或切片。 rp._age++; Person* ptrs = &s; ptrs->_age++;//这里++的都是子类中父类那部分的_age。 int i = 1; double d = 2.2; i = d;//类型转换中间会产生临时变量,临时变量的类型是int类型 // int& ri = d;//ri不能变成d的别名,因为中间产生的临时变量具有常性,需要用const引用 //基类与派生类对象的赋值转换也叫做向上转换,但是不能向下转换,因为父类缺少子类中特殊的那一部分,无法进行赋值转换。 //向上转换的过程是天然的,子可以给父,但父不可以给子。 return 0; }
三、继承中的作用域(不同作用域的隐藏或重定义)
1.
到现在为止,我们已经学过很多域了,比如局部域、全局域、类域、命名空间域等等,派生类和基类的作用域当然也是不同的,如果继承中基类和子类有名字相同的成员函数或变量,则子类会屏蔽父类的这些同名成员,如果调用则优先调用子类的同名成员,这样的情况被称为隐藏或重定义。
class Person { protected: string _name = "小李子"; // 姓名 int _num = 111; // 身份证号 }; class Student : public Person { public: void Print() { cout << " 姓名:" << _name << endl; cout << " 身份证号:" << Person::_num << endl;//可以指定类域访问Person类里面的_num成员变量。 cout << " 学号:" << _num << endl; //这里默认访问的是子类的成员,未指定作用域访问限定符时,编译器采用就近原则,如果自己的所在作用域有,则直接使用。 //如果局部有,就直接用局部。局部没有,编译器才会去全局找。 } protected: int _num = 999; // 学号:同名成员变量 }; void Test1() { Student s1; s1.Print(); };
2.
上面所说的隐藏是针对于成员变量名相同时的隐藏,下面这种隐藏是对于成员函数名的隐藏,只要函数名相同,无论参数列表是否相同都会构成隐藏,如果不显示指定基类的域访问限定符,则调用同名函数时,编译器优先会调用派生类的隐藏函数。这叫就近原则。
3.
需要额外注意一点,有的人可能会以为当参数列表不同,函数名相同时,两个函数不是正好构成重载函数了吗?答案是错误,因为构成重载函数的前提是必须在同一作用域,基类和派生类是两个不同的域,所以并不构成重载,而是构成隐藏。
class A { public: void fun() { cout << "func()" << endl; } }; // 同名成员函数fun()构成什么关系呢? // 重载 重写 隐藏(重定义) 编译报错 //父类和子类的同名成员函数,只要函数名相同就构成隐藏,参数列表不同也没有关系,只要函数名相同就构成隐藏。 class B : public A { public: void fun(int i)//同名成员函数 { A::fun(); cout << "func(int i)->" << i << endl; } }; void Test2() { B b; b.fun(10); }; class A { public: void fun() { cout << "func()" << endl; } }; class B : public A { public: void fun(int i)//同名成员函数 { A::fun(); cout << "func(int i)->" << i << endl; } }; void Test2() { B b; //b.fun();//就近原则,优先去B类域去找。这里会报错。 b.A::fun();//隐藏后,需要指定类域才能访问基类的成员函数。 }; int main() { Test1(); //当子类和父类中拥有同名成员的时候,子类会屏蔽父类对同名成员的直接访问,这样的方式叫隐藏,可以用基类:基类成员显示访问 Test2(); }
四、派生类的默认成员函数(使用切割赋值的拷贝和赋值函数,无须调用的析构函数)
1.
派生类的构造函数只能调用基类的构造函数来初始化基类的那部分成员,不能在自己的构造函数里面初始化基类成员,值得注意的是,如果基类有默认构造函数,那我们不需要管基类成员的初始化工作,只要把派生类自己的成员在构造函数里面初始化即可,因为编译器会自动调用基类成员的默认构造。
但如果基类没有合适的默认构造,那则必须在派生类的初始化列表显示调用基类的有参构造函数进行基类成员的初始化。
2.
拷贝构造函数与构造不同,必须在派生类的拷贝构造的初始化列表处显示调用基类的拷贝构造,完成基类成员的复制。在传参时有人可能会有疑问,调用基类的拷贝构造该如何将子类中基类成员提取出来呢?这里就用到上面所说的切割向上赋值,正好可以完成基类成员的复制工作。
3.
复制重载和拷贝构造有一点不一样,由于复制重载函数名在基类和子类中函数名相同,所以在调用基类的复制重载时必须指定基类域,否则会导致死循环调用子类复制重载,最终导致堆栈溢出。但和复制重载相同的是,在调用基类赋值函数进行传参时,所采取的策略依旧是向上切割赋值。
4.
派生类对象初始化时,先调用基类构造再调用子类构造,在析构时与栈结构相同,先调用子类的析构函数,在子类析构函数调用完毕时,编译器会自动调用基类的析构函数。所以说,派生类中其他的三个默认成员函数都必须我们自己手动调用基类的对应默认成员函数,但是析构函数不需要我们自己调用,编译器在子类析构调用结束后会自动调用基类析构。
5.
额外多说一点的是,如果我们自己调用父类析构函数的话,则必须指明父类域,因为编译器会把析构函数名特殊处理成destructor(),所以如果不指定类域就会出现派生类的析构函数内部调用自己的析构函数,则编译器会报错。
6.
最后归纳一下,将派生类分为三部分,内置类型,自定义类型,基类成员,基类成员统一调用基类成员函数进行处理,除析构不需要显式调用外,其他都需要显示调用。
对于内置类型则构造析构不处理,赋值和拷贝进行浅拷贝。
自定义类型成员会被自定义类型对应的默认成员函数来处理。
某些较小版本的编译器会对内置类型进行处理,但我们不可以依赖编译器的这种行为,因为将来我们的程序必须具有良好的移植性,如果你的代码换平台之后出现了问题,那么请记住,一定是你的代码出现了问题,和平台是没什么关系的,所以在编程时不要依赖编译器对某些语法的特殊处理,因为很有可能在换平台之后,另一个平台并没有这样的特殊处理,那你的程序就会出现问题。所以还是要严格按照标准走,编译器对内置类型就是不处理的。
class Person { public: Person(const char* name ) : _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; // 姓名 }; //派生类中: // 1、构造函数,父类成员会默认调用父类的构造函数完成初始化。 class Student : public Person { public: Student(const char* name, int num) //:_name(name)//规定死了,如果要初始化基类成员,必须调用基类的构造函数。 //如果父类没有合适的默认构造,则必须在子类中调用有参的基类构造完成基类成员的初始化。 :Person(name) ,_num(num) {} 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; } ~Student() { //Person::~Person();//编译器当作调用基类的destructor //1.子类析构和父类析构构成隐藏关系(由于多态的关系需求,所有的析构函数都会特殊处理成destructor函数名) //2.调用了一次Person的构造,两次析构,其实是因为我们显示调用了Person的析构,编译器自己会自动调用析构,所以调2次 // 子类先析构,父类后析构。可得结论:子类析构函数不需要显示调用父类析构,依靠编译器之后的自动调用即可。 cout << "~Student()" << endl; //在子类对象析构函数调用之后,编译器又会自动调用父类析构,这是编译器的默认行为。 //构造顺序和析构顺序相反,基类成员先构造,则析构时基类就后析构。 } protected: int _num; //内置类型不处理 string _address;//调用string自己的无参构造进行处理 //继承下来的基类成员调用基类对应函数进行处理 }; int main() { //Student s1;//如果子类s1里面什么都没有,则这里会调用父类的析构和无参构造。 //Student s1("小李子", 18); //Student s2(s1);//子类中父类成员会调用父类的拷贝构造。 把子类分为三个部分,内置类型,自定义类型,父类的那一部分,父类那一部分规定死只能调用父类的成员函数。 //Student s3("张三", 20); //s1 = s3; //Person p = s1;//调用拷贝构造,发生切片赋值 //Person& rp = s1;//不需要用const引用,这里是天然的赋值过程。 Student s("张三",18); }
五、继承与友元(父类的友元关系不能继承到子类)
1.
父类和某个函数的友元关系不能继承到子类上去,比如下面代码中Display是父类Person的友元函数,可以访问父类的保护成员,但是继承到子类后Display并不可以访问子类的保护成员。
所以基类的友元不能访问子类的私有和保护成员。
class Student; class Person { public: friend void Display(const Person& p, const Student& s); protected: string _name; // 姓名 }; class Student : public Person { protected: int _stuNum; // 学号 }; void Display(const Person& p, const Student& s) { cout << p._name << endl;//Display是Person的友元函数,但不是Student的友元函数。 //cout << s._stuNum << endl;//所以可以访问_name但不能访问_stuNum } int main() { Person p; Student s; Display(p, s); }
六、继承与静态成员
1.
基类若定义出一个静态成员,则在继承体系里面有且仅有这样一个静态成员,无论基类派生出多少子类,都只有一个static成员实例。
2.
类静态成员属于整个类,为所有对象共享,并不单属于某个特定对象,所以静态成员可以认为是属于基类和所有派生类,即属于整个继承体系。
但非静态成员并不符合上面所说的特性,非静态成员理应属于其所属类,所以基类对象中的成员和派生类对象中的基类成员互不干扰,即使两部分的成员在命名上完全相同,但是他们属于不同的类,不同的对象,互不干扰。
3.
这里存在一个笔试中常喜欢考的问题,就是关于nullptr的成员访问,需要注意的是对于静态成员和成员函数的访问并不构成越界访问,因为静态成员存在静态区也就是数据段,成员函数存在公共代码段也就是常量区,而nullptr指向的空对象存在于栈,只有在访问对象的成员变量时才会出现越界访问的情况,因为这时编译器会去栈里面找空对象里面的成员变量,但是空对象并没有给成员变量分配内存空间,所以在真正访问时,访问的是OS使用的内存空间,自然会产生越界访问的问题。
class Person { public: Person() { ++_count; } void Print() { cout << this << endl; //cout << _name << endl;//如果是空对象访问_name,那就会出错,因为空什么成员都没有存储,发生越界访问。 cout << _count << endl; } //protected: string _name; // 姓名 public: static int _count; // 统计人的个数。 }; int Person::_count = 0;//类内只是声明,类外进行定义 class Student : public Person { protected: int _stuNum; // 学号 }; //静态成员属于整个类的所有对象,同时也属于所有的派生类的所有对象,换句话可以说为静态成员属于基类和派生类 int main() { Person p; Student s; p._name = "张三"; s._name = "李四"; //虽然对象s是子类继承父类之后实例化的,但是s里面的_name和p里面的_name并不是相同的。 p._count++; s._count++; cout << p._count << endl; cout << s._count << endl; cout << &p._count << endl; cout << &s._count << endl; cout << Person::_count << endl; cout << Student::_count << endl; //类静态成员为所有类对象所共享,不属于某个具体的对象,属于整个类,存放在静态区。 //类静态成员访问有两种方式,一种是通过类加类域访问限定符直接访问,另一种是通过对象来间接访问。 //非静态成员对于基类和派生类来说各有一份互不干扰,因为基类和派生类本身就是不同的类,下来的对象也是不同的,理应有各自的 //成员变量 Person* ptr = nullptr; //cout << ptr->_name << endl; // no,这里是解引用因为_name在对象里面 ptr->Print(); // ok,这里不是解引用因为Print在代码段,也就是常量区。实际就是传递空this指针而已。 cout << ptr->_count << endl;// ok,这里不是解引用因为_count在静态区 (*ptr).Print();//并不是只要解引用就会报错,而是只要访问了空对象的成员才会报错,因为空对象没有成员。 cout << (*ptr)._count << endl; return 0; }
七、复杂的菱形继承及菱形虚拟继承
1.菱形继承产生数据冗余及二义性问题
1.
菱形继承实际是在多继承的基础上产生的大坑,多继承实际并没有什么问题,而且也比较合理,一个子类去多继承多个父类,就比如下面的助教,他既可能是老师也可能是学生,拥有双重身份,则多继承也很合理。
2.
但只要有多继承的存在,就会出现菱形继承,而菱形继承就是C++继承的一个大坑,Java知道C++因为有了多继承之后,出现菱形继承的大坑,所以Java为了避免菱形继承的大坑,规定Java只能有单继承不能有多继承,即一个派生类只能有一个基类,不能出现多个父类。
3.
为什么说菱形继承是一个大坑呢?下面代码就可以体现出来了,_name被继承到Teacher和Student之后,再被菱形继承到Assisant,则Assisant中会存在两份_name,则Assisant在访问_name成员变量时就会出现二义性,因为Teacher和Student都有_name,所以如果从访问角度来讲,避免二义性就必须通过指定类域来解决,但即使二义性问题被解决之后,数据冗余的问题依旧无法得到处理,因为Assisant的名字不能有两个吧?怎么说也不河狸啊。
//有多继承本身没什么问题,但有多继承可能就会有菱形继承,菱形继承会引发数据冗余和二义性。 class Person { public: string _name; // 姓名 }; class Student : virtual public Person { protected: int _num; //学号 }; class Teacher : virtual public Person { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 }; // 空间角度来讲是数据冗余,访问角度来讲是二义性。数据冗余会导致空间浪费 int main() { Assistant a; a._name = "小张";//这里就会出现二义性,访问的_name是属于Person的哪个派生类的,是Student还是Teacher呢? a.Teacher::_name = "张老师"; a.Student::_name = "张三";//指定访问类域也只是解决了二义性,还没有解决数据冗余的问题。 cout << a._name << endl;//虚拟继承之后,_name就只有一份了。 }
4.
C++为了解决菱形继承带来的问题采用了虚拟继承的方式来进行解决,即在菱形继承的腰部位置采用virtual继承来解决菱形继承的大坑。
5.
iostream就是C++用菱形继承设计出来的,但是也就大佬能用用了,如果我们自己用肯定得被别人骂死。因为有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2.虚拟继承解决数据冗余和二义性的原理(虚继承中的切片赋值和虚基表指针)
1.
为了弄明白菱形虚拟继承解决数据冗余和二义性的原理,下面我们通过内存窗口和简单的菱形继承体系来观察虚拟菱形和菱形继承后对象的成员模型。
2.
菱形继承后,BC派生类中各自存储虚基类成员,这就导致了数据冗余。现在的虚基类成员是4字节,可是如果虚基类成员100字节呢?或者更多呢?则派生类各存储一份,数据冗余带来的消耗就会更大。
3.
虚拟继承后,BC派生类中不再存储虚基类成员,改为存储虚基表指针,虚基表指针指向的一张表叫做虚基表,这个表中存储了派生类成员到虚基类成员的地址偏移量,通过偏移量就可以找到虚基类成员的内存地址。而这个内存地址只有一份,所以这就解决了数据冗余的问题,因为内存中不再像原来一样,派生类中分别存储虚基类成员导致数据冗余,而是仅仅只存一份虚基类成员,派生类改为存储虚基类指针。
4.
有人可能会有疑问,为什么要存派生类到虚基类的地址偏移量呢?虚基类成员不就在D类的最下面吗?我们D类对象的内存空间大小已知,那通过D类指针不就能找到最下面的虚基类成员吗?腰部的派生类存虚基表指针干嘛呢还。
5.
虚拟菱形继承后,派生类B的对象成员模型也变为存储偏移量的方式来找虚基类成员,如果派生类B指针被对象d进行切片赋值后,则指针会重新指向对象d中派生类B成员的那一部分,在这部分当中肯定是没有虚基类成员的,所以想要找到虚基类成员则必须通过B成员里面多存储的一份虚基表指针来进行寻找。
所以如果发生了切片赋值这样的情况,想要在不存在虚基类对象的成员部分中找到虚基类,则必须通过虚基表指针来进行地址偏移量的查找。
6.
在菱形虚拟继承之后,存储的逻辑模型也变为下方所示。
//C++引入虚拟继承来解决菱形继承的数据冗余和二义性问题。virtual继承 class A { public: int _a; }; // class B : public A class B : virtual public A { public: int _b; }; // class C : public A class C : virtual 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; //监视窗口被编译器优化处理过,不是那么准了,我们改用内存窗口进行观察。 B b; b._a = 1; b._b = 2; B* ptr = &b; ptr->_a = 10; ptr->_b = 11; ptr = &d;//发生切片赋值 ptr->_a = 20; ptr->_b = 21; //虚继承之后,虚基类对象不属于派生类对象里面了,而是到派生类对象外面,只能通过存偏移量的方式来找到虚基类对象。 //这里是因为节省的比消耗的小,所以体现出来的是没有节省内存,但如果A对象很大的花,那消耗用于存储地址偏移量的内存是 //远远小于节省出的A对象的字节的。存地址这里消耗8字节,节省了A对象的4字节,多消耗4字节,但如果A很大,那就节省空间了。 //模型上来说,把虚基类对象放到最下面,喜欢把有效内容上方的地址所指向的表叫做虚基表,虚基表中存储的是偏移量 //Java直接就不支持多继承,这样根本就没有菱形继承这样的存在,不会引发大坑的出现了就。 return 0; }
八、继承和组合(is-a,has-a:都是代码复用的一种手段)
1.
多继承可以认为是C++的一个大坑,所以很多后来产生的语言都没有多继承,只允许单继承的存在,例如Java.继承和组合都是代码复用的一种手段。
2.
虽然我们有解决菱形继承的方法,但是千万不要设计出菱形继承,如果组合和继承都可以用的话,则优先使用组合而不是继承。
public继承可以认为是is-a的关系,即每一个派生类对象都是一个基类对象,因为基类成员都在派生类里面。而组合可以认为是has-a的关系,如果B里面组合了A对象,则每一个B对象中都会有一个A对象。
3.
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承在一定程度上破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
4.
对象组合是类继承之外的另一种代码复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,组合对象的改变对类本身的影响是比较小的,所以组合的耦合度低。优先使用对象组合有助于保持每个类都被良好的封装。
5.
实际中尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。
如果用继承或组合都可以,则优先使用组合。
//继承和组合,两者都是复用。继承是白箱复用,组合是黑箱复用。 //黑盒测试和白盒测试,未知底层针对功能设计测试用例,已知底层针对代码实现设计测试用例。 class X { int _x; }; class Y:public X { int _y;//X的保护成员,Y的对象可以直接访问,因为是公有继承,protect到Y里面后访问限定符还是protect }; //组合的耦合度低,继承的耦合度高,软件工程思想高内聚低耦合,耦合就是模块之间的依赖关系,或者叫类之间的依赖关系 //互相影响的 class M { int _m; }; class N { M mm;//mm的保护成员,N对象不能访问,因为类外不能访问类内私有或保护成员。 int _n; };
九、经典笔试面试题
1.什么是菱形继承?菱形继承的问题是什么?
菱形继承就是在多继承的基础上,多继承的父类还有一个共同的父类,这就会导致菱形继承的出现,基类中的成员在多继承的子类中会出现多份数据占用内存的情况,即数据冗余问题的出现,并且在访问多继承子类的基类成员时,如果不指定基类的派生类类域,则还会出现二义性的问题。
2.什么是菱形虚拟继承?如何解决数据冗余和二义性的
菱形虚拟继承即在原有菱形继承上,对腰部类采用virtual继承的方式来解决菱形继承所产生的问题,在内存空间中,腰部类的派生类不再存储两份冗余的数据,而是仅仅只存储虚基表指针,如果腰部类想要找到这份冗余的数据,则可以通过虚基表指针所指向的虚基表中虚基类成员的地址偏移量来寻找虚基类成员。
内存空间中只有一份数据时,无论是否指定腰部类类域,访问的都是这一份虚基类成员数据。
3.继承和组合的区别?什么时候用继承?什么时候用组合?
继承可以看作是白箱复用,即父类内部细节在子类全部都是可视化的,破坏了父类的封装性,一旦父类发生某种改变则子类大概率需要跟着修改,父类和子类的依赖关系较强,耦合度较高,代码维护性较低。
组合可以看作是黑箱复用,组合对象的内部实现细节并不暴露给组合对象的所在类,而只能通过对象提供的public接口进行对象内部数据的访问,两个类的耦合度更低一些,代码维护性较高。
继承和组合都可以使用时优先使用组合,如果只能用继承或继承更加合适的时候我们才会用继承,譬如要实现多态,则必须使用继承,另外如果某些情景下,真的较为适合使用继承,那我们就选继承不选组合。例如is-a的关系,就比如宝马是车,但是宝马有轮胎,前后则分别较为适合使用继承和组合。