继承的机制是面向对象程序设计时,可以使代码复用的最重要的手段。继承可以在保持原有类特性的基础上进行扩展,产生新的类,称作派生类。继承表现了面向对象程序设计的层次结构。
//父类 class Person { public: void Print() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; } protected: string _name = "Peter"; int _age = 18; }; //定义一个子类:学生类 //但是学生本身也是Person的一种类型 class Student : public Person { protected: int _id; int _major; }; //定义一个子类:老师类 class Teacher : public Person { protected: int _jobid; int _teach_subject; };
比如上面给的例子:
Person类是父类,也就是基类。
而学生类和老师类是Person类的子类,也就是扩展类。
可以这么解释,人是一个比较大的概念,它有很多基础属性,也就是不同类型的人所具有的共同属性。而人又由不同身份的个体组成,就比如学生、老师等等,他们具有人的属性,但是也有一些属于他们身份的特殊属性。
因此,人可以看作是基类,各种不同属性的身份的人可以继承人的共同属性,也包含他们自己的独有属性。
访问限定符和继承方式都有public、protected和private三种方式,那么这两者之间有什么关系呢?
在派生类继承基类之后,根据继承方式的不同,可能会改变基类的访问方式。如下表所示:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类protected成员 | 派生类的protected成员 | 派生类的public成员 | 派生类的private成员 |
基类private成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
那么对上表进行一个总结,有以下几点:
1、基类的private成员基类无论以什么方式继承都是不可见的。这里的不可见不是没有继承到派生类中,而是虽然基类的private成员被派生类继承了,但是语法上限制了派生类不能去进行访问。
2、如果定义的基类成员不想在类外被访问,但是需要在派生类中能够访问,就可以定义为protected。从这里就可以看出,protected限定符是因为继承才出现的。
3、从表格中可以看出,除了基类的私有成员在派生类中是不可见的之外,基类的其他成员在子类中的访问方式是取权限较小的作为派生类的成员。
在实际的使用中,基本上都是public继承,因为protected/private继承只能在派生类中使用,可拓展性和可维护性不强。
1、派生类对象可以赋值给基类的对象/基类的指针/基类的引用,可以称作切片或者切割,也就是将派生类中父类的那部分切割下来赋值过去。
2、基类对象不能赋值给派生类对象。这里解释一下:派生类对象可以赋值给基类对象是因为基类对象中所包含的成员在派生类中也存在,赋值过去可以将基类部分进行切割赋值;而基类中没有派生类中增加的那部分成员,因此赋值过去基类找不到对应的内容进行切割。
3、**当基类的指针是指向派生类对象时,基类的指针可以通过强制类型转换赋值给派生类的指针。**只有在这种情况下才是安全的。
我们以下面代码中的基类和派生类来举例:
class Person { protected: string _name; // 姓名 string _sex; // 性别 int _age; // 年龄 }; class Student : public Person { public: int _No; // 学号 };
派生类对象可以赋值给基类的对象/基类的指针/基类的引用
基类对象不能赋值给派生类对象
当基类的指针是指向派生类对象时,基类的指针可以通过强制类型转换赋值给派生类的指针。
当基类指针指向基类对象时,此时的p2中是没有派生类增加的成员,强制类型转换之后进行访问就会造成越界访问,是不安全的。
1、在继承中基类和派生类都有独立的作用域。
2、子类和父类中有同名成员,子类成员会屏蔽父类中同名成员的直接访问,也就是使用该成员默认使用的是子类的,这种情况叫做隐藏,也叫重定义。(如果在子类中想访问父类的同名成员,可以用基类::同名基类成员来访问)。
class Person { protected: string _name = "张三"; int _num = 111; //身份证号 }; class Student : public Person { public: void test() { cout << "姓名" << _name << endl; cout << "身份证号" << _num << endl; cout << "学号" << Person::_num << endl; } protected: int _num = 100; //学号 };
在上面的代码中,在Person类和Student类中分别有一个_num表示不同的意义。那么我们在子类的test成员函数中,如果不加作用域的话,就会默认访问子类中的_num,想要访问父类中的_num,就必须加上作用域的标识符。
3、在继承中,父类和子类的成员函数只要名字相同就构成隐藏。不构成重载是因为不在同一作用域。
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; } };
可以看出,如果不加作用域,默认调用B中的fun;想要调用基类的fun必须加上作用域。
注意:尽量在继承体系中不要定义同名的成员。
我们先给出一段代码,通过这段代码来分别看这几个成员函数是怎样生成的。
class Person { public: //构造函数 Person(const char* 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=(const Person& p)" << endl; if (this != &p) _name = p._name; return *this; } //析构函数 ~Person() { cout << "~Person()" << endl; } protected: string _name; // 姓名 }; class Student : public Person { public: //构造函数 Student(const char* name, int num) : Person(name) , _num(num) { cout << "Student()" << endl; } //拷贝构造 Student(const Student& s) : Person(s) , _num(s._num) { cout << "Student(const Student& s)" << endl; } //赋值运算符重载 Student& operator = (const Student& s) { cout << "Student& operator= (const Student& s)" << endl; if (this != &s) { Person::operator =(s); _num = s._num; } return *this; } //析构函数 ~Student() { cout << "~Student()" << endl; } protected: int _num; //学号 };
这是一个Person类和Student类,Student类继承了Person类。
1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类默认的构造函数,则必须在派生类构造函数的初始化列表中显式调用。
如果基类有构造函数,那么在子类中就算不去显式调用,子类在进行初始化的时候,也会先去调用基类的构造函数。从下面运行函数的顺序就可以看到。
但是如果基类没有默认的构造函数,并且子类没有去显式调用的话就会报错。
因此在基类没有默认的构造函数的时候,必须去显式调用。
2、派生类的拷贝构造必须调用基类的拷贝构造完成基类的拷贝初始化。
这里如果不去调用基类的拷贝构造函数,就会去调用基类的构造函数来对当前子类中的父类成员进行创建。
3、派生类的operator=必须要调用基类的operator=。
如果不去调用基类的operator=,就不会对所继承的父类中的成员变量进行赋值。
4、派生类的析构函数会在调用之后,自动调用基类的析构函数清理基类的成员,这样能够保证派生类的对象先清理派生类成员再清理基类成员的顺序。
5、派生类对象初始化先调用基类构造再调用派生类的构造。
6、派生类对象析构清理先调用派生类再调用基类的析构。
前面介绍过友元,友元不是成员函数,但是可以访问类中的private成员。
在继承这一块,要强调的是友元关系是不能继承。
class Student;//前置声明,因为在Person内部定义友元的时候,找不到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; cout << s._stuNum << endl; } int main() { Person p; Student s; Display(p, s); }
这里定义了一个Person类,该类中有一个友元函数Display;而Student继承了Person类。
在main函数中,我们定义了一个Person类对象p和Student对象s,调用Display函数时报错。说明友元关系是不能继承的。
基类定义了static成员,在整个继承体系中只有这一个static成员,无论定义了多少个派生类对象,都只有一个static实例。
class Person { public : Person () {++ _count ;} protected : string _name ; // 姓名 public : static int _count; // 统计人的个数。 }; int Person :: _count = 0; class Student : public Person { protected : int _stuNum ; // 学号 }; class Graduate : public Student { protected : string _seminarCourse ; // 研究科目 }; void TestPerson() { Student s1 ; Student s2 ; Student s3 ; Graduate s4 ; cout <<" 人数 :"<< Person ::_count << endl; Student ::_count = 0; cout <<" 人数 :"<< Person ::_count << endl; }
我们给出了这样一个代码,在Person类中有一个static成员count,然后Student继承了Person类,Graduate类继承了Student类。然后定义了三个Student成员,一个Graduate成员,打印count的值为4,这个很好理解,因为我们调用Person类的构造函数4次,count变为4。
但是之后我们又通过Student修改了count,再次打印发现count变成了0,这就说明了在整个继承体系中static成员count只会实例化一次。
在讲菱形继承之前,介绍一下单继承和多继承的概念。
单继承:一个子类只有一个直接父类的继承关系
多继承:一个子类有两个或者以上直接父类的继承关系。
但是多继承有一种特殊情况,就是菱形继承。菱形继承会产生两个问题,一个是数据冗余;一个是二义性。
在下面的对象成员构造中,Person类中的成员在Assistant中会存在两份。
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 ; // 主修课程 };
我们定义一个Assistant对象a,由于对象a继承了Student类和Teacher类,而Student类和Teacher类都继承了Person类。因此在对象a中存在两个_name成员变量。
void Test () { Assistant a ; a._name = "peter"; // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; }
我们可以通过作用域标识符来解决二义性的问题,但是数据冗余的问题无法解决。
为了解决菱形继承所带来的二义性和数据冗余的问题,可以使用虚拟继承来解决。
在百度百科中,是这样解释虚继承的:虚继承是面向对象编程中的一种技术,是指一个指定的基类,在继承体系结构中,将其成员数据实例共享给也从这个基类型直接或间接派生的其它类。
比如上面的继承关系,Student和Teacher在继承Person时使用虚继承,可以解决问题。
我们使用一个简化的菱形继承体系来详细讲解菱形继承。
通过内存窗口可以看到,菱形继承会带来数据冗余的问题,在内存中会给B类的_a和C类的_a分别开辟空间进行存储。
再来看使用虚继承之后的内存情况:
可以很容易看到,D对象当中将_a放在了对象组的最下面,同时属于B类和C类。那么这里就会出现一个问题,放在一起的话,B和C怎样去找公共的_a呢?
这里是通过途中绿框和紫框中标记的第一行,存储了像地址一样的东西,这两个指针指向的是一张表。这两个指针分别叫做虚基表指针。虚基表中存储的是偏移量,通过偏移量可以找到公共的_a。
这里的偏移量是指B类和C类到公共的_a所在的地址的偏移。
最后要提醒一下:一般不建议设计出多继承,更加不要设计出菱形继承,否则会在复杂度以及性能上出现问题。