面向对象设计(OOP)

OOP 概述

C++ 面向对象程序设计的三个核心概念:

  • 数据抽象
  • 继承
  • 动态绑定(多态)

数据抽象

数据抽象强调:

接口与实现分离

即类只暴露“能做什么”,隐藏“怎么做”。

继承

继承强调:

描述类型之间的共同点和差异

基类描述公共部分,派生类描述特有部分。

动态绑定(多态)

动态绑定强调:

用统一接口处理不同类型对象

同样是基类指针/引用,运行时可表现出不同派生类行为。

继承:基本概念

具有继承关系的类形成一个层次结构

  • 位于上层的是 基类(base class)
  • 从基类继承得到的是 派生类(derived class)

基类负责什么

基类通常负责定义:

  • 所有派生类共有的数据
  • 所有派生类共有的接口

派生类负责什么

派生类通常负责:

  • 添加自己的新成员
  • 重新定义(覆盖)某些虚函数
  • 表现出与基类不同的具体行为

继承的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
virtual int get_age() const {
return age_;
}
private:
int age_ = 40;
};

class Son : public Base {
public:
int get_age() const override {
return age_;
}
private:
int age_ = 15;
};

含义:

1
class Son : public Base

表示 Son 公有继承 Base

动态绑定(多态)

动态绑定又叫:

  • 运行时绑定
  • 多态

核心条件

只有同时满足以下条件,才会发生动态绑定:

  1. 调用的是虚函数
  2. 通过基类指针或基类引用调用

示例

你原例子里有一个小错误,应该用 ->,而不是 .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
virtual int get_age() const { return 40; }
};

class Son : public Base {
public:
int get_age() const override { return 15; }
};

void print_age(const Base* item) {
cout << item->get_age() << endl;
}

Base b;
Son s;

print_age(&b); // 40
print_age(&s); // 15

这里 item静态类型const Base*
但它实际可能指向 Base 对象,也可能指向 Son 对象。

由于 get_age() 是虚函数,因此调用哪个版本取决于对象的动态类型

一句话理解多态

基类指针/引用调用虚函数时,运行时根据对象真实类型决定执行哪个版本。

基类与派生类

基类

如果一个类要被当作基类使用,通常需要注意以下几点。

基类通常应有虚析构函数

这是最重要的规则之一。

1
2
3
4
class Base {
public:
virtual ~Base() = default;
};

原因:

如果通过基类指针删除派生类对象:

1
2
Base* p = new Derived;
delete p;

只有基类析构函数是 virtual 时,才能正确调用派生类析构函数。

否则会导致:

未定义行为

基类中的虚函数

基类中有两类成员函数:

希望派生类重写的函数

这些函数应声明为 virtual

希望派生类直接继承使用的函数

这些函数不必是虚函数

virtual 的特点

  • 只能用于类内部声明
  • 只能修饰非静态成员函数
  • 构造函数不能是虚函数
  • 析构函数可以是虚函数,而且通常应该是

如果一个函数在基类中是虚函数,那么它在派生类中自动仍然是虚函数

基类必须先定义

如果一个类要作为基类,必须已经有完整定义:

1
2
class Base;              // 只有声明
class Derived : public Base {}; // 错误

因为编译器必须知道基类长什么样。

派生类

派生类的定义格式

1
2
3
class Derived : public Base {
// ...
};

冒号后面的部分叫 派生列表

可以写访问说明符:

  • public
  • protected
  • private

派生类对象包含什么

一个派生类对象由两部分组成:

  1. 基类部分
  2. 派生类自己新增的部分

可以理解为:

派生类对象里“内嵌”了一个基类子对象

构造顺序

构造时:

  1. 先构造基类部分
  2. 再构造派生类部分

析构时顺序相反:

  1. 先析构派生类部分
  2. 再析构基类部分

这是高频考点。

派生类声明

派生类前向声明时不能写派生列表

1
2
class Derived;              // 正确
class Derived : public Base; // 错误

访问控制与继承

三种访问级别

类成员可以是:

  • public
  • protected
  • private

派生类能访问什么

派生类成员函数中:

  • 可以访问基类的 public
  • 可以访问基类的 protected
  • 不能直接访问基类的 private

这是最基本的规则。

protected 的意义

protected 可以理解为:

对子类开放,对类外关闭

即:

  • 对普通用户不可见
  • 对派生类可见

protected 的一个易错点

派生类虽然能访问基类的 protected 成员,
但这种访问是有限制的:

派生类只能通过“派生类对象”访问继承来的 protected 成员,不能随意通过一个“基类对象”访问

这是教材里常考的细节。

