02-属性与方法
Python 版本要求:Python 3.11+ 贯穿项目:
oop_demo/代码位置:oop_demo/app/domain/book/model.py+oop_demo/app/domain/member.py测试验证:cd oop_demo && uv run pytest -k TestBookItemBasics -v
概念铺垫
为什么需要区分属性和方法?
问题场景
在图书馆系统中,我们需要同时管理两类数据:
- 每本书独有的信息:书名、作者、ISBN、出版年份
- 全体共享的信息:当前馆藏总数
错误做法 — 用全局变量管理共享数据:
total_books = 0 # 全局变量
class BookItem:
def __init__(self, title: str, author: str, isbn: str, year: int) -> None:
self.title = title
global total_books
total_books += 1
# 全局变量容易被意外修改
total_books = 100 # 某个模块误操作!数据被污染问题:
- 共享数据暴露在类外部,任何人都能修改
- 无法区分"每个对象独立的数据"和"所有对象共享的数据"
- 工具函数(如 ISBN 校验)和类没有语义关联
正确做法 — 用类属性和类方法:
class BookItem:
total_books: int = 0 # 类属性:所有实例共享
def __init__(self, title: str, author: str, isbn: str, year: int) -> None:
self.title = title
BookItem.total_books += 1 # 通过类名修改
@classmethod
def get_total(cls) -> int:
return cls.total_books
@staticmethod
def validate_isbn(isbn: str) -> bool:
digits = isbn.replace("-", "")
return len(digits) in (10, 13) and digits.isdigit()
b1 = BookItem("Python", "Guido", "9780000000001", 2020)
b2 = BookItem("Java", "Gosling", "9780000000002", 2021)
print(BookItem.get_total()) # 2
print(BookItem.validate_isbn("123")) # False生活类比:工厂流水线
想象一个图书印刷工厂:
| 概念 | 工厂类比 | 代码对应 |
|---|---|---|
| 类属性 | 工厂配置(生产线速度、总产量) | BookItem.total_books — 所有书共享 |
| 实例属性 | 每本书的独立规格(书名、作者) | self.title, self.author — 每本不同 |
| 实例方法 | 质检员检查某本书的状态 | book.get_info() — 需要具体某本书 |
| 类方法 | 厂长查看总产量 | BookItem.get_total() — 不关心具体哪本 |
| 静态方法 | 测量工具(尺子、秤) | BookItem.validate_isbn() — 不依赖工厂状态 |
关键区别:类属性是工厂级别的,实例属性是产品级别的。修改工厂配置会影响所有产品;修改某本书的规格不影响其他书。
L1 理解层:会用
实例属性 vs 类属性
实例属性(每个对象独立)
定义在 __init__ 中,通过 self 赋值。每个实例拥有独立副本。
代码引用:domain/book/model.py:24-30
class BookItem:
def __init__(self, title: str, author: str, isbn: str, year: int) -> None:
self.title: str = title # 实例属性
self.author: str = author # 实例属性
self._isbn: str = isbn # 实例属性(私有)
self._year: int = year # 实例属性(私有)
self._available: bool = True # 实例属性(私有)b1 = BookItem("Python", "Guido", "9780000000001", 2020)
b2 = BookItem("Java", "Gosling", "9780000000002", 2021)
print(b1.title) # Python
print(b2.title) # Java — 各自独立类属性(所有对象共享)
定义在类体中、方法之外。所有实例共享同一份数据。
代码引用:domain/book/model.py:22
class BookItem:
total_books: int = 0 # 类属性
def __init__(self, title: str, author: str, isbn: str, year: int) -> None:
# ...
BookItem.total_books += 1 # 每创建一个实例就 +1b1 = BookItem("Python", "Guido", "9780000000001", 2020)
b2 = BookItem("Java", "Gosling", "9780000000002", 2021)
print(b1.total_books) # 2
print(b2.total_books) # 2 — 共享同一个值
print(BookItem.total_books) # 2 — 推荐通过类名访问Member 类同样使用了类属性追踪总数:
# domain/member.py
class Member:
_member_count: int = 0 # 类属性(私有命名约定)
def __init__(self, name: str, email: str, member_id: str) -> None:
self.name: str = name
self.__email: str = self._check_email(email)
self._member_id: str = member_id
self._borrowed_isbns: list[str] = []
Member._member_count += 1 # 每注册一个会员就 +1_member_count 使用单下划线前缀,表示"内部使用,不建议外部直接访问",这是 Python 的命名约定。
⚠️ 常见陷阱:通过实例修改类属性
b1 = BookItem("Python", "Guido", "9780000000001", 2020)
b2 = BookItem("Java", "Gosling", "9780000000002", 2021)
b1.total_books = 100 # ❌ 创建了实例属性,遮蔽了类属性!
print(b1.total_books) # 100(实例属性)
print(b2.total_books) # 2(仍然读取类属性)
print(BookItem.total_books) # 2(类属性未变)为什么会出现这种现象?
Python 的属性查找顺序是:先找实例属性,没找到再找类属性。
访问 b1.total_books 时:
1. 检查 b1.__dict__ 中是否有 'total_books'?
→ 有(上一步 b1.total_books = 100 刚创建的)
→ 返回 100,查找结束 ✅
访问 b2.total_books 时:
1. 检查 b2.__dict__ 中是否有 'total_books'?
→ 没有
2. 检查 BookItem.__dict__ 中是否有 'total_books'?
→ 有(值为 2)
→ 返回 2 ✅
访问 BookItem.total_books 时:
→ 直接检查 BookItem.__dict__,返回 2 ✅关键理解:b1.total_books = 100 没有修改类属性,而是在 b1 的 __dict__ 中创建了一个同名的实例属性。这个实例属性"遮蔽"了类属性,只对 b1 生效,b2 和其他实例不受影响。
# 验证:查看 b1 和 b2 的 __dict__
print(b1.__dict__)
# {'title': 'Python', 'author': 'Guido', ..., 'total_books': 100} ← 多了一个!
print(b2.__dict__)
# {'title': 'Java', 'author': 'Gosling', ...} ← 没有 total_books正确做法:始终通过类名修改类属性 → BookItem.total_books = 100
⚠️ 常见陷阱:可变对象作为类属性
# ❌ 可变类属性 — 所有实例共享同一个列表
class Library:
books: list[str] = [] # 类属性
lib1 = Library()
lib2 = Library()
lib1.books.append("Python")
print(lib2.books) # ['Python'] — 被意外污染!
# ✅ 改为实例属性
class Library:
def __init__(self) -> None:
self.books: list[str] = [] # 每个实例独立
lib1 = Library()
lib2 = Library()
lib1.books.append("Python")
print(lib2.books) # [] — 互不影响规则:类属性只用不可变类型(int, str, tuple)。需要可变数据时,在 __init__ 中初始化为实例属性。
三种方法
实例方法
需要访问实例属性,第一个参数是 self。
代码引用:domain/book/model.py:63-64
class BookItem:
def get_info(self) -> str:
return f"[{self._isbn}] {self.title} — {self.author} ({self._year})"
book = BookItem("Python", "Guido", "9780000000001", 2020)
print(book.get_info()) # [9780000000001] Python — Guido (2020)类方法
需要访问类属性,用 @classmethod 装饰,第一个参数是 cls。
代码引用:domain/book/model.py:48-50,domain/member.py:43-45
# BookItem 的类方法
class BookItem:
@classmethod
def get_total(cls) -> int:
return cls.total_books
# Member 的类方法
class Member:
_member_count: int = 0
def __init__(self, name: str, email: str, member_id: str) -> None:
# ...
Member._member_count += 1
@classmethod
def get_total_members(cls) -> int:
return cls._member_countprint(BookItem.get_total()) # 通过类调用
print(BookItem("A", "B", "9780000000001", 2020).get_total()) # 通过实例也可以
m1 = Member("张三", "zhang@example.com", "M001")
m2 = Member("李四", "li@example.com", "M002")
print(Member.get_total_members()) # 2静态方法
不访问类或实例属性,纯粹的工具函数。用 @staticmethod 装饰,无 self/cls 参数。
代码引用:domain/book/model.py:52-56,domain/member.py:47-50
class BookItem:
@staticmethod
def validate_isbn(isbn: str) -> bool:
digits = isbn.replace("-", "")
return len(digits) in (10, 13) and digits.isdigit()
class Member:
@staticmethod
def validate_email(email: str) -> bool:
parts = email.split("@")
return len(parts) == 2 and "." in parts[1]print(BookItem.validate_isbn("9780000000001")) # True
print(BookItem.validate_isbn("123")) # False
print(Member.validate_email("a@b.com")) # True
print(Member.validate_email("not-email")) # False对比表
| 类型 | 装饰器 | 第一个参数 | 能访问 | 典型用途 |
|---|---|---|---|---|
| 实例方法 | 无 | self | 实例属性 + 类属性 | 操作实例数据(get_info()) |
| 类方法 | @classmethod | cls | 类属性 | 工厂方法、统计(get_total()) |
| 静态方法 | @staticmethod | 无 | 都不能 | 工具函数(validate_isbn()) |
贯穿实战
oop_demo/app/main.py 的 demo_ch02 函数展示了完整流程:
# 重置计数器,确保演示干净
BookItem.total_books = 0
# 创建两个实例
b1 = BookItem("Python", "Guido", "9780000000001", 2020)
b2 = BookItem("Java", "Gosling", "9780000000002", 2021)
# 实例属性 — 各自独立
print(f"b1.title = {b1.title!r}") # 'Python'
print(f"b2.title = {b2.title!r}") # 'Java'
# 类属性 — 共享
print(f"b1.total_books = {b1.total_books}") # 2
print(f"b2.total_books = {b2.total_books}") # 2
# 类方法 — 读取共享数据
print(f"BookItem.get_total() = {BookItem.get_total()}") # 2
# 静态方法 — 工具函数
print(f"validate_isbn('9780000000001') = {BookItem.validate_isbn('9780000000001')}") # True
# 另一个类也用了类方法
print(f"Member.get_total_members() = {Member.get_total_members()}")L2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
| 通过类名访问/修改类属性 | 避免实例属性遮蔽 | BookItem.total_books 而非 b1.total_books |
| 类属性用不可变类型 | 避免实例间意外共享修改 | total_books: int = 0 |
| 类方法用于工厂/统计 | cls 支持继承,子类调用自动指向子类 | @classmethod def get_total(cls) |
| 静态方法用于纯工具函数 | 不依赖状态,语义清晰 | @staticmethod def validate_isbn(isbn) |
计数器在 __init__ 中递增 | 自动跟踪实例创建 | BookItem.total_books += 1 |
| 类方法通过类名调用 | 语义更清晰 | BookItem.get_total() |
反模式:不要这样做
# ❌ 通过实例修改类属性 — 创建实例属性遮蔽
b1.total_books = 100
# ✅ 始终通过类名修改/访问
BookItem.total_books = 100
# ❌ 可变对象作为类属性
class Library:
books: list[str] = []
# ✅ 可变数据在 __init__ 中初始化为实例属性
class Library:
def __init__(self) -> None:
self.books: list[str] = []
# ❌ 不该用 @classmethod 却用了
class BookItem:
@classmethod
def get_title(cls) -> str:
return cls.title # cls 是类,没有 title 实例属性!
# ✅ 需要实例数据用实例方法
class BookItem:
def get_title(self) -> str:
return self.title
# ❌ 静态方法中使用 self
class BookItem:
@staticmethod
def validate_isbn(self, isbn: str) -> bool: # self 无效!
...
# ✅ 静态方法无 self/cls 参数
class BookItem:
@staticmethod
def validate_isbn(isbn: str) -> bool:
...适用场景
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 追踪实例创建总数 | 类属性 + 类方法 | total_books + get_total() |
| 校验输入格式(不依赖类状态) | 静态方法 | validate_isbn(), validate_email() |
| 需要访问实例数据的操作 | 实例方法 | get_info(), checkout() |
| 创建替代构造函数 | 类方法 | BookItem.from_json(data) |
| 多个类共享同一工具函数 | 模块级函数(非静态方法) | 避免让静态方法变成全局函数的容器 |
常见陷阱
| 陷阱 | 问题 | 解决方案 |
|---|---|---|
self.xxx = value 修改类属性 | 创建了实例属性遮蔽类属性 | 通过类名修改:ClassName.attr = value |
| 可变对象作为类属性 | 所有实例共享同一对象,修改互相影响 | 改为实例属性或在 __init__ 中初始化 |
不该用 @classmethod 却用了 | 函数不依赖 cls,参数多余 | 改为 @staticmethod |
@staticmethod 中使用 self | 静态方法没有 self 参数 | 改为实例方法或传入实例参数 |
| 通过实例调用类方法引起混淆 | 语义不清晰,读者以为是实例方法 | 统一通过类名调用:Class.method() |
L3 专家层:深入
Python 如何实现:属性查找链与描述符协议
当你写 book.title 时,CPython 内部实际发生的查找过程:
book.title
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 1: 检查 type(book).__mro__ 的每一个类 │
│ 在类的 __dict__ 中查找 'title' │
│ 如果找到的值是 数据描述符(有 __set__/__delete__)│
│ → 调用描述符的 __get__ 并返回 │
│ │
│ Step 2: 检查 book.__dict__(实例字典) │
│ 如果找到 → 返回实例属性值 │
│ │
│ Step 3: 回到 type(book).__mro__ │
│ 如果找到的值是 非数据描述符(仅有 __get__) │
│ → 调用描述符的 __get__ 并返回 │
│ 如果是普通值 → 返回该值 │
│ │
│ Step 4: 如果都没找到 → 调用 __getattr__(如果定义了) │
│ 否则 → 抛出 AttributeError │
└─────────────────────────────────────────────────────────┘关键规则:数据描述符优先级高于实例字典
# 验证:@property 是数据描述符,优先级高于实例 __dict__
class Demo:
@property
def name(self):
return "from property"
d = Demo()
d.__dict__["name"] = "from dict" # 在实例字典中存入
print(d.name) # "from property" — 数据描述符优先级更高!
# 而没有 setter 的 property 也是数据描述符(因为 __set__ 抛 AttributeError)普通方法也是描述符(非数据描述符)
class Demo:
def greet(self):
return "hello"
d = Demo()
# Demo.__dict__['greet'] 是一个 function 对象
# 但 d.greet 返回的是绑定方法(bound method)
# 这是因为 function 实现了 __get__ 描述符协议
print(type(Demo.greet)) # <class 'function'>
print(type(d.greet)) # <class 'method'> — 描述符__get__自动包装__dict__ 查找链示意图:
对象属性查找 (obj.attr)
│
▼
┌──────────────────┐
│ 数据描述符 │ ← type(obj) 的 __dict__ 中
│ (property/slot) │ 有 __set__ 或 __delete__?
│ YES → 调用 .__get__()
│ NO → 继续 │
└──────┬───────────┘
▼
┌──────────────────┐
│ 实例 __dict__ │ ← obj.__dict__
│ 有 attr? │
│ YES → 返回 value │
│ NO → 继续 │
└──────┬───────────┘
▼
┌──────────────────┐
│ 非数据描述符 │ ← type(obj) 的 __dict__ 中
│ (function/方法) │ 只有 __get__?
│ YES → 调用 .__get__()
│ NO → 继续 │
└──────┬───────────┘
▼
┌──────────────────┐
│ MRO 链中的普通值 │ ← type(obj).__mro__
│ 返回类属性值 │
└──────┬───────────┘
▼
┌──────────────────┐
│ __getattr__ │ ← 定义了则调用
│ 否则 → │
│ AttributeError │
└──────────────────┘性能考量
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
实例属性访问 obj.attr | O(1) 均摊 | 先查描述符,再查 __dict__ |
类属性访问 Class.attr | O(1) | 直接 __dict__ 查找 |
实例属性赋值 obj.attr = val | O(1) 均摊 | 先查数据描述符,有 setter 则拦截 |
首次方法调用 obj.method() | 涉及描述符调用 | function 的 __get__ 创建 bound method |
| 缓存后方法调用 | O(1) | 实例字典或类字典缓存查找 |
| 设置 10000 个属性 | O(n) | 每个属性都要写入 __dict__ |
# 验证:方法每次调用都通过描述符
class Demo:
def greet(self):
return "hello"
d = Demo()
m1 = d.greet # 通过描述符 __get__ 创建 bound method
m2 = d.greet # 再次创建(不是同一个对象)
print(m1 is m2) # False — 每次访问都创建新的 bound method
# 但 CPython 内部有优化:对于 __slots__ 和某些场景,方法访问有缓存知识关联
属性与方法知识关联:
┌───────────────┐
│ 第 1 章:类 │
│ __init__ │
└───────┬───────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 实例属性 │ │ 类属性 │ │ 实例方法 │
│ self.xxx │ │ ClassName │ │ def m(s): │
└───────────┘ └───────────┘ └───────────┘
│ │
↓ ↓
┌───────────────┐ ┌─────────────┐
│ @classmethod │ │ @property │
│ @staticmethod │ │ 封装(第4章) │
└───────────────┘ └─────────────┘
│
↓
┌───────────────┐
│ 描述符协议 │
│ __get__ │
│ __set__ │ ← 数据描述符 > 实例 __dict__
└───────────────┘自检清单
1. 实例属性和类属性的区别是什么?
实例属性定义在
__init__中通过self赋值,每个实例独立拥有;类属性定义在类体中,所有实例共享。
2. 修改类属性时为什么不能用 self.attr = value?
这会在实例的
__dict__中创建同名属性,遮蔽类属性。应使用ClassName.attr = value。
3. @classmethod 和 @staticmethod 的核心区别?
@classmethod接收cls参数,可以访问类属性;@staticmethod没有特殊参数,不能访问类或实例属性。
4. 什么时候该用哪种方法?
需要访问实例数据 → 实例方法;需要访问类数据或做工厂方法 → 类方法;纯工具函数 → 静态方法。
5. BookItem.get_total() 和 b1.get_total() 结果一样吗?
一样。类方法可以通过类或实例调用,
cls始终指向类本身。但推荐通过类名调用以明确语义。
本章能力清单
学完本章后,你应该能够:
- [ ] 区分类属性和实例属性,知道何时使用哪种
- [ ] 通过类名安全地修改类属性,避免实例遮蔽
- [ ] 定义和使用
@classmethod,理解cls的作用 - [ ] 定义和使用
@staticmethod,理解其作为工具函数的定位 - [ ] 在三种方法之间做出正确选择
- [ ] 解释
BookItem.total_books的共享机制 - [ ] 用
validate_isbn()和validate_email()演示静态方法的用法 - [ ] 用
get_total()和get_total_members()演示类方法的用法