1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com
  1. Python 系列(一) 从零开始认识 Python,入门其实没那么难
  2. Python 系列(二) 学 Python 的第一步,把开发环境搭建好
  3. Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
  4. Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
  5. Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
  6. Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
  7. Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
  8. Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
  9. Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
  10. Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
  11. Python 系列(十一) list、tuple、set、dict,到底该怎么选?
  12. Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
  13. Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
  14. Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
  15. Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
  16. Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
  17. Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
  18. Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
  19. Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
  20. Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
  21. Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
  22. Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
  23. Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
  24. Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
  25. Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
  26. Python 系列(二十六) Python 模块入门,从 import 到模块复用
  27. Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
  28. Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
  29. Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
  30. Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
  31. Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
  32. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server = {
"hostname": "web01",
"ip": "192.168.1.10",
"os": "Rocky Linux",
}


def check_disk(server):
print(f"正在检查 {server['hostname']} 的磁盘空间")


def check_memory(server):
print(f"正在检查 {server['hostname']} 的内存状态")


check_disk(server)
check_memory(server)

运行结果:

1
2
正在检查 web01 的磁盘空间
正在检查 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 服务器为例:

属性示例
hostnameweb01
ip192.168.1.10
osRocky Linux
statusrunning

这些信息描述了服务器的特征。在 Python 中,属性可以用来保存对象的数据。

行为:对象能够执行什么操作

服务器除了保存信息,还可以执行一些操作:

行为作用
check_disk()检查磁盘空间
check_memory()检查内存状态
check_cpu()检查 CPU 使用率
generate_report()生成巡检报告

这些操作通常通过方法(Method)实现。

方法是定义在类中的函数。 实例方法通常可以通过 self 访问当前对象的属性和其他方法。

将属性和行为组织起来,可以让程序更容易理解:服务器对象保存服务器自身的信息,也提供与服务器相关的操作。

从零编写一个可以运行的类

先不急着学习继承和多态,我们先编写一个最简单的 Server 类,看看类和对象在 Python 中究竟是什么样子。

创建文件 server.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Server:
def __init__(self, hostname, ip, os_name):
self.hostname = hostname
self.ip = ip
self.os_name = os_name

def show_info(self):
print(f"主机名:{self.hostname}")
print(f"IP 地址:{self.ip}")
print(f"操作系统:{self.os_name}")


# 创建两个 Server 对象
web_server = Server("web01", "192.168.1.10", "Rocky Linux")
db_server = Server("db01", "192.168.1.20", "RHEL")

# 分别调用对象的方法
web_server.show_info()

print("-" * 30)

db_server.show_info()

运行程序:

1
python3 server.py

输出结果:

1
2
3
4
5
6
7
主机名:web01
IP 地址:192.168.1.10
操作系统:Rocky Linux
------------------------------
主机名:db01
IP 地址:192.168.1.20
操作系统:RHEL

这段代码可以直接运行,不需要安装第三方库。

我们来拆解几个关键点。

class:定义一个类

1
class Server:

class 是 Python 定义类的关键字,Server 是类名。

按照 Python 的常见命名规范,类名一般使用大驼峰命名法,例如 Server、BankAccount 和 DatabaseServer。

__init__():初始化对象

1
2
3
4
def __init__(self, hostname, ip, os_name):
self.hostname = hostname
self.ip = ip
self.os_name = 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
2
web_server = Server("web01", "192.168.1.10", "Rocky Linux")
db_server = Server("db01", "192.168.1.20", "RHEL")

两个对象分别保存自己的属性值,互不影响。

可以把这个过程理解为:

flowchart TD
    A["创建 Server 对象"] --> B["调用 __init__()"]
    B --> C["保存 hostname"]
    C --> D["保存 ip"]
    D --> E["保存 os_name"]
    E --> F["得到初始化完成的对象"]

实例方法:定义对象可以执行的操作

1
2
3
4
def show_info(self):
print(f"主机名:{self.hostname}")
print(f"IP 地址:{self.ip}")
print(f"操作系统:{self.os_name}")

show_info() 是一个实例方法,它通过 self 访问当前对象的数据。

当我们调用:

1
web_server.show_info()

Python 会把 web_server 对象作为实例方法的第一个参数传入,也就是 self。

因此,方法内部的 self.hostname 就会读取 web_server 对象的主机名。

到这里,你已经编写了一个完整的类,并创建了两个不同的对象。

封装:组织数据与行为,并保护对象的状态

