Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
- Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
- Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
- Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
- Python 系列(二十六) Python 模块入门,从 import 到模块复用
- Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
- Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
- Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
- Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
- Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
- Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
前面我们学习了 Python 的标准库,使用
datetime处理日期时间,也通过实际案例接触了 Linux 服务器巡检、证书到期检查等运维场景。但是,随着程序越来越复杂,一个新的问题就出现了:如果我们需要管理几十台服务器、几百个用户,甚至成千上万个业务账户,应该如何组织代码?
难道每台服务器都定义一堆独立变量,每个账户都编写一套重复的函数吗?如果后续还需要增加监控指标、告警规则和巡检报告,代码又该如何维护?
有没有一种办法,能够把相关的数据和操作组织起来,让程序的结构更接近我们实际理解问题的方式?
这就是今天要学习的内容:面向对象编程(Object-Oriented Programming,OOP)。
什么是面向对象编程?
先不急着背概念,我们从一个实际场景开始。
假设你正在编写一个 Linux 服务器巡检程序,需要管理多台服务器。每台服务器都有自己的信息,例如:
- 主机名:
web01、db01 - IP 地址:
192.168.1.10、192.168.1.20 - 操作系统:Rocky Linux、RHEL
- CPU 使用率、内存使用率、磁盘使用率
同时,每台服务器还需要执行一些操作,例如:
- 检查 CPU 使用率
- 检查内存状态
- 检查磁盘空间
- 输出巡检报告
使用之前学过的知识,我们完全可以通过字典保存服务器信息,再使用函数完成检查工作。
1 | server = { |
运行结果:
1 | 正在检查 web01 的磁盘空间 |
这段代码没有问题。对于简单的脚本,字典加函数往往就是一种很合适的实现方式。
但如果程序不断扩展,我们还需要管理服务器的连接状态、监控指标、告警规则和巡检历史,那么数据和操作之间的关系就会越来越复杂。
面向对象编程的核心思想,就是围绕我们要解决的问题,把相关的数据和操作组织起来。
例如,我们可以把一台服务器看作一个对象:
- 它有什么信息,例如主机名、IP 地址,这是对象的属性。
- 它能够执行什么操作,例如检查磁盘、检查内存,这是对象的行为。
在银行系统中,可以有客户、账户和贷款等概念;在操作系统中,可以有进程、文件等概念。
这些现实问题中的概念,都可以成为设计程序时的参考。
需要注意,面向对象并不意味着所有程序都必须使用类。简单任务使用函数就很好;当数据、行为和对象之间的关系变得复杂时,面向对象设计才可能带来更明显的收益。
理解类和对象
理解面向对象,首先要认识两个基本概念:类(Class)和对象(Object)。
假设你需要管理多台 Linux 服务器。每台服务器都有主机名、IP 地址等共同属性,也都需要执行巡检操作。
如果每台服务器都单独编写一套代码,就会产生大量重复内容。
这时,我们可以先定义一种统一的描述方式,规定服务器应该有哪些信息、能够执行哪些操作。
这种描述方式就是类。
而根据类创建出来的具体实例,就是对象。
可以把类理解为一份设计图纸,把对象理解为根据图纸制造出来的具体产品。
<AsyncImageGroup query={[“architectural technical blueprint engineering drawing”,”finished manufactured industrial products production line”]} aspectRatio=”5:4” layout=”carousel” />
一份图纸可以制造很多产品,每个产品又可以拥有不同的属性值。同样,一个 Python 类也可以创建多个对象。它们共享同一套设计,但各自保存自己的实例数据。
flowchart TD
A["Server 类<br/>统一描述服务器"] --> B["web01 对象"]
A --> C["db01 对象"]
A --> D["cache01 对象"]
B --> E["主机名、IP、巡检操作"]
C --> F["主机名、IP、巡检操作"]
D --> G["主机名、IP、巡检操作"]例如,web01 和 db01 都可以由同一个 Server 类创建,但它们拥有不同的主机名和 IP 地址。
对象的属性与行为
属性:对象具有什么信息
以 Linux 服务器为例:
| 属性 | 示例 |
|---|---|
hostname | web01 |
ip | 192.168.1.10 |
os | Rocky Linux |
status | running |
这些信息描述了服务器的特征。在 Python 中,属性可以用来保存对象的数据。
行为:对象能够执行什么操作
服务器除了保存信息,还可以执行一些操作:
| 行为 | 作用 |
|---|---|
check_disk() | 检查磁盘空间 |
check_memory() | 检查内存状态 |
check_cpu() | 检查 CPU 使用率 |
generate_report() | 生成巡检报告 |
这些操作通常通过方法(Method)实现。
方法是定义在类中的函数。 实例方法通常可以通过 self 访问当前对象的属性和其他方法。
将属性和行为组织起来,可以让程序更容易理解:服务器对象保存服务器自身的信息,也提供与服务器相关的操作。
从零编写一个可以运行的类
先不急着学习继承和多态,我们先编写一个最简单的 Server 类,看看类和对象在 Python 中究竟是什么样子。
创建文件 server.py:
1 | class Server: |
运行程序:
1 | python3 server.py |
输出结果:
1 | 主机名:web01 |
这段代码可以直接运行,不需要安装第三方库。
我们来拆解几个关键点。
class:定义一个类
1 | class Server: |
class 是 Python 定义类的关键字,Server 是类名。
按照 Python 的常见命名规范,类名一般使用大驼峰命名法,例如 Server、BankAccount 和 DatabaseServer。
__init__():初始化对象
1 | def __init__(self, hostname, ip, os_name): |
__init__() 是一个特殊方法。当创建对象时,Python 会自动调用它来初始化对象的属性。
例如:
1 | web_server = Server("web01", "192.168.1.10", "Rocky Linux") |
这里传入了主机名、IP 地址和操作系统名称,初始化方法将这些值保存到新创建的对象中。
严格来说,对象的创建由 __new__() 等机制负责,__init__() 负责初始化已经创建的对象。入门阶段可以先把它理解为对象的初始化方法。
self:当前对象本身
1 | self.hostname = hostname |
这里的 self 指向当前实例。
等号右侧的 hostname 是传入的参数,等号左侧的 self.hostname 是当前对象的实例属性。
当执行:
1 | web_server = Server("web01", "192.168.1.10", "Rocky Linux") |
两个对象分别保存自己的属性值,互不影响。
可以把这个过程理解为:
flowchart TD
A["创建 Server 对象"] --> B["调用 __init__()"]
B --> C["保存 hostname"]
C --> D["保存 ip"]
D --> E["保存 os_name"]
E --> F["得到初始化完成的对象"]实例方法:定义对象可以执行的操作
1 | def show_info(self): |
show_info() 是一个实例方法,它通过 self 访问当前对象的数据。
当我们调用:
1 | web_server.show_info() |
Python 会把 web_server 对象作为实例方法的第一个参数传入,也就是 self。
因此,方法内部的 self.hostname 就会读取 web_server 对象的主机名。
到这里,你已经编写了一个完整的类,并创建了两个不同的对象。
封装:组织数据与行为,并保护对象的状态
封装(Encapsulation)是面向对象编程的重要特征之一。
在入门阶段,经常会看到这样的解释:封装就是把数据和操作数据的方法放在一起。
这个说法可以帮助我们建立初步认识,但还不够完整。
更准确地说,封装既强调把相关状态与行为组织在一个抽象单元中,也强调通过明确的接口管理对象的状态和实现细节。
我们仍然以银行账户为例。
假设账户余额是 1000 元,如果程序中任何地方都可以随意修改余额,就可能绕过存取款规则。
1 | balance = 1000 |
Python 会正常执行这段代码,但业务数据可能已经不符合预期。
我们可以将余额与存取款操作组织在同一个类中,并通过方法校验操作是否合法。
1 | class BankAccount: |
运行结果:
1 | 存款成功,当前余额:1500 |
这里,存取款规则集中在 deposit() 和 withdraw() 方法中,避免每个调用位置都重复编写校验逻辑。
不过,这个示例还没有真正限制外部代码修改余额。下面的代码依然可以执行:
1 | account.balance = -5000 |
这是因为 Python 默认不会禁止外部访问普通实例属性。
Python 如何表达访问控制?
与 Java 等语言不同,Python 没有通过 public、protected、private 关键字实现同样的访问控制体系。
Python 通常通过命名约定和属性机制表达访问意图。
| 写法 | 含义 |
|---|---|
name | 公共属性,通常可以直接访问 |
_name | 约定为内部使用,不建议外部直接访问 |
__name | 触发名称改写,降低意外冲突的概率,但不是绝对的私有访问控制 |
特别注意:单下划线不是访问限制机制,双下划线也不是不可绕过的安全边界。
例如,__balance 在类定义中通常会被改写成包含类名的属性名,以降低子类或外部代码意外覆盖它的可能性,但它并不意味着数据获得了真正的安全隔离。
如果希望控制余额的修改方式,可以使用属性机制提供受控的读取接口,并只允许通过存取款方法修改余额。
1 | class BankAccount: |
输出结果:
1 | 1300 |
这里,@property 将 balance() 方法包装成一个可读取的属性,因此可以通过 account.balance 获取余额,而不需要调用 account.balance()。
由于没有定义 balance 的 setter,下面这种直接赋值会触发 AttributeError:
1 | account.balance = -5000 |
而存款和取款仍然可以通过对应方法完成。
需要注意,Python 的双下划线名称改写并不提供真正的安全隔离;这套机制主要是为了改善接口设计和减少意外修改,而不是抵御恶意代码。
抽象:关注对外提供什么能力
抽象(Abstraction)与封装密切相关,但两者关注的重点不同。
- 封装侧重于组织数据与行为,并管理对象的状态和实现细节。
- 抽象侧重于提炼出使用者真正需要的功能,隐藏不必要的实现复杂性。
例如,使用服务器巡检程序时,我们只需要调用:
1 | server.check_disk() |
调用者不一定需要了解内部如何执行系统命令、解析输出、处理异常以及记录日志。
只要对外提供的接口保持稳定,内部实现就可以独立改进。
这就是抽象带来的好处:使用者关注做什么,具体实现负责怎么做。
在 Python 中,清晰的公共方法就可以帮助实现这种抽象。对于更复杂的设计,还可以使用抽象基类等机制,约定子类需要实现哪些方法。
继承:复用已有类型的共同能力
继续沿用 Linux 服务器巡检的场景。
假设我们需要管理不同类型的服务器:
- Web 服务器
- 数据库服务器
它们都有主机名和 IP 地址,也都需要执行基本巡检。但 Web 服务器需要检查 Nginx,数据库服务器则需要检查数据库服务。
如果每种服务器都重新编写全部功能,就会出现重复代码。
继承(Inheritance)允许我们基于已有的类定义新的类。新类可以继承父类的属性和方法,并根据需要扩展或覆盖行为。
我们先编写一个通用的 Server 父类,再定义两个子类。
1 | class Server: |
运行结果:
1 | web01:检查磁盘空间 |
这里发生了什么?
Server 是父类,定义了所有服务器都需要的初始化方法和磁盘检查方法。
WebServer(Server) 表示 WebServer 继承自 Server。因此,WebServer 对象可以直接调用继承来的 check_disk() 方法。
DatabaseServer(Server) 同理。
两个子类都拥有通用的服务器属性和磁盘检查能力,同时又可以提供各自的专属功能。
classDiagram
class Server {
hostname
ip
check_disk()
}
class WebServer {
check_service()
}
class DatabaseServer {
check_service()
}
Server <|-- WebServer
Server <|-- DatabaseServer这就是继承的基本用途:把共同的能力放到合适的父类中,再由子类复用和扩展。
继承适合表达“是一种”的关系。例如,Web 服务器是一种服务器。
不过,继承不是代码复用的唯一方式。如果两个类只是需要共享某项功能,而不存在合理的类型关系,组合(Composition)也值得考虑。
多态:相同的调用方式,不同的具体行为
有了继承之后,我们继续完善刚才的服务器巡检程序。
现在,Web 服务器和数据库服务器都提供了 check_service() 方法,但它们执行的操作不同:
- Web 服务器检查 Nginx。
- 数据库服务器检查数据库服务。
如果我们希望批量检查所有服务器,就不必为每一种类型编写独立的调用逻辑。
1 | class Server: |
运行结果:
1 | web01:检查磁盘空间 |
注意观察 for 循环:
1 | for server in servers: |
循环内部只调用了同一个方法 check_service(),并不需要先判断当前对象究竟是 Web 服务器还是数据库服务器。
Python 会根据当前对象实际所属的类型,调用相应的方法实现。
这种通过统一调用方式获得不同具体行为的特征,就是多态(Polymorphism)。
Python 的多态一定需要继承吗?
不一定。
Python 支持基于对象行为的多态。只要对象提供了所需的方法,调用代码就可以使用它,不要求所有对象都必须继承同一个父类。
例如,我们可以增加一个新的缓存服务器类:
1 | class CacheServer: |
即使 CacheServer 没有继承 Server,只要它提供了巡检代码所需的 check_disk() 和 check_service() 方法,就可以参与同样的调用。
1 | servers = [ |
输出结果:
1 | web01:检查磁盘空间 |
这体现了 Python 的动态类型特征,也常被称为鸭子类型(Duck Typing):关注对象是否具备所需行为,而不是只看它属于哪个类。
当然,真实的巡检程序还需要处理方法缺失、命令执行失败和权限不足等问题。这里先关注面向对象的设计思想。
面向对象编程有什么好处?
了解了这些概念,再来看看它们在实际开发中能解决什么问题。
减少重复代码
多个对象可以共享同一个类中定义的行为。通过合理的抽象和继承,可以减少重复实现。
例如,Web 服务器和数据库服务器可以共用通用的磁盘检查逻辑,不必各写一份。
提高可维护性
把相关的数据与操作组织在一起,能够让程序的结构更清晰。
需要修改磁盘检查逻辑时,可以集中修改通用方法,而不是在多个位置重复修改。
方便扩展
当系统增加新的业务类型时,可以在既有设计基础上扩展功能。
例如,增加缓存服务器时,只需要提供符合巡检接口的具体实现,就可以接入统一的调用流程。
更适合表达复杂业务
客户、账户、订单、服务器、容器等概念,本身就具有各自的数据和行为。通过合理的对象设计,可以让程序结构更接近业务问题的组织方式。
当然,面向对象不是万能的。对于一次性脚本、简单的数据转换任务,直接使用函数往往更简洁。选择合适的编程方式,比为了使用类而使用类更重要。
写在最后
今天我们学习了面向对象编程的基本思想,以及类、对象、属性、行为、封装、抽象、继承和多态等概念。
先记住几个关键点:
- 类用于描述一类事物,对象则是根据类创建出来的具体实例。
- 属性用于保存对象的数据,方法定义对象可以执行的操作。
- 封装强调组织相关数据与行为,并通过接口管理对象状态和实现细节。
- 抽象强调提炼使用者需要的功能,隐藏不必要的实现复杂性。
- 继承允许新类复用和扩展已有类的能力。
- 多态允许不同类型的对象通过相同的调用方式执行各自的行为。
面向对象编程并不是要把所有代码都改写成类,而是帮助我们在程序复杂起来之后,更合理地组织数据和行为。
下一篇,我们将正式学习 Python 的 class 关键字,进一步理解 __init__()、self、实例属性和实例方法,逐步掌握自定义类的编写方法。
