Python:类、继承、协议、魔术方法应该怎么理解才不会越写越乱

Python 学到类这一层时,很多新手会突然开始乱。

前面写脚本时,代码大多还是线性的:

  • 读输入
  • 处理数据
  • 打印结果

一旦开始接触类,代码会立刻多出这些问题:

  • 到底什么时候该建类,什么时候直接用字典就够
  • 继承是不是一上来就该用
  • 为什么有些对象明明不是同一个类,却也能放进同一个函数里处理
  • __init____str____repr____len__ 这些魔术方法到底什么时候值得写

这一篇不按定义去背,而是围绕一个实际功能展开:做一个任务清单脚本
这个脚本要完成这些事:

  • 管理多个任务
  • 区分普通任务和告警任务
  • 打印任务列表
  • 统计任务数量
  • 用不同导出器把任务导出成文本

用这个场景把四个容易混在一起的概念拆开:

  1. 类到底在解决什么问题
  2. 继承适合解决什么问题
  3. 协议式调用为什么能让代码更灵活
  4. 魔术方法什么时候写,代码会更顺

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成这些动作:

  • 用类把一组相关数据和行为组织起来
  • 判断某个场景该不该用继承
  • 写一个接受“同类行为对象”的函数,而不是把类型写死
  • 理解常见魔术方法在实际代码中的作用
  • 识别几类典型错误,例如共享可变属性、继承滥用、对象打印不可读

如果这些动作能独立做出来,类这一层就不再只是“知道语法”,而是开始能写出结构更清晰的代码。

二、先看最终功能

这篇文章最终要做的是一个最小任务清单脚本。

希望运行后看到类似输出:

1
2
3
4
5
6
7
8
9
共有 3 个任务
[todo] 修复登录接口超时
[todo] 核对压测报告字段
[alert] 处理生产巡检告警

导出结果:
修复登录接口超时,todo
核对压测报告字段,todo
处理生产巡检告警,alert

这个功能不大,但已经足够把类、继承、协议和魔术方法串起来。

三、先从不用类的版本开始

如果先不用类,最直接的写法往往是:

1
2
3
4
5
6
7
8
tasks = [
{"title": "修复登录接口超时", "level": "todo"},
{"title": "核对压测报告字段", "level": "todo"},
{"title": "处理生产巡检告警", "level": "alert"},
]

for task in tasks:
print(task["title"], task["level"])

执行:

1
python task_app.py

输出:

1
2
3
修复登录接口超时 todo
核对压测报告字段 todo
处理生产巡检告警 alert

这个版本当然能跑,但很快会碰到两个实际问题:

  • 任务的显示格式、状态判断、导出逻辑会散在各处
  • 一旦字段名改动,字典访问很容易全局跟着出错

这就是类真正值得上场的地方:把一组相关数据和相关行为放到同一个对象里。

四、类到底在解决什么问题

1. 先写一个最小类

新建 task_app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def display(self):
return f"[{self.level}] {self.title}"


task = Task("修复登录接口超时")
print(task.title)
print(task.level)
print(task.display())

执行:

1
python task_app.py

输出:

1
2
3
修复登录接口超时
todo
[todo] 修复登录接口超时

这里类的价值已经很清楚了:

  • titlelevel 这些数据被放进对象里
  • 和任务有关的展示逻辑也放进对象里

这样后面再处理任务,不需要每次都记得去拼接字符串或查字典字段。

2. 一个实际错误:把行为写到类外面

错误写法:

1
2
3
4
5
6
7
8
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level


def display():
return f"[{self.level}] {self.title}"

报错:

1
NameError: name 'self' is not defined

问题很直接:

  • display() 不是实例方法
  • 函数里却试图访问 self

修复写法:

1
2
3
4
5
6
7
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def display(self):
return f"[{self.level}] {self.title}"

如果某个行为必须依赖对象内部的数据,它通常就应该是实例方法。

五、__init__ 不是装饰,它决定对象怎么出生

1. __init__ 最实际的作用

__init__ 的作用不是“语法规定必须写”,而是对象创建时把最基本的状态准备好。

例如:

1
2
3
4
5
class Task:
def __init__(self, title, level="todo", done=False):
self.title = title
self.level = level
self.done = done

创建对象:

1
2
3
4
task = Task("处理生产巡检告警", level="alert")
print(task.title)
print(task.level)
print(task.done)

输出:

1
2
3
处理生产巡检告警
alert
False

2. 一个实际错误:忘了传必要参数

错误写法:

1
task = Task()

报错:

1
TypeError: Task.__init__() missing 1 required positional argument: 'title'

这类报错很常见,说明对象创建时缺了必要数据。

修复写法:

1
task = Task("修复登录接口超时")

3. 一个更隐蔽的实际错误:共享可变默认值

错误写法:

1
2
3
class TaskGroup:
def __init__(self, tasks=[]):
self.tasks = tasks

这会带来非常典型的问题:不同实例可能共享同一份列表。

验证代码:

1
2
3
4
5
6
7
8
9
10
11
class TaskGroup:
def __init__(self, tasks=[]):
self.tasks = tasks


