刚开始写 Python 时,我们很容易用字典表示所有东西。一个学习任务可以是 {"title": "完成列表练习", "done": False},十个任务就放进列表里。这个办法没有错,甚至在数据很简单时相当省事。麻烦通常出现在需求往前走了几步以后:任务需要标记完成、计算剩余天数、拒绝空标题,还要以统一格式显示。此时操作任务的函数越来越多,每个函数都得记住字典里有哪些键、哪些值才算合法,一处把 done 写成 is_done,程序就会在很远的地方出错。
类解决的正是这种组织问题。它让我们把一类对象共同拥有的数据、允许执行的操作,以及对象始终要满足的规则放在一起。类不是为了把每个名词都改写成代码里的“实体”,也不是代码越面向对象越高级。我们真正想得到的是一个清楚的边界:外部代码怎样使用这个对象,对象自己怎样守住有效状态。
这一章会一直围绕一个小型学习平台推进。我们会从最简单的 StudyTask 开始,逐步加入属性验证、类方法、继承、组合和数据类。你会看到每个语法解决了什么问题,也会看到什么时候不该用它。
Python 里的所有数据都由对象表示。每个对象都有身份、类型和值。is 比较的是两个名字是否指向同一个对象,== 比较的则是类型定义的“值是否相等”。类本身也是对象;调用一个类,通常会得到这个类的新实例。

类定义共同规则,每个实例保存自己的状态。
先看没有类时的写法。下面两个字典都表示学习任务,外部函数负责修改它们:
task_a = {"title": "完成列表练习", "done": False}
task_b = {"title": "复习函数参数", "done": False}
def finish_task(task):
task["done"] = True
finish_task(task_a)代码还很短,但它藏着几个没有写出来的约定:每个任务必须有 title 和 done;done 应该是布尔值;只有拿到任务字典的人才应该按这些键修改状态。约定只存在于开发者脑中,Python 不会替我们检查。
把数据和操作放进类以后,这些约定有了明确的归属:
class StudyTask:
"""表示一项可以完成的学习任务。"""
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
self.done = False
def finish(self):
self.done = True
def summary(self):
status = "已完成" if self.done else "待完成"
return类定义像是在告诉 Python:“创建一种名为 StudyTask 的新类型。”类体里的函数称为方法。self.title、self.minutes 和 self.done 是实例属性,它们保存某个具体任务的状态。
接着调用类,创建两个实例:
reading = StudyTask("阅读类与对象", 35)
practice = StudyTask("完成建模练习", 50)
reading.finish()
print(reading.summary())
print(practice.summary())输出为:
阅读类与对象|35 分钟|已完成
完成建模练习|50 分钟|待完成reading 和 practice 都由同一个类创建,因此都能调用 finish() 和 summary()。不过它们是两个独立对象,各有一份实例状态。修改 reading.done 不会自动修改 practice.done。
点号是使用对象最常见的入口:
print(reading.title) # 读取属性
reading.minutes = 40 # 修改属性
print(reading.summary()) # 查找并调用方法当表达式读取 reading.title 时,Python 会执行属性查找。对这里这种普通实例属性,可以先把过程理解为:先看实例自己的属性空间,再去类以及它的基类中找同名属性。后面遇到 property 等描述符时,查找规则会更细,但“点号触发属性查找”这个理解一直有效。
实例属性不需要像某些语言那样提前声明。第一次执行 self.done = False 时,它就被放进了当前实例的属性空间。这个自由度很方便,也意味着拼写错误可能悄悄创建新属性:
reading.mintues = 99 # 拼错了,不会修改 reading.minutes
print(reading.minutes) # 40
print(reading.mintues) # 99如果你发现“明明赋值了,方法里为什么还是旧值”,第一件事就是检查属性名是否拼错。vars(reading) 可以帮助你查看普通实例当前保存的属性:
print(vars(reading))
# {'title': '阅读类与对象', 'minutes': 40, 'done': True, 'mintues': 99}学习对象之前还要补上一块很容易漏掉的基础:变量名和对象不是一回事。赋值通常是让名字指向一个对象,不会因为换了名字就自动复制对象。
original = StudyTask("理解对象别名", 30)
alias = original
alias.finish()
print(original.done) # True
print(alias is original) # Truealias = original 之后,两个名字指向同一个任务。通过任意一个名字修改可变状态,另一个名字再读取时当然会看到变化。这种现象称为别名,它不是类特有的问题;列表、字典和大多数自定义实例都会遇到。
这也解释了为什么“每个实例都有自己的属性”不能简单理解成“里面所有对象都被深度复制”。假设两个任务有意共享同一个资料列表:
materials = ["官方教程"]
a = StudyTask("阅读资料", 20)
b = StudyTask("整理资料", 25)
a.materials = materials
b.materials = materials
a.materials.append("课堂笔记")
print(b.materials) # ['官方教程', '课堂笔记']a 和 b 的确各有一个名为 materials 的实例属性,但这两个属性保存的引用可以指向同一个列表。排查共享状态时,不能只看属性属于谁,还要继续问属性值是否是同一个可变对象。a.materials is b.materials 能直接检查身份。
函数传参也遵循同样的对象模型。把实例传给函数,不会自动克隆实例;函数参数成为指向同一对象的新名字。函数内执行 task.finish() 会让调用者看到状态变化,而把局部参数重新绑定成另一个对象,不会替换调用者原来的名字:
def replace_locally(task):
task = StudyTask("函数内部的新任务", 10)
task.finish()
outside = StudyTask("函数外部的任务", 20)
replace_locally(outside)
print(outside.title) # 函数外部的任务
print(outside.done) # False先分清“修改同一个对象”和“让名字改指另一个对象”,很多看似玄学的类问题都会变成普通的引用关系。
class 不是只给编辑器看的静态声明。Python 执行类定义时,会建立一个新的命名空间,顺序执行类体,把方法函数、类属性和文档字符串等名字收集起来,最后创建类对象并绑定给类名。因此类可以像其他对象一样被传给函数、放进容器、读取属性,也可以在运行时被检查:
print(isinstance(StudyTask, type)) # True
print(StudyTask.__name__) # StudyTask
print(StudyTask.__doc__) # 表示一项可以完成的学习任务。调用 StudyTask(...) 的写法看起来像调用函数,是因为类对象本身可调用,常规结果是一个新实例。知道类也是对象很有用,但不要因此频繁在运行时给类追加方法或改写属性。动态修改会让行为来源难追踪;普通业务代码仍应把公开属性和方法清楚写在类定义中。
不要因为学了类,就把每一个字典都改成类。只保存少量临时数据、没有自己的规则和行为时,字典、元组或普通函数往往更直接。数据与操作开始形成稳定边界,或者多个函数反复维护同一组状态时,类才真正开始省事。
self 与绑定方法并不神秘新手第一次看到 self,常问两个问题:它是不是 Python 的关键字?为什么定义方法时要写,调用时却不传?答案是:self 只是约定俗成的参数名,不是关键字;真正起作用的是 Python 的方法绑定机制。
先把同一个调用写成两种形式:
reading.finish()
StudyTask.finish(reading)在这个例子里,两种写法产生相同效果。执行 reading.finish 时,Python 从 StudyTask 中找到函数 finish,并把它和实例 reading 绑定成一个方法对象。随后调用这个方法,实例会自动放到参数列表最前面,于是方法体里的 self 正好接到 reading。
我们甚至可以把绑定方法先保存起来:
action = practice.finish
print(action.__self__ is practice) # True
print(action.__func__ is StudyTask.finish) # True
action()
print(practice.done) # Trueaction.__self__ 是已经绑定的实例,action.__func__ 是类里原来的函数。理解这一层以后,self 就不再像隐藏语法:它只是当前接收消息、即将被读取或修改的那个对象。