封装(Encapsulation)是面向对象编程的重要特征之一。

在入门阶段,经常会看到这样的解释:封装就是把数据和操作数据的方法放在一起。

这个说法可以帮助我们建立初步认识,但还不够完整。

更准确地说,封装既强调把相关状态与行为组织在一个抽象单元中,也强调通过明确的接口管理对象的状态和实现细节。

我们仍然以银行账户为例。

假设账户余额是 1000 元,如果程序中任何地方都可以随意修改余额,就可能绕过存取款规则。

1
2
3
4
5
6
balance = 1000

# 直接修改余额
balance = -5000

print(balance)

Python 会正常执行这段代码,但业务数据可能已经不符合预期。

我们可以将余额与存取款操作组织在同一个类中,并通过方法校验操作是否合法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class BankAccount:
def __init__(self, owner, balance=0):
if balance < 0:
raise ValueError("初始余额不能为负数")

self.owner = owner
self.balance = balance

def deposit(self, amount):
if amount <= 0:
print("存款金额必须大于 0")
return

self.balance += amount
print(f"存款成功,当前余额:{self.balance}")

def withdraw(self, amount):
if amount <= 0:
print("取款金额必须大于 0")
return

if amount > self.balance:
print("余额不足,取款失败")
return

self.balance -= amount
print(f"取款成功,当前余额:{self.balance}")


account = BankAccount("张三", 1000)

account.deposit(500)
account.withdraw(200)
account.withdraw(2000)

print(f"账户持有人:{account.owner}")
print(f"最终余额:{account.balance}")

运行结果:

1
2
3
4
5
存款成功,当前余额:1500
取款成功,当前余额:1300
余额不足,取款失败
账户持有人:张三
最终余额:1300

这里,存取款规则集中在 deposit() 和 withdraw() 方法中,避免每个调用位置都重复编写校验逻辑。

不过,这个示例还没有真正限制外部代码修改余额。下面的代码依然可以执行:

1
account.balance = -5000

这是因为 Python 默认不会禁止外部访问普通实例属性。

Python 如何表达访问控制?

与 Java 等语言不同,Python 没有通过 public、protected、private 关键字实现同样的访问控制体系。

Python 通常通过命名约定和属性机制表达访问意图。

写法含义
name公共属性,通常可以直接访问
_name约定为内部使用,不建议外部直接访问
__name触发名称改写,降低意外冲突的概率,但不是绝对的私有访问控制

特别注意:单下划线不是访问限制机制,双下划线也不是不可绕过的安全边界。

例如,__balance 在类定义中通常会被改写成包含类名的属性名,以降低子类或外部代码意外覆盖它的可能性,但它并不意味着数据获得了真正的安全隔离。

如果希望控制余额的修改方式,可以使用属性机制提供受控的读取接口,并只允许通过存取款方法修改余额。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class BankAccount:
def __init__(self, owner, balance=0):
if balance < 0:
raise ValueError("初始余额不能为负数")

self.owner = owner
self.__balance = balance

@property
def balance(self):
return self.__balance

def deposit(self, amount):
if amount <= 0:
raise ValueError("存款金额必须大于 0")

self.__balance += amount

def withdraw(self, amount):
if amount <= 0:
raise ValueError("取款金额必须大于 0")

if amount > self.__balance:
raise ValueError("余额不足")

self.__balance -= amount


account = BankAccount("张三", 1000)

account.deposit(500)
account.withdraw(200)

print(account.balance)

输出结果:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Server:
def __init__(self, hostname, ip):
self.hostname = hostname
self.ip = ip

def check_disk(self):
print(f"{self.hostname}:检查磁盘空间")


class WebServer(Server):
def check_service(self):
print(f"{self.hostname}:检查 Nginx 服务")


class DatabaseServer(Server):
def check_service(self):
print(f"{self.hostname}:检查数据库服务")


web = WebServer("web01", "192.168.1.10")
db = DatabaseServer("db01", "192.168.1.20")

web.check_disk()
web.check_service()

db.check_disk()
db.check_service()

运行结果:

1
2
3
4
web01:检查磁盘空间
web01:检查 Nginx 服务
db01:检查磁盘空间
db01:检查数据库服务

这里发生了什么?

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Server:
def __init__(self, hostname, ip):
self.hostname = hostname
self.ip = ip

def check_disk(self):
print(f"{self.hostname}:检查磁盘空间")


