欢迎阅读本博客关于C++继承主题的系列文章。继承作为C++中非常重要的概念之一,它在实际开发中具有广泛的应用。通过深入理解和应用继承,我们可以更好地组织和管理代码,提高代码的可重用性和可扩展性。
在本系列博客中,我们将从继承的概念和定义开始,详细探讨了继承的特性和使用方式。我们将学习如何创建派生类,并继承基类的属性和行为。同时,我们还将介绍基类和派生类对象之间的赋值转换(“赋值兼容转换”),帮助我们在实际开发中灵活地使用继承。
继承中的作用域问题也是我们需要重点关注的内容。我们将探讨当派生类继承了基类的成员变量和成员函数时,可能会出现的命名冲突问题,并提供解决方案,使代码更加清晰易读。
在实际开发中,派生类有时候没有自己的构造函数或析构函数。我们将讨论编译器生成的默认成员函数的行为和使用方式,并通过实际开发场景来说明如何正确使用这些默认成员函数。
此外,我们还将介绍继承与友元的关系,以及如何通过友元函数或友元类在派生类中访问基类的私有成员。我们还将讨论继承中静态成员的行为和使用方式,并结合实际开发场景来说明如何正确地访问和使用基类中的静态成员。
在博客的最后,我们将探讨菱形继承的结构以及菱形虚拟继承的解决方案。这些内容将帮助我们更好地理解继承中可能出现的数据冗余和二义性问题,并学习如何通过虚拟继承来解决这些问题。
最后,我们将总结和反思继承相关知识、介绍继承和组合的区别,并提供一些与继承相关的笔试面试题,帮助读者巩固和检验自己对继承的理解和掌握程度。
希望本系列博客能够帮助读者更好地理解和应用继承的概念。通过学习和掌握这些知识,您将能够在实际开发中更加灵活和高效地使用继承,提升代码的可维护性和可扩展性。让我们一起开始这个关于C++继承的精彩旅程吧!
个人主页:Oldinjuly的个人主页
文章收录专栏:C++
欢迎各位点赞收藏⭐关注❤️
目录
1.继承的概念和定义
(1)继承的概念
(2)继承的定义
2.基类和派生类对象的赋值转换(“赋值兼容转换”)
3.继承中的作用域
4.派生类的默认成员函数
4.1 默认构造
1.我们不写,编译器默认生成的
2.我们自己写的默认构造
4.2 拷贝构造
1.我们不写,编译器默认生成的
2.我们自己写的拷贝构造
4.3 赋值运算符重载
1.我们不写,编译器默认生成的
2.我们自己写的赋值
4.4 析构函数
1.我们不写,编译器默认生成的
2.我们自己写的析构:对于父类继承下来的成员啥也不用写!!!
4.5 不能继承的类
5.继承与友元
6.继承与静态成员
7.菱形继承及菱形虚拟继承
7.1 单继承、多继承、菱形继承
7.2 C++的内存对象成员模型
7.2.1 单继承
7.2.2 多继承
7.3 菱形继承的问题
7.4 虚拟继承
7.5 虚拟继承解决数据冗余和二义性的原理
7.5.1 普通继承的内存对象模型:
7.5.2 虚拟继承的内存对象模型:
7.5.3 普通继承和虚拟继承的内存对象成员模型比较:
8.继承的总结和反思
8.1 组合和继承?
8.1.1 组合
8.1.2 继承
8.2 继承的总结反思
9.笔试面试题
继承是面向对象程序设计中使代码可以复用的最重要的手段。
继承也是类设计层次的复用。
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;//学号
};
class Teacher :public Person
{
protected:
int _jobid;//教号
};
定义格式:
class Student: public Person
{
...;
}
Person:父类,基类
Student:子类,派生类
public:继承方式
继承关系和访问限定符:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。(在继承体系中很少使用private成员)
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式== Min(成员在基类的访问限定符,继承方式),public > protected> private。
- 继承方式可以不用写出,使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
class Person
{
protected:
string _name;//姓名
string _sex; //性别
int _age;//年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
void Test()
{
Student sobj;
//1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派生类对象
//sobj = pobj;
//3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj;//基类指针指向子类对象
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;//基类指针指向基类对象
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
ps2->_No = 10;
}
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(类似“局部优先原理”)
- 在子类成员函数中,可以使用基类::基类成员显示访问(指定调用)。注意:这里父类的同名成员其实也在子类中,只是不能直接访问,需要用作用域限定符访问。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
- 要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
注意:函数重载和隐藏的区别:
//B中的func和A中的func不构成重载,因为不在一个作用域中
//B中的func和A中的func构成隐藏,成员函数满足函数名相同就构成隐藏
class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B :public A
{
public:
void func(int i)
{
A:: func();
cout << "func(int i)->" << i << endl;
}
};
子类可以认为是一个合成的类:
- 继承下来的父类成员(看做一个整体):就要用父类的默认成员函数
- 自己的成员:用自己的默认成员函数
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
{
public:
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator = (const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s);
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
子类默认生成的默认构造:
- 自己的成员:内置类型不做处理,自定义类型去调用他的默认构造。
- 继承下来的父类成员:去调用父类的默认构造。(如果父类没有默认构造,报错)
Student(const char* name = “tom”, int num = "100")
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Person(name); ---> 这种写法就是父类成员调用父类的默认构造。类似匿名对象的写法。
子类默认生成的拷贝构造:
- 自己的成员:内置类型直接值拷贝,自定义类型去调用它的拷贝构造。
- 继承下来的父类成员:去调用父类的拷贝构造。
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
这里可以直接用子类对象来调用父类的拷贝构造Person(s):
原因:赋值兼容转换,子类对象可以赋值给父类的引用。
子类默认生成的赋值:
- 自己的成员:内置类型直接值拷贝,自定义类型去调用它的赋值。
- 继承下来的父类成员:去调用父类的赋值运算符重载。
Student& operator = (const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s);
_num = s._num;
}
return *this;
}
由于我们没有父类对象来直接赋值,我们直接显示调用运算符重载的函数:
Person::operator=(s);
注意:一定要指定作用域Person,调用父类的赋值运算符重载。
解释:如果只是operator=(s),Student类中的operator=()和基类Person中的operator=()就会同名造成隐藏(函数名相同即可),然后就会调用子类的operator=()造成无限递归,栈溢出。
子类默认生成的析构:
- 自己的成员:内置类型不做处理,自定义类型去调用它的析构。
- 继承下来的父类成员:去调用父类的析构。
~Student()
{
cout << "~Student()" << endl;
}
两个问题:
- ~Person()这样显示的写并不对
原因:子类的析构函数和父类的析构函数构成了隐藏(这里涉及了后面的多态,所有类的析构函数的名字都会统一成destructor(),~Person()函数其实和 ~Student()函数同名,构成隐藏),所以要指定调用Person:: ~Person();
- 其实子类的析构中并不需要显示调用父类的析构函数(上面三个函数都是显示调用,和上面不一样)
解释:析构有一个隐形规定:后构造的先析构,先构造的后析构(栈的原因);
- 派生类对象初始化先调用基类构造再调用派生类构造。父类成员先被构造,子类成员后被构造。
- 派生类对象析构清理时就先调用派生类析构再调用基类析构。子类成员先被析构,父类成员后被析构。
- 不显示写的时候,编译器就会先调用子类的析构,然后自动调用父类的析构,保证了“先析构子类成员在析构父类成员”的顺序。
- 我们自己不能保证这个顺序,所以让编译器自己写。如果自己多写了一个并且涉及资源申请时,会造成一块内存重复释放的问题。
C++98:
1.父类的默认构造函数设置私有,在子类中不可见。
2.子类对象实例化(要使用父类的默认构造),无法调用构造函数。
所以这种类不能被继承。
C++11:
final关键字:用于不能被继承的类。
class A final {};
一句话:友元关系不能被继承,也就是基类的友元不能访问子类的的私有保护成员。
class Student;
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);
}
静态成员知识回顾:
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。
class Person
{
public:
Person() { ++_count; }
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;//4
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;//0
}
非静态成员:子类继承下来的非静态成员和父类的成员是独立的,不是同一个。(隐藏)
静态成员:一个继承体系中只有一份静态成员,都是同一个。
面试题:
- 计算一个类实例化了多少对象?
类中声明一个静态成员变量_count并在类外定义为0,类的默认构造函数和拷贝构造函数里面++_count,析构函数--_count。
- 计算一个继承体系中派生了多少子类?
父类声明一个静态成员变量_count并在类外定义为0,父类构造函数里面++_count。每次子类构造都会调用父类构造,_count就会++。
备注:C++的菱形继承是C++语法的一个缺陷,毕竟最早吃螃蟹的人不知道螃蟹怎么吃,所以在实际开发中要避免使用菱形继承甚至是多继承。
补充:C++的内存对象成员模型,只针对普通继承,虚拟继承不一样,会面会讲
class Base{...};
class Derive:public Base{...};
Derive d;
class Base1{...};
class Base2{...};
class Derive:public Base1,public Base2{...};
Derive d;
Base1* p1 = &d;//赋值兼容转换,派生类赋值给基类指针
Base2* p2 = &d;
Derive* p3 = &d;
总结:
- 对象组成中,继承的父类成员都在上面(低地址),自己类中的成员在下面(高地址)。并且多继承来说,存放顺序就是继承顺序,
- 类中的成员在内存中的存放是按声明顺序来的。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
在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";
}
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如下面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
//代码二
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
{
protected :
string _majorCourse ; // 主修课程
};
void Test ()
{
Assistant a ;
a._name = "peter";
}
class A
{
public:
int _a;
};
// class B : public A
class B : virtual public A
{
public:
int _b;
};
// class C : public A
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;
return 0;
}
可以看到,D类中有两个A类的成员变量,发生了数据冗余和二义性问题。
我们发现,虚拟继承的内存对象成员模型和普通继承不一样,从父类继承下来的_a放在了下面(高地址),并且还多了一个指针用来找到_a。
下图是菱形虚拟继承中d对象的内存成员模型:这里可以分析出d对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
A:虚基类
所以我们发现,通过虚拟继承,D类中只有一个A类的成员变量,不会造成数据冗余和二义性问题。D类中的B和C部分通过偏移量来找到自己的A。
有人会有疑问为什么D中B和C部分要去找属于自己的A?那么大家看看当下面的赋值发生时,d是不是要去找出B/C成员中的A才能赋值过去?
D d;
B b = d;
C c = d;
虚拟继承
class C
{
...
};
class D
{
...
protected:
C _c;
};
class C
{
...
};
class D:public C
{
...
};
区别:
对于C类中的protected成员:
组合中的D类不可见(黑箱)
继承中的D类可见(白箱)
- public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
菱形继承是指一个派生类直接或者间接继承了两个基类,而这两个基类又共同继承自同一个基类的情况。菱形继承是多继承的一种情况,这是C++语法的一种缺陷。
问题:数据冗余和二义性的问题。
虚拟继承就是在普通继承的基础上加上了virtual关键字,他是专门用来解决菱形继承所造成的数据冗余和二义性问题的。不能在其他地方使用。
如何解决:虚拟继承相比普通继承,普通继承会从公共基类中继承多个成员,造成了数据冗余和二义性问题。而虚拟继承的内存对象模型中只有一个公共基类的成员,从而避免了数据冗余问题;虚拟继承又会在继承体系中设置虚基表和虚基指针,虚基表中存放着偏移量,通过偏移量可以找到对应类所继承的公共基类成员,解决二义性问题。
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。