前言
继承(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; }
并且我们只保留一个对象来观察,下面我们来看看运行结果吧
这里怎么先调用了父类的析构,又调用了子类的析构又调用了父类的析构,我们就一个对象怎么调用多了一次父类的析构呢?按理说只有一个父类一个子类才对,这是怎么回事呢?我们先检查一下哪里多调用了:
我们发现在子类的析构中将父类的调用代码注释掉就没了那个多余的父类析构,这是什么原因呢?其实这是因为子类中析构函数不要显示的调用父类的析构,因为会自动调用父类的析构,为什么要这样做呢?因为要保证先后顺序,我们都知道,先声明的对象后析构,如下图:
所以要满足这样的规则我们就不能在析构函数中显式的调用父类的析构函数,因为如果我们显式调用那么就不能保证先构造的后析构的顺序了。所以:子类析构函数完成时,会自动调用父类的析构函数,保证先析构子再析构父。如下图:
对于为什么先析构子在析构父还有一个主要的原因,由于子类继承父类可能会比父类多出成员,一旦子类中有一个父类的指针,指针指向一段空间,一旦将父类析构了那么这个指针就变成野指针了,子类中用这个指针指向任意的成员都会报错,所以为了安全性而言也要先调用子类的析构再调用父类的析构。