继承方式:public / protected / private

三种继承方式

1
2
3
class D1 : public Base {};
class D2 : protected Base {};
class D3 : private Base {};

含义

继承方式会影响:

基类公有成员和受保护成员在派生类中的可访问性

简记:

  • public 继承:基类接口保留为公有接口
  • protected 继承:基类公有成员变成受保护成员
  • private 继承:基类公有成员变成私有成员

重点记 public 继承

在面向对象设计中,最常见、最符合“is-a”关系的是:

public 继承

即:

1
Derived is a Base

例如:

  • Circle 是一种 Shape
  • Dog 是一种 Animal

类型转换与继承

这是 OOP 中最重要的一部分之一。

派生类 -> 基类:允许

可以把派生类对象的指针或引用转换为基类指针或引用

1
2
3
Derived d;
Base* p = &d;
Base& r = d;

这是合法的,称为:

向上转型(upcasting)

为什么允许

因为:

每个派生类对象中都包含一个基类部分

所以派生类可以“当成”基类使用。

基类 -> 派生类:不允许隐式转换

例如:

1
2
Base b;
Derived* p = &b; // 错误

因为一个基类对象不一定真的是派生类对象

所以:

基类到派生类不存在隐式转换

转换只对指针/引用成立

从派生类到基类的自动转换,主要针对:

  • 指针
  • 引用

而不是对象本身。

对象赋值会发生对象切片(slicing)

例如:

1
2
Derived d;
Base b = d;

此时只会复制 Derived 中的 Base 部分
派生类特有部分会被“切掉”。

这叫:

对象切片

这是容器和继承一起用时最重要的问题之一。

静态类型与动态类型

这是理解多态的关键。

静态类型

静态类型是:

变量声明时的类型

例如:

1
Base* p = new Derived;

p 的静态类型是:

1
Base*

动态类型

动态类型是:

指针/引用实际绑定对象的类型

上例中动态类型是:

1
Derived

多态与两种类型的关系

  • 非虚函数:看静态类型
  • 虚函数:通过基类指针/引用调用时,看动态类型

override 与 final

override

派生类重写虚函数时,推荐加 override

1
2
3
4
class Derived : public Base {
public:
void f() override;
};

作用:

  • 表明“我就是想重写基类虚函数”
  • 如果签名不匹配,编译器会直接报错

所以:

只要是重写虚函数,建议都写 override

final 修饰虚函数

可以禁止派生类继续覆盖某个虚函数:

1
2
3
4
class Base {
public:
virtual void f() final;
};

final 修饰类

可以禁止一个类被继承:

1
class Last final {};

则:

1
class Bad : public Last {};   // 错误

虚函数

什么是虚函数

在基类中用 virtual 声明的成员函数就是虚函数。

1
2
3
4
class Base {
public:
virtual void f();
};

虚函数的调用规则

通过基类指针或引用调用虚函数时,执行哪个版本要到运行时决定。

派生类重写虚函数的要求

若派生类要正确覆盖基类虚函数,函数签名必须匹配:

  • 函数名相同
  • 参数列表相同
  • const 属性一致
  • 引用限定符等一致

返回类型也要匹配。
例外是:

协变返回类型

即基类返回 Base* / Base&,派生类可返回 Derived* / Derived&

回避动态绑定

如果想强制调用基类版本,可用作用域运算符:

1
Base::f();

例如在派生类成员函数内部:

1
2
3
void Derived::f() {
Base::f(); // 调用基类版本
}

如果不加作用域限定,可能再次调用派生类自身版本,导致递归。

抽象基类与纯虚函数

纯虚函数

写法:

1
virtual void f() = 0;

含义:

该函数在基类中只有接口,没有默认实现要求(或不打算直接使用)

抽象类

只要一个类中有纯虚函数,这个类就是抽象类

抽象类不能直接创建对象:

1
Base b;   // 错误,如果 Base 是抽象类

抽象类的作用

抽象类通常用来:

  • 规定统一接口
  • 作为继承体系的根
  • 让派生类提供具体实现

纯虚函数也可以有定义

常见记法里会说“纯虚函数可以不定义”,这是对的;
但更准确地说:

纯虚函数可以有定义,只是它仍然是纯虚函数

不过复习时记住核心即可:

  • =0 表示纯虚函数
  • 含纯虚函数的类是抽象类
  • 抽象类不能实例化

构造函数与析构函数

构造函数不能是虚函数

因为构造对象时,其派生部分还没构造完成,无法依赖动态绑定机制。

析构函数通常应是虚函数

如果类要作为基类使用,析构函数几乎总应写成:

1
virtual ~Base() = default;