对象调用方法时,会把自己交给方法,动作因此落到正确对象上。
self.方法体里的局部变量不会自动变成属性:
class BrokenTask:
def __init__(self, title):
title = title # 左边只是局部变量,实例没有得到 title 属性正确写法是把参数值交给实例:
class StudyTask:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
self.done = False右边的 title 是这次调用传进来的参数,左边的 self.title 是当前实例的属性。名字相同没有关系,它们位于不同的命名空间。显式写出 self.,读代码的人一眼就能区分“临时计算用的局部变量”和“对象之后还要保留的状态”。
__init__ 负责初始化,不负责凭空创建我们经常把 __init__ 口头叫作“构造方法”,但更准确的理解是初始化方法。通常在调用 StudyTask(...) 时,Python 先为实例创建对象,再把这个新实例交给 __init__ 设置初始状态。真正参与创建实例的是 __new__,日常业务类几乎不需要重写它。
__init__ 有两个实用目标:
class StudyTask:
def __init__(self, title, minutes):
if not title.strip():
raise ValueError("任务标题不能为空")
if minutes <= 0:
raise ValueError("预计分钟数必须大于 0")
self.title = title.strip()
self.minutes = minutes
self.done = False这比先创建一个半成品对象、再指望调用者补字段可靠得多。不过也别把 __init__ 写成大型工作流。连接网络、读取大文件、发送消息等可能失败或耗时的动作,最好放到明确的方法或服务对象中。创建一个对象不应该顺手触发一串难以察觉的外部操作。

