前言:
本篇我们将开始讲解C++的继承,我想要说的是,C++的主体基本就是围绕类和对象展开的,继承也是以类和对象为主体,可以说,C++相较于C优化的地方就在于它对于结构体的使用方法的高度扩展和适用于更多实际的场景,这里的继承便是一个很好的体现。
1.继承的概念:
何为继承?
这个词常见于子一代接过来上一代的资产和社会地位,比如中国古代的君位传给嫡长子,或者欧洲的爵位可以世代去传承,而由前言我们又得到继承用于类和类之间,因此我这样推测:继承一定是某一个类的一些成员传给另一个和它有继承关系的类。
由此,我们引入继承的概念:
**继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类,继承后父类的成员(成员函数+成员变量)都会变成子类的一部分。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用,继承后父类的成员(成员函数+成员变量)都会变成子类的一部分。
如下的例子:
class person
{
public:
void print()
{
cout<<"hello world!"<<endl;
}
int _a;
};
class student :public person
{
public:
int _s;
};
此时,我们就说student继承了person.
2.继承的使用规定:
1.继承的成员构成:
依旧是以上面的例子为例:
我们将诸如person这种类称为父类,或者也叫基类。
将诸如student这种类称为子类,也叫派生类。
2.格式:
我们的继承格式是:在派生类即子类后面加上我们的继承方式和要继承的基类,即可构成继承。
和我们的类内部的三种访问限定符一样,继承方式的访问限定符也是private,protected,public三种。如下:
由排列组合可知,他们之间一共可以构成9种情况,如下:
我们之前说过,在类和对象中,private和protected是没有去别的,当时一定有人疑惑那分开这两个的目的是什么,而它的目的就在这里,根据上面的表格,我们可以这样大致总结一下:
1.对于基类的private成员,无论继承关系是什么,在派生类中都是不可见的
2.对于基类不是private成员的,取继承关系和访问限定符中权限最小的为这个成员在派生类中的最终的权限属性,比如,基类的一个成员是public成员,然后被以private的方式继承,则这个成员最后就是派生类中的private成员
注意:
1.这里要区分一下不可见和私有的区别:
首先一定要注意,只要是继承,派生类就会继承全部的基类成员,只是权限导致访问不了,而不是没有被继承下来。
其次,不可见是不论在派生类内还是类外都不能访问和使用,而私有是在类外不能访问和使用,但是在类内是可以的
2.省略继承方式:(不建议不写,有时候会出现错误,还是写上为好)
不写继承方式,直接跟父类名称也是可以的,不过这样就不能随意调控继承方式了,而是遵从默认的继承方式:
一般struct的父类的继承方式为public,而class的父类的继承方式为private
3.派生类和基类对象的赋值转换:
在进行派生类和基类的赋值转换之前,我们首先先复习一下我们之前的赋值转换的一些规则:
double a=3.3;
const int& m=3;
const int& s=a;
我们以引用为例子,这里加上const的原因就在于,浮点型转换为整型的时候会涉及到赋值转换,即会有一个临时变量将a转换成整型的结果存储起来,这个临时变量具有常性,就像我们之间引用常数一样,因此我们需要加上const.包括我们的常规的类的赋值转换也是如此,比如:
string& str="XXXX";
我们这里采用了匿名构造PPP,匿名构造就是类的临时对象,报错结果如下:
没错,匿名构造的临时对象也具有常性,也需要我们加上const才能编译通过。
那么,对于具有继承关系的子类和派生类来说呢?
我们先尝试一下:
class person
{
public:
void print()
{
cout<<"hello world!"<<endl;
}
int _a;
};
class student :public person
{
public:
int _s;
};
int main()
{
student q;
person sq;
person& sm=q;
student& m=sq;
return 0;
}
报错的条件如下:
我们发现可以直接将派生类赋值给基类,但是把基类赋值给派生类就会报错。
派生类和基类赋值转换的原理如下:
1.派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
2.基类对象不能赋值给派生类对象,倘若非要赋值,就只能通过强转成派生类类型后赋值,但是那样会存在越界的问题
图解如下:
3.派生类赋值给基类对象的方式有三种:指针,引用和赋值三种,他们对应的含义在上方图解:
1.如果是赋值,就是把子类中的部分拷贝一份给这个基类对象
2.如果是指针,就是直接指向子类中属于基类这部分的地址
3.如果是引用,就是直接是派生类中属于基类这部分的别名
4.继承中的作用域:
继承中一般的规则如下:
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员(包括成员函数),子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。且是子类对父类的同名成员隐藏,故我们直接访问的时候默认访问的是子类的成员,若想访问父类的成员需要我们加上父类的访问限定符才能访问。
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
如下:
class Person
{
protected :
string _name = "MMM";
int _num = 111;
};
class Student : public Person
{
public:
void Print()
{
cout<<" name:"<<_name<< endl;
cout<<" number:"<<Person::_num<< endl;
cout<<" num:"<<_num<<endl;
}
protected:
int _num = 999;
};
void Test()
{
Student s1;
s1.Print();
};
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
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 Test()
{
B b;
b.fun(10);
};
在这里,我想强调一下函数重载和函数隐藏的区别:
函数重载的前提是在同一个作用域下,两个函数的函数名相同但参数返回值等不同,而函数隐藏是在不同的作用域下,是否在同一个作用域下,就是函数重载和函数隐藏的本质区别
5.派生类的默认成员函数:
在类的对象中,我们详细的对派生类的默认成员函数进行了讲解,那么在继承这里还有哪些新的概念呢?
首先我想问的一个问题是:我们如何看待派生类里的基类,是否把它当成一个派生类的成员来看呢?
我认为应该将其当成一个派生类成员来看,而C++的处理方式也是类似的,它的大体规则如下:
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。因此,就不用我们单独再去写一次基类的析构了,会出现多次释放的问题
!!!!7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual(即虚函数多态)的情况下,子类析构函数和父类析构函数构成隐藏关系!!!!第7点很关键,我们要记得析构函数的使用被统一处理成了destrutor(),所以后面的多态才能使用析构。
我用我写的一个例子来证明上面的语法规定:
#include<iostream>
#include<assert.h>
using namespace std;
class person //final
{
public:
person(int age=20 , int score=30)
:_age(age)
, _score(score)
{
cout << "person()" << endl;
my_count++;
}
int count()
{
return my_count;
}
/*person(const person& p)//拷贝构造古典写法
:_age(p._age)
, _score(p._score)
{
cout << "person(const person& p)" << endl;
}*/
void swap( person& s)//交换数据
{
std::swap(_age, s._age);
std::swap(_score,s._score);
}
person( const person& s)//拷贝构造现代写法
{
cout << "person (const person& s)" << endl;
person p(s._age,s._score);
swap(p);
}
/*person& operator=( person& p)//赋值运算符重载古典写法
{
cout << "person& operator=(const person& p)" << endl;
if (&p != this)
{
_age = p._age;
_score = p._score;
_name = p._name;
}
return *this;
}*/
person& operator=( person p)//赋值运算符重载现代写法
{
cout << "person operator=(person p)" << endl;
swap(p);
return *this;
}
~person()
{
cout << "~person()" << endl;
}
void Print1()
{
cout << _name << endl;
cout << _age << endl;
}
string _name = "peter";
int _age;
int _score;
private:
static int my_count;
};
int person::my_count = 0;
class student :public person
{
public:
student(int a1=32,int a2=33,int stuid=100)
:_stuid(stuid)
,person(a1,a2)
{
cout << "student()" << endl;
}
student(const student& p)
:person(p)//由于我们传的是p的引用在person的部分,因此它代表的正是p的父类成员部分的别名,直接拷贝即可
,_stuid(p._stuid)
{
cout << "student(const student& p)" << endl;
}
student& operator=(const student& p)
{
cout << "student& operator=(const student& p)" << endl;
if (&p != this)
{
person::operator=(p);//赋值也是父类调用父类的赋值,子类单独调用子类的
_stuid = p._stuid;
}
return *this;
}
void Print1()
{
person::Print1();
}
~student()
{
cout << "~student()" << endl;
}
int _stuid;
int _major;
};
class teacher :public person
{
public:
int _jobid;
};
int main()
{
student s;
teacher t;
s.Print1();
t.Print1();
person& s1 = s;
person* s3 = &s;
s1._age++;
s1._name = "hbw";
s3->_age = 100;
s3->_name = "LCNB";
s.Print1();
person a4(12, 33);
person MM(a4);
person ss(100,200);
person s10;
s10 = ss;
student zcx(100, 200, 300);
student jbl(1000, 2000, 3000);
jbl = zcx;
student HBW(300000, 20000, 1);
student LC(HBW);
person CXZ;
cout << CXZ.count() << endl;
return 0;
}
通过我的例子,你可以发现,我几乎都使用了分别处理基类和派生类成员的方式,就像我说的,我把基类单独看成一个派生类的成员去看,每次都是分开去处理基类和派生类。写派生类的各种默认函数时,就在其内部单独处理基类的对应的默认函数。
用图解表示就是这样。
6.友元继承问题:
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员,如果想要派生类也具有和父类一样的友元关系,必须在派生类里也加上友元声明才可以。
例如:
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);
}
在这里,这样写,会报错,原因正是友元的问题
7.静态成员的继承问题:
静态成员的本质依旧是不变的,不管有多少个类存在继承,依旧只存在一个这样的静态成员,都只有一个static成员实例,因此对于派生类来说,说它继承了这个静态成员变量也好,说没有继承也可以,而由于派生类又会调用基类的默认成员函数,所以派生类有时会自动去调用静态成员变量。
不过,静态成员变量依旧严格遵守继承关系和访问限定符的修饰的权限设置,虽然可以继承,但是访问权限的限制导致不一定可以访问到。
8.继承的类型 菱形继承以及虚拟继承virtual:
继承主要的类型分为两种:单继承和多继承
单继承:
如图所示,就是一种子类去继承一个父类,然后依次向下:
多继承:
如图所示,就是一个子类同时继承了多种父类,如图:
多继承是一个泛用于对一个对象进行各种精细功能和建模的构建形成的,当然,这只是我的一种解法,比如很多以时装和外观修饰饰品的游戏来说,由于它的目的是针对各种不同的部位作为卖点吸引玩家,因此它的实现过程可能就是把每个部位作为一个父类来实现,然后由一个子类作为整体去继承这些代表部位的父类。
难点:菱形继承问题:
菱形继承是多继承的一种特殊情况:
如上图所示,这种就属于是菱形继承,但菱形继承不仅仅是这种仅限于4个类构成菱形结构的继承方式,我认为叫它菱形继承不够严谨,比如下面的情况:
这种也属于一种菱形继承,因此**,我更喜欢叫它闭环继承,即继承关系之间形成了一个环状结构的封闭圆环。**
那么这种结构的问题出现在哪里呢?
我们还是以第一个标准菱形继承为例:
菱形继承的问题:从上面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。
这是由于我们的最底层的派生类同时继承了两个基类,但这两个基类又同时继承了同一个基类,因此导致我们的最底层的派生类有两份名字和类型都相同的成员,因此在我们访问的时候不知道应该访问哪个,就会出现二义性,同时数据也存在冗余的问题,比如电话号码只需要一份就可以,但是同时存储了两份不同的电话号码还都是打给同一个人的,数据就会冗余,尤其是在切片时,引用或者指针不知道怎样指向公共数据的位置。
如何解决二义性?:
二义性是一个问题,但同时也不一定是一个问题,因为比如一个人在社会中有时确实有不同的称呼,因此想要访问不同的名字,只需要加上对应基类的访问限定符显式访问即可,如下:
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";
}
但是数据冗余的问题依旧无法解决,依旧是有两份重复的数据被存储了起来。
如何解决数据冗余的问题?
在C++中了一种方式可以让我们解决这个问题:虚拟继承virtual
即是在继承方式的前面加上vitual表示虚拟继承即可,这样数据冗余就被解决了,并且变成了,修改一个公有的基类的成员,则所有的拥有这个成员的类都会对应做出相应的改变,统一去进行改变。并且,此时的共有的父类的成员只被存储了一份,而不是被重复存储多份了
virtual的添加位置也有讲究,要在第一批继承了重复数据的子类上加,比如在这里就是Student和teacher加,而Assistant是不需要加的。
注意,一定是第一批继承了基类的重复数据的派生类上加!
虚拟继承解决数据冗余和二义性的原理:
为了查看原理,我们在这里用一个实验性质的程序进行查看:
class A
{
public:
int _a;
};
class B : public A//普通继承
class B : virtual public A //vitual继承
{
public:
int _b;
};
class C : public A//普通继承
class C : virtual public A //vitual继承
{
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的普通继承时,内存的存储情况,你可以发现,它是在两个类里重复存储了两份相同的a,从而实现了效果
然后是加上vitual的虚拟继承时,内存的情况:
这里可以分析出D对象中将A放到的了对象组成的最下面(有的时候,根据编译器的不同,也可能在最上面),这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,他们分别指向一个存储着一个整型数据的地址,而这个整型数据就是在最底层派生类在内存中的当前位置距离成员a的偏移量,B C两个指针对应的地址存储的数据不同,正好对应着他们由于存储的位置不同,同a的偏移量也不同。如下:
由此我们可以这样总结其原理:
虚继承的原理其实有点类似静态成员变量/成员函数的处理方式,对于这种冗余的数据,在存储时直接将其存在派生类成员内存的最上面(最下面),并作为一个公共的数据存储在内存中的特定位置,而对应包括继承了这个类的成员来说,想访问公共成员会利用他们在内存中存下的一个地址,这个地址所存储的数据时这个类的当前位置距离这个公共数据位置的偏移量,这样,不管是切片还是访问都只要通过这个地址+偏移量即可找到对应的公共数据的地址进行访问
9.继承的总结和反思以及一些细节的探究:
1.在继承中,谁先被继承,谁就先被声明,先去调用相关的函数,即继承的顺序是按照从左到右的顺序进行
例子如下:
class A;
class B;
class C;
class D:public C,public B,public A
在这里,就是先去拷贝构造C,然后是B,然后是A,根据继承的先后顺序去调用
2.一般可以去使用多继承,但不建议使用菱形继承,问题很大
3.多继承由于菱形继承的问题,有很大的缺陷,因此后续的语言都没有多继承,如JAVA
4.继承和组合的优缺点:
1.继承是is-a型,也就是说只要是继承,则派生类里必定有一个基类的对象存在
而组合是has-a型,也就是说在一个类里显式写了另一个类,则这个类才会包含在当前的类中,否则这就是两个独立的类,没有关系
2.在两者都通用的前提下,优先可以考虑组合,其次是继承,因为组合对权限有更多的限制,或许你会说继承的private基类权限限制更高,但是那样的基类即使继承了也没有什么意义,大多实际使用的还是public和protected,因此此时的组合对类的权限限制更加严谨,类和类之间的修改导致的影响更小,符合高内聚低耦合的设计思想
3.继承是一种白箱复用,权限更大,能看清楚底层,但随之导致耦合度高,而组合是一种黑箱复用,权限更小,看不清底层,只有上层提供的缺口,但耦合度更低。**
虽然如此,但是我们已经要根据实际情况去考虑,根据设计的逻辑和设计的侧重点去考虑使用哪种,不要公式化套用。先理清当前的场景是组合好还是继承好
5.如何构建一个不能被继承的类:
法一:构造函数私有化:
即在访问限定符private的作用域内部写构造函数,这样派生类没法调用基类的构造函数,也就没法继承
法二:
在C++11中提供了一个关键字final,最终类修饰基类,从而直接让其不能被继承**
总结:
以上便是继承的全部内容了,可以说,继承的出现让我们对类和对象的使用又有了更多的花样,同时也进一步确定了类和对象在C++的地位之高,基本上几乎所有的语法都是在为类和对象的使用进行扩展和补充,他们在实际的工程中十分常用,现在你可以用自己的想象力为自己构建一个游戏人物的对象模型,让我们进一步熟悉继承和类和对象这方面的知识点。