第九章 组合与继承
一、比较 is-a 关系和 is-like-a 关系
1 is-a 关系
表示严格的继承关系。
含义:
派生类是基类的一种特殊类型。
例如:
Dog is a Animal
代码:
class Animal{};
class Dog : public Animal{};
特点:
- 派生类对象 可以当作基类对象使用
- 满足 替代原则
例如:
Animal* p = new Dog();
2 is-like-a 关系
表示 行为相似,但不是严格继承关系。
例如:
Airplane is like a Bird
飞机和鸟都会飞,但:
- 飞机不是鸟
- 不应该继承
3 区别总结
| is-a | 真实继承关系 |
| is-like-a | 功能相似但不是继承 |
原则:
只有满足 is-a 才应该使用继承
二、public / private / protected 继承区别
C++有三种继承方式:
class B : public A
class B : protected A
class B : private A
1 public 继承
最常用。
基类成员访问级别变化:
| public | public |
| protected | protected |
| private | 不可访问 |
特点:
- 表示 is-a关系
- 外部仍可访问基类public成员
2 protected 继承
| public | protected |
| protected | protected |
| private | 不可访问 |
特点:
- 外部不能访问基类public成员
- 只有派生类能访问
3 private 继承
| public | private |
| protected | private |
| private | 不可访问 |
特点:
- 外部完全不可见
- 更像 实现复用
如何选择
一般原则:
| 表示 is-a 关系 | public |
| 实现复用 | private |
| 只给子类使用 | protected |
实际开发:
90% 使用 public
三、派生类对象的内存布局与初始化
1 内存布局
派生类对象在内存中:
+—————-+
| 基类成员 |
+—————-+
| 派生类成员 |
+—————-+
例如:
class A{
int x;
};
class B : public A{
int y;
};
内存:
B对象
+——+
| x |
+——+
| y |
+——+
2 初始化顺序
构造顺序:
先基类 → 再派生类
例如:
class A{
public:
A(){ cout<<"A\\n"; }
};
class B : public A{
public:
B(){ cout<<"B\\n"; }
};
执行:
B b;
输出:
A
B
3 析构顺序
相反:
先派生类 → 再基类
输出:
~B
~A
四、多重继承可能导致的问题
多重继承:
class C : public A, public B {};
1 命名冲突
如果两个基类有同名成员:
class A{ void func(); };
class B{ void func(); };
调用:
C c;
c.func(); // 二义性
解决:
c.A::func();
2 菱形继承问题
例如:
A
/ \\
B C
\\ /
D
代码:
class A{};
class B : public A{};
class C : public A{};
class D : public B, public C{};
问题:
D中会有两个A
导致:
- 内存浪费
- 二义性
解决方法
使用 虚继承
class B : virtual public A{};
class C : virtual public A{};
这样:
D只有一个A
五、替代原则(Liskov Substitution Principle)
替代原则是 面向对象设计原则之一。
含义:
派生类对象必须能够替代基类对象使用,而程序行为不改变。
举例
class Bird{
public:
virtual void fly();
};
class Sparrow : public Bird{};
使用:
Bird* b = new Sparrow();
b->fly();
是合理的。
错误设计例子
class Bird{
public:
virtual void fly();
};
class Penguin : public Bird{};
企鹅不会飞。
如果写:
Bird* b = new Penguin();
b->fly(); // 不合理
违反替代原则。
原则总结
设计继承关系时必须满足:
子类可以完全替代父类
否则:
不应该使用继承
七.如果要在派生类中覆盖基类的成员函数,在定义派生类成员函数时必须满足什么要求?
如果派生类要覆盖基类的成员函数,基类函数必须声明为虚函数,派生类函数的函数名、参数列表必须与基类完全相同,返回类型必须一致或满足协变返回类型,同时访问权限不能比基类更严格。通常建议使用override关键字以保证函数正确覆盖。
一.继承和组合是面向对象的两种重要的代码复用机制,阐述它们有什么区别,如何选择应用?
继承(Inheritance)和组合(Composition)都是面向对象中 代码复用的重要机制,但含义不同。
1 继承(Inheritance)
继承表示 is-a关系。
含义:
派生类是一种特殊的基类。
例如:
class Animal{};
class Dog : public Animal{};
这里:
Dog is a Animal
特点:
- 可以复用基类的成员
- 支持多态
- 体现类之间的层次关系
优点:
- 代码复用
- 支持运行时多态
缺点:
- 耦合度较高
- 继承结构不易修改
2 组合(Composition)
组合表示 has-a关系。
含义:
一个类包含另一个类的对象。
例如:
class Engine{};
class Car{
private:
Engine engine;
};
这里:
Car has a Engine
特点:
- 类中包含其他类对象
- 通过对象组合实现功能
优点:
- 灵活性高
- 耦合度低
- 更容易扩展
缺点:
- 不能直接复用接口
3 区别总结
| 关系 | is-a | has-a |
| 耦合程度 | 高 | 低 |
| 是否支持多态 | 支持 | 一般不支持 |
| 灵活性 | 较低 | 较高 |
4 如何选择
一般原则:
如果是 is-a 关系 → 使用继承
如果是 has-a 关系 → 使用组合
现代软件设计中常说:
优先使用组合,而不是继承
第十章 虚函数与多态性
1. 派生类向基类转换导致类型信息丢失怎么办?
当派生类对象通过 基类指针或引用访问时,会发生 向上转型(upcasting),此时只能看到基类部分的信息。
如果希望 根据对象的实际类型调用对应函数,需要:使用虚函数(virtual function)。
示例:
class Base{
public:
virtual void show(){
cout << "Base" << endl;
}
};
class Derived : public Base{
public:
void show(){
cout << "Derived" << endl;
}
};
Base* p = new Derived();
p->show(); // 调用 Derived::show()
如果没有 virtual:
只会调用 Base::show()
2. 什么是动态绑定?它与虚函数有什么关系?
动态绑定(Dynamic Binding):
在程序运行时根据对象的实际类型决定调用哪个函数。
特点:
- 又称 晚绑定(Late Binding)
- 发生在 通过指针或引用调用虚函数时
关系:
虚函数是实现动态绑定的机制
没有虚函数:
函数调用在编译时确定(静态绑定)
3. 使用虚函数实现多态的一般过程
实现多态通常需要三个步骤:
① 在基类中声明虚函数
class Base{
public:
virtual void show();
};
② 在派生类中重写虚函数
class Derived : public Base{
public:
void show();
};
③ 使用基类指针或引用调用函数
Base* p = new Derived();
p->show();
运行结果:
调用 Derived 的 show()
这就是 运行时多态。
4. 抽象类有什么作用?如何定义?
作用
抽象类用于:
- 描述 一类对象的共同特征
- 作为 基类供派生类继承
- 实现 接口规范
特点:
抽象类不能创建对象
定义方式
只要类中包含 纯虚函数,这个类就是 抽象类。
纯虚函数写法:
virtual 返回类型 函数名() = 0;
示例:
class Shape{
public:
virtual double area() = 0;
};
5. 什么时候使用抽象类或纯抽象类?
一般在以下情况使用:
① 只定义接口,不提供实现
例如:
图形类
动物类
设备接口
② 需要统一操作不同类型对象
例如:
Shape
├─ Circle
├─ Rectangle
└─ Triangle
统一调用:
Shape* p;
p->area();
③ 需要强制派生类实现某些函数
纯虚函数要求:
派生类必须重写
6. dynamic_cast 的作用(举例)
dynamic_cast 用于 运行时安全类型转换,通常用于 基类指针转换为派生类指针。
示例:
class Base{
public:
virtual ~Base(){}
};
class Derived : public Base{
public:
void func(){
cout << "Derived function" << endl;
}
};
Base* p = new Derived();
Derived* d = dynamic_cast<Derived*>(p);
if(d){
d->func();
}
如果转换失败:
返回 nullptr
作用总结:
用于多态类型的安全向下转型
7. 如何理解C++中的多态机制?
**多态(Polymorphism)**指:
同一个接口对不同对象产生不同的行为。
在 C++ 中主要通过 虚函数 + 继承 + 基类指针/引用 实现。
示例:
class Animal{
public:
virtual void speak(){
cout << "Animal sound" << endl;
}
};
class Dog : public Animal{
public:
void speak(){
cout << "Dog bark" << endl;
}
};
class Cat : public Animal{
public:
void speak(){
cout << "Cat meow" << endl;
}
};
调用:
Animal* p;
p = new Dog();
p->speak();
p = new Cat();
p->speak();
输出:
Dog bark
Cat meow
同一个函数 speak() 表现不同。
一页速记版(考试背诵版)
动态绑定是指在程序运行时根据对象的实际类型决定调用哪个函数,它通常通过虚函数实现。实现多态的一般过程是:在基类中声明虚函数,在派生类中重写该函数,然后通过基类指针或引用调用函数。抽象类是包含纯虚函数的类,不能实例化对象,主要用于定义接口规范并作为基类供派生类继承。dynamic_cast 用于运行时安全类型转换,常用于基类指针向派生类指针的转换。C++的多态机制是指通过继承和虚函数,使得同一个接口在不同对象上表现出不同的行为。
这两题是 C++虚函数章节最经典的简答题,我给你整理成 考试标准答案 + 易理解版。
1. 什么是虚函数?什么是纯虚函数?为什么要引入它们?
(1)什么是虚函数
**虚函数(Virtual Function)**是指在基类中使用 virtual 关键字声明的成员函数。
特点:
- 可以在派生类中被 重写(override)
- 通过 基类指针或引用调用时,根据对象的 实际类型 决定调用哪个函数
示例:
class Base{
public:
virtual void show(){
cout << "Base show" << endl;
}
};
class Derived : public Base{
public:
void show(){
cout << "Derived show" << endl;
}
};
Base* p = new Derived();
p->show(); // 调用 Derived::show()
(2)什么是纯虚函数
**纯虚函数(Pure Virtual Function)**是没有函数实现的虚函数。
声明方式:
virtual 返回类型 函数名() = 0;
例如:
class Shape{
public:
virtual double area() = 0;
};
特点:
- 包含纯虚函数的类称为 抽象类
- 抽象类 不能创建对象
- 派生类 必须实现纯虚函数
(3)为什么引入虚函数
引入虚函数是为了实现:
① 运行时多态
同一个接口,对不同对象产生不同的行为。
例如:
Animal* p = new Dog();
p->speak();
② 动态绑定
函数调用在 运行时决定。
(4)为什么引入纯虚函数
纯虚函数主要用于:
① 定义接口规范
规定派生类 必须实现某些函数。
例如:
Shape
├── Circle
├── Rectangle
└── Triangle
所有图形都必须实现:
area()
② 实现抽象类
用于表示 抽象概念。
例如:
Shape
Animal
Device
2. 析构函数应该是虚函数吗?为什么?
一般来说:
如果类可能被继承,析构函数应该定义为虚函数。
原因
当使用 基类指针删除派生类对象时,如果析构函数不是虚函数,只会调用基类析构函数。
示例:
class Base{
public:
~Base(){
cout << "Base destructor" << endl;
}
};
class Derived : public Base{
public:
~Derived(){
cout << "Derived destructor" << endl;
}
};
Base* p = new Derived();
delete p;
如果析构函数 不是虚函数:
只调用 Base destructor
Derived 的资源不会释放。
正确写法
class Base{
public:
virtual ~Base(){
cout << "Base destructor" << endl;
}
};
此时执行:
delete p;
调用顺序:
Derived destructor
Base destructor
总结
如果一个类作为基类使用,其析构函数通常应该声明为虚函数,以保证通过基类指针删除派生类对象时能够正确调用派生类析构函数,从而避免资源泄露。
考试背诵版(简化)
虚函数是在基类中使用 virtual 声明的成员函数,在派生类中可以被重写,通过基类指针或引用调用时,根据对象的实际类型决定调用哪个函数,从而实现运行时多态。纯虚函数是没有函数体的虚函数,其形式为 virtual 函数 = 0,包含纯虚函数的类称为抽象类,抽象类不能创建对象,派生类必须实现纯虚函数。引入虚函数是为了实现动态绑定和多态,引入纯虚函数是为了定义接口规范。
如果一个类作为基类使用,其析构函数通常应该声明为虚函数,因为当通过基类指针删除派生类对象时,虚析构函数可以保证先调用派生类析构函数再调用基类析构函数,从而正确释放资源。
这两题其实是在考 C++多态 + 容器存储对象时的对象切片问题,很多人容易答不完整。我给你整理成 考试标准答案 + 原理解释版。
5. 能否用 vector 或 vector<Component&> 代替 vector<Component*>?
结论:
不能使用 vector<Component>,也不能使用 vector<Component&>
原因如下。
(1)为什么不能使用 vector
如果使用
vector<Component>
会发生 对象切片(Object Slicing)。
原因:
Component 是基类,而 Part、Assembly 等是派生类。
当派生类对象放入 vector<Component> 时:
派生类对象 → 转换为基类对象
结果:
派生类特有的数据和行为会被丢弃
例如:
class Component{};
class Part : public Component{};
存入:
vector<Component> v;
Part p;
v.push_back(p);
结果:
p 会被复制成 Component 对象
此时:
Part 的成员全部消失
并且 多态也无法实现。
(2)为什么不能使用 vector<Component&>
C++ 标准规定:
容器不能存储引用类型
因此:
vector<Component&>
是 非法的。
原因:引用必须在初始化时绑定对象,且 不能重新绑定,而容器需要频繁移动和赋值元素。
(3)为什么使用 vector<Component*>
使用指针:
vector<Component*>
可以:
1️⃣ 保存不同派生类对象
例如:
Part*
Assembly*
2️⃣ 保持对象真实类型
3️⃣ 实现多态调用
例如:
Component* c = new Part();
c->print();
运行时调用:
Part::print()
总结
vector<Component> 会导致对象切片,丢失派生类信息;
vector<Component&> 在C++中是不允许的;
因此必须使用 vector<Component*> 来保存不同类型的组件对象,
从而保持多态性。
6. 为什么说“面向对象编程必须使用指针和引用”?
这句话的意思是:
要实现多态,必须通过指针或引用访问对象
(1)对象本身不能实现多态
例如:
Base b = Derived();
b.show();
发生:
对象切片
结果:
只调用 Base::show()
因为:
b 已经变成 Base 对象
(2)只有指针或引用才能保持对象真实类型
例如:
Base* p = new Derived();
p->show();
此时:
p 指向 Derived 对象
运行时:
调用 Derived::show()
这就是:
动态绑定
(3)多态必须满足三个条件
1️⃣ 继承
2️⃣ 虚函数
3️⃣ 基类指针或引用
例如:
Animal* p;
p = new Dog();
p = new Cat();
调用:
p->speak();
不同对象表现不同。
总结理解
这句话的含义是:
在C++中,如果直接使用对象进行赋值或传递,
会发生对象切片,丢失派生类信息;
而使用指针或引用可以保持对象的真实类型,
从而通过虚函数实现运行时多态。
因此,在面向对象编程中,经常需要使用 指针或引用来操作对象。
考试简答版(推荐写法)
在组件库存管理程序中,不能使用 vector<Component>,因为当派生类对象存入该容器时会发生对象切片,派生类特有的数据和行为会丢失,从而无法实现多态;也不能使用 vector<Component&>,因为C++标准容器不允许存储引用类型。因此需要使用 vector<Component*> 来保存组件对象,从而能够存储不同派生类对象并保持多态。
“无法直接使用对象进行面向对象编程”是因为如果直接使用对象赋值或传递,会发生对象切片,丢失派生类信息,而使用基类指针或引用可以保持对象的真实类型,并通过虚函数实现运行时多态,因此在C++面向对象编程中通常需要通过指针或引用来操作对象。
这些是 C++模板(Template)章节的典型简答题,我给你整理成 标准答案 + 简洁考试版,考试基本按这个写就可以。
第十一章 模板与泛型编程
1. C++的模板机制解决什么问题?
**模板(Template)**主要用于解决 代码复用问题。
在很多情况下,不同数据类型需要执行 相同的算法或操作,如果不用模板,就需要为每种类型写一套函数或类。
例如:
int max(int a,int b);
double max(double a,double b);
float max(float a,float b);
代码会大量重复。
使用模板:
template <typename T>
T max(T a,T b){
return a>b?a:b;
}
一个模板即可适用于多种类型。
总结:
C++模板机制通过参数化类型,使函数或类能够适用于多种数据类型,从而提高代码复用性,减少重复代码,并提高程序的通用性。
2. 什么叫函数模板实例?函数模板在什么时候被实例化?
函数模板实例是指:
编译器根据函数模板和具体的数据类型生成的具体函数。
例如:
模板:
template <typename T>
T add(T a,T b){
return a+b;
}
调用:
add(1,2);
add(1.5,2.3);
编译器会生成:
int add(int,int);
double add(double,double);
这些具体函数就叫 函数模板实例。
实例化时刻:
函数模板在被调用时由编译器自动实例化
也就是说:
只有当模板被使用时才会生成具体函数
3. 什么叫类模板实例?类模板在什么时候被实例化?
类模板实例是指:
根据类模板和具体类型参数生成的具体类。
例如:
template <typename T>
class Box{
public:
T data;
};
使用:
Box<int> b1;
Box<double> b2;
编译器会生成:
class Box<int>
class Box<double>
这些就是 类模板实例。
实例化时刻:
类模板在使用具体类型创建对象时进行实例化
也就是说:
Box<int> b1;
这一行代码会触发实例化。
4. 模板的显式实例化有什么作用?
**显式实例化(Explicit Instantiation)**是指:
程序员显式指定模板生成某种类型的实例。
语法:
template 返回类型 函数名<类型>(参数);
例如:
template int add<int>(int,int);
作用:
1️⃣ 强制编译器生成指定类型的模板实例
2️⃣ 提前生成代码,避免多次实例化
3️⃣ 在模板实现分离(.h/.cpp)时解决链接问题
例如:
模板定义在cpp中时,需要显式实例化
一页考试简答版(推荐背)
1️⃣ C++模板机制通过参数化类型,使函数或类能够适用于多种数据类型,从而提高代码复用性,减少重复代码,提高程序的通用性。
2️⃣ 函数模板实例是编译器根据函数模板和具体类型参数生成的具体函数。函数模板通常在被调用时由编译器自动实例化。
3️⃣ 类模板实例是根据类模板和具体类型参数生成的具体类。类模板一般在使用具体类型创建对象时进行实例化。
4️⃣ 模板的显式实例化是指程序员显式指定模板生成某种类型的实例,其作用是强制编译器生成特定类型的模板代码,并可用于解决模板分离编译时的链接问题。
一、模板的特点
C++模板是一种参数化类型机制,它允许函数或类在定义时不指定具体类型,而在使用时再确定具体类型。模板具有代码复用性高、类型安全、编译期实例化和支持泛型编程等特点。模板通常用于不同数据类型执行相同操作、实现通用数据结构以及编写通用算法等场景。