g1 = TaskGroup()
g2 = TaskGroup()

g1.tasks.append("task-a")
print(g1.tasks)
print(g2.tasks)

输出:

1
2
['task-a']
['task-a']

这不是两个对象各自独立的状态,而是同一个列表被复用了。

修复写法:

1
2
3
4
5
class TaskGroup:
def __init__(self, tasks=None):
if tasks is None:
tasks = []
self.tasks = tasks

再执行:

1
2
3
4
5
6
g1 = TaskGroup()
g2 = TaskGroup()

g1.tasks.append("task-a")
print(g1.tasks)
print(g2.tasks)

输出:

1
2
['task-a']
[]

这类错误几乎是 Python 类入门时最常见的坑之一。

六、魔术方法什么时候值得写

魔术方法不是为了炫技,而是为了让对象在常见操作里表现得更自然。

这一篇先只讲最常用、最值得先掌握的几个。

1. __str__:让打印结果更可读

如果不写 __str__

1
2
3
4
5
6
7
8
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level


task = Task("修复登录接口超时")
print(task)

输出通常像这样:

1
<__main__.Task object at 0x1023f9b80>

这对排查和调试几乎没有帮助。

写上 __str__

1
2
3
4
5
6
7
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def __str__(self):
return f"[{self.level}] {self.title}"

再打印:

1
2
task = Task("修复登录接口超时")
print(task)

输出:

1
[todo] 修复登录接口超时

2. __repr__:调试时看对象更清楚

1
2
3
4
5
6
7
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def __repr__(self):
return f"Task(title={self.title!r}, level={self.level!r})"

验证:

1
2
task = Task("修复登录接口超时")
print(repr(task))

输出:

1
Task(title='修复登录接口超时', level='todo')

3. __len__:让容器对象支持 len()

如果要做一个任务清单对象,__len__ 会很顺手。

1
2
3
4
5
6
7
8
class TaskList:
def __init__(self, tasks=None):
if tasks is None:
tasks = []
self.tasks = tasks

def __len__(self):
return len(self.tasks)

验证:

1
2
3
4
5
6
task_list = TaskList([
Task("修复登录接口超时"),
Task("核对压测报告字段"),
])

print(len(task_list))

输出:

1
2

4. __iter__:让对象可以直接遍历

1
2
3
4
5
6
7
8
class TaskList:
def __init__(self, tasks=None):
if tasks is None:
tasks = []
self.tasks = tasks

def __iter__(self):
return iter(self.tasks)

这样就可以直接写:

1
2
for task in task_list:
print(task)

七、继承适合解决什么问题

继承不是“只要有两个类就该继承”,而是当两类对象:

  • 有共同字段
  • 有共同方法
  • 又有少量差异

这时继承才有意义。

1. 先写父类和子类

1
2
3
4
5
6
7
8
9
10
11
12
class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def __str__(self):
return f"[{self.level}] {self.title}"


class AlertTask(Task):
def __init__(self, title):
super().__init__(title, level="alert")

验证:

1
2
3
4
5
task1 = Task("核对压测报告字段")
task2 = AlertTask("处理生产巡检告警")

print(task1)
print(task2)

输出:

1
2
[todo] 核对压测报告字段
[alert] 处理生产巡检告警

这里继承就比较合理,因为:

  • AlertTask 也是一种任务
  • 它只是默认等级不同

2. 一个实际错误:继承只是为了复用一点代码

错误思路通常像这样:

  • 有个 Task
  • 有个 ReportExporter
  • 因为两边都要打印文本,于是强行让 ReportExporter(Task)

这种继承没有业务上的“is-a”关系,代码很快会越长越歪。

判断继承是否合适,最直接的问题是:

子类到底是不是父类的一种?

如果答不上来,通常就不该继承。

八、协议为什么能让代码更灵活

这一节说的协议,不先强调 typing.Protocol,先看更实际的运行方式。

假设现在要支持两种导出器:

  • 文本导出器
  • JSON 导出器

先写两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import json


class TextExporter:
def export(self, tasks):
lines = []
for task in tasks:
lines.append(f"{task.title},{task.level}")
return "\n".join(lines)


class JsonExporter:
def export(self, tasks):
data = []
for task in tasks:
data.append({
"title": task.title,
"level": task.level,
})
return json.dumps(data, ensure_ascii=False)

再写一个统一调用函数:

1
2
def dump_tasks(tasks, exporter):
return exporter.export(tasks)

调用:

1
2
3
4
5
6
7
tasks = [
Task("修复登录接口超时"),
AlertTask("处理生产巡检告警"),
]

print(dump_tasks(tasks, TextExporter()))
print(dump_tasks(tasks, JsonExporter()))

输出:

1
2
3
修复登录接口超时,todo
处理生产巡检告警,alert
[{"title": "修复登录接口超时", "level": "todo"}, {"title": "处理生产巡检告警", "level": "alert"}]

这里关键点是:

  • TextExporterJsonExporter 不是同一个父类
  • 但它们都提供了 export(tasks) 这个方法