初始化负责补齐必需信息,让对象从创建之初就处于可用状态。
先找出对象创建后立刻要成立的条件。对学习任务来说,标题不能为空,预计时长必须为正数,完成状态应有明确初值。
再把只属于当前对象的数据写成 self.xxx。列表、字典这类可变数据也应在这里为每个实例单独创建。
最后检查初始化过程是否偷偷做了耗时 I/O。如果有,把它移到名字清楚的方法里,让调用者知道什么时候真的会发生外部动作。
并非所有属性都应该属于单个实例。假设平台规定所有学习任务默认使用同一个分类名,我们可以把它放在类体中:
class StudyTask:
category = "学习"
def __init__(self, title):
self.title = title
self.done = Falsecategory 是类属性,title 和 done 是实例属性。类属性适合描述整个类共享的配置、常量或统计信息:
a = StudyTask("阅读文档")
b = StudyTask("完成练习")
print(a.category) # 学习
print(b.category) # 学习
print(StudyTask.category) # 学习读取 a.category 时,实例自身没有这个名字,Python 就继续到 StudyTask 中找到它。接下来这一步很容易让人误判:
a.category = "重点学习"
print(a.category) # 重点学习
print(b.category) # 学习
print(StudyTask.category) # 学习赋值并没有修改类属性,而是在 a 身上创建了同名实例属性,把类属性遮住了。若要修改所有没有遮蔽该名字的实例所看到的共享值,应明确写 StudyTask.category = ...。实际项目中,不要让同一个名字一会儿表示共享配置、一会儿表示实例定制;这种设计很难读。
最危险的情况,是把列表或字典这类可变对象写成类属性,却把它当成每个实例自己的容器:
class BadTask:
tags = []
def __init__(self, title):
self.title = title
def add_tag(self, tag):
self.tags.append(tag)
reading = BadTask("阅读文档")
practice = BadTask("完成练习")
reading.add_tag("理论")
practice.add_tag("动手")
print(reading.tags) # ['理论', '动手']
两个实例查找到的是类中同一个列表,append() 又是在原地修改,所以两个任务的标签混在了一起。修复方法不是复制时再补救,而是从建模上承认“标签属于任务实例”,在 __init__ 中创建:
class StudyTask:
def __init__(self, title):
self.title = title
self.tags = []
def add_tag(self, tag):
if tag not in self.tags:
self.tags.append(tag)每次初始化都会执行新的 [],因此各实例拿到不同列表。

实例属性各自拥有;可变类属性共同共享,一处修改会影响所有实例。
普通属性读取会沿“实例、类、基类”寻找,普通赋值则通常写入实例自己的属性空间。于是读取 task.category 可能得到类属性,而 task.category = "重点" 却创建实例属性。这种不对称正是遮蔽现象的来源。
property 会进一步改变这条规则。带 setter 的属性是一个负责管理读写的数据描述符,赋值 task.progress = 80 时不会简单把 progress 塞进实例字典,而会进入类中定义的 setter。你可以把描述符理解成“类属性参与属性访问时的一份协议”:对象定义了 __get__、__set__ 或 __delete__ 中的相应方法后,就能接管点号访问。方法绑定、property、类方法和静态方法背后都利用了这套机制。
初学阶段不需要手写描述符,但要知道属性访问不等于机械查字典。遇到下面这种调试场景时,这个判断很有用:
task.__dict__["progress"] = 999
print(task.progress)如果类中的 progress 是带 setter 的 property,正常读取仍会走它的 getter,未必返回实例字典中硬塞进去的同名值。直接操作 __dict__ 会绕开类建立的接口,日常代码不应这样修改状态。vars() 和 __dict__ 更适合观察和调试,不是逃过验证的后门。
还有一条很实用的命名原则:真正的类常量可以使用全大写名字,例如 MAX_TITLE_LENGTH = 60,并由代码约定不去修改。Python 不会强制常量不可变,所以命名是在向读者说明意图。若配置本来就会在不同实例间变化,把它放在实例或专门配置对象中,别让可变类属性承担隐蔽的全局变量角色。
类属性也可以故意保存共享状态,例如统计创建过多少个任务:
class StudyTask:
created_count = 0
def __init__(self, title):
self.title = title
type(self).created_count += 1不过共享可变状态会让测试、并发和子类行为更复杂。计数若需要持久化或跨进程准确,就不该靠类属性;数据库或专门的仓储对象才是合适位置。
下面的交互页把类空间和两个实例空间并排展开。先创建实例,再分别修改实例属性与类属性,最后触发“共享列表”场景。每做一步,都对照观察属性究竟写到了哪个位置。
property 守住对象边界很多初学者一听“封装”,就开始为每个属性写 get_name() 和 set_name()。Python 通常不这样起步。简单、公开的数据属性可以直接使用;当读取或赋值需要验证、换算或兼容旧接口时,再把它升级成 property,调用方仍然使用自然的点号语法。
先看任务进度。我们希望进度始终在 0 到 100 之间:
class StudyTask:
def __init__(self, title, progress=0):
self.title = title
self.progress = progress
@property
def progress(self):
return self._progress
@progress.setter
def progress(self, value):
if not isinstance(value, int):
raise TypeError(
调用者仍然写普通属性:
task = StudyTask("练习类的属性", 20)
print(task.progress) # 20
task.progress = 80
task.progress = 120 # ValueError: 进度必须在 0 到 100 之间读取 task.progress 会调用 getter,赋值会调用 setter,真正的数据保存在 _progress。初始化时写 self.progress = progress,故意经过 setter,这样构造对象和后续修改共用同一套验证规则。若在 __init__ 中直接给 _progress 赋值,初始化就可能绕过检查。
单下划线 _progress 表示“这是非公开实现细节,外部代码不要依赖”。它是社区约定,不是访问控制;你仍然可以读取它。双前导下划线如 __progress 会触发名称改写,主要用于避免子类不小心撞名,也不等于真正的安全或隐私。不要为了看起来更封闭而到处使用双下划线,它会让调试和继承变麻烦。
只有 getter、没有 setter 的属性,从常规接口看就是只读的:
class StudyTask:
def __init__(self, title, completed_units, total_units):
self.title = title
self.completed_units = completed_units
self.total_units = total_units
@property
def progress(self):
if self.total_units == 0:
return 0
return round(self.completed_units / self.total_units progress 由另外两个属性计算出来,调用者不能直接给它赋值。这个设计避免了“三份状态互相打架”:如果同时保存 completed_units、total_units 和 progress,前两项变化后很容易忘记同步第三项。
属性访问看起来像读取数据,因此应该快速、稳定、少副作用。不要让 student.report 每次都悄悄请求网络,也不要在 getter 中修改多个状态。耗时或有明显动作的操作,用 load_report()、refresh() 这类方法名更诚实。
setter 很适合检查一个字段自身是否合法,但有些规则涉及多个属性或一次完整状态转换。例如任务只有在所有必做步骤完成后才能标为完成。如果外部代码可以随意写 task.done = True,对象就无法保证规则。
这时更自然的做法是把状态转换写成有业务含义的方法:
class StudyTask:
def __init__(self, title, required_steps):
self.title = title
self.required_steps = set(required_steps)
self.completed_steps = set()
self._done = False
@property
def done(self):
return self._done
def complete_step(self, step):
if step not
外部只能读取 done,必须通过 complete_step() 推进状态。这个方法能同时维护 completed_steps 和 _done 的一致性。封装在这里不是“别人绝对碰不到数据”,而是类提供了一条不容易把状态改坏的正常路径。
异常类型也属于接口的一部分。传入类型不对通常抛 TypeError,值的类型正确但范围无效通常抛 ValueError。不要在 setter 里悄悄把 120 截成 100,除非产品规则明确要求自动修正;明确报错更容易发现上游数据问题。

受控属性把验证放在对象边界,避免无效数据破坏内部状态。
property 的价值是维持一个清楚的属性接口,而不是把复杂逻辑藏起来。验证范围、做轻量换算、提供向后兼容都很合适;网络请求、写文件、发送消息等操作应使用明确方法。
这个观察器会把 task.progress = value 背后的步骤逐一显示出来,并与绑定方法调用对照。你可以故意输入越界值,看 setter 在什么位置拒绝赋值,以及对象为什么仍保留原来的有效状态。
类体里最常见的是实例方法,它接收 self,能读取和修改具体对象。除此之外,Python 还提供类方法和静态方法。三者的差别不在装饰器长得像不像,而在“这段行为需要谁的状态”。
class StudyTask:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
self.done = False
def finish(self):
self.done = Truefinish() 修改某个任务的 done,所以它必须知道实例是谁,应该是实例方法。
平台从文本导入任务,格式约定为“标题|分钟数”。解析后仍要创建当前类的实例:
class StudyTask:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
@classmethod
def from_text(cls, text):
title, minutes_text = text.split("|", maxsplit=1)
return cls(title.strip(), int(minutes_text))
task = StudyTask.from_text("复习继承 | 45")
类方法收到的第一个参数是类,约定命名为 cls。这里返回 cls(...),而不是把类名写死成 StudyTask(...)。将来子类继承 from_text() 时,调用 SpecialTask.from_text(...) 会创建子类实例,扩展性更好。
类方法也可以读取类级配置,但不要把它当作全局状态的藏身处。如果方法只因为“和任务有一点关系”就塞进类中,类会逐渐变成杂物箱。
class StudyTask:
@staticmethod
def is_valid_title(title):
return bool(title.strip()) and len(title) <= 60静态方法不会自动接收 self 或 cls。它适合表达一个与类概念紧密相关、但不依赖实例或类状态的小函数。也要保留一个判断:如果这个函数可以被多个模块复用,或者放在类里只是为了“归类”,普通模块级函数往往更简单。
另一种常见误用,是方法明明需要实例状态,却为了能从类上调用而硬改成静态方法,再要求调用者手动把对象传进去:
class BadTask:
@staticmethod
def finish(task):
task.done = TrueBadTask.finish(task) 本质上只是把普通函数藏在类的命名空间里,失去了 task.finish() 的自然接口。需要当前对象就使用实例方法;静态方法不是“更独立、更高级”的方法。
反过来,类方法也不该用来读取某个具体实例的字段。它拿到的是类,不知道调用来自哪个实例。虽然类方法既可通过类调用,也可通过实例调用,但通过实例调用时,实例本身会被忽略,只传入它的实际类。为了让意图清楚,替代构造器通常直接写 StudyTask.from_text(...)。
super() 的真实含义当两种对象之间存在稳定的“是一种”关系,而且子类确实能在需要父类的地方正常工作时,可以考虑继承。比如视频资源是一种课程资源,拥有标题和预计学习时长,同时多了视频地址:
class LearningResource:
def __init__(self, title, minutes):
if minutes <= 0:
raise ValueError("学习时长必须大于 0")
self.title = title
self.minutes = minutes
def describe(self):
return f"{self.title},预计 {self.minutes} 分钟"
class
VideoResource 继承了基类的属性约定和方法。子类重新定义 describe(),称为方法重写。调用 video.describe() 时,Python 会按这个实例所属类的方法解析顺序查找,先找到子类版本。
在子类初始化中调用 super().__init__(...),可以复用基类建立状态的逻辑。如果忘记调用,title 和 minutes 就不会被设置:
video = VideoResource("类与对象演示", 18, "https://example.test/video")
print(video.describe())
# 视频:类与对象演示,预计 18 分钟super() 不等于“写死的父类”在单继承里,把 super() 暂时理解为“继续调用基类”通常能工作。但它更准确的含义是:返回一个代理,从当前类之后继续沿方法解析顺序查找。这个顺序可以通过类的 __mro__ 查看:
print(VideoResource.__mro__)
# (VideoResource, LearningResource, object)多继承时,这个区别很关键:super() 的下一站可能是方法解析顺序中的兄弟类,不一定是类定义中肉眼看到的某个“直属父类”。Python 会把继承图线性化,尽量保持各类声明的先后关系,并避免在菱形继承中重复访问同一个类。
看一个只用于观察调用顺序的小例子:
class Base:
def prepare(self):
print("Base")
class CacheMixin(Base):
def prepare(self):
print("CacheMixin")
super().prepare()
class LogMixin(Base):
def prepare(self):
print("LogMixin")
super().prepare()
输出顺序是:
(Resource, CacheMixin, LogMixin, Base, object)
CacheMixin
LogMixin
Base所以协作式多继承要求链上的方法愿意调用 super(),而且参数签名能够配合。只要一层不继续传递,链就会提前停止。多继承并非不能用,但它需要所有参与类都按同一协议设计,不适合在缺少清楚约定时随意拼装业务类。
基类方法在内部写 self.describe() 时,Python 仍会根据 self 的实际类型查找方法,并不因为当前代码写在基类中就锁定基类版本:
class LearningResource:
def describe(self):
return "通用资源"
def print_card(self):
print(self.describe())
class VideoResource(LearningResource):
def describe(self):
return "视频资源"
VideoResource().print_card() # 视频资源print_card() 从基类继承,但里面的 self 是 VideoResource 实例,所以 self.describe() 找到子类重写版本。这种动态分派让基类能够定义流程,把某些步骤留给子类扩展;也带来一个风险:基类初始化过程中若调用可被子类重写的方法,子类字段可能还没初始化完成。稳妥的做法是避免在基类 __init__ 中调用预期会被重写的复杂方法,或者把初始化协议写得非常清楚。
重写还应尽量保持调用约定。父类方法接受 describe(verbose=False),子类却改成必须传三个完全不同的参数,调用者把子类当基类用时就会失败。Python 在运行时不会替你证明替换关系,设计和测试需要守住这份契约。

方法查找遵循固定顺序,继续调用上层实现就是沿这条顺序前进。
不要只凭语句读起来像“是一个”就决定继承。还要问替换是否成立。若函数接收 LearningResource 并调用 describe(),传入 VideoResource 应该仍然得到合理结果。如果子类要不断禁用父类方法、改变参数含义,或者调用者必须频繁判断具体子类,继承关系就在泄漏。
过深的继承树会把一次简单调用分散到很多文件中。改基类可能影响所有后代,子类又可能依赖基类没有承诺的内部细节。业务建模通常先保持一两层;如果关系是“拥有某个能力”或“可更换某种策略”,优先考虑组合。
组合就是让一个对象持有另一个对象,再把工作交给它。学习计划“拥有提醒方式”,但学习计划不是提醒方式的一种,所以这里不该继承。
class ConsoleNotifier:
def send(self, message):
print(f"[提醒] {message}")
class StudyPlan:
def __init__(self, title, notifier):
self.title = title
self.notifier = notifier
self.tasks = []
def add_task(self, task):
self.tasks.append(task)
使用时,把协作者传进去:
plan = StudyPlan("Python 入门计划", ConsoleNotifier())
plan.add_task(StudyTask("理解 self", 30))
plan.add_task(StudyTask("练习 property", 40))
plan.remind()以后要改成邮件提醒,不必建立 EmailStudyPlan 子类,只要提供另一个也有 send() 方法的对象:
class EmailNotifier:
def __init__(self, address):
self.address = address
def send(self, message):
print(f"向 {self.address} 发送:{message}")这种设计把“计划怎样管理任务”和“消息怎样发送”拆开。测试 StudyPlan 时还可以传入一个只记录消息的假通知器,不需要真的发邮件。

类别关系适合继承,部件协作关系通常更适合组合。
遇到两段相似代码时,不必立刻建立父类。可以按下面的顺序判断:
先确认重复的是稳定概念,还是碰巧相似的几行代码。只出现两次、未来变化方向不同的代码,硬抽父类可能更难改。
问子类能否在所有需要基类的地方被自然使用。如果必须禁用父类行为或改变方法含义,继承不合适。
再问这个能力是否需要在运行时替换。通知方式、存储方式、计费策略这类协作者通常适合组合和依赖注入。
只有当关系稳定、共同接口清楚,并且重写是设计的一部分时,再使用继承。继承层次保持短,公开哪些扩展点也要写清楚。
组合不是对象越多越好。若 calculate_total(items) 只是一个没有状态的计算,普通函数已经很清楚,就没必要创造 TotalCalculatorFactory、TotalCalculatorManager 等一串空壳类。类适合表达有状态、有生命周期或有明确协议的东西;函数适合表达输入到输出的动作。两者可以自然配合。
下面的工坊一边展示 super() 沿 MRO 前进的路线,一边让你根据需求判断该用继承还是组合。重点不是把每道题都归入固定模板,而是练习检查替换关系、可变能力和调用契约。
__init__ 只是特殊方法中的一个。特殊方法通常以双下划线开头和结尾,Python 会在对应语法出现时调用它们。你定义 __len__,对象就能交给 len();定义 __contains__,就能响应 in;定义 __repr__,调试显示会更有信息。
下面让学习计划表现得像一个小容器:
class StudyPlan:
def __init__(self, title):
self.title = title
self._tasks = []
def add_task(self, task):
self._tasks.append(task)
def __len__(self):
return len(self._tasks)
def __contains__(self, task):
return task in self._tasks
def __iter__(self):
现在可以直接使用 Python 熟悉的操作:
plan = StudyPlan("本周计划")
task = StudyTask("完成类的练习", 45)
plan.add_task(task)
print(len(plan)) # 1
print(task in plan) # True
print(repr(plan)) # StudyPlan(title='本周计划', tasks=1)
for item in plan:
print(item.title)特殊方法应该实现到模型真正说得通的程度。一个对象可以逐项访问,不代表切片、加法和大小比较都必须实现。为了展示技巧而塞满运算符,只会让接口更难猜。
还有一个容易忽略的机制:len(obj) 这类隐式调用会从对象的类型上查找特殊方法。给单个实例临时设置 obj.__len__ = ...,通常不能改变 len(obj) 的结果。应该把特殊方法定义在类中,并让它表达整个类型一致的行为。
__repr__ 和 __str__ 各服务谁__repr__ 面向开发和调试,最好信息充分、歧义少;能写成接近重建对象的表达式时更好。__str__ 面向普通显示,可以更友好:
class StudyTask:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
def __repr__(self):
return f"StudyTask(title={self.title!r}, minutes={self.minutes!r})"
def __str__(self):
return f"{self.title}(约 task = StudyTask("阅读数据模型", 25)
print(repr(task)) # StudyTask(title='阅读数据模型', minutes=25)
print(task) # 阅读数据模型(约 25 分钟)如果只定义 __repr__,而没有定义 __str__,需要普通字符串表示时也会回退使用 __repr__。别在表示字符串里放密码、令牌等敏感信息;对象一旦进入日志,这些字段就可能被记录。
两个任务字段相同,它们是同一个对象吗?
a = StudyTask("复习特殊方法", 30)
b = StudyTask("复习特殊方法", 30)
print(a is b) # False
print(a == b) # 默认也是 Falseis 检查身份,a is b 只有在两个名字指向同一对象时才为真。判断 None 应使用 is None。== 会寻找类型定义的相等规则;普通自定义类若没有实现值相等,默认基本按身份判断,所以两个字段恰好一样的新实例也不相等。
如果业务上规定“标题和预计时长相同的任务视为相等”,可以实现 __eq__:
class StudyTask:
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
def __eq__(self, other):
if not isinstance(other, StudyTask):
return NotImplemented
return (self.title, self.minutes) == (other.title, other.minutes)遇到不支持比较的类型时返回 NotImplemented,让 Python 有机会尝试另一侧的比较或采用后续规则,而不是把“不会比较”武断地当成 False。
设计相等性之前,必须先回答“对象的哪部分构成身份”。任务标题可以改,若把可变字段参与哈希,又把对象放进集合或作为字典键,修改后就可能再也找不到它。因此只实现 __eq__ 的可变类通常会变成不可哈希对象,不能直接当集合元素或字典键。这是保护,不要随手用 unsafe_hash 绕开。
在很多业务系统里,稳定的任务编号比标题更适合作为相等依据:
class StudyTask:
def __init__(self, task_id, title):
self.task_id = task_id
self.title = title
def __eq__(self, other):
if not isinstance(other, StudyTask):
return NotImplemented
return self.task_id == other.task_id是否要实现 __hash__ 还取决于编号与对象状态是否真正不可变。拿不准时,让对象保持不可哈希更安全;需要查找时,用稳定编号作为字典键。
dataclass 适合数据味很重的类有些类主要任务就是保存一组字段,我们为它反复编写 __init__、__repr__ 和 __eq__,代码长却没有多少业务含义。标准库的 dataclasses 可以生成这些常见特殊方法:

字段声明清楚后,数据类可自动提供初始化、对象展示和相等比较等常用能力。
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class LearningNote:
title: str
content: str
tags: list[str] = field(default_factory=list)
source: str | None = None
max_title_length: ClassVar[int] = 80
def word_count(self):
使用时:
note_a = LearningNote("类属性", "类属性由实例共享")
note_b = LearningNote("类属性", "类属性由实例共享")
print(note_a)
# LearningNote(title='类属性', content='类属性由实例共享', tags=[], source=None)
print(note_a == note_b) # True默认情况下,数据类根据类型标注识别字段,生成初始化方法、适合调试的表示,以及按字段顺序进行的相等比较。它仍是普通 Python 类,可以添加自己的方法、属性和继承关系。
ClassVar 明确告诉数据类:max_title_length 是类变量,不是实例字段,不应出现在生成的初始化参数和相等比较中。
tags 不能写成 tags: list[str] = []。每个实例都需要新列表,所以使用 field(default_factory=list)。工厂函数会在需要默认值时调用一次:
first = LearningNote("属性", "内容")
second = LearningNote("方法", "内容")
first.tags.append("重点")
print(first.tags) # ['重点']
print(second.tags) # []
print(first.tags is second.tags) # False这和普通类在 __init__ 中写 self.tags = [] 是同一条原则:每个实例拥有自己的可变容器。
类型标注主要给读者、编辑器和静态检查工具使用,@dataclass 通常不会因为传入了错误类型就自动拒绝。需要轻量验证时,可以使用 __post_init__:
@dataclass
class LearningNote:
title: str
content: str
tags: list[str] = field(default_factory=list)
def __post_init__(self):
self.title = self.title.strip()
if not self.title:
raise ValueError("笔记标题不能为空")当类的主要难点是字段存储和展示时,数据类很合适。若对象需要复杂验证、很多可变状态转换、外部资源生命周期或定制初始化,手写普通类通常更清楚。数据类不是“高级版 class”,只是减少一类机械代码。
frozen=True 可以让字段赋值通常抛出异常,适合表示创建后不希望再改的值对象:
@dataclass(frozen=True)
class ResourceId:
course_id: str
resource_id: strkey = ResourceId("python", "class-objects")
# key.course_id = "java" # FrozenInstanceError这里的“不变”更接近接口约束,不代表能把内部一切变成真正深度不可变。如果冻结数据类的字段指向列表,列表内容仍可能被原地修改。因此不可变值对象更适合使用字符串、整数、元组等不可变字段。
决定是否使用数据类时,可以问三个问题。第一,这个类型的核心是不是一组有名字的字段?第二,字段相等是否自然代表对象相等?第三,自动生成的初始化参数是否就是你愿意长期公开的创建接口?如果三个答案大多为“是”,数据类会很省心。若相等语义依赖数据库身份、创建过程需要多个阶段、字段之间有复杂不变量,手写类能让规则更显眼。
数据类也不意味着所有字段都必须公开修改。你仍然可以使用只读属性、业务方法和 __post_init__,只是要避免一边依赖自动生成,一边又大量对抗它。装饰器参数和 field() 配置越来越复杂时,往往说明普通类已经更容易读。
数据类默认把所有参与比较的字段都纳入 __eq__。如果缓存、最后访问时间等字段不应决定业务相等,可以排除:
@dataclass
class LearningNote:
note_id: str
title: str
cached_html: str = field(default="", repr=False, compare=False)这里 cached_html 不出现在 repr,也不参与相等比较。是否排除不是格式偏好,而是业务语义:两份笔记是否相等,应该由稳定的领域规则决定。
类写到几十个以后,真正的问题往往不再是语法,而是依赖关系。一个文件同时定义模型、访问数据库、发送通知、启动命令行界面,任何改动都可能牵动其他部分。
可以按职责做一个朴素拆分:
learning_app/
├── __init__.py
├── models.py # StudyTask、StudyPlan、LearningNote
├── notifiers.py # ConsoleNotifier、EmailNotifier
├── services.py # 编排“创建计划并发送提醒”的流程
└── main.py # 程序入口models.py 关心对象有效状态,notifiers.py 关心消息怎样送出,services.py 负责把对象协作起来,main.py 解析输入并启动流程。拆分依据不是“每个类一个文件”,而是哪些代码一起变化、对外提供什么接口。
导入时优先让来源清楚:
from learning_app.models import StudyPlan, StudyTask
from learning_app.notifiers import ConsoleNotifier避免使用:
from learning_app.models import *星号导入会让当前命名空间中出现哪些名字变得不清楚,也更容易发生冲突。模块名较短且能提供上下文时,导入模块同样很好:
from learning_app import models
task = models.StudyTask("整理模块", 30)如果 models.py 导入 services.py,services.py 又在加载时导入 models.py,其中一方可能只看到“加载到一半”的模块。循环导入往往说明依赖方向混乱。
常见处理办法包括:
模块组织不需要一开始就设计成复杂架构。先让依赖大体单向:入口依赖服务,服务依赖模型和能力接口,基础模型尽量不依赖具体外部设施。这样已经能避开很多维护问题。
学习类最容易走向两个极端:要么只是把字典换成属性,所有规则仍散落在外部;要么给每个细节套上继承和设计模式,简单需求被层层跳转淹没。下面这些症状更值得你在真实代码里警惕。
如果一个任务能以空标题创建,之后只有某个页面碰巧检查它,错误就会拖到很远。让 __init__ 或属性 setter 尽早拒绝无效值。对需要分阶段收集信息的流程,可以建立明确的草稿类型或构建步骤,不要让一个类型同时表示半成品和成品。
看到类体中的 []、{}、set() 就停一下:它真的是所有实例有意共享的吗?若不是,移到 __init__;数据类则使用 default_factory。这是类代码里最常见、也最隐蔽的串数据来源之一。
__init__ 做了太多外部工作创建对象就查询数据库、下载文件、发送请求,会让异常处理、测试和性能都变得不可预测。初始化只建立对象状态,耗时动作放进名字明确的方法,或交给服务对象。
没有验证或转换需求时,公开属性已经足够。未来需要保持同名接口又加入规则,再改为 property。别把 Java 式访问器当作 Python 类的入场券。
report = student.report 看起来像便宜读取。如果背后每次查数据库,调用者很难判断性能和失败点。昂贵动作改成动词方法,例如 student.load_report()。
如果子类并不满足基类语义,或者必须覆盖大半方法,抽一个辅助函数或组合协作者更好。复用实现不是继承的充分理由,稳定的类型关系才是。
super()覆盖初始化或协作式方法时,漏掉 super() 可能让基类状态缺失,或让多继承调用链中断。查看 ClassName.__mro__,再逐层确认哪些方法应该继续调用。
is 与 ==判断是否为 None 用 is None;比较业务值通常用 ==。不要依赖小整数或短字符串偶尔复用对象的实现细节来做身份比较。
repr() 经常进入日志和异常信息。令牌、密码、身份证号等字段不要直接放进 __repr__。数据类字段可以用 repr=False 排除,但更重要的是从模型上控制敏感信息的传播。
当对象行为和预期不一致时,可以按顺序检查:
print(type(obj)) # 实际类型是什么
print(vars(obj)) # 普通实例当前保存了哪些属性
print(type(obj).__mro__) # 方法会沿什么顺序查找
print(obj.method) # 拿到的是哪个绑定方法
print(obj.method.__self__)
print(obj.method.__func__)然后问自己:属性在实例还是类上?是否被同名实例属性遮蔽?可变对象是否共享?子类是否覆盖了方法?super() 是否沿预期链条继续?这套问题比盯着“面向对象四大特性”更能解决眼前的 bug。
现在把本章知识压缩到一个小任务中。请实现 Checklist:每份清单有标题和独立的条目列表;能从逗号分隔文本创建;len(checklist) 返回条目数;打印调试表示时能看到标题和数量;标题不能为空。
先自己写,再展开参考实现。
再判断下面这段代码会输出什么:
class Counter:
total = 0
def __init__(self):
type(self).total += 1
self.total = 100
a = Counter()
b = Counter()
print(Counter.total, a.total, b.total)学完类与对象,真正需要记住的不是“类是模板、对象是实例”这一句话,而是一套写代码时能反复使用的判断。
先问数据和行为是否形成了稳定边界。如果只是一次性的输入到输出,函数就很好;如果多段代码共同维护一组状态,类可以把规则收回来。创建对象时让它处于有效状态,可变实例数据放进 __init__。公开属性先保持简单,需要验证和兼容时再用 property。
接着辨认行为依赖谁:依赖当前对象就用实例方法,依赖当前类并负责替代构造就用类方法,不需要任何自动状态的紧密辅助逻辑才考虑静态方法。遇到复用需求,先看组合能否表达“拥有一个可替换能力”;只有稳定的“是一种”关系和可替换语义成立时,再使用继承。使用 super() 时记住它沿 MRO 继续查找,不是把某个父类名字换成了缩写。
最后,让特殊方法只接入真正符合直觉的 Python 操作。明确对象的相等语义,给调试准备有用但不泄密的 repr。数据味很重的类型可以交给 dataclass 减少机械代码,但验证、共享状态和业务边界仍然需要你自己设计。
当你能回答“状态属于谁、规则放在哪里、变化会沿哪条依赖传播”,类就不再是一堆双下划线语法,而是一个能让程序保持清楚的工具。
这份实现把 _items 放在 __init__ 中,因此每个清单都有独立列表。title 通过属性 setter 统一验证;from_text 是替代构造器,所以使用 cls;__len__ 和 __iter__ 让对象接入熟悉的 Python 操作。它没有为了“封装”给 _items 写一对机械访问器,也没有建立任何不必要的继承层次。