class WebServer(Server):
def check_service(self):
print(f"{self.hostname}:检查 Nginx 服务")


class DatabaseServer(Server):
def check_service(self):
print(f"{self.hostname}:检查数据库服务")


servers = [
WebServer("web01", "192.168.1.10"),
DatabaseServer("db01", "192.168.1.20"),
]

for server in servers:
server.check_disk()
server.check_service()

运行结果:

1
2
3
4
web01:检查磁盘空间
web01:检查 Nginx 服务
db01:检查磁盘空间
db01:检查数据库服务

注意观察 for 循环:

1
2
for server in servers:
server.check_service()

循环内部只调用了同一个方法 check_service(),并不需要先判断当前对象究竟是 Web 服务器还是数据库服务器。

Python 会根据当前对象实际所属的类型,调用相应的方法实现。

这种通过统一调用方式获得不同具体行为的特征,就是多态(Polymorphism)。

Python 的多态一定需要继承吗?

不一定。

Python 支持基于对象行为的多态。只要对象提供了所需的方法,调用代码就可以使用它,不要求所有对象都必须继承同一个父类。

例如,我们可以增加一个新的缓存服务器类:

1
2
3
4
5
6
7
8
9
10
class CacheServer:
def __init__(self, hostname, ip):
self.hostname = hostname
self.ip = ip

def check_disk(self):
print(f"{self.hostname}:检查磁盘空间")

def check_service(self):
print(f"{self.hostname}:检查 Redis 服务")

即使 CacheServer 没有继承 Server,只要它提供了巡检代码所需的 check_disk() 和 check_service() 方法,就可以参与同样的调用。

1
2
3
4
5
6
7
8
9
servers = [
WebServer("web01", "192.168.1.10"),
DatabaseServer("db01", "192.168.1.20"),
CacheServer("cache01", "192.168.1.30"),
]

for server in servers:
server.check_disk()
server.check_service()

输出结果:

1
2
3
4
5
6
web01:检查磁盘空间
web01:检查 Nginx 服务
db01:检查磁盘空间
db01:检查数据库服务
cache01:检查磁盘空间
cache01:检查 Redis 服务

这体现了 Python 的动态类型特征,也常被称为鸭子类型(Duck Typing):关注对象是否具备所需行为,而不是只看它属于哪个类。

当然,真实的巡检程序还需要处理方法缺失、命令执行失败和权限不足等问题。这里先关注面向对象的设计思想。

面向对象编程有什么好处?

了解了这些概念,再来看看它们在实际开发中能解决什么问题。

减少重复代码

多个对象可以共享同一个类中定义的行为。通过合理的抽象和继承,可以减少重复实现。

例如,Web 服务器和数据库服务器可以共用通用的磁盘检查逻辑,不必各写一份。

提高可维护性

把相关的数据与操作组织在一起,能够让程序的结构更清晰。

需要修改磁盘检查逻辑时,可以集中修改通用方法,而不是在多个位置重复修改。

方便扩展

当系统增加新的业务类型时,可以在既有设计基础上扩展功能。

例如,增加缓存服务器时,只需要提供符合巡检接口的具体实现,就可以接入统一的调用流程。

更适合表达复杂业务

客户、账户、订单、服务器、容器等概念,本身就具有各自的数据和行为。通过合理的对象设计,可以让程序结构更接近业务问题的组织方式。

当然,面向对象不是万能的。对于一次性脚本、简单的数据转换任务,直接使用函数往往更简洁。选择合适的编程方式,比为了使用类而使用类更重要。

写在最后

今天我们学习了面向对象编程的基本思想,以及类、对象、属性、行为、封装、抽象、继承和多态等概念。

先记住几个关键点:

  • 类用于描述一类事物,对象则是根据类创建出来的具体实例。
  • 属性用于保存对象的数据,方法定义对象可以执行的操作。
  • 封装强调组织相关数据与行为,并通过接口管理对象状态和实现细节。
  • 抽象强调提炼使用者需要的功能,隐藏不必要的实现复杂性。
  • 继承允许新类复用和扩展已有类的能力。
  • 多态允许不同类型的对象通过相同的调用方式执行各自的行为。

面向对象编程并不是要把所有代码都改写成类,而是帮助我们在程序复杂起来之后,更合理地组织数据和行为。

下一篇,我们将正式学习 Python 的 class 关键字,进一步理解 __init__()、self、实例属性和实例方法,逐步掌握自定义类的编写方法。