文章目录
前言一、继承的语法二、基类和派生类对象赋值转换 1.例子2.继承中的作用域3.派生类的默认成员4.继承与友元5.继承与静态成员6.复杂的菱形继承和菱形虚拟继承总结
前言
继承(inheritance)机制是面向对象程序设计 使代码可以复用的最重要的手段,它允许程序员在 保 持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承 呈现了面向对象 程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用, 继 承是类设计层次的复用
一、继承的语法
class Person{public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}protected:string _name = "peter"; int _age = 18;};class Student:public Person{protected:int _stuid; //学号};int main(){Student s;s.Print();return 0;}
我们可以看到上面的代码中student类并没有print函数以及name和age变量,但是在调用的时候竟然能调用父类的成员函数:
为什么能成功调用呢?因为student继承了person,所以继承是完成了复用。父类也可以叫做基类,子类也可以叫做派生类。但是在继承中还分三种继承方式,公有继承,私有继承,保护继承,下面我们来看一下继承的规则:
我们通过上面的表格其实可以发现一些规律,比如公有继承中公有成员还是公有,保护成员还是保护,私有成员还是私有。其实规律就是类成员的继承方式是根据权限小的那个继承方式继承的。比如保护继承中公有成员还是保护(因为保护的权限小于公有的权限), 保护成员还是保护,私有成员还是私有因为私有的权限小于保护,而私有继承由于权限已经是最小的所以成员都是私有的,并且私有成员在派生类中不可见。
总结:
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的 不可见是指基类的私 有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面 都不能去访问它。 2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。 可以看出保护成员限定符是因继承才出现的。 3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他 成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。 4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public, 不过 最好显示的写出继承方式。 5. 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡 使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里 面使用,实际中扩展维护性不强。 下面我们演示一下这三种方式:class Person{public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}protected:string _name = "peter";private:int _age = 18;};class Student :public Person{public:void func(){cout << "_name:" << _name << endl;//cout << "_age" << _age << endl;}protected:int _stuid; //学号};int main(){Student s;s.Print();s.func();return 0;}
我们发现受保护的成员派生类可以调用,而私有成员不可以访问:
我们可以看到保护和私有在当前类没有区别,在派生类就不一样了,私有在派生类不可见,而保护是在子类是可见的那么在什么时候我们会定义私有呢?当我们不想被子类继承就可以定义为私有。那我们将成员设为私有有什么办法可以在派生类中使用呢?当然可以,我们只需要在子类中调用父类的函数即可,如下:
class Person{public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}private:string _name = "peter";int _age = 18;};class Student :public Person{public:void func(){Print();}protected:int _stuid; //学号};int main(){Student s;s.Print();s.func();return 0;}
当然,我们的继承方式可以像类中默认权限一样可以不写,不写默认是私有继承,如下图:
同样的,struct的默认权限为公有,struct的默认继承权限也是公有的。
二、基类和派生类对象赋值转换
1.例子
我们先看一下下面的代码:
int main(){int i = 5;double d = i;return 0;}
我们之前说过,像这样的两个类型不相同的赋值一定会发生隐式类型转换。int类型的i先给一个double类型的临时变量,再将临时变量给double d这个值。那么如果我们将一个子类给一个父类对象会发生什么呢?
int main(){Student s;Person p = s;return 0;}
在公有继承下,子类可以赋值给父类,这里是天然的,不存在类型转换发生。因为在公有继承下,子类是一个特殊的父类,那么子类会有可能比父类多出来变量或对象,那该怎么解决呢?
其实就是把子类中父类的那部分切出来然后给父类,下面我们来验证一下:
我们不能直接将d给int &是因为这里发生了隐式类型转换,而临时变量是具有常性的所以我们加个const就解决了:
那么如果是父类和子类呢?我们试试:
我们能看到父类的引用能直接引用子类并且没有报错说"非常量限定",这就说明子类到父类的没有隐式类型转换,这也就证明了我们刚刚说的子类赋值给父类是天然的,不存在类型转换。那么子类可以赋值给父类,能把父类赋值给子类吗?
我们可以看到是不能的,下面我们总结一下:
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片 或者切割。寓意把派生类中父类那部分切来赋值过去。 基类对象不能赋值给派生类对象。 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类 的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run - Time Type Information)的dynamic_cast 来进行识别后进行安全转换。2.继承中的作用域
1. 在继承体系中 基类和 派生类都有 独立的作用域。 2. 子类和父类中有同名成员, 子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(在子类成员函数中,可以 使用 基类 :: 基类成员 显示访问) 3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。 4. 注意在实际中在 继承体系里面最好 不要定义同名的成员。 下面我们来看一下基类和派生类中的同名成员: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; // 学号};void Test(){Student s1;s1.Print();};int main(){Test();return 0;}
我们可以看到person中有一个_num变量,student中也有一个同名的_num变量,在这种情况下我们如何知道要调用的是哪个变量呢?当我们想用父类的变量的时候我们需要在前面加上域作用限定符,子类的话直接用变量名即可,像上面代码这种情况就是父类的num和子类的num构成了隐藏。
当我们在子类中将域作用限定符拿掉,会自动调用子类中的同名变量num。
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。
我们接着往下看:
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;}};int main(){B b;b.fun(10);return 0;}
A中的fun和B中的fun是什么关系呢?参数不同是函数重载吗?不是!因为重载是在同一个作用域,这里都不是一个作用域肯定不是函数重载了,那么是隐藏吗?答案是是的,因为成员函数只要函数名相同那就构成隐藏,隐藏默认调用本类的成员函数,想要调用父类的需要加域作用限定符。
3.派生类的默认成员
1.派生类的构造函数
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{protected:int _num; //学号};int main(){student s;return 0;}
我们看上面的代码,子类什么函数也没实现,现在我们创建一个子类对象,然后看看什么结果:
我们发现我们创建子类的对象,竟然调用的是父类的构造函数和析构函数,刚刚我们没写子类的构造函数,现在我们写一个看是什么结果:
我们发现居然报错非法的成员初始化,我们继承父类是有string _name的这是为什么呢?其实c++规定,在子类中初始化父类的成员要用父类的构造函数初始化,也就是说子类的归子类管,父类的归父类管,所以正确的构造函数应该是这样:
student(const char* name,int num):Person(name),_num(num){cout << "student(const char* name)" << endl;}
上面是我们写了用父类的构造函数,如果我们不写会调用谁呢?:
我们在调试的时候发现,当我们没有显式调用父类的构造函数的时候编译器也会默认去初始化列表调用父类的构造函数,也就是说我们不写也可以完成任务。如果父类没有默认的构造该怎么办?那我们就必须显式的去调用了。
下面我们解释一下派生类的默认成员函数:
6个默认成员函数, “ 默认 ”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢? 1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。 2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。 3. 派生类的operator=必须要调用基类的operator=完成基类的复制。 4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能 保证派生类对象先清理派生类成员再清理基类成员的顺序。 5. 派生类对象初始化先调用基类构造再调派生类构造。 6. 派生类对象析构清理先调用派生类析构再调基类的析构。 7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲 解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。有了上面的知识我们再来实现一下子类的拷贝构造函数:
student(const student& s):Person(s),_num(s._num){cout << "student(const student& s)" << endl;}
与刚才的构造函数不同的是,我们不写父类的构造函数是不会调用父类的构造函数的:
student(const student& s)//:Person(s) :_num(s._num){cout << "student(const student& s)" << endl;}
所以:派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。
下面我们再写一个赋值重载:
student& operator=(const student& s){if (this != &s){operator=(s);_num = s._num;}return *this;}
我们发现出错了,栈溢出了,这是为什么呢?其实回想我们刚刚讲的知识,成员函数只要函数名相同就构成隐藏,也就是说子类和父类的operator=构成隐藏了,我们默认调用的是我们自己的赋值重载,而我们本意是想调用父类的赋值重载,所以我们修改一下:
student& operator=(const student& s){if (this != &s){Person::operator=(s);_num = s._num;}return *this;}
这次我们发现成功赋值了,并且确实调用了父类的赋值。
下面我们实现一下析构函数,对于析构函数我们也像刚刚的思想一样:父类的东西让父类析构,然后子类再析构:
~student(){~Person();cout << "~student()" << endl;}
然而当我们写出来却发现编译不过去:
这是为什么呢?因为每个类的析构函数都会被编译器处理为destructor(这个单词就是析构的意思),也就是说父类和子类的析构函数名字是一样的,又构成隐藏了,刚刚默认调用我们子类自己的析构所以出错了,下面我们修改一下:
~student(){Person::~Person();cout << "~student()" << endl;}
并且我们只保留一个对象来观察,下面我们来看看运行结果吧
这里怎么先调用了父类的析构,又调用了子类的析构又调用了父类的析构,我们就一个对象怎么调用多了一次父类的析构呢?按理说只有一个父类一个子类才对,这是怎么回事呢?我们先检查一下哪里多调用了:
我们发现在子类的析构中将父类的调用代码注释掉就没了那个多余的父类析构,这是什么原因呢?其实这是因为子类中析构函数不要显示的调用父类的析构,因为会自动调用父类的析构,为什么要这样做呢?因为要保证先后顺序,我们都知道,先声明的对象后析构,如下图:
所以要满足这样的规则我们就不能在析构函数中显式的调用父类的析构函数,因为如果我们显式调用那么就不能保证先构造的后析构的顺序了。所以:子类析构函数完成时,会自动调用父类的析构函数,保证先析构子再析构父。如下图:
对于为什么先析构子在析构父还有一个主要的原因,由于子类继承父类可能会比父类多出成员,一旦子类中有一个父类的指针,指针指向一段空间,一旦将父类析构了那么这个指针就变成野指针了,子类中用这个指针指向任意的成员都会报错,所以为了安全性而言也要先调用子类的析构再调用父类的析构。
4.继承与友元
我们先说结论:友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
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;cout << s._stuNum << endl;}void main(){Person p;Student s;Display(p, s);}
对于上面的继承关系,person有一个友元函数,这个函数可以访问父类的成员变量,当子类继承父类后,我们用这个函数是会报错的,因为友元关系是不会被继承的:
那么该如何解决这种情况呢?我们只需要在子类中也声明一下友元关系即可:
class Student;class Person{public:friend void Display(const Person& p, const Student& s);protected:string _name = "peter"; // 姓名};class Student : public Person{friend void Display(const Person& p, const Student& s);protected:int _stuNum = 10086; // 学号};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);return 0;}
5.继承与静态成员
基类定义了 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;}int main(){TestPerson();return 0;}
我们可以看到,静态成员变量是所以对象所共有的,无论继承多少次,都只有一个count。下面我们验证一下:
通过上图可以看到父类中的name和子类中的name根本不是一个name,那么count呢?
通过验证我们也能发现静态成员变量确实只有一个,不管继承了多少次。
下面我们实现一个不能被继承的类:
class A{private:A(){}};class B :public A{};int main(){B bb;return 0;}
一旦我们将构造函数私有化了那么就不能继承了。
这样的情况是因为我们不想我们的类被继承,那我们本类自己如何调用这个私有化的构造函数呢?
class A{public:static A CreateObj(){return A();}private:A(){}};class B :public A{};int main(){//B bb;A::CreateObj();return 0;}
我们可以实现一个函数让这个函数返回一个A类型的匿名对象即可,由于函数必须由对象去调用我们无法创建一个对象,所以我们将这个函数设为static静态的函数,这样就可以用类名去调用这个函数了。
6.复杂的菱形继承与菱形虚拟继承
我们先看看单继承和多继承的区别:
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
菱形继承有数据冗余和二义性的问题,现在我们通过代码调试来观察:
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; // 主修课程};void Test(){// 这样会有二义性无法明确知道访问的是哪一个Assistant a;a._name = "peter";// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";}
首先在上面的代码中类assistant继承了student和teacher,所以我们创建了assistant对象后发现访问成员变量name无法访问,编译器报错不明确,如下图:
这个问题解决起来很简单,就是将我们的变量前面加上域作用限定符,然后指定访问的是哪一个name变量,如下图:
那么数据冗余是什么呢?当一个子类继承两个父类的时候,这两个父类同样是继承另一个父类的,这样就会有很多相同的数据被继承到了子类中,一旦在项目中代码非常多非常复杂那么这种情况是非常浪费空间的,那么这个问题c++的祖师爷是如何解决的呢?这里就引入了虚继承,虚继承可以解决数据冗余和二义性的问题:
虚继承的语法就是在原先继承方式的前面加上virtual关键字。下面我们通过一个简单的菱形继承模型来看看c++祖师爷是如何解决数据冗余和二义性的问题:
class A{public:int _a;};// class B : public Aclass B : virtual public A{public:int _b;};// class C : public Aclass 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;return 0;}
我们先看一下不加virtual继承的情况:
通过上图我们可以看到B中继承的a在内存第一行第二行就是B中的成员b,C中继承的a在内存中第三行,最后一个红色的是d的值。那么我们再看看虚继承的结果:
我们发现虚继承中的内存地址和我们刚刚看到的完全不一样,虚继承里面存放的竟然是指针,B类中有一个指针和3这个值,C类中有一个指针和4这个值,那么这能代表什么呢?我们再开一个内存来看看刚刚里面存放的地址到底是什么东西:
我们发现这个地址里面的开头都是0,这是什么意思呢?
我们通过那个0下面的值发现,16进制转化为十进制分别是20和12,然后我们试着在第一个地址加上这个值发现,上面的地址加上这个值正好指向了A:
也就是说虚继承解决数据冗余和二义性的本质是通过偏移量找到A让其这三个类中的a变量都指向父类的那个变量的地址。
总结:
这里是通过了 B 和 C 的两个指针,指 向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量 可以找到下面的 A。下面我们做一道多继承的题:
对于上面这道题选哪个选项呢?我们来解释一下:
首先D类指针p开了一个D的空间,然后进入D的构造函数,在D的构造函数中我们发现初始化列表对三个类都进行了初始化,而初始化的顺序是谁先继承谁就先初始化,A先被B继承所以先去调用A的构造函数初始化,随后打印class A,接着B类对象初始化,在B类初始化列表中我们发现又初始化一次A,那么A会成功初始化吗?答案是不会因为我们虚继承了,三份A只用初始化一次A,所以这次直接打印class B,然后初始化C,C与B同理打印class C,走完D的初始化列表后进入构造函数打印class D所以答案选A。最重要的是要知道虚继承后只有一份A走了构造函数。
下面我们再看一下组合和继承的区别:
组合的耦合度低,将类C改了类D不会受很大的影响,而继承一旦A改了B继承的很多成员都会随之改变。
总结
1. 很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱 形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。 2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。 3. 继承和组合 public继承是一种 is-a的关系。也就是说每个派生类对象都是一个基类对象。 组合是一种 has-a的关系。假设B组合了A,每个B对象中都有一个A对象。 优先使用对象组合,而不是类继承 。 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称 为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的 内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很 大的影响。派生类和基类间的依赖关系很强,耦合度高。 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象 来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复 用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被 封装。 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有 些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用 继承,可以用组合,就用组合。