目录
一、继承与友元
二、继承与静态成员
三、菱形继承及菱形虚拟继承
1. 继承的方式
2. 菱形继承的问题
3. 菱形虚拟继承
4. 虚拟继承解决数据冗余和二义性的原理
4.1 普通菱形继承的内存布局
4.2 虚拟继承的内存布局
四、继承的总结和反思
1. 多继承的复杂性
2. 继承与组合
2.1 继承(is-a关系)
2.2 组合(has-a关系)
3. 优先使用组合
4. 小结
五、笔试面试题解析
1. 什么是菱形继承?菱形继承的问题是什么?
2. 什么是菱形虚拟继承?如何解决数据冗余和二义性?
3. 继承和组合的区别?什么时候用继承?什么时候用组合?
一、继承与友元
友元关系不能被继承。也就是说,基类中声明的友元函数或友元类不能访问派生类的私有或保护成员。因为友元关系是类之间的一种特殊访问权限约定,它不具备继承性,派生类并不会自动继承基类的友元所拥有的特殊访问权。
在下面代码中,Display
函数是 Person
类的友元,因此它可以访问 Person
类的保护成员 _name
。然而,Display
函数并不是 Student
类的友元,因此它不能访问 Student
类的保护成员 _stuNum
。这表明友元关系不具备继承性,派生类不会自动继承基类的友元声明。
#include <iostream>
#include <string>
using namespace std;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; // 可以访问,因为Display是Person的友元cout << s._stuNum << endl; // 编译错误,Display不是Student的友元
}void main()
{Person p;Student s;Display(p, s);
}
若想让 Display
函数能够访问派生类 Student
的私有或保护成员,需要在 Student
类中显式地声明 Display
函数为友元:
class Student : public Person
{
public:friend void Display(const Person& p, const Student& s); // 声明Display是Student的友元
protected:int _stuNum;
};
二、继承与静态成员
基类中定义的静态成员在整个继承体系中是共享的,无论派生出多少个子类,都只有一个该静态成员的实例。静态成员属于类而非某个具体对象,所以在继承体系中,所有类和对象都共用这一个静态成员,对它的访问和修改会影响整个继承体系中的所有相关部分。
在基类Person
中定义了一个静态成员变量_count
,用于统计实例化的对象数量:
#include <iostream>
#include <string>
using namespace std;class Person
{
public:Person() { ++_count; } // 构造函数中自增_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; // 输出创建的对象数量Student::_count = 0; // 通过Student类修改_count的值cout << "人数 : " << Person::_count << endl; // 输出修改后的值
}int main()
{TestPerson();return 0;
}
在上述代码中,无论创建多少个Student
或Graduate
对象,Person::_count
始终是一个实例。这是因为静态成员变量属于类本身,而不是类的某个特定对象。我们可以通过打印Person
类和Student
类中静态成员_count
的地址来证明它们是同一个变量:
cout << &Person::_count << endl; // 输出Person类中_count的地址
cout << &Student::_count << endl; // 输出Student类中_count的地址
三、菱形继承及菱形虚拟继承
1. 继承的方式
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
2. 菱形继承的问题
在多继承情况下,若一个类有两个或以上基类,而这些基类又有一个共同的基类,就可能形成菱形继承。这种情况下容易出现数据冗余(同一个基类被多次继承,导致其成员在派生类对象中重复出现)和二义性(访问共同基类的成员时,编译器无法确定具体访问哪一个基类路径中的成员)问题。
从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在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;// 这里会报错:二义性成员访问,因为 Assistant 同时从 Student 和 Teacher 继承了 _namea._name = "peter";
}
通过显式指定父类可以解除二义性,但会导致数据冗余(每个父类都有自己的 _name 成员)。
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
3. 菱形虚拟继承
为了解决菱形继承的二义性和数据冗余问题,出现了虚拟继承。通过将继承方式改为虚拟继承,可以让派生类共享同一个基类的实例,从而解决数据冗余和二义性问题。在虚拟继承中,编译器会采用特殊的机制,如虚基表和虚基表指针,来确保基类在内存中只出现一次,并且正确地解析对基类成员的访问路径。
#include <iostream>
#include <string>
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
{
protected:string _majorCourse; //主修课程
};int main()
{Assistant a;a._name = "peter"; //不再二义性cout << a.Student::_name << endl; // 输出 "peter"cout << a.Teacher::_name << endl; // 输出 "peter"cout << &a.Student::_name << endl; // 地址相同cout << &a.Teacher::_name << endl; // 地址相同return 0;
}
4. 虚拟继承解决数据冗余和二义性的原理
为了研究虚拟继承原理,我们写一个简化的菱形继承体系,再借助内存窗口观察对象成员的模型。
4.1 普通菱形继承的内存布局
#include <iostream>
using namespace std;class A
{
public:int _a;
};class B : public A
{
public:int _b;
};class C : 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
对象的内存布局如下:
-
首先是类
B
继承自类A
的部分,包含_a
(来自A
)和_b
。 -
然后是类
C
继承自类A
的部分,再次包含_a
(来自A
)和_c
。 -
最后是类
D
自己的成员_d
。
这种布局导致了两个_a
成员的存在。
4.2 虚拟继承的内存布局
#include <iostream>
using namespace std;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;return 0;
}
使用虚拟继承后,类D
对象的内存布局调整为:
-
类
B
和类C
各自包含一个虚基表指针(vbptr),这些指针指向虚基表(vbtbl)。 -
虚基表中存储了从类
B
和类C
到共享类A
实例的偏移量。 -
类
D
对象中只包含一份类A
的成员_a
,并且这个_a
被放置在内存布局的最后。
这里可以分析出D对象中将A放到了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
下面是上面的Person关系菱形虚拟继承的原理解释:
四、继承的总结和反思
1. 多继承的复杂性
很多人觉得C++语法复杂,多继承就是一个典型例子。多继承引入后,很容易形成菱形继承结构,而菱形继承又需要通过虚拟继承来解决数据冗余和二义性问题。这些机制在底层实现上非常复杂。因此,一般不建议设计多继承结构,尤其是菱形继承。它不仅会增加代码的复杂度,还可能对性能产生负面影响。
2. 继承与组合
在面向对象编程中,继承和组合是两种重要的复用机制。它们各有特点,适用于不同的场景。
2.1 继承(is-a关系)
继承体现的是“is-a”的关系。例如,BMW
继承自Car
,因为BMW
是一种汽车。通过继承,BMW
类可以复用Car
类的功能和属性。然而,继承也被称为“白箱复用”,因为基类的内部实现对子类是可见的。这种高可见性意味着基类的任何修改都可能影响到所有子类,导致子类和基类之间的耦合度很高。
class Car
{
protected:string _colour = "白色"; // 颜色string _num = "甘EBIT00"; // 车牌号
};class BMW : public Car
{
public:void Drive() {cout << "好开-操控" << endl;}
};
2.2 组合(has-a关系)
组合体现的是“has-a”的关系。例如,Car
类组合了Tire
类,因为汽车有一个或多个轮胎。组合被称为“黑箱复用”,因为被组合对象的内部实现对组合类是不可见的。这种方式耦合度低,组合类之间的依赖关系较弱。
class Tire
{
protected:string _brand = "Michelin"; // 品牌size_t _size = 17; // 尺寸
};class Car
{
protected:string _colour = "白色"; // 颜色string _num = "甘EBIT00"; // 车牌号Tire _t; // 组合的轮胎对象
};
3. 优先使用组合
尽管继承在某些场景下非常有用,但在实际开发中,我们应优先使用组合。组合的低耦合特性使得代码更易于维护和扩展。继承则更适合用于那些具有明确“is-a”关系的场景,或者当需要实现多态性时。
4. 小结
-
继承:适合“is-a”关系,复用基类实现,但耦合度高。
-
组合:适合“has-a”关系,通过组合对象实现功能,耦合度低。
五、笔试面试题解析
1. 什么是菱形继承?菱形继承的问题是什么?
菱形继承:
菱形继承是一种多继承的特殊情况,其类层次结构形如菱形。具体来说,一个基类派生出两个或多个子类,然后另一个类又同时继承自这两个子类。例如:
class A {}; class B : public A {}; class C : public A {}; class D : public B, public C {};
在这里,类
D
同时继承自类B
和类C
,而类B
和类C
又都继承自类A
,从而形成了菱形的继承结构。主要问题:
数据冗余:由于多继承路径,基类
A
的成员在派生类D
中会存在多个副本(每个继承路径各一份)。这不仅浪费内存空间,还可能导致数据不一致的问题。二义性:在访问基类
A
的成员时,编译器无法确定应该通过哪条继承路径(B
或C
)来访问该成员,从而引发编译错误。例如D d; d.a_member = 1; // 错误:无法确定是通过 B 还是 C 访问 a_member
2. 什么是菱形虚拟继承?如何解决数据冗余和二义性?
菱形虚拟继承:
菱形虚拟继承是一种通过使用
virtual
关键字来解决菱形继承问题的机制。具体来说,在继承基类时使用virtual
关键字,可以让所有派生类共享同一个基类实例。例如:class A {}; class B : virtual public A {}; // 虚拟继承 class C : virtual public A {}; // 虚拟继承 class D : public B, public C {};
解决数据冗余和二义性的原理:
共享基类实例:虚拟继承确保在派生类
D
中,基类A
的成员只存在一个副本。这样就消除了数据冗余。消除二义性:通过共享同一个基类实例,访问基类成员时不再需要指定继承路径,编译器可以明确地找到唯一的成员实例。例如
D d; d.a_member = 1; // 正确:直接访问共享的 a_member
内存布局的变化:
使用虚拟继承后,派生类
B
和C
中会包含虚基表指针(vbptr),这些指针指向虚基表(vbtbl),而虚基表中存储了从派生类到共享基类的偏移量。这样,派生类D
对象中只包含一份基类A
的成员,并且可以通过虚基表指针找到该成员。
3. 继承和组合的区别?什么时候用继承?什么时候用组合?
继承(is-a关系):
特点:派生类是基类的特化(如“猫是动物”),可复用基类接口并扩展功能。
适用场景:
需要实现多态(如虚函数)。
明确逻辑上的层次关系(如GUI控件继承自基类
Widget
)。缺点:高耦合性,基类修改可能影响所有派生类。
组合(has-a关系):
特点:类通过包含其他类的对象实现功能复用(如“汽车包含引擎”)。
适用场景:
需要复用功能但无需继承接口(如
Stack
类组合vector
实现存储)。降低耦合,提升代码灵活性(组合类可替换成员对象实现不同功能)。
优点:封装性好,维护成本低。
选择原则:
优先组合:除非需要多态或明确的层次关系,否则优先使用组合。
谨慎继承:避免复杂的继承链,尤其是菱形继承,尽量使用虚继承解决冗余问题。