目录
- 前言
- 一、继承的定义及概念
- 二、继承方式
- 三、基类和派生类之间的转换
- 四、切片
- 五、继承中的作用域
- 六、派生类中的默认成员函数
- 七、继承中的友元与静态成员
- 继承与友元
- 继承中的静态成员
- 八、棱形继承和虚继承
- 棱形继承
- 虚继承
- 总结
前言
继承是面向对象的一个重点,而继承和多态也息息相关。继承继承,顾名思义,就是继承父亲的所有。现实中有继承,那么C++里面也有继承。所以说,程序实际上就是对现实世界的抽象。废话不多说,接下来步入正题。
一、继承的定义及概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象
程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继
承是类设计层次的复用。
那么继承如何定义呢? 很简单,继承的定义格式class 类名 : 继承方式(public/protected/private) 基类类名。
代码演示:
首先我们需要有一个基类,我暂且把基类的成员设置为公有。
class Person
{
public:
string _name;
};
其次我们需要一个派生类,来继承基类。我们的基类是Person,那我们再定义一个Student类,来继承Person。
Student定义:
class Student : public Person
{
public:
int _num;
};
这样,Student类和Person类就完成了继承关系。Student 就是Person的子类。但这也不意味着子类就可以使用父类的成员了。因为C++中的继承方式有9种。
二、继承方式
C++中的继承方式有以下9种。
类成员/继承方式 | public方式继承 | protected方式继承 | private方式继承 |
---|---|---|---|
基类的public成员 | 在派生类中是public | 在派生类中是protected | 在派生类中是private |
基类的protected成员 | 在派生类中是protected | 在派生类中是protected | 在派生类中是private |
基类的private成员 | 在派生类种不可见 | 在派生类种不可见 | 在派生类种不可见 |
也就是说,用public继承的话,基类的成员会原封不动的继承到派生类。所以我们上面用public继承,那么基类的public成员继承到派生类还是public成员。所以我们可以直接使用父类对象的public成员。
三、基类和派生类之间的转换
那么我们把子类(派生类) 赋值给 父类(基类),可以吗?
我们用下面这个代码来试试。
void test2()
{
Person p;
Student s;
s._name = "李四";
s._num = 2;
p = s;
cout << p._name << endl;
}
测试结果 :
子类对象是可以给父类的。那么父类对象可以给子类吗?
答案是不行,但是如果你真的想转,也是可以的。
//父类传给子类
void test3()
{
Person p;
Student s;
Person* pp = &p;
Student* ps = (Student*)pp;
ps->_name = "张三";
cout << ps->_name << endl;
}
但是这有很大的风险,那就是内存越界!因为基类没有派生类的成员。而用派生类的指针去强制指向父类,那么当派生类去访问自己的成员的时候,就会导致内存越界。
为什么会这样呢?那是因为在转换的过程会进行切片操作。
四、切片
当基类和派生类对象进行转换时,会发生切片操作。
基类有一个成员的_name,而派生类Student继承了Person。所以Student有个隐藏的成员_name。
当把Student 赋值给 Person时,会发生切片操作。
简单理解就是把 Person 给 Student的成员 赋值给了 Person,而Student自己的成员_num则不会赋过去。
为什么Person 不能赋值给Student? 因为Person没有足够的成员给Student,Student自己的成员_num无法被给予。
在这里插入图片描述
为什么指针强制转换就可以呢?
因为pp一开始指向Person
而Person 的空间范围是
此时把它强制转换成 Student指针的话,那么它的范围会变成这样。
这就造成了越界访问,所以这是非常危险的操作。
五、继承中的作用域
如果2个类中,有相同的成员名,那么父类的成员会被隐藏。优先使用子类的成员,简单说就是就近原则。
代码演示:
class Person
{
public:
void f()
{
cout << "Person" << endl;
}
string _name;
};
class Student : public Person
{
public:
void f() //和父类有相同的函数成员
{
cout << "Student" << endl;
}
int _num;
};
void test4()
{
Student s;
s.f();
}
运行结果:
我们会发现它只调用了自己的成员,如果想调用父类的成员,那么需要::域限定符才能调用父类成员。
void test4()
{
Student s;
s.Person::f();
}
这样,我们就调用了父类的成员函数
六、派生类中的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1 . 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2 . 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3 . 派生类的operator=必须要调用基类的operator=完成基类的复制。
4 . 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5 . 派生类对象初始化先调用基类构造再调派生类构造。
6 . 派生类对象析构清理先调用派生类析构再调基类的析构。
7 . 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
接下来我们来测试一下
class Person
{
public:
Person()
{
cout << "Person" << endl;
}
Person(const Person& p)
{
if (&p != this)
{
_name = p._name;
}
cout << "&Person" << endl;
}
~Person()
{
cout << "~Person" << endl;
}
string _name;
};
class Student : public Person
{
public:
Student()
{
cout << "Student" << endl;
}
~Student()
{
cout << "~Student" << endl;
}
int _num;
};
void test1()
{
Student s;
}
int main()
{
test1();
}
我们可以发现,它先调用了基类的构造函数,再调用派生类的构造函数。随后派生类先调用析构函数,基类再调用析构函数。
同样的,派生类再调用拷贝构造函数前,必须显示调用基类的拷贝构造函数。
class Student : public Person
{
public:
Student()
{
cout << "Student" << endl;
}
Student(const Student& p)
:Person(p) //先调用基类的拷贝构造
{
if (&p != this)
{
_num = p._num;
}
cout << "&Student" << endl;
}
~Student()
{
cout << "~Student" << endl;
}
int _num;
};
而重写派生类 赋值操作符时,也必须先调用基类的 赋值操作符重写。
//基类的 =重写函数
Person& operator=(const Person& p)
{
_name = p._name;
}
//派生类的=重写函数
Student& operator=(const Student& p)
{
Person::operator=(p); //调用基类的 =赋值重载
_num = p._num;
}
那么派生类的析构函数是否也要先调用基类的构造函数呢? 答案是不用!因为栈是一个后进先出的结构。基类先调用构造函数,那么基类就必须后析构。 这样才符合栈先进后出的性质,如果在析构函数里面调用了基类的析构函数,那么基类就会析构俩次。此时如果基类有动态申请的空间。那么程序就会报错,因为一块空间被释放了2次。
七、继承中的友元与静态成员
继承与友元
父类的友元不会继承给子类。通俗来讲,就是你父亲的朋友你不一定认识。如果要父类的友元也是子类的友元,那么需要子类自己去friend一下。
继承中的静态成员
静态成员会被继承,但是不会给它新开空间。通俗的说,子类父类共用同一个静态成员。
我们给父类加一个静态成员 _count,每次调用构造函数就++一次。
class Person
{
public:
Person()
{
++_count;
cout << "Person" << endl;
}
Person(const Person& p)
{
if (&p != this)
{
_name = p._name;
}
cout << "&Person" << endl;
}
Person& operator=(const Person& p)
{
_name = p._name;
}
~Person()
{
cout << "~Person" << endl;
}
static int _count;
string _name;
};
int Person::_count = 0;
然后我们打印一下子类父类的count值以及count的地址
void test3()
{
Person p;
Student s;
cout << p._count << ":" << &p._count << endl;
cout << s._count << ":" << &s._count << endl;
}
这里我们可以看出,子类会继承父类的 静态成员,并且与父类共用一个静态成员。
八、棱形继承和虚继承
棱形继承
C++是支持多继承的,而多继承就会面临一个问题,那就是菱形继承。什么是菱形继承呢? 看一下这张图就知道了。
如图,assistant是助手类,它既可以继承Teacher,也可以继承Student。而Student 和 Teacher 都继承 Person。而这就形成了一个菱形继承,而菱形继承具有很大的冗余性和二义性。
那么我们也这段代码来测测它的冗余性。
class Person
{
public:
Person()
{
cout << "Person" << endl;
}
Person(const Person& p)
{
if (&p != this)
{
_name = p._name;
}
cout << "&Person" << endl;
}
Person& operator=(const Person& p)
{
_name = p._name;
}
~Person()
{
cout << "~Person" << endl;
}
string _name;
};
class Student : public Person
{
public:
Student()
{
cout << "Student" << endl;
}
Student(const Student& p)
:Person(p) //先调用基类的拷贝构造
{
if (&p != this)
{
_num = p._num;
}
cout << "&Student" << endl;
}
Student& operator=(const Student& p)
{
Person::operator=(p); //调用基类的 =赋值重载
_num = p._num;
}
~Student()
{
cout << "~Student" << endl;
}
int _num;
};
class Teacher : public Person
{
public:
Teacher()
{
cout << "Teacher" << endl;
}
Teacher(const Teacher& p)
:Person(p) //先调用基类的拷贝构造
{
if (&p != this)
{
_num = p._num;
}
cout << "&Teacher" << endl;
}
Teacher& operator=(const Teacher& p)
{
Person::operator=(p); //调用基类的 =赋值重载
_num = p._num;
}
~Teacher()
{
cout << "~Teacher" << endl;
}
int _num;
};
class Assistant : public Teacher,public Student
{
public:
Assistant()
{
cout << "Assistant" << endl;
}
~Assistant()
{
cout << "~Assistant" << endl;
}
int _num;
};
void test4()
{
Assistant a;
}
int main()
{
test4();
}
我们发现,创建一个对象,Person被调用了2次。如果Person上面有大型数组,那么就会造成数据冗余。
再调用Assistant类的构造函数之前,必须先调用其父类的构造函数。而其继承顺序是先继承Teacher,所以它会先调用Teacher的构造函数。但又因为Teacher又继承了Person,所以Person的构造函数要在Teacher之前调用。所以调用顺序就是 Person->Teacher->Person->Student->Assistant 。析构顺序则是反过来。
而棱形继承不仅有冗余性,还有二义性。我们用Assistant对象调用一下_name成员。
我们会发现它提示,_name不明确。那是因为不知道用Teacher的_name还是用Student的_name。所以会出现这种情况。要解决这个问题也很简单,加上域限定符指定即可。
即便解决了二义性的问题,但依然存在冗余的情况,所以这种情况我们可以用虚继承。
虚继承
虚继承是在"腰"上进行的,也就是在 :后面加上virtual
Teacher类虚继承Person
Student类虚继承Person
然后我们再运行看看
我们可以看到,二义性解决了,冗余性也得到了改善。Person只会被构造一次。可能看似没什么,但如果我Person 有一个成员变量是 1个大小为100000的数组呢? 那么构造2次就会产生2个这样的数组,这样就造成了严重的空间浪费。
那么虚继承在内存中是怎么操作的呢? 首先,我们先写个棱形继承。
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
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;
d._a = 0;
return 0;
}
然后我们调试,打开内存监测窗口。
然后一步一步调试观察
最后的结果是这样的
我们发现 B和C的公共成员都存放在最后一行。
那么B和C上面的地址是什么呢?我们在内存观测一下
48 7b 00 01 ,小端存储的话就是倒过来的,实际地址是 01 00 7b 48
54 7b 00 01 ,小端存储就是 01 00 7b 54
我们发现第二行全是0,这是给其他东西预留的,我们可以不用管。我们只看第二行。而14是16进制的值,转换为10进制就是20,0c转换成十进制就是12。
这样我们就可以得出,第二行存放的是到公共成员的偏移量/相对距离。而偏移量就是地址处到公共成员的距离。
也就是说,在D里面,A放在了一个公共的位置,不属于B也不属于C。而B和C要去找A的时候。会通过 地址处(虚基表) 保存的偏移量,来找到A。
总结
在java或者其他的一些语言中,是没有多继承的,只有单继承。因为多继承可能是一个缺陷,也很少用到。但这也正是C++的特点,而和继承息息相关的还有多态,多态的难度还要大于继承。所以后面会为大家写一篇关于多态的文章。如果文中有讲的不好的,或者错误的地方。欢迎各位指出。