这就是最常见的协议式调用:只要对象表现出你需要的行为,就能接进来用。

一个实际错误

如果误传了一个没有 export() 的对象:

1
2
3
4
5
class BadExporter:
pass


print(dump_tasks(tasks, BadExporter()))

报错:

1
AttributeError: 'BadExporter' object has no attribute 'export'

修复方式不是去加一层无意义继承,而是让传入对象满足这份行为约定:

1
2
3
class BadExporter:
def export(self, tasks):
return "not implemented"

九、把类、继承、协议和魔术方法串起来

下面把前面的内容合到一个完整版本里。

task_app.py

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import json


class Task:
def __init__(self, title, level="todo"):
self.title = title
self.level = level

def __str__(self):
return f"[{self.level}] {self.title}"

def __repr__(self):
return f"Task(title={self.title!r}, level={self.level!r})"


class AlertTask(Task):
def __init__(self, title):
super().__init__(title, level="alert")


class TaskList:
def __init__(self, tasks=None):
if tasks is None:
tasks = []
self.tasks = tasks

def add(self, task):
self.tasks.append(task)

def __len__(self):
return len(self.tasks)

def __iter__(self):
return iter(self.tasks)


class TextExporter:
def export(self, tasks):
lines = []
for task in tasks:
lines.append(f"{task.title},{task.level}")
return "\n".join(lines)


class JsonExporter:
def export(self, tasks):
data = []
for task in tasks:
data.append({
"title": task.title,
"level": task.level,
})
return json.dumps(data, ensure_ascii=False)


def dump_tasks(tasks, exporter):
return exporter.export(tasks)


def main():
task_list = TaskList()
task_list.add(Task("修复登录接口超时"))
task_list.add(Task("核对压测报告字段"))
task_list.add(AlertTask("处理生产巡检告警"))

print(f"共有 {len(task_list)} 个任务")
for task in task_list:
print(task)

print("\n导出结果:")
print(dump_tasks(task_list, TextExporter()))


if __name__ == "__main__":
main()

执行:

1
python task_app.py

输出:

1
2
3
4
5
6
7
8
9
共有 3 个任务
[todo] 修复登录接口超时
[todo] 核对压测报告字段
[alert] 处理生产巡检告警

导出结果:
修复登录接口超时,todo
核对压测报告字段,todo
处理生产巡检告警,alert

十、怎么测试这一层知识是不是真的掌握了

这一层不能只看懂,要自己改一遍。

可以直接做这些动作:

  1. 增加一个 done() 方法,把任务标记为完成
  2. 增加一个 DoneTask 或直接在 Task 里加状态字段
  3. 增加一个 Markdown 导出器
  4. TaskList 支持删除任务
  5. print(task_list) 输出更可读

如果这些改动能独立做出来,说明类这一层已经开始会用了。

十一、一个实际排错场景

这类脚本里非常常见的实际问题是:对象是加进去了,但打印结果一团糟。

例如:

1
2
3
task_list = TaskList()
task_list.add(Task("修复登录接口超时"))
print(task_list.tasks)

如果没有写好 __repr__,输出可能像这样:

1
[<__main__.Task object at 0x102f5db80>]

这时排查顺序通常是:

  1. 先看打印的是对象本身还是对象属性
  2. 再看类里有没有 __str____repr__
  3. 再决定是为了调试补 __repr__,还是为了展示补 __str__

如果调试时总看到一串内存地址,优先回头看对象的字符串表示,而不是先怀疑循环或容器有问题。

十二、一个实际练习

可以直接把这篇变成一个完整练习。

练习目标:做一个“巡检任务管理脚本”。

要求:

  1. 至少定义一个基础任务类 Task
  2. 至少定义一个子类,例如 AlertTaskDeployTask
  3. 定义一个任务列表类,支持新增和统计
  4. 至少写一个导出器,例如文本导出器
  5. 给关键对象补上 __str____repr__
  6. 补一个错误场景,例如传入不符合导出协议的对象

如果这个练习能独立做完,说明这一篇最核心的对象组织方式已经开始真正掌握。

十三、这篇文章学完以后,下一步应该补什么

如果这一篇已经能跟着做完,下一步最适合继续补的是:

  1. 装饰器、迭代器、生成器在真实项目里怎么用
  2. 异常处理、上下文管理和资源清理怎么写才稳
  3. 项目从脚本到工程时,目录、职责和边界怎么继续拆

因为到这一步,已经开始从“写脚本”往“组织代码”走了。接下来最容易卡住的,是对象之间的边界、函数与类的分工,以及代码如何继续工程化。

十四、结语

类、继承、协议和魔术方法并不是四块互不相干的知识点。

在实际代码里,它们通常是在解决同一类问题:

  • 数据怎么和行为放在一起
  • 相似对象怎么复用
  • 不同实现怎么按同一接口接入
  • 对象怎么在打印、遍历、统计时更自然

只要把这些问题和一个实际功能绑在一起去学,类这一层就不会再只是“看懂定义”,而是开始变成能真正落到代码里的组织能力。