C++三大特性---继承
Posted 可乐不解渴
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++三大特性---继承相关的知识,希望对你有一定的参考价值。
诗句的最终意义是只向着你。
继承的概念
继承是面向对象程序设计使代码可以进行复用的最重要的手段之一,它允许程序员在保持原有类特性的基础上进行扩展,增加新功能,这样产生新的类称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
继承的定义
在下面的图中我们可以看到Person这个类是父类,也称作基类。Student是子类,也称作派生类。
在C++的继承语法中,有三种继承方式,每种继承方式对应不同的效果,具体如下图所示:
其中上面的不可见是针对的子类,在子类中让子类无法直接去得到父类的成员,但父类的所有成员(包括成员函数和成员变量)都被继承下来,只是无法去调用它。
总结:
- 其中在我们使用继承时,基本不会用到private与protect继承方式,因为在子类中就不能直接访问到,除非你将子类声明为父类的友元。既然要声明为友元,倒不如直接将基类的成员弄成public权限和public的继承方式,这样更加方便。
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
类型兼容
什么是类型兼容?
类型兼容是指在公有派生的情况下,一个派生类对象可以作为基类的对象使用的情况。类型兼容又称为类型赋值兼容或者类型适应。
在C++中,类型兼容主要指以下3种情况:
- 派生类对象可以赋值给基类对象。
- 派生类对象可以初始化基类的引用。
- 派生类对象的地址可以赋给指向基类的指针。
针对这三种情况有个形象的说法叫切片或者切割。寓意是把派生类中父类那部分切出来赋值给基类。
子类对象赋值给父类对象
下面我们都以下图这两个类为例来说明
我们创建一个父类对象p和一个子类对象s,将s赋值给p。
void test() //测试子类对象赋值给基类
{
Person p;
Student s;
p = s; //子类对象可以赋值给父类对象
//s=p; //error 基类对象不能赋值给派生类对象
}
子类对象赋值给父类指针
这里创建了一个Person类的指针和一个Student的对象s,然后将s的地址赋值给父类的指针
void test2() //子类对象可以赋值给父类指针
{
Person* pp;
Student s;
pp = &s;
}
子类对象赋值给父类引用
void test3() ///子类对象可以赋值给父类引用
{
Student s;
Person& pp = s;
}
注意:
- 基类对象不能赋值给派生类对象。
- 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。
继承中的作用域
- 在继承中基类和派生类都有独立的作用域范围(即类的花括号内)。
- 当子类和父类中有同名的成员时(成员变量与成员函数),子类成员会将父类的同名成员直接访问屏蔽,针对这种情况我们称为隐藏。
注意:
1、如果想调用父类的成员,可以加以作用域区分来调用。
2、需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
成员隐藏
当你执行如下代码时,我们会发现此时调用func函数是子类的。这就是我们上面所说的子类会将父类同名的成员所隐藏,优先调用子类的。
class Person
{
public:
void func()
{
cout << "I am Person func()" << endl;
}
public:
string m_name;//姓名
string m_age;//年龄
string m_sex;//性别
};
class Student:public Person
{
public:
void func()
{
cout << "I am Student func()" << endl;
}
private:
string m_no; //学号
};
int main()
{
Student s;
s.func();
system("pause");
return 0;
}
如果我们想强制调用到父类的func该怎么办呢?
只需要加一个父类的作用域即可,如下:
s.Person::func();
同理,既然成员函数被隐藏了,那么父类和子类有相同的成员变量同样也会被隐藏。
class A
{
public:
int m_a=0;
int m_b=1;
};
class B :public A
{
public:
int m_b=10;
};
int main()
{
B b;
cout << b.m_b << endl;
return 0;
}
在上面的代码中,我们可以发现在A类中有m_b,同样在B类中也有,此时成员变量之间发生隐藏,我们利用缺省值给成员赋值,在A类中的m_b等于1,而B类中的m_b=10,此时结果如上图所示。在子类对象调用同名的成员时,优先调用子类的成员。
其中在继承关系中,基类与父类的析构函数也都构成隐藏,为什么在继承关系中,基类与类的析构函数也都构成隐藏呢?
答:它们的析构函数名字不是不相同吗,为什么能构成隐藏呢?这是因为编译器会将它们的名字统一处理成destructor。为什么编译器被将它们处理成destructor呢?
这个和我们之后将的多态有关,我们之后在说。
总结 :
1. 当两个成员函数分别在基类和派生类中,同时满足函数名相同就构成隐藏。
2. 注意在实际中在继承体系里面最好不要定义同名的成员。
3. 其中在继承关系中,基类与父类的析构函数也都构成隐藏。
派生类的默认成员函数
这6个默认成员函数,“默认”的意思就是指我们不去写,编译器会帮我们自动生成一个。但如果我们的父类中有指针变量时,需要我们去自己写一个,防止造成浅拷贝问题。下面我们以Person类与Student类为例:
class Person
{
public:
Person(string name, int age, string sex):m_name(name),m_age(age),m_sex(sex)
{
}
Person(const Person& p):m_name(p.m_name),m_age(p.m_age),m_sex(p.m_sex)
{
}
Person& operator=(const Person& p)
{
if (this != &p)
{
this->m_name = p.m_name;
this->m_age = p.m_age;
this->m_sex = p.m_sex;
}
return *this;
}
~Person()
{
}
private:
string m_name;//姓名
int m_age;//年龄
string m_sex;//性别
};
class Student:public Person
{
public: //显示的去调用基类的构造函数初始化属于基类的那部分成员
Student(int no, string name, int age, string sex) :m_no(no), Person(name, age, sex)
{
}
Student(const Student& s):m_no(s.m_no),Person(s) //显示去调用父类的拷贝构造函数,利用子类对象赋值给父类的引用形成切片,
//让父类的拷贝构造初始化它的那部分成员
{
}
Student& operator=(const Student& s)
{
if (this != &s)
{
this->m_no = s.m_no;
Person::operator=(s); //由于子类的operator=与父类的operator=构成隐藏,且有可能会出现浅拷贝问题
//所以我们这里显示的去调用父类的operator=,防止父类有指针成员变量造成浅拷贝问题
}
return *this;
}
~Student()
{
//这里不需要显示的去调用父类的析构函数,即便它们构成隐藏,这里编译器会自动调用子类的析构函数释放子类的部分成员空间
//后会自动的调用父类的析构函数,释放父类的部分成员的空间
//因为对象是在栈上创建的,编译器先去调用父类的构造函数在调用子类的构造函数
//所以销毁就是会自动先调用子类的析构函数在调用父类的构造函数
//Person::~Person()
}
private:
int m_no; //学号
};
由于我们用的是封装好的string类,其类里面封装的是一个字符串类型的指针,在string类里面有深拷贝,所以我们在这直接赋值给它也不会有问题。但如果我们是char*的成员变量就得向上面的方式一样去显示的调用父类的拷贝构造函数或者赋值函数。
总结:
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后,会自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造函数。
6. 派生类对象析构清理先调用派生类析构再调基类的析构函数
继承与友元
在之前的篇幅中,我们讲过友元。那么在继承体系中,继承关系能被继承吗?
答:不行。也就是基类友元关系不能访问子类私有和保护成员。
继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。因为静态成员不是某个对象单独的属性,它在程序开始时产生,在程序结束时销毁。静态数据成员具有静态生存期。
以A和B类为例:
class A
{
public:
A()
{
++m_c;
}
public:
int m_a;
int m_b;
static int m_c;
};
int A::m_c = 0;
class B :public A
{
public:
B(int b):m_b(b)
{}
public:
int m_b;
};
void test()
{
A a1;
B b2(10);
B b3(10);
B b4(10);
B b5(10);
cout << A::m_c << endl;
cout << B::m_c << endl;
}
在上面的代码中和根据图中结果的值我们发现A里面的m_c与B类里面的m_c的值加的是同一个m_c,这就说明静态成员在继承体系中仍然还是只有一个实体,不会因为继承关系而改变。
菱形继承与虚继承
在说菱形继承之前我们先要了解一下单继承、以及多继承的概念。
-
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。如下图所示:我们这里的学生类只继承了一个父类(Person)。
-
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。 如下图所示,助手类(Assistant)使用公有继承方式继承了学生类(Student)和教师类(Teacher)。
-
菱形继承:菱形继承是多继承的一种特殊情况。即多个类继承了同一个公共基类,而这些派生类又同时被另一个类继承。
菱形继承这么做会引发什么问题呢,让我们来看一段代码吧!
class Person
{
public:
string m_name; //姓名
};
class Student:public Person
{
public:
string m_no; //学号
};
class Teacher: public Person
{
public:
string m_subject; //教学科目
};
class Assistant : public Student, public Teacher
{
public:
int m_id; //编号
};
int main()
{
Assistant a;
a.m_name = "张三"; //编译器报错 对m_name的访问不明确
return 0;
}
我们可以看见Assistant的对象模型a里面保存了两份Person,当我们想要调用我们从Person里继承的m_name时就会出现调用不明确问题,并且会造成数据冗余的问题,明明可以只要一份就好,而我们却保存了两份。这种问题被我们称为二义性。
那么我们可以怎样解决二义性呢?
第一种解决方法,使用域限定我们所要成员。
int main()
{
Assistant a;
a.Student::Person::m_name = "张三";
a.Teacher::Person::m_name = "李四";
a.m_name = "王五";
return 0;
}
这种方法是没有问题的,但是,这样做非常的不方便,并且当程序十分大的时候会造成我们思维混乱。在这里并没有解决我们的根本问题数据冗余,这里Assistant类的对象还是继承了两份Person的东西,且我们直接用Assistant 类的对象还是无法直接去调用m_name。那么在C++中给了我们一个另外的解决方案——虚继承。
虚继承
虚继承是什么?
为了解决多继承时的命名冲突和冗余数据问题,C++提出了虚继承,使得在派生类中只保留一份间接基类的成员。虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。
我们只需要在上面的代码中添加一个关键字即可解决菱形继承的二义性问题。观察这个新的继承体系,我们会发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上图中,当定义 Assistant 类时才出现了对虚派生的需求,但是如果 Student 类和 Teacher 类不是从 Person 类虚派生得到的,那么Assistant 类还是会保留 Person 类的两份成员。
下面我们就将上面的代码修改成虚继承如下:
class Person
{
public:
string m_name; //姓名
};
class Student:virtual public Person
{
public:
string m_no; //学号
};
class Teacher:virtual public Person
{
public:
string m_subject; //教学科目
};
class Assistant : public Student, public Teacher
{
public:
int m_id; //编号
};
在上面的动图中我们发现,此时三个m_name现在变成了同一个,最后的结果都变为了王五。
那么虚继承是怎么解决二义性的问题呢?
那么我们用下面这个代码来做示例。
class A
{
public:
int m_a;
};
class B : virtual public A
{
public:
int m_b;
};
class C : virtual public A
{
public:
int m_c;
};
class D : public B, public C
{
public:
int m_d;
};
首先我们想问大家一个问题,这里变为了虚继承后的D类创建的对象是多大字节,没加之前D类的大小是20,那么加了后的占多数字节呢,即求sizeof(D)。在32位平台下,这里我们会惊奇发现是24个字节。
没加虚继承之前D类的大小,且继承了两份m_a,如下图所示:
使用虚继承之后我们会发现D类的大小变为了24,并且我们发现其内部只继承了一份m_a了。既然只继承了一份m_a,并且从B类和C类中继承下来的是一个叫vbptr的东西,那么这个vbptr是什么呢?
vbptr它其实是一个虚基类指针(v—virtual b—base ptr—pointer)。
这个虚基类指针会指向一个vbtable(虚基类表),如下图所示:
其实D从B和C继承下来的都是一个指针,这个指针指向的是B和C的对应的一个虚基类表,这个表内存储的是偏移量,然后这个指针的地址加上这个偏移量之后就可以找到这个唯一的数据。所以这份数据只有一个,我们通过这种虚继承的方式来解决数据的冗余。
总结:
1. 换个角度讲,虚派生只影响从指定了虚基类的派生类(Student或者Teacher)中进一步派生出来的类(Assistant),它不会影响派生类本身
2. 在继承中尽量少用多继承,由于多继承会导致出现菱形继承这种复杂关系 ,可以换成组合。
3. 这里的虚继承不要与后面虚函数和纯虚函数弄混淆,它们之间没有任何关系。
如果小伙伴还没看懂可以在评论区留言,我会在评论区给你解答!
如有错误之处还请各位指出!!!
那本篇文章就到这里啦,下次再见啦!
以上是关于C++三大特性---继承的主要内容,如果未能解决你的问题,请参考以下文章