C++_面向对象设计
面向对象设计(OOP)
OOP 概述
C++ 面向对象程序设计的三个核心概念:
- 数据抽象
- 继承
- 动态绑定(多态)
数据抽象
数据抽象强调:
接口与实现分离
即类只暴露“能做什么”,隐藏“怎么做”。
继承
继承强调:
描述类型之间的共同点和差异
基类描述公共部分,派生类描述特有部分。
动态绑定(多态)
动态绑定强调:
用统一接口处理不同类型对象
同样是基类指针/引用,运行时可表现出不同派生类行为。
继承:基本概念
具有继承关系的类形成一个层次结构。
- 位于上层的是 基类(base class)
- 从基类继承得到的是 派生类(derived class)
基类负责什么
基类通常负责定义:
- 所有派生类共有的数据
- 所有派生类共有的接口
派生类负责什么
派生类通常负责:
- 添加自己的新成员
- 重新定义(覆盖)某些虚函数
- 表现出与基类不同的具体行为
继承的写法
1 | class Base { |
含义:
1 | class Son : public Base |
表示 Son 公有继承 Base。
动态绑定(多态)
动态绑定又叫:
- 运行时绑定
- 多态
核心条件
只有同时满足以下条件,才会发生动态绑定:
- 调用的是虚函数
- 通过基类指针或基类引用调用
示例
你原例子里有一个小错误,应该用 ->,而不是 .:
1 | class Base { |
这里 item 的静态类型是 const Base*,
但它实际可能指向 Base 对象,也可能指向 Son 对象。
由于 get_age() 是虚函数,因此调用哪个版本取决于对象的动态类型。
一句话理解多态
基类指针/引用调用虚函数时,运行时根据对象真实类型决定执行哪个版本。
基类与派生类
基类
如果一个类要被当作基类使用,通常需要注意以下几点。
基类通常应有虚析构函数
这是最重要的规则之一。
1 | class Base { |
原因:
如果通过基类指针删除派生类对象:
1 | Base* p = new Derived; |
只有基类析构函数是 virtual 时,才能正确调用派生类析构函数。
否则会导致:
未定义行为
基类中的虚函数
基类中有两类成员函数:
希望派生类重写的函数
这些函数应声明为 virtual
希望派生类直接继承使用的函数
这些函数不必是虚函数
virtual 的特点
- 只能用于类内部声明
- 只能修饰非静态成员函数
- 构造函数不能是虚函数
- 析构函数可以是虚函数,而且通常应该是
如果一个函数在基类中是虚函数,那么它在派生类中自动仍然是虚函数。
基类必须先定义
如果一个类要作为基类,必须已经有完整定义:
1 | class Base; // 只有声明 |
因为编译器必须知道基类长什么样。
派生类
派生类的定义格式
1 | class Derived : public Base { |
冒号后面的部分叫 派生列表。
可以写访问说明符:
publicprotectedprivate
派生类对象包含什么
一个派生类对象由两部分组成:
- 基类部分
- 派生类自己新增的部分
可以理解为:
派生类对象里“内嵌”了一个基类子对象
构造顺序
构造时:
- 先构造基类部分
- 再构造派生类部分
析构时顺序相反:
- 先析构派生类部分
- 再析构基类部分
这是高频考点。
派生类声明
派生类前向声明时不能写派生列表:
1 | class Derived; // 正确 |
访问控制与继承
三种访问级别
类成员可以是:
publicprotectedprivate
派生类能访问什么
派生类成员函数中:
- 可以访问基类的
public - 可以访问基类的
protected - 不能直接访问基类的
private
这是最基本的规则。
protected 的意义
protected 可以理解为:
对子类开放,对类外关闭
即:
- 对普通用户不可见
- 对派生类可见
protected 的一个易错点
派生类虽然能访问基类的 protected 成员,
但这种访问是有限制的:
派生类只能通过“派生类对象”访问继承来的 protected 成员,不能随意通过一个“基类对象”访问
这是教材里常考的细节。
继承方式:public / protected / private
三种继承方式
1 | class D1 : public Base {}; |
含义
继承方式会影响:
基类公有成员和受保护成员在派生类中的可访问性
简记:
public继承:基类接口保留为公有接口protected继承:基类公有成员变成受保护成员private继承:基类公有成员变成私有成员
重点记 public 继承
在面向对象设计中,最常见、最符合“is-a”关系的是:
public 继承
即:
1 | Derived is a Base |
例如:
Circle是一种ShapeDog是一种Animal
类型转换与继承
这是 OOP 中最重要的一部分之一。
派生类 -> 基类:允许
可以把派生类对象的指针或引用转换为基类指针或引用:
1 | Derived d; |
这是合法的,称为:
向上转型(upcasting)
为什么允许
因为:
每个派生类对象中都包含一个基类部分
所以派生类可以“当成”基类使用。
基类 -> 派生类:不允许隐式转换
例如:
1 | Base b; |
因为一个基类对象不一定真的是派生类对象。
所以:
基类到派生类不存在隐式转换
转换只对指针/引用成立
从派生类到基类的自动转换,主要针对:
- 指针
- 引用
而不是对象本身。
对象赋值会发生对象切片(slicing)
例如:
1 | Derived d; |
此时只会复制 Derived 中的 Base 部分,
派生类特有部分会被“切掉”。
这叫:
对象切片
这是容器和继承一起用时最重要的问题之一。
静态类型与动态类型
这是理解多态的关键。
静态类型
静态类型是:
变量声明时的类型
例如:
1 | Base* p = new Derived; |
p 的静态类型是:
1 | Base* |
动态类型
动态类型是:
指针/引用实际绑定对象的类型
上例中动态类型是:
1 | Derived |
多态与两种类型的关系
- 非虚函数:看静态类型
- 虚函数:通过基类指针/引用调用时,看动态类型
override 与 final
override
派生类重写虚函数时,推荐加 override:
1 | class Derived : public Base { |
作用:
- 表明“我就是想重写基类虚函数”
- 如果签名不匹配,编译器会直接报错
所以:
只要是重写虚函数,建议都写 override
final 修饰虚函数
可以禁止派生类继续覆盖某个虚函数:
1 | class Base { |
final 修饰类
可以禁止一个类被继承:
1 | class Last final {}; |
则:
1 | class Bad : public Last {}; // 错误 |
虚函数
什么是虚函数
在基类中用 virtual 声明的成员函数就是虚函数。
1 | class Base { |
虚函数的调用规则
通过基类指针或引用调用虚函数时,执行哪个版本要到运行时决定。
派生类重写虚函数的要求
若派生类要正确覆盖基类虚函数,函数签名必须匹配:
- 函数名相同
- 参数列表相同
- const 属性一致
- 引用限定符等一致
返回类型也要匹配。
例外是:
协变返回类型
即基类返回 Base* / Base&,派生类可返回 Derived* / Derived&。
回避动态绑定
如果想强制调用基类版本,可用作用域运算符:
1 | Base::f(); |
例如在派生类成员函数内部:
1 | void Derived::f() { |
如果不加作用域限定,可能再次调用派生类自身版本,导致递归。
抽象基类与纯虚函数
纯虚函数
写法:
1 | virtual void f() = 0; |
含义:
该函数在基类中只有接口,没有默认实现要求(或不打算直接使用)
抽象类
只要一个类中有纯虚函数,这个类就是抽象类。
抽象类不能直接创建对象:
1 | Base b; // 错误,如果 Base 是抽象类 |
抽象类的作用
抽象类通常用来:
- 规定统一接口
- 作为继承体系的根
- 让派生类提供具体实现
纯虚函数也可以有定义
常见记法里会说“纯虚函数可以不定义”,这是对的;
但更准确地说:
纯虚函数可以有定义,只是它仍然是纯虚函数
不过复习时记住核心即可:
=0表示纯虚函数- 含纯虚函数的类是抽象类
- 抽象类不能实例化
构造函数与析构函数
构造函数不能是虚函数
因为构造对象时,其派生部分还没构造完成,无法依赖动态绑定机制。
析构函数通常应是虚函数
如果类要作为基类使用,析构函数几乎总应写成:
1 | virtual ~Base() = default; |
构造与析构期间的虚函数调用
这是经典易错点:
在构造函数和析构函数中调用虚函数,不会发生“跨层动态绑定”
也就是说,在基类构造函数里调用虚函数,只会调用基类版本,而不会调用派生类版本。
原因是对象尚未完全构造/正在销毁。
派生类的拷贝控制
派生类构造函数要负责基类部分
派生类对象不仅有自己成员,还有基类部分。
因此派生类构造函数要初始化:
- 基类部分
- 自己的成员
派生类拷贝/移动构造函数
如果自己定义派生类的拷贝/移动构造函数,通常要在初始化列表中显式调用基类对应构造函数:
1 | Derived(const Derived& d) : Base(d), mem(d.mem) {} |
否则基类部分可能只会被默认构造。
派生类赋值运算符
派生类赋值时也要处理基类部分:
1 | Derived& operator=(const Derived& rhs) { |
派生类析构函数
派生类析构函数只需要负责清理自己直接管理的资源。
成员对象和基类部分会自动析构。
继承构造函数
C++11 可以让派生类继承基类构造函数:
1 | class Derived : public Base { |
意思是:
把
Base的构造函数“引入”到Derived
注意点
- 只能继承直接基类的构造函数
- 不能继承默认/拷贝/移动构造函数的语义本身
- 如果派生类自己定义了同签名构造函数,则以自己的为准
静态成员与继承
如果基类有静态成员,那么整个继承体系中该静态成员只有一份。
1 | class Base { |
无论有多少个派生类,共用这一个 cnt。
但访问权限仍然受:
publicprotectedprivate
控制。
友元与继承
友元关系不能继承,也不能传递。
即:
- 基类的友元不是派生类的友元
- 派生类的友元也不是基类的友元
记住一句:
每个类只负责自己的访问控制
using 改变继承成员的访问性
派生类可以用 using 改变一个可访问基类成员在派生类中的可见性。
例如:
1 | class Base { |
这里 Base::f 在 Derived 中重新变成公有。
注意:
只能
using那些派生类本来就有权访问的名字
因此不能 using 基类的私有成员。
容器与继承
这是实际编程中非常重要的一节。
不要把派生类对象直接按值放入基类容器
例如:
1 | vector<Base> vec; |
这样会发生:
对象切片
即只保留 Base 部分,派生类信息丢失。
正确做法:存指针(更推荐智能指针)
例如:
1 | vector<shared_ptr<Base>> vec; |
这样:
- 不会切片
- 能保留真实动态类型
- 能正确触发多态
为什么容器中常放基类智能指针
因为我们希望:
- 容器元素类型统一
- 实际对象类型可以不同
- 调用虚函数时保留多态行为
常见易错点总结
多态必须满足两个条件
- 虚函数
- 基类指针/引用调用
少一个都不行。
构造函数不能是虚函数
基类析构函数通常必须是虚函数
尤其当类会被当作基类并通过基类指针删除对象时。
对象赋值会切片
1 | Base b = Derived(); |
派生类部分丢失。
容器中不要按值存放继承体系对象
优先存:
Base*shared_ptr<Base>unique_ptr<Base>
派生类不能直接访问基类 private 成员
友元关系不能继承
纯虚函数使类成为抽象类
抽象类不能实例化。
重写虚函数时建议一定写 override
构造/析构期间调用虚函数,不会表现出通常意义上的多态
高频结论
关于继承
- 派生类对象 = 基类部分 + 派生类部分
- 先构造基类,后构造派生类
- 先析构派生类,后析构基类
关于多态
- 只有虚函数才支持动态绑定
- 只有通过基类指针/引用调用虚函数才有多态
- 实际调用版本看动态类型
关于转换
- 派生类 -> 基类:指针/引用可隐式转换
- 基类 -> 派生类:不能隐式转换
- 对象按值转换会切片
关于抽象类
- 有纯虚函数就是抽象类
- 抽象类不能创建对象
- 抽象类常用来定义接口
总结
可以把 OOP 这一章记成一句话:
继承描述“是什么关系”,虚函数提供“统一接口”,基类指针/引用 + 虚函数实现“运行时多态”。




