目录
一、继承的概念及定义
1.1概念
1.2定义
1.2.1定义格式
1.2.2继承关系和访问限定符
1.2.3继承基类成员访问方式的变化
二、基类和派生类对象赋值转换
三、继承中的作用域
四、派生类的默认成员函数
五、继承与友元、静态成员
六、菱形继承与虚拟继承
6.1单继承与多继承
6.2菱形继承(继承缺陷)
6.3虚拟继承(继承缺陷的解决)
6.3.1虚拟继承使用
6.3.2虚拟继承原理
七、继承的反思
一、继承的概念及定义
1.1概念
继承是面向对象中的一个重要概念,它由一个类(子类又叫派生类)继承另一个类(父类又叫基类)的属性和方法。通过继承,子类可以复用父类的代码,并且可以添加自己特定的功能或行为。
样例:
#include <iostream>
using namespace std;
//父类/基类
class Person
{
public:
void fun()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
const char* _name = "张三";
int _age = 18;
};
//字类/派生类
class Student : public Person//子类继承父类
{
//虽然子类中没有自己的方法,但是子类继承了父类,那么子类拥有父类中的方法fun
//只不过这个方法在子类中并没有显示出来,可以通过调试进行观察
//这个方法是属于自己的,对该方法进行修改,不会影响父类中的方法,如果要修改,
//那么就要在子类中显示写出该函数进行修改,但是又有其他规则限定,待后续一步一步讲解。
protected:
int _id;
};
int main()
{
Student stu;
stu.fun();//子类拥有父类中的方法,父类方法中的成员给了初始值,在这里可以直接调用,若没给,需要自己重新实现方法,否则会报错
return 0;
}
调试结果(通过调试可以验证,子类中拥有父类的成员):
输出结果:
根据样例,有了对继承概念的初步了解,但是继承的规则远不止这么简单,有许多细节要注意,接下来进一步深究。
1.2定义
1.2.1定义格式
该定义格式的形式如上图所示,但继承根据继承方式存在一定的限定。
1.2.2继承关系和访问限定符
继承方式有三种:
访问限定符有三种:
访问权限的大小正如上图排序所示,public>protected>private,在没学继承之前,经常用public和private,并且呢private和protected的作用可以认为一致, 但是在继承中因为继承方式的变化,public、private、protected之间的组合会产生不一样的化学反应,究竟是火花四溅,还是一缕如平,且品下回。
1.2.3继承基类成员访问方式的变化
父类成员\子类继承方式 | public继承 | protected继承 | private继承 |
父类的public成员 | 继承下来的子类中拥有的父类成员也为public成员 | 继承下来的子类中拥有的父类成员为protected成员 | 继承下来的子类中拥有的父类成员为private成员 |
父类的protected成员 | 继承下来的子类中拥有的父类成员也为protected成员 | 继承下来的子类中拥有的父类成员也为protected成员 | 继承下来的子类中拥有的父类成员也为private成员 |
父类的private成员 | 父类的private成员,子类不可继承,即在子类中不可见 | 父类的private成员,子类不可继承,即在子类中不可见 | 父类的private成员,子类不可继承,即在子类中不可见 |
根据上述表的统计,可以归纳几点:
①父类的私有成员,子类不能继承且都不可直接访问
②继承下来的子类中拥有的父类成员的访问权限是根据父类中成员的访问权限与子类继承方式中取权限小的那一个,权限相等就取权限相等的那一个,权限大小排行public>protected>private。
例如:父类成员为protected,子类继承方式为public,取权限小的那个为protected
那么根据上述描述,protected和private到底有什么区别呢?
①不是继承关系的外界依然不能访问protected成员
②如果不是私有继承,子类可以访问父类的protected成员
所以protected的作用基本上只能在继承中才能体现。
除了有上述的组合产生不同的权限访问问题,还有几点也要了解:
①继承方式可以省略不写,若没写,父类如果是class类,其继承方式默认为私有继承,父类如果是struct类,其继承方式默认为公有继承
②在实际运用中一般使用都是public继承,几乎很少使用protected/private继承,也不提倡使用,因为protected/private继承下来的成员都只能在派生类的类里面使用,实际中维护性并不高。
例子(展示省略继承方式):
#include <iostream>
using namespace std;
//父类/基类
class Person
{
public:
void fun()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
const char* _name = "张三";
int _age = 18;
};
//字类/派生类
class Student : Person//省略继承方式,父类是class类,其继承方式默认为私有继承
{
//那么此时子类中拥有的fun成员是私有的
protected:
int _id;
};
int main()
{
Student stu;
stu.fun();//fun是私有的,外界不可直接访问,此时就会报错
return 0;
}
struct类就不演示了。
二、基类和派生类对象赋值转换
既然存在了继承,那么对于父类对象和子类对象是否存在赋值关系?是的,但是他们之间的赋值存在着规则限定。
- 子类对象可以赋值给父类对象/父类指针/父类引用。这种赋值还有另外一个说法叫做切片/切割,表示把子类中父类的那部分切割/赋值给父类对象。如图:
- 父类对象不能直接赋值给子类对象
- 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用。但是必须是父类的指针指向子类对象时才是安全的,否则可能发生越界访问。
例子:
#include <iostream>
using namespace std;
//父类/基类
class Person
{
public:
void fun()
{
cout << "name:" << _name << endl;
cout << "sex:" << _sex << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "张三";
string _sex = "男";
int _age = 18;
};
//字类/派生类
class Student : public Person
{
public:
int _id;
};
int main()
{
Student stu;
Person p = stu;//把子类中父类的那部分切割/赋值给父类对象
Person* ptr = &stu;//父类指针ptr指向子类对象中父类的那一部分成员
Person& pptr = stu;//父类引用pptr引用子类对象中父类的那一部分成员
Student* s = (Student*)ptr;//ptr指向子类对象,进行强转后,s依然指向子类对象。注意强转并不能改变ptr所能访问的大小,
//ptr所能访问的大小还是根据其本身的类型决定,ptr是父类指针,其所能访问的大小是sizeof(Person),进行强转后,依然是这么大,
//强转不会对其原本的类型发生改变,ptr依然是Person*类型
s->_id = 10;
ptr = &p;//改变父类指针ptr指向为父类对象p
Student* ss = (Student*)ptr;//此时ptr指向了父类对象p,进行强转赋值给了Student*对象,那么此时ss指向了父类对象
//ss所能访问的大小为sizeof(Student)
ss->_id = 20;//由于ss指针指向了父类,父类中并没有_id成员,此时就发生了越界访问。
return 0;
}
运行越界错误,由于父类指针给了子类指针,即子类指针指向了父类,但又访问了不属于所指向类的成员:
在了解完基类与派生类之间的赋值之后,有一个问题没有体现,那就是如果子类有与父类相同的成员,然后子类对象调用该成员究竟是调用子类的还是父类的?既然子类中有父类的成员,那子类又该如何访问那么接下来就来解答
三、继承中的作用域
了解作用域的划分,限定,方能明白如何去访问子类父类成员。那就有以下几条规则。
1.在继承体系中父类和子类都有独立的作用域,子类中拥有父类的成员是属于自己的,对其进行修改并不会影响原父类的成员
2.子类和父类有同名成员,即子类中显示的与父类有同名成员,子类成员将屏蔽父类对同名成员的直接访问(子类访问该同名成员时,访问的就不是属于父类作用域的同名成员,而是子类中的同名成员,但是子类中依然有父类中的成员),这种情况叫隐蔽,也叫重定义(如果要访问父类同名成员,就要显示进行访问,即 父类::父类成员)
3.对于成员函数的隐藏,只要子类的成员函数的名字与父类成员函数的名字相同就构成隐藏,与返回值,参数类型无关。
例子:
#include <iostream>
using namespace std;
//父类/基类
class Person
{
public:
void fun()
{
cout << "name:" << _name << endl;
cout << "sex:" << _sex << endl;
cout << "age:" << _age << endl;
cout << endl;
}
protected:
string _name = "张三";
string _sex = "男";
int _age = 18;
};
//字类/派生类
class Student : public Person
{
public:
void fun()//与父类fun同名,构成隐藏
{
Person::fun();//由于fun构成隐藏要访问父类的fun,就得显示调用,如果不指定,调用的就是构成隐藏的fun,就会造成无限递归
cout << "name:" << _name << endl;
cout << "sex:" << _sex << endl;
cout << "age:" << _age << endl;
cout << endl;
Person::fun();//第二次显示调用
}
protected:
string _name = "李四";//子类该成员与父类该成员同名,构成隐藏,并进行修改,不会影响父类的_name,因为这是属于自己的
int _age = 20;//子类与父类同名,构成隐藏,并进行修改,不会影响父类的_age
int _id = 10;
};
int main()
{
Student stu;
stu.fun();
Person p = stu;//虽然子类出现了隐藏,但并不影响赋值/切割,子类中依然有父类中的成员并把这些成员赋值给父类对象
p.fun();
return 0;
}
调试结果:
输出结果:
有一点需注意的是,当在fun中修改成员时,不要认为是属于Student的同名成员:
class Student : public Person
{
public:
void fun()//与父类fun同名,构成隐藏
{
Person::fun();//由于fun构成隐藏要访问父类的fun,就得显示调用,如果不指定,调用的就是构成隐藏的fun,就会造成无限递归
_name = "李四";//虽然fun构成了隐藏,但是该fun中的成员还是属于父类类域,对其进行修改就是修改父类中的成员
_age = 20;
cout << "name:" << _name << endl;
cout << "sex:" << _sex << endl;
cout << "age:" << _age << endl;
cout << endl;
Person::fun();//第二次显示调用
}
protected:
int _id = 10;
};
将鼠标光标放于该成员上,也可看出其依然是属于Person类域,编译器会自动去识别。
调试结果:
输出结果:
研究完子类的访问情况,对于子类的默认成员函数又和之前学过的类的默认成员函数是否有什么不同,来进一步学习。
四、派生类的默认成员函数
同样的,是个类就会有6个默认成员函数,虽说我们不写,编译器会默认实现,但是子类对应的6个成员函数编译器具体是如何生成的呢?其符合以下规则。
1.子类的构造函数必须调用父类的构造函数来初始化父类的那一部分成员。也就相当于完成了子类中父类成员的初始化,如果父类没有默认构造函数,则必须在子类的构造函数的初始化列表阶段显示调用
2.子类的拷贝构造函数必须调用父类的拷贝构造完成父类的那一部分成员的拷贝初始化。也就相当于完成了子类中父类成员的拷贝初始化。
3.子类的operator=必须要调用父类的operator=完成父类的赋值。也就相当于完成了子类中父类成员的赋值
4.子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员。这样保证了先清理子类对象的成员再清理子类对象中父类的那部分成员,也保证了在清理子类对象前可以去访问子类对象中父类的那部分成员。如果调用子类析构时,先完成了父类的析构,后续子类可能还会访问父类成员,而父类成员已经释放了,会导致野指针访问
那么我们就来手动实现一下编译器具体是如何生成这些函数的:
#include <iostream>
using namespace std;
//父类/基类
class Person
{
public:
Person(const string name = "张三")//默认构造函数
:_name(name)
{
cout << "Person()" << endl;//通过打印字符标记是否调用过该默认构造
}
//Person(const string name)//构造函数
// :_name(name)
//{}
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()
{
node = nullptr;
_name = "";
cout << "~Person()" << endl;//通过打印字符标记是否调用过该析构
}
protected:
string _name;
struct p
{
public:
int a = 1;
};
p a;
p* node = &a;
};
class Student : public Person
{
public:
//若父类有默认构造,则在调用子类构造初始化子类成员前会先去调用父类默认构造,
//若父类没有默认构造,但有构造函数,则需在子类中显示调用父类构造
//当然对编译器来说,我们啥都没写,会自己生成默认构造的
Student(int id)//构造
:Person("李四")//显示调用。注意:将该显示调用与_id初始化互换上下位置,并不会影响规则,编译器还是会先去调用父类构造
,_id(id)
{
cout << "Student(int id)" << endl;//通过打印字符标记是否调用过该默认构造
}
//调用子类的拷贝构造时必须先去调用父类的拷贝构造完成父类的那一部分成员的拷贝初始化
Student(const Student& s)//拷贝构造
:Person(s)//显示调用父类拷贝构造。同理将该显示调用与_id初始化互换上下位置,并不会影响规则,编译器还是会先去调用父类拷贝构造
,_id(s._id)
{
cout << "Student(const Student& s)" << endl;//通过打印字符标记是否调用过该拷贝构造
}
//调用子类的operator=必须必须先去调用父类的operator=完成父类的赋值
Student& operator=(const Student& s)//赋值重载
{
if (this != &s)
{
Person::operator=(s);//显示调用父类的赋值重载
_id = s._id;
}
return *this;
}
//子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员
~Student()
{
//Person::~Person();//如果在这里调用子类析构时,先完成父类的析构,
//node->a = 3;//然后访问父类成员,而父类成员已经释放了,node为空指针,导致对空指针的解引用
//node->a = 2;//先调用子类析构,父类还未析构,那么子类可以继续访问父类成员。
cout << "~Student" << endl;//通过打印字符标记是否调用过该析构
}
protected:
int _id;
};
int main()
{
Student stu(20);
Student s(stu);
Student ss = s;
return 0;
}
当调用子类析构时,先完成了父类的析构,然后访问父类成员,程序崩溃:
代码正确时的调试结果:
总结:对于派生类这些成员函数规则,其实跟我们之前玩的类的规则类似,唯一不同的是,不管是构造/拷贝/析构,就是多了父类那一部分,那么区别的他们的原则是:父类那部分就调用父类对应的函数完成。
艰难的完成了成员函数的原理实现,接着就是熟悉的友元和静态成员了
五、继承与友元、静态成员
- 继承与友元:
友元关系不能继承,也就是说父类的友元只能访问父类的私有和保护成员,不能访问子类的私有和保护成员。
#include <iostream>
using namespace std;
class Student;
class Person
{
friend void Print(const Person& p, const Student& s);//在上面声明一下Student类,这样友元中的Student可以在这书写,因为Student类是在下面定义的
protected:
string _name = "王五";
};
class Student : public Person
{
protected:
int _num = 1;
};
void Print(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._num << endl;//友元不能继承,所以在这s不能直接访问保护成员
}
int main()
{
Person p;
Student s;
Print(p,s);
return 0;
}
报错,也可看出友元不能继承:
- 继承与静态成员:
父类中定义了静态成员,则整个继承体系中只有一个这样的成员。无论派生出多少个子类,都只有一个静态成员实例。因为静态成员不属于某个具体的类,而是存放在静态区
#include <iostream>
using namespace std;
class Person
{
public:
Person()
{
_count++;
}
public:
static int _count;
};
int Person::_count = 0;//静态成员类外定义
class Student : public Person
{
protected:
int _num;
};
class ID : public Student
{
protected:
int _id;
};
int main()
{
Student s1;//调用父类默认构造
Student s2;//调用父类默认构造
ID i;//调用Student的默认构造,Student又会调用Person默认构造
cout << Person::_count << endl;
Student::Person::_count = 0;//改变子类中的静态成员就是改变所有类的静态成员
cout << Student::Person::_count << endl;
cout << ID::Person::_count << endl;
cout << Person::_count << endl;
s2.Person::_count = 1;
cout << Person::_count << endl;
return 0;
}
输出结果:
最后就是到了继承最复杂的部分,无疑是多继承,然而多继承又会存在一定的弊端。让我们拭目以待
六、菱形继承与虚拟继承
6.1单继承与多继承
我们先来了解继承的单继承和多继承。
单继承:一个子类只有一个直接父类。
class Person
{};
class Student : public Person
{};
class ID : public Student
{};
每个子类只有一个直接父类,示意图:
多继承:一个子类有两个或两个以上直接父类。
class Person
{};
class Student
{};
class ID : public Person,public Student
{};
其中ID有两个父类,这就是一个多继承。示意图:
如上面,多继承似乎比单继承有一定的优势,多继承下来的子类可以同时拥有两个父类的成员,
确实,但多继承也存在着缺点,那就是菱形继承的存在。
6.2菱形继承(继承缺陷)
菱形继承:是多继承的一种特殊情况
菱形继承有着一定的缺陷,它存在二义性和冗余性。如下:
#include <iostream>
using namespace std;
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 _course;
};
int main()
{
Student s;
s._name = "张三";//访问子类中父类的成员,该父类成员由Person提供
Teacher t;
t._name = "张坤";
Assistant a;
//a._name = "李四";//Assistant有两个父类,这两个父类继承了Person,都有_name成员,
// 而Assistant继承了这两个父类,那么Assistant就有两份该成员,这就导致了数据的冗余
//其次,访问Assistant子类中父类的成员_name究竟是由Student提供的还是由Teacher提供的,就存在二义性
//那么只能显示指定访问哪个父类成员才可以解决二义性,但是数据的冗余性还没有解决。
a.Student::_name = "李四";
a.Teacher::_name = "王五";
return 0;
}
菱形继承示意图:
Assistant子类示意图:
虽然Student对象和Teacher对象中Person成员各自拥有一份,但Assistant对象中Person成员拥有两份,导致数据的冗余性,当Assistant对象访问其父类中的成员_name时,该成员不知道是Student中的还是Teacher中的,导致了数据的二义性。
调试结果:
正如上述示意图所示。
多继承可以认为是该语法的缺陷,可能祖师爷当时也并没有想到会出现这么一个幺蛾子,所以为了解决该两处问题,引入了虚拟继承。
6.3虚拟继承(继承缺陷的解决)
6.3.1虚拟继承使用
虚拟继承需要使用一个的关键字virtual,其关键字加在所继承父类的前面。其符合以下继承规则:
虚拟继承的作用是使得重复的数据变成一份,存放在另一处,而当要访问该数据时,便到所存储的该数据位置进行查找,从而解决数据的冗余性导致访问时的二义性。
例如,将该菱形继承改成虚拟继承,Student和Teacher继承了最头部,那么在继承时,Person前面加关键字virtual:
#include <iostream>
using namespace std;
class Person
{
public:
string _name;
};
class Student : virtual public Person//虚拟继承,在继承的父类前加关键字
{
protected:
int _num;
};
class Teacher : virtual public Person//虚拟继承,在继承的父类前加关键字
{
protected:
int _id;
};
class Assistant : public Student, public Teacher//由于其两个父类采用了虚拟继承,那么Assistant中的_name成员只有一份了
{
protected:
string _course;
};
int main()
{
Student s;
s._name = "张三";//访问子类中父类的成员,该父类成员由Person提供
Teacher t;
t._name = "李四";
Assistant a;
a._name = "王五";//由于Assistant对象中的_name成员只有一份了,可以直接访问
return 0;
}
虚拟继承下Assistant子类示意图:
由于其两个父类采用了虚拟继承,那么Assistant中的_name成员只有一份了,被放在了最下面。
调试结果:
通过调试结果,也可观察出,采用虚拟继承,重复的部分被放在了最下面,数据不在出现冗余,解决了二义性。但是,调试结果是为了方便观察这么一个现象,是一种形象表达,并不代表已经不是冗余的数据是直接存放在该子类对象中,所以就要去探究其到底有何玄学。那么接下来就来诠释其底层的奥秘。
6.3.2虚拟继承原理
为了更清楚的了解虚拟继承的原理,就要从内存的角度来观察,这里引入了两个新的名词概念,
虚基表指针和虚基表,虚基表指针指向虚基表,虚基表存放的是偏移量,代表虚基表指针的地址距离那只有一份成员地址的距离。
结合概念和代码来演示:
#include <iostream>
using namespace std;
class Person
{
public:
string _name;
};
class Student : virtual public Person//虚拟继承,在继承的父类前加关键字
{
public:
int _num;
};
class Teacher : virtual public Person//虚拟继承,在继承的父类前加关键字
{
public:
int _id;
};
class Assistant : public Student, public Teacher//由于其两个父类采用了虚拟继承,那么Assistant中的_name成员只有一份了,那么它属于a对象中两个父类和自己共享
{
public:
int _size;
};
int main()
{
Assistant a;
a._num = 1;
a._id = 2;
a._size = 3;
//_name只有一份,它属于a对象中两个父类和自己共享
a.Student::_name = "张三";
a.Teacher::_name = "李四";
a._name = "王五";
return 0;
}
调试监视窗口角度:
通过调试结果也可证实, _name只有一份,它属于a对象中两个父类和自己共享,修改_name就会影响全部。
内存角度:
根据内存角度就可以更好的诠释虚拟继承的原理是如何体现的,图中有一处值得注意的是,a对象父类Student的成员的第一个为何是虚基表指针,而第二个是 _num成员,其实这样的分布顺序是根据成员声明的顺序来排布的,_name在Person中已经声明了,那么Person的子类中,_name就相当于是先声明的,其实在调试中也可观察出这一现象。
七、继承的反思
1. 毫无疑问的,继承充分体现了C++语法的复杂,从单继承->多继承->菱形继承->虚拟继承,这一路的变化,使得其体系无比炸裂,对于很多人难以招架,这也是C++的缺陷之一了,有了这样的教训,在后来许多的语言就没有多继承了,如java。所以我们在实践中,也不建议设计出多继承,尤其是菱形继承。
2.尽量为了避免继承,实践中会选择组合
- 继承是一种is-a的关系。每个派生类对象都是一个基类对象
继承中的复用又称为“白箱”。“白箱”:其内部是可见的。在继承中,基类内部对子类而言是可见 的,那么基类的改变就会影响子类,一定程度上破坏了基类的封装,使得基类和派生类之间 的依赖关系耦合度高。
- 组合是一种has-a的关系。B中组合了A,那么每个B对象都有一个A对象
组合中的复用称为“黑箱”。“黑箱”:其内部不可见。B对象中的A对象对外是不可见的,在B被 使用时,外部不会对其内部造成影响,那么组合与组合之间的耦合度就低。
所以在实践中,优先使用对象组合,耦合度低,代码维护性好。但并不代表继承毫无用处,在实现多态时,就需要用到继承。
那么本章节就到此结束了~