构造与析构期间的虚函数调用

这是经典易错点:

在构造函数和析构函数中调用虚函数,不会发生“跨层动态绑定”

也就是说,在基类构造函数里调用虚函数,只会调用基类版本,而不会调用派生类版本。

原因是对象尚未完全构造/正在销毁。

派生类的拷贝控制

派生类构造函数要负责基类部分

派生类对象不仅有自己成员,还有基类部分。
因此派生类构造函数要初始化:

  1. 基类部分
  2. 自己的成员

派生类拷贝/移动构造函数

如果自己定义派生类的拷贝/移动构造函数,通常要在初始化列表中显式调用基类对应构造函数:

1
Derived(const Derived& d) : Base(d), mem(d.mem) {}

否则基类部分可能只会被默认构造。

派生类赋值运算符

派生类赋值时也要处理基类部分:

1
2
3
4
5
Derived& operator=(const Derived& rhs) {
Base::operator=(rhs); // 先给基类部分赋值
// 再给派生类部分赋值
return *this;
}

派生类析构函数

派生类析构函数只需要负责清理自己直接管理的资源。
成员对象和基类部分会自动析构。

继承构造函数

C++11 可以让派生类继承基类构造函数:

1
2
3
4
class Derived : public Base {
public:
using Base::Base;
};

意思是:

Base 的构造函数“引入”到 Derived

注意点

  • 只能继承直接基类的构造函数
  • 不能继承默认/拷贝/移动构造函数的语义本身
  • 如果派生类自己定义了同签名构造函数,则以自己的为准

静态成员与继承

如果基类有静态成员,那么整个继承体系中该静态成员只有一份。

1
2
3
4
class Base {
public:
static int cnt;
};

无论有多少个派生类,共用这一个 cnt

但访问权限仍然受:

  • public
  • protected
  • private

控制。

友元与继承

友元关系不能继承,也不能传递

即:

  • 基类的友元不是派生类的友元
  • 派生类的友元也不是基类的友元

记住一句:

每个类只负责自己的访问控制

using 改变继承成员的访问性

派生类可以用 using 改变一个可访问基类成员在派生类中的可见性。

例如:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
void f() {}
protected:
int x = 0;
};

class Derived : private Base {
public:
using Base::f; // 重新公开 f
};

这里 Base::fDerived 中重新变成公有。

注意:

只能 using 那些派生类本来就有权访问的名字

因此不能 using 基类的私有成员。

容器与继承

这是实际编程中非常重要的一节。

不要把派生类对象直接按值放入基类容器

例如:

1
2
3
vector<Base> vec;
Derived d;
vec.push_back(d);

这样会发生:

对象切片

即只保留 Base 部分,派生类信息丢失。

正确做法:存指针(更推荐智能指针)

例如:

1
2
vector<shared_ptr<Base>> vec;
vec.push_back(make_shared<Derived>());

这样:

  • 不会切片
  • 能保留真实动态类型
  • 能正确触发多态

为什么容器中常放基类智能指针

因为我们希望:

  • 容器元素类型统一
  • 实际对象类型可以不同
  • 调用虚函数时保留多态行为

常见易错点总结

多态必须满足两个条件

  • 虚函数
  • 基类指针/引用调用

少一个都不行。

构造函数不能是虚函数

基类析构函数通常必须是虚函数

尤其当类会被当作基类并通过基类指针删除对象时。

对象赋值会切片

1
Base b = Derived();

派生类部分丢失。

容器中不要按值存放继承体系对象

优先存:

  • Base*
  • shared_ptr<Base>
  • unique_ptr<Base>

派生类不能直接访问基类 private 成员

友元关系不能继承

纯虚函数使类成为抽象类

抽象类不能实例化。

重写虚函数时建议一定写 override

构造/析构期间调用虚函数,不会表现出通常意义上的多态

高频结论

关于继承

  • 派生类对象 = 基类部分 + 派生类部分
  • 先构造基类,后构造派生类
  • 先析构派生类,后析构基类

关于多态

  • 只有虚函数才支持动态绑定
  • 只有通过基类指针/引用调用虚函数才有多态
  • 实际调用版本看动态类型

关于转换

  • 派生类 -> 基类:指针/引用可隐式转换
  • 基类 -> 派生类:不能隐式转换
  • 对象按值转换会切片

关于抽象类

  • 有纯虚函数就是抽象类
  • 抽象类不能创建对象
  • 抽象类常用来定义接口

总结

可以把 OOP 这一章记成一句话:

继承描述“是什么关系”,虚函数提供“统一接口”,基类指针/引用 + 虚函数实现“运行时多态”。