继承和多态
- 继承
- 多态
-
- 构成多态的条件
- 多态的原理
- 虚函数的重写
-
- [虚函数重写的第一个例外--- 析构函数的重写](#虚函数重写的第一个例外--- 析构函数的重写)
- 虚函数重写的第二个例外---协变
- 重载、覆盖(重写)、隐藏(重定义)的对比
- 抽象类
- C++11的override和final
- 面试问题
继承
继承的权限
继承的子父类访问
派生类的默认成员函数
菱形继承(C++独有)【了解】
iostream就是菱形继承 可以去查库
虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student
和
Teacher
的继承Person
时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地
方去使用
当一个类从多个类中继承时,这些类又共同继承自另一个基类,这时可以使用虚拟继承来确保基类的共享实例。具体来说,就是在派生类声明中使用virtual
关键字来继承基类,这样在进一步的派生中,基类的成员就会被共享,而不是重复复制。
例如,如果Father和Mother类都虚拟继承自GrandParent
类,那么当GrandSon
类继承Father
和Mother
时,GrandSon
对象中只会有一个GrandParent
的实例,这样就避免了数据冗余。同时,由于虚拟基类的引入,对于基类成员的访问也变得明确,解决了二义性问题。
cpp
class GrandParent {
public:
void sayHello() {
std::cout << "Hello from GrandParent!" << std::endl;
}
};
class Father : virtual public GrandParent {
};
class Mother : virtual public GrandParent {
};
class GrandSon : public Father, public Mother {
};
int main() {
GrandSon son;
son.sayHello(); // 输出 "Hello from GrandParent!"
return 0;
}
什么是菱形继承?菱形继承的问题是什么?
菱形继承是一种多继承的特殊情况,它涉及四个类形成一个菱形结构。
在菱形继承中,存在一个基类,两个派生类继承这个基类,然后另一个类同时继承这两个派生类。这种继承方式在类的层次结构图中看起来像一个菱形,因此得名。
菱形继承的主要问题是数据冗余和二义性。
由于最底层的派生类继承了两个基类,而这两个基类又继承了同一个基类,所以会造成最顶部基类的两次调用。这会导致相同数据的重复存储,即冗余性。更重要的是,当访问某个继承自基类的属性或方法时,会产生歧义,因为不清楚应该访问哪个派生类中的版本,这就是所谓的二义性。
总而言之,菱形继承是多继承中特有的一种复杂情况,在设计类的继承关系时应谨慎使用,以避免引起数据冗余和二义性问题。
什么是菱形虚拟继承?如何解决数据冗余和二义性的
菱形虚拟继承是一种特殊的多继承方式,它通过虚拟基类来解决菱形继承中的数据冗余和二义性问题。
在C++中,菱形虚拟继承是通过使用关键字virtual
来实现的。当一个类从多个类中继承时,这些类又共同继承自另一个基类,这时可以使用虚拟继承来确保基类的共享实例。具体来说,就是在派生类声明中使用virtual
关键字来继承基类,这样在进一步的派生中,基类的成员就会被共享,而不是重复复制。
例如,如果Father
和Mother
类都虚拟继承自GrandParent
类,那么当GrandSon
类继承Father
和Mother
时,GrandSon
对象中只会有一个GrandParent
的实例,这样就避免了数据冗余。同时,由于虚拟基类的引入,对于基类成员的访问也变得明确,解决了二义性问题。
总的来说,虽然菱形虚拟继承可以解决这些问题,但它也会增加代码的复杂性。因此,在设计类的继承结构时,应当谨慎考虑是否真的需要使用多继承和虚拟继承,以及它们带来的复杂性和可能的性能影响。
继承和组合的区别?什么时候用继承?什么时候用组合?
下面以表格形式对比继承和组合的区别以及它们的适用场景:
特性 | 继承 | 组合 |
---|---|---|
定义 | 继承 是一种从现有类派生新类的关系。 |
组合 是指一个类包含另一个类的实例。 |
耦合性 | 通常较高,因为子类与父类紧密相关。 | 较低,因为类之间通过接口进行交互。 |
封装性 | 可能破坏封装性,因为子类能访问父类保护成员。 | 维护良好的封装性,只通过接口交互。 |
代码重用 | 允许子类重用父类的代码和行为。 | 通过聚合或包含实现代码重用。 |
多态性 | 支持,子类可以覆盖或扩展父类方法。 | 不直接支持,需要通过其他机制实现。 |
设计灵活性 | 修改父类可能会影响所有子类。 | 更灵活,整体与部分独立变化。 |
使用场景 | 适用于"是一个"关系(如猫是动物)。 | 适用于"有一个"关系(如车有引擎)。 |
示例 | Dog 继承自Mammal ,Mammal 继承自Animal 。 |
Car 包含Engine 对象作为其组成部分。 |
何时使用继承:
- 当你想表达一种类型层级,例如,所有的猫都是哺乳动物,所有的哺乳动物都是动物。
- 当子类需要父类的属性和方法,并且可能还需要在子类中添加额外的特性或重写父类的方法。
何时使用组合:
- 当你想表达的是聚合关系,例如,一辆车有一个引擎,但车不是引擎的一种类型。
- 当你希望保持类之间的松耦合,使得一个类的内部实现可以独立于使用它的类而变化。
在实际的软件开发中,组合通常被认为是比继承更有优势的设计选择,因为它提供了更好的灵活性和封装性。然而,在某些情况下,继承仍然是合适的,特别是在表示自然的层次关系时。
多态
构成多态的条件
继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
多态的原理
虚函数表的打印
cpp
class Base {
private:
int _b=1;
public:
Base()
:_b(10)
{
++_b;
}
virtual void fun1() {
cout << "Base::fun1" << endl;
}
virtual void fun2() {
cout << "Base::fun2" << endl;
}
void fun3() {
cout << "Base::fun3" << endl;
}
};
class Derive :public Base {
private:
int _d = 2;
public:
virtual void fun1() {
cout << "Derive::fun1" << endl;
}
virtual void fun4() {
cout << "Derive::fun4" << endl;
}
};
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table) {
for (int i = 0;table[i] != nullptr;++i) {
printf("[%d]:%p->", i, table[i]);
VF_PTR f = table[i];
f();
}
cout << endl;
}
int main() {
Base b;
Derive d;
PrintVFTable((*(VF_PTR**)&b));
PrintVFTable((*(VF_PTR**)&d));
return 0;
}
虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
虚函数重写的第一个例外--- 析构函数的重写
基类与派生类析构函数的名字不同
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor
虚函数重写的第二个例外---协变
基类与派生类虚函数返回值类型不同
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)
重载、覆盖(重写)、隐藏(重定义)的对比
在C++中,重载(Overload)、覆盖(Override,也称为重写)和隐藏(Hide,也称为重定义)是三种不同的函数关系。它们的区别可以通过下表进行总结:
概念 | 作用域 | 参数列表 | 返回类型 |
---|---|---|---|
重载 | 同一作用域 | 必须不同 | 可相同也可不同 |
覆盖/重写 | 派生类与基类之间 | 相同 | 必须相同(C++11起,返回类型也可以被协变) |
隐藏 | 不同作用域(如基类与派生类) | 可以相同,也可以不同 | 无特定要求 |
具体解释如下:
- 重载:
- 作用域:发生在同一作用域内,通常是同一个类中。
- 参数列表:同名函数必须有不同的参数列表(参数类型、个数或顺序至少有一项不同)。
- 返回类型:可以相同,也可以不同。
- 覆盖/重写:
- 作用域:发生在基类与派生类之间。
- 参数列表:派生类中的函数必须与基类中的虚函数有完全相同的参数列表。
- 返回类型:从C++11开始,返回类型可以是相同的,或者是派生类类型的派生类(协变返回类型)。
- 隐藏:
- 作用域:发生在不同作用域,例如基类与派生类中的非虚函数。
- 参数列表:同名函数的参数列表可以相同,也可以不同。
- 返回类型:没有特定的要求。
综上所述,重载允许在同一作用域内有多种接受不同参数的同名函数;覆盖/重写是指派生类重新定义了基类的虚函数,通常用于实现多态;而隐藏则是当派生类中的函数与基类中的函数同名时,无论参数列表是否相同,基类中的函数都会被隐藏。理解这些概念对于编写正确的C++面向对象程序至关重要。
重写和隐藏的详细解释
重写(Overriding):
当你在派生类中定义一个与基类中同名且函数签名(包括参数类型和返回类型)完全相同的虚函数时,你实际上是在提供一个新的实现。当通过基类的指针或引用调用这个函数时,C++运行时将动态地(在程序运行时)决定执行基类的版本还是派生类的版本,这是多态的一个特征。
cpp
class Base {
public:
virtual void doSomething() {
cout << "Base's doSomething" << endl;
}
};
class Derived : public Base {
public:
virtual void doSomething() override { // 重写基类的方法
cout << "Derived's doSomething" << endl;
}
};
在这个例子中,Derived
类重写了Base
类的虚函数doSomething
。
隐藏(Hiding):
当派生类定义了一个与基类中同名的成员函数,哪怕是参数个数或类型不同,或者不是虚函数,基类的那个成员函数在派生类的对象上就无法直接访问了。这被称为隐藏。这不是多态的表现,而是简单的名字覆盖,这种情况下不会有运行时的动态调用。
cpp
class Base {
public:
void doSomething() {
cout << "Base's doSomething" << endl;
}
};
class Derived : public Base {
public:
void doSomething(int x) { // 隐藏了基类的doSomething
cout << "Derived's doSomething with int" << endl;
}
};
在这个例子中,Derived
类隐藏了Base
类的doSomething
函数。
总结一下:
- 重写是多态的一个体现,重写必须涉及到虚函数,函数签名必须相同,C++通过虚函数表来实现运行时的动态绑定。
- 隐藏发生在派生类声明了一个与基类同名的函数后(而不管签名是否相同),此时通过派生类的对象将不能访问到基类中被同名函数隐藏的成员,除非显式指定作用域。隐藏不涉及虚函数或运行时的动态绑定。
抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。**包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。**派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
cpp
class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
C++11的override和final
final
final:修饰虚函数,表示该虚函数不能再被重写
我的理解是这个虚函数是父类特有的功能,不能被子类所继承。
cpp
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒适" << endl;}//这里会报错说不能继承
};
override
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
我的理解是这功有点鸡肋,就是你在子类继承后面写override,override会帮你检查该函数是否是需要虚函数重写。
面试问题
inline函数可以是虚函数吗?
答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。
对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
什么是抽象类?抽象类的作用?
答:参考(3.抽象类)。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。