Skip to content

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


概念铺垫

为什么需要区分属性和方法?

问题场景

在图书馆系统中,我们需要同时管理两类数据:

  1. 每本书独有的信息:书名、作者、ISBN、出版年份
  2. 全体共享的信息:当前馆藏总数

错误做法 — 用全局变量管理共享数据:

python
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 校验)和类没有语义关联

正确做法 — 用类属性和类方法:

python
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

python
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  # 实例属性(私有)
python
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

python
class BookItem:
    total_books: int = 0  # 类属性

    def __init__(self, title: str, author: str, isbn: str, year: int) -> None:
        # ...
        BookItem.total_books += 1  # 每创建一个实例就 +1
python
b1 = 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 类同样使用了类属性追踪总数:

python
# 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 的命名约定。

⚠️ 常见陷阱:通过实例修改类属性

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 和其他实例不受影响。

python
# 验证:查看 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

⚠️ 常见陷阱:可变对象作为类属性

python
# ❌ 可变类属性 — 所有实例共享同一个列表
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

python
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-50domain/member.py:43-45

python
# 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_count
python
print(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-56domain/member.py:47-50

python
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]
python
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()
类方法@classmethodcls类属性工厂方法、统计(get_total()
静态方法@staticmethod都不能工具函数(validate_isbn()

贯穿实战

oop_demo/app/main.pydemo_ch02 函数展示了完整流程:

python
# 重置计数器,确保演示干净
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()

反模式:不要这样做

python
# ❌ 通过实例修改类属性 — 创建实例属性遮蔽
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                       │
└─────────────────────────────────────────────────────────┘

关键规则:数据描述符优先级高于实例字典

python
# 验证:@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)

普通方法也是描述符(非数据描述符)

python
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.attrO(1) 均摊先查描述符,再查 __dict__
类属性访问 Class.attrO(1)直接 __dict__ 查找
实例属性赋值 obj.attr = valO(1) 均摊先查数据描述符,有 setter 则拦截
首次方法调用 obj.method()涉及描述符调用function 的 __get__ 创建 bound method
缓存后方法调用O(1)实例字典或类字典缓存查找
设置 10000 个属性O(n)每个属性都要写入 __dict__
python
# 验证:方法每次调用都通过描述符
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() 演示类方法的用法