Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
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 系列(三十三) 什么是类和对象?零基础理解 Python class
- Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
- Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
- Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
- Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
- Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
- Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
- Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
- Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
上一篇我们学习了 Python 中的类与对象,知道了如何使用 class 定义一个类,如何通过 __init__() 初始化对象,以及 self 如何帮助我们操作对象自身的数据。
不过,学会创建对象只是第一步。
假设我们要编写一个程序,用来管理学生信息、服务器配置或者几何图形。对象中的数据不仅需要读取和修改,有时还需要进行检查,甚至根据已有数据计算出新的结果。
例如:
- 学生的年龄不能是负数。
- 圆形的半径必须大于 0。
- 一个班级中的学生可以有各自的姓名和年龄,但班级名称可能是大家共享的。
- 圆形的面积可以根据半径计算出来,不一定需要单独保存。
这些问题涉及 Python 中的属性。
本文将从实例属性与类属性的区别开始,逐步学习 @property、属性初始化、数据检查和异常捕获。我们会使用完整的代码进行演示,让每个知识点都有实际的运行结果。
从一个实际问题开始
先回顾一下上一篇学习过的类与对象。
假设我们要创建一个圆形对象,用来保存圆形的半径。
新建一个名为 circle.py 的文件,写入以下代码:
1 | class Circle: |
在终端中执行:
1 | python circle.py |
运行结果:
1 | 5 |
这里,self.radius = radius 将传入的半径保存到对象中,circle.radius 则用于读取这个值。
现在,我们尝试修改半径:
1 | circle.radius = -10 |
运行结果:
1 | -10 |
程序没有报错,半径也确实被修改了。
但是,圆形的半径不应该是负数。程序虽然能够保存数据,却没有检查数据是否合理。
如果我们希望修改半径时自动检查数据,就需要一种更灵活的属性访问方式。
在了解 @property 之前,我们先把属性本身弄清楚。
认识实例属性
实例属性就是保存在某个对象中的数据,用来描述这个对象自身的状态。
例如,学生对象可以保存姓名和年龄,服务器对象可以保存主机名和 IP 地址,圆形对象可以保存半径。
来看一个学生的例子:
1 | class Student: |
运行结果:
1 | 小明 |
虽然 student1 和 student2 都是由 Student 类创建的对象,但它们分别保存自己的姓名和年龄。
我们可以继续修改其中一个对象:
1 | student1.age = 20 |
运行结果:
1 | 20 |
为什么修改 student1.age 不会影响 student2.age?
因为 age 是实例属性,每个对象都有属于自己的属性值。
可以这样理解:
Student是描述学生的类。student1和student2是两个不同的学生对象。student1.age和student2.age分别保存两个对象自己的年龄。
这也是实例属性最重要的特点:不同对象可以拥有不同的实例属性值。
认识类属性
现在换一个需求。
假设这些学生都属于同一个班级,班级名称都是“Python 入门班”。
如果每创建一个学生对象,都单独保存一次班级名称,虽然能够实现,但可能存在重复。
有没有办法让所有学生对象共享同一个班级名称?
这时候就可以考虑使用类属性。
类属性是在类的定义中、方法之外声明的属性。它属于类,可以通过类名直接访问。
修改 Student 类:
1 | class Student: |
运行结果:
1 | Python 入门班 |
这里的:
1 | class_name = "Python 入门班" |
就是类属性。
它定义在 Student 类中,而不是定义在 __init__() 方法中。
我们可以通过 Student.class_name 直接读取它,也可以通过实例 student1.class_name 读取它。
当实例本身没有同名属性时,Python 会继续从类中查找这个属性,因此能够读取到类属性。
实例属性和类属性有什么区别
把两个概念放在一起比较,就更容易理解了。
| 对比项目 | 实例属性 | 类属性 |
|---|---|---|
| 定义位置 | 通常在 __init__() 等实例方法中通过 self 赋值 | 在类中、方法之外定义 |
| 归属 | 某个具体对象 | 类本身 |
| 常见访问方式 | student1.name | Student.class_name |
| 是否适合保存每个对象不同的数据 | 适合 | 通常不适合 |
| 是否适合表示对象共享的信息 | 通常不适合 | 适合 |
需要注意,类属性并不是只能通过类名访问。实例在没有同名实例属性的情况下,也可以读取类属性。
不过,读取和赋值的行为并不完全相同。
修改类属性时要注意什么
先看下面的代码:
1 | class Student: |
运行结果:
1 | Python 进阶班 |
我们通过 Student.class_name 修改了类属性。由于两个对象都没有自己的同名实例属性,因此读取时都能看到修改后的类属性值。
但如果这样操作:
1 | student1.class_name = "Python 运维班" |
运行结果:
1 | Python 运维班 |
为什么会出现这种结果?
因为:
1 | student1.class_name = "Python 运维班" |
通常会为 student1 创建一个同名的实例属性,而不是修改 Student 中的类属性。
此后,读取 student1.class_name 时,Python 会优先读取这个实例自己的属性;读取 student2.class_name 时,则仍然能找到类属性。
可以用下面这张图理解属性查找过程:
flowchart TD
A["读取 student1.class_name"] --> B{"实例中有 class_name 吗?"}
B -->|有| C["返回实例属性"]
B -->|没有| D["查找 Student 类属性"]
D --> E{"类中有 class_name 吗?"}
E -->|有| F["返回类属性"]
E -->|没有| G["继续查找或抛出 AttributeError"]因此,选择实例属性还是类属性,取决于数据应该属于谁。
- 学生姓名、年龄:每个学生不同,使用实例属性。
- 班级名称、学校名称等共享信息:可以考虑使用类属性。
实际项目中,类属性也可以保存常量或所有实例共享的配置。不过,如果属性保存的是可变对象,例如列表,多个对象可能会共享同一个列表,修改时要格外小心。
使用下划线约定表示内部属性
回到圆形的例子。
我们可以把原来的 radius 改成 _radius:
1 | class Circle: |
运行结果:
1 | 5 |
这里出现了一个新的写法:_radius。
在 Python 中,属性名开头的单个下划线 _ 是一种命名约定,通常表示这个属性主要供类的内部使用,不建议外部代码直接操作。
但要注意,单个下划线并不会真正禁止外部访问。
下面的代码仍然能够运行:
1 | circle._radius = -10 |
运行结果:
1 | -10 |
所以,单纯给属性名前面加上下划线,并不能自动检查或限制数据。
它更多是在提醒其他开发者:这个属性属于类的内部实现,通常应该通过类提供的接口来操作。
那么,如何让外部通过一个统一的接口读取数据,并在修改数据时执行检查呢?
这就需要用到 @property。
认识 @property 装饰器
先看下面这段代码:
1 | class Circle: |
运行结果:
1 | 5 |
和之前的代码相比,这里多了两部分:
1 |
|
先理解最下面的方法:
1 | def radius(self): |
它定义了一个名为 radius 的方法。调用时,这个方法会返回当前对象的 _radius。
如果没有 @property,普通方法通常需要这样调用:
1 | circle.radius() |
但加上 @property 后,我们可以这样访问:
1 | circle.radius |
不需要括号。
这是因为 @property 将这个方法变成了类似普通属性的访问接口。
当执行:
1 | print(circle.radius) |
Python 会调用对应的 getter 方法,并将返回值交给 print() 输出。
这里的 getter 可以理解为“获取数据的方法”。
装饰器是什么
我们刚刚使用的 @property,就是 Python 中的一个内置装饰器。
初学阶段,可以先把装饰器理解为一种能够为函数或方法增加特定行为的机制。
装饰器通常写在函数或方法定义的上一行:
1 |
|
其中,@property 会让 radius 可以通过类似属性的方式访问。
现在不需要深入装饰器的底层实现。先记住它的基本作用,以及 @property 解决了什么问题即可。
理解属性初始化为什么会调用 setter
在学习 setter 之前,先解决一个非常容易让初学者困惑的问题。
前面我们学习过:
1 | class Circle: |
这里的 __init__() 是初始化方法。当我们执行 Circle(5) 创建对象时,传入的 5 会成为 radius 参数的值,然后通过 self._radius = radius 保存到对象中。
但如果我们希望初始化时也检查半径是否合法,就需要让初始化过程经过检查逻辑。
先理解对象创建与初始化之间的关系:
flowchart TD
A["执行 Circle(5)"] --> B["Python 创建对象"]
B --> C["调用 __init__(self, 5)"]
C --> D["初始化对象的数据"]
D --> E["返回可使用的对象引用"]这里需要区分两个概念:
- 创建对象:Python 为新对象建立实例。
- 初始化对象:执行
__init__(),为这个实例设置初始状态。
日常交流中,我们经常把它们合称为“创建对象”,但它们并不是完全相同的步骤。
直接初始化内部属性
下面这种写法:
1 | class Circle: |
会直接把传入的值保存到 _radius 中。
例如:
1 | circle = Circle(-5) |
运行结果:
1 | -5 |
因为代码没有执行任何检查,所以负数也被保存了下来。
通过属性接口初始化
现在,我们改成:
1 | class Circle: |
注意初始化方法中的这一行:
1 | self.radius = radius |
它与 self._radius = radius 不同。
因为类中已经定义了 radius 的 property 和 setter,所以通过 self.radius = radius 赋值时,会进入 setter 执行对应的逻辑。
这意味着,对象初始化时传入的值也会经过检查。
例如:
1 | circle = Circle(5) |
运行结果:
1 | 5 |
而下面的代码:
1 | circle = Circle(-5) |
会抛出 ValueError,提示半径必须大于 0。
这就是为什么,在希望初始化和后续修改使用同一套验证规则时,通常会在 __init__() 中写:
1 | self.radius = radius |
而不是直接写:
1 | self._radius = radius |
前提是这个类确实已经定义了对应的 property 和 setter。
使用 setter 检查修改的数据
现在,我们正式认识 @radius.setter。
@property 负责定义属性的读取逻辑,而 @radius.setter 则可以为同一个属性定义赋值逻辑。
完整示例如下:
1 | class Circle: |
运行结果:
1 | 初始半径: 5 |
我们逐步理解这段代码。
当执行:
1 | circle = Circle(5) |
Python 调用 __init__(),其中的 self.radius = radius 进入 setter。
在 setter 中:
1 | if value <= 0: |
用于检查新值是否符合要求。
如果 value 小于或等于 0,就主动抛出异常,后面的赋值语句不会执行。
如果检查通过,就执行:
1 | self._radius = value |
把新半径保存到内部属性中。
当我们再次执行:
1 | circle.radius = 8 |
也会进入 setter。因此,无论是初始化还是后续修改,都可以使用同一套检查规则。
为什么 setter 中不能再次写 self.radius
初学者还容易犯一个错误:在 setter 中再次给 self.radius 赋值。
例如,错误的写法是:
1 |
|
因为 self.radius = value 会再次调用同一个 setter,setter 又执行 self.radius = value,于是不断重复调用,最终导致递归深度错误。
正确的做法是将实际数据保存在另一个属性中:
1 |
|
这里,radius 是对外提供的属性接口,而 _radius 用来保存实际的半径值。
理解这个区别,才能正确使用 property 和 setter。
使用异常捕获,让程序更健壮
前面我们使用了:
1 | raise ValueError("半径必须大于 0") |
raise 的作用是主动抛出异常。
异常可以理解为程序执行过程中出现的一种错误情况。例如,用户输入了不合理的数据,或者程序无法完成某个操作。
但是,如果异常一直向外传播而没有被处理,程序就可能中断。
在实际应用中,我们通常希望提示用户错误原因,并允许用户重新输入,而不是让整个程序直接结束。
这就需要学习 try 和 except。
认识 try 和 except
先看一个简单的例子:
1 | try: |
运行结果:
1 | 输入内容无法转换为整数 |
这里发生了什么?
int("hello") 试图将字符串 "hello" 转换为整数,但这个字符串不是合法的整数表示,因此 Python 抛出了 ValueError。
由于这行代码位于 try 代码块中,程序会寻找能够处理这个异常的 except ValueError。
找到后,执行:
1 | print("输入内容无法转换为整数") |
程序就可以继续执行异常处理逻辑,而不必因为这个错误直接退出。
可以先记住:
try:放置可能发生异常的代码。except:指定遇到某类异常时如何处理。raise:主动抛出异常。
raise 和 except 的作用不同:一个负责报告错误,另一个负责处理错误。
实战练习:让用户输入合法的圆形半径
现在,我们把属性、setter 和异常捕获组合起来,编写一个可以交互的程序。
程序需要完成以下任务:
- 让用户输入圆形半径。
- 检查输入是否为数字。
- 检查半径是否大于 0。
- 如果输入不合法,提示错误原因。
- 如果输入合法,显示半径和圆形面积。
请将下面的代码保存为 circle.py:
1 | import math |
运行程序:
1 | python circle.py |
当输入:
1 | 请输入圆形的半径:5 |
运行结果:
1 | 圆形半径: 5.0 |
这里的 round(circle.area, 2) 表示将面积结果四舍五入到小数点后两位,方便阅读。
测试不同的输入
输入正常的半径
输入 5,程序计算面积并正常结束。
输入负数
输入 -3,程序会提示:
1 | 输入错误: 半径必须大于 0 |
这是因为 Circle(-3.0) 初始化对象时执行 self.radius = radius,调用 setter 后触发了 ValueError。
输入非数字内容
输入 hello,程序会提示类似下面的信息:
1 | 输入错误: could not convert string to float: 'hello' |
这次异常不是 setter 抛出的,而是:
1 | float(input("请输入圆形的半径:")) |
在将字符串转换成浮点数时发生的。
这个例子非常值得理解:同一个 try 代码块中,可能出现不同位置、不同原因的异常。只要异常类型能够匹配相应的 except,就可以进行处理。
在这里,ValueError 既可能来自 float() 的转换失败,也可能来自 setter 中对半径范围的检查。
为什么要捕获具体的异常类型
我们分别使用了:
1 | except ValueError as error: |
as error 表示将捕获到的异常对象保存到变量 error 中,这样就可以读取异常信息。
在这个示例中,由于 float() 已经将用户输入转换成数字,因此输入 hello 时会在转换阶段产生 ValueError,程序不会执行到创建 Circle 对象的步骤。
另外,当前程序只要求用户输入一次。如果输入不合法,程序会显示错误信息并结束。后续学习循环时,我们可以进一步改进它,让用户能够反复输入,直到输入正确为止。
使用 @property 计算属性值
@property 不仅可以控制属性的读取和修改,还可以用于计算一个值。
例如,圆形的面积可以根据半径计算。我们不一定要在创建对象时单独保存面积,因为只要知道半径,就可以得到面积。
圆形面积的计算公式是:
这里使用 HTML 的 <sup> 标签显示平方,不依赖 KaTeX 或 Markdown 数学公式。
下面把计算逻辑加入类中:
1 | import math |
运行结果:
1 | 半径: 5 |
这里出现了两个新知识点。
import math 用于导入 Python 内置的数学模块。通过 math.pi,我们可以使用圆周率的近似值。
self._radius ** 2 中的 ** 是乘方运算符,表示半径的平方。
再看下面这段代码:
1 |
|
这里定义了一个名为 area 的计算属性。读取 circle.area 时,Python 会执行方法中的计算逻辑,并返回结果。
因此,我们写的是:
1 | print(circle.area) |
而不是:
1 | print(circle.area()) |
由于面积由当前半径计算得到,如果半径发生变化,再次读取面积时就会得到新的结果。
例如:
1 | circle.radius = 10 |
运行结果:
1 | 314.16 |
这就是计算属性的用途:外部代码可以像读取普通属性一样获取结果,而类内部负责完成计算。
当前示例中的 area 只有 getter,没有 setter,所以不能直接通过 circle.area = ... 来设置面积。
用一张图理解属性访问
前面涉及了实例属性、类属性、读取、赋值、检查和计算。我们可以把圆形对象的属性访问过程整理成一张图:
flowchart TD
A["访问 circle.radius"] --> B["调用 getter"]
B --> C["读取 self._radius"]
C --> D["返回半径"]
E["设置 circle.radius"] --> F["调用 setter"]
F --> G{"数据类型正确?"}
G -->|否| H["抛出 TypeError"]
G -->|是| I{"半径大于 0?"}
I -->|否| J["抛出 ValueError"]
I -->|是| K["更新 self._radius"]
L["访问 circle.area"] --> M["根据当前半径计算面积"]
M --> N["返回计算结果"]记住三个主要过程:
- 读取
circle.radius:调用 getter,获取内部保存的半径。 - 设置
circle.radius:调用 setter,检查数据后再决定是否修改。 - 读取
circle.area:执行计算逻辑,返回面积。
而类属性与实例属性的区别,则在于数据是属于某个对象,还是由类提供共享的属性值。
什么时候应该使用 @property
并不是所有属性都需要 @property。如果一个属性只是保存普通数据,不需要额外检查或计算,直接使用实例属性通常就足够了。
| 使用场景 | 示例 | 是否适合使用 @property |
|---|---|---|
| 保存普通数据 | 学生姓名 | 通常不需要 |
| 修改前检查数据 | 圆形半径必须大于 0 | 适合 |
| 读取时计算结果 | 根据半径计算面积 | 适合 |
| 读取数据时需要额外逻辑 | 根据当前状态返回结果 | 可以考虑 |
最重要的原则是:当一个值的读取、修改或计算需要额外逻辑时,可以考虑使用 @property,让外部代码继续使用简洁的属性访问方式。
同时还要记住,单个下划线只是 Python 的命名约定,并不是真正的访问权限控制。
写在最后
这篇文章从圆形和学生的例子出发,逐步学习了 Python 属性的不同用法。
现在,你应该能够理解:
- 实例属性:保存某个对象自己的数据,例如学生姓名和年龄。
- 类属性:定义在类中,可以为实例提供共享的属性值。
- 单个下划线:通常表示内部使用的属性,但不会禁止外部访问。
@property:让方法能够以类似普通属性的方式读取。@radius.setter:为属性增加赋值逻辑,可以在修改数据时进行检查。- 属性初始化:
__init__()中的self.radius = radius可以调用 setter,让初始值也经过检查。 raise和try...except:前者用于抛出异常,后者用于捕获和处理异常。- 计算属性:读取时执行计算,例如根据半径计算圆形面积。
建议你动手完成下面几个练习:
- 将圆形的半径改成
0,观察程序会发生什么。 - 将半径输入改成
hello,观察异常来自哪里。 - 在
Student类中增加一个类属性school_name,创建两个学生对象并观察它们如何读取这个属性。 - 修改圆形半径后,再次读取面积,验证计算属性是否使用了最新的数据。
下一篇,我们将继续学习 Python 的特殊方法,看看为什么有些方法的名字前后都有双下划线,以及它们如何让对象支持更多 Python 原生操作。
