04-封装
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 encapsul -v
概念铺垫
为什么需要封装?
问题场景
直接暴露属性会导致数据被随意修改,破坏业务规则:
# ❌ 没有封装 — 属性裸暴露
eb = EBook("Python", "作者", "9780000000001", 2020, 3.0)
eb.__file_size_mb = -100 # 文件大小变成负数!
eb._isbn = "fake" # ISBN 被篡改!# ✅ 用 @property 保护
eb = EBook("Python", "作者", "9780000000001", 2020, 3.0)
eb.file_size_mb # → 3.0(通过 getter 读取)
eb.file_size_mb = 10.0 # → 通过 setter 验证后赋值
eb.file_size_mb = -1 # → ValueError: 文件大小必须大于 0 MB
eb.isbn = "new" # → AttributeError: 只读属性不可修改封装解决的问题:
| 问题 | 后果 | 封装方案 |
|---|---|---|
| 属性可随意赋值 | 数据不一致(如负的文件大小) | property setter 验证 |
| 敏感数据暴露 | ISBN、邮箱被篡改 | 私有属性 + 只读 property |
| 无验证入口 | 无法拒绝非法数据 | setter 中集中校验 |
| 实现细节外露 | 内部存储改动影响调用方 | 隐藏属性,只暴露接口 |
生活类比:保险箱
把封装想象成保险箱:
┌──────────────────────────────────────────────┐
│ 保 险 箱 │
│ │
│ 数据 = 贵重物品(锁在保险箱内部) │
│ @property = 密码锁(控制谁能访问) │
│ 私有属性 = 保险箱内壁(外面看不到里面) │
│ 验证逻辑 = 安保系统(拒绝非法操作) │
│ 只读属性 = 展示窗(只能看,不能拿) │
└──────────────────────────────────────────────┘- 没有保险箱:贵重物品放在桌上,谁都能拿走或破坏
- 有了保险箱:只有知道密码的人才能存取,安保系统会拒绝非法请求
代码中的对应关系:
| 保险箱组件 | 代码对应 | 示例 |
|---|---|---|
| 保险箱内部 | __file_size_mb | 私有属性,外部看不到 |
| 密码锁(读取) | @property file_size_mb | getter 控制读取 |
| 密码锁(写入) | @file_size_mb.setter | setter 验证后放行 |
| 展示窗 | @property isbn | 只读,只能看不能改 |
| 安保系统 | if value <= 0: raise ValueError | 拒绝非法操作 |
L1 理解层:会用
@property:将方法伪装成属性
只读属性
BookItem.isbn 是只读 property —— 只能读取,不能修改:
# oop_demo/app/domain/book/model.py
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
@property
def isbn(self) -> str: # 只读 getter,无 setter
return self._isbn
@property
def year(self) -> int:
return self._year
@property
def available(self) -> bool:
return self._availablebook = BookItem("流畅的Python", "Ramalho", "9781491946008", 2015)
print(book.isbn) # ✅ 读取:'9781491946008'
book.isbn = "fake" # ❌ AttributeError: property has no setter关键:只定义 @property getter,不定义 @xxx.setter,即创建只读属性。
带验证的 setter
EBook.file_size_mb 有 getter 和 setter,setter 中包含验证逻辑:
# oop_demo/app/domain/book/model.py
class EBook(BookItem):
def __init__(
self, title: str, author: str, isbn: str, year: int,
file_size_mb: float, fmt: str = "PDF",
) -> None:
super().__init__(title, author, isbn, year)
self.__file_size_mb: float = file_size_mb # 双下划线:私有
self._format: str = fmt
@property
def file_size_mb(self) -> float:
return self.__file_size_mb
@file_size_mb.setter
def file_size_mb(self, value: float) -> None:
if value <= 0:
raise ValueError("文件大小必须大于 0 MB")
self.__file_size_mb = valueeb = EBook("Python", "作者", "9780000000001", 2020, 3.0)
print(eb.file_size_mb) # ✅ 3.0
eb.file_size_mb = 10.0 # ✅ 修改成功
print(eb.file_size_mb) # 10.0
eb.file_size_mb = -1 # ❌ ValueError: 文件大小必须大于 0 MB
eb.file_size_mb = 0 # ❌ ValueError: 文件大小必须大于 0 MBsetter 验证规则:value <= 0 时拒绝,确保文件大小始终为正数。
私有属性:名称改写(Name Mangling)
双下划线前缀
EBook.__file_size_mb 被 Python 自动改写为 _EBook__file_size_mb:
eb = EBook("Python", "作者", "9780000000001", 2020, 3.0)
hasattr(eb, "__file_size_mb") # False — 外部访问不到
hasattr(eb, "_EBook__file_size_mb") # True — 实际存储位置
eb._EBook__file_size_mb # 3.0(可以访问,但不推荐)名称改写规则:__attr → _ClassName__attr
# 查看实际存储
print(eb.__dict__)
# {'title': 'Python', 'author': '作者', '_isbn': '...',
# '_EBook__file_size_mb': 3.0, '_format': 'PDF', ...}单下划线 vs 双下划线
| 前缀 | 机制 | 保护强度 | 使用场景 |
|---|---|---|---|
_attr | 约定:内部使用 | 弱(仍可访问) | 子类需要访问的受保护属性 |
__attr | 名称改写:_Class__attr | 强(外部看不到原名) | 真正要隐藏的属性 |
# BookItem 用单下划线 — isbn 需要被子类访问
class BookItem:
def __init__(self, ...):
self._isbn = isbn # 单下划线:子类可访问
# EBook 用双下划线 — file_size_mb 要完全隐藏
class EBook(BookItem):
def __init__(self, ...):
self.__file_size_mb = size # 双下划线:名称改写为什么名称改写不是真正的安全:它只是防止意外访问,不是加密。通过 _ClassName__attr 仍然可以访问,但不应该这样做。
Member 的封装实践
Email 验证
Member.email 的 property 和 setter 验证邮箱格式:
# oop_demo/app/domain/member.py
class Member:
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] = []
@property
def email(self) -> str:
return self.__email
@email.setter
def email(self, value: str) -> None:
self.__email = self._check_email(value) # setter 也验证
@staticmethod
def validate_email(email: str) -> bool:
parts = email.split("@")
return len(parts) == 2 and "." in parts[1]
@staticmethod
def _check_email(email: str) -> str:
if not Member.validate_email(email):
raise ValueError(f"邮箱格式不正确: {email!r}")
return emailm = Member("张三", "zhang@example.com", "M001")
print(m.email) # ✅ 'zhang@example.com'
m.email = "new@example.com" # ✅ 修改成功
m.email = "not-email" # ❌ ValueError: 邮箱格式不正确: 'not-email'验证逻辑集中在 _check_email:__init__ 和 setter 都调用它,保证邮箱始终合法。
只读 member_id
class Member:
@property
def member_id(self) -> str:
return self._member_id
@property
def borrow_count(self) -> int:
return len(self._borrowed_isbns)m = Member("张三", "zhang@example.com", "M001")
print(m.member_id) # ✅ 'M001'
m.member_id = "M999" # ❌ AttributeError: can't set attribute
print(m.borrow_count) # ✅ 0(计算属性,自动统计)贯穿实战
运行 oop_demo 的第 4 章演示:
cd oop_demo && uv run python -m app # 选 4# oop_demo/app/main.py — demo_ch04()
from app.domain.book.model import EBook
from app.domain.member import Member
# 只读属性
book = EBook("Python", "作者", "9780000000001", 2020, 3.0)
print(f"只读属性: book.isbn = {book.isbn!r}")
# 尝试修改: book.isbn = 'new' → AttributeError ❌
# 带验证的 setter
print(f"带验证的 setter: book.file_size_mb = {book.file_size_mb}")
book.file_size_mb = 10.0
print(f"修改后: book.file_size_mb = {book.file_size_mb}")
# 尝试设置负数: book.file_size_mb = -1 → ValueError ❌
# 名称改写
print(f"hasattr(book, '__file_size_mb') = {hasattr(book, '__file_size_mb')}")
# → False(访问不到)
print(f"hasattr(book, '_EBook__file_size_mb') = {hasattr(book, '_EBook__file_size_mb')}")
# → True(实际存储位置)
# 邮箱验证
m = Member("张三", "zhang@example.com", "M001")
print(f"有效邮箱: m.email = {m.email!r}")
m.email = "new@example.com"
print(f"修改成功: m.email = {m.email!r}")
# 无效邮箱: m.email = 'not-email' → ValueError ❌
# 只读 member_id
print(f"只读属性: m.member_id = {m.member_id!r}(不可修改)")L2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
| 用 @property 替代 getter/setter 方法 | 调用语法更优雅,符合 Python 风格 | @property def isbn(self): |
| setter 中添加验证逻辑 | 保证对象内部状态始终合法 | if value <= 0: raise ValueError |
| 只读属性只定义 getter | 明确表达不可修改意图 | @property isbn 无 setter |
| 敏感数据用双下划线 | 名称改写,防止意外访问 | self.__email |
| 子类需访问的用单下划线 | 约定内部使用,子类可继承 | self._isbn |
| 验证逻辑集中在私有方法 | 避免 __init__ 和 setter 重复代码 | _check_email() |
| 计算属性用 @property | 像属性一样访问,内部动态计算 | @property borrow_count |
反模式:不要这样做
# ❌ 属性裸暴露,无验证
class BookItem:
def __init__(self, title):
self.title = title # 外部可直接赋值,无保护
book = BookItem("Python")
book.title = 123 # 可以赋任意类型
# ✅ 用 @property 保护
class BookItem:
def __init__(self, title: str):
self._title = title
@property
def title(self) -> str:
return self._title
@title.setter
def title(self, value: str):
if not isinstance(value, str):
raise TypeError("title 必须是字符串")
self._title = value# ❌ property 与实例属性同名导致递归
class Foo:
@property
def name(self):
return self.name # 递归调用!self.name 又触发 property
# ✅ 内部存储用不同名称
class Foo:
@property
def name(self):
return self._name # 或 self.__name# ❌ 所有属性都私有化 — 过度封装
class Config:
def __init__(self):
self.__host = "localhost"
self.__port = 8080
self.__timeout = 30
# ✅ 简单数据类用公开属性,验证用 @property
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
timeout: int = 30适用场景
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 需要输入验证的属性 | 推荐 @property + setter | 集中验证逻辑 |
| 不可变标识符(ISBN、ID) | 推荐 只读 @property | 防止被意外修改 |
| 计算派生值(borrow_count) | 推荐 @property | 动态计算,无需存储 |
| 简单的数据传输对象 | 不推荐 @property | 用 @dataclass 更简洁 |
| 需要缓存昂贵计算的属性 | 推荐 @property + 内部缓存 | 首次计算后缓存结果 |
常见陷阱
| 陷阱 | 问题 | 解决方案 |
|---|---|---|
| property 每次访问都重算 | 性能问题 | 缓存到实例属性 _cache |
| setter 抛异常不直观 | x.prop = y 看起来不会报错 | 复杂验证改用方法返回 bool |
| 所有属性都私有化 | 过度封装,代码臃肿 | 简单数据类用公开属性 |
| property 名与实例属性同名 | self.age 与 @property age 递归 | 实例属性用 self.__age |
| 通过名称改写访问私有属性 | obj._Class__attr 破坏封装 | 仅在调试时使用 |
L3 专家层:深入
Python 如何实现:名称改写与 property 描述符
名称改写(Name Mangling)的内部机理
CPython 编译器在编译阶段完成名称改写,而非运行时:
Python 源码: self.__file_size_mb
│
▼ 编译阶段(tokenizer → AST → bytecode)
│
Python 字节码: self._EBook__file_size_mb
改写规则:
- 仅在 class 定义体内部生效
- 至少两个前导下划线 + 最多一个尾部下划线
- 替换为 _ClassName__attr
- 不会在函数/方法外部改写# 验证:名称改写发生在编译阶段
import dis
class Demo:
def __init__(self):
self.__secret = 42
self._normal = 0
print(dis.dis(Demo.__init__))
# 字节码中显示的是 _Demo__secret,不是 __secret
# LOAD_FAST 0 (self)
# LOAD_CONST 1 (42)
# STORE_ATTR 0 (_Demo__secret) ← 编译时已改写property 是数据描述符
property 类实现了 __get__、__set__、__delete__ 三个描述符方法,因此是数据描述符。这意味着 property 在查找链中优先级高于实例 __dict__。
# 验证:property 的底层实现
# property(fget, fset, fdel, doc) 等价于类定义:
class property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)# 验证:数据描述符优先于实例 __dict__
class MyProp:
def __get__(self, obj, objtype=None):
print("MyProp.__get__ called")
return obj._value if obj else self
def __set__(self, obj, value):
print(f"MyProp.__set__ called with {value}")
obj._value = value
class Demo:
attr = MyProp() # 类属性(数据描述符)
d = Demo()
d.__dict__["attr"] = "from dict" # 在实例字典中存入
print(d.attr) # MyProp.__get__ called → 描述符优先!
d.attr = 42 # MyProp.__set__ called → 描述符拦截!slots 内存优化
当有大量实例时,__slots__ 可以显著减少内存:
# 对比:__dict__ vs __slots__
import sys
class WithDict:
def __init__(self):
self.a = 1
self.b = 2
self.c = 3
class WithSlots:
__slots__ = ('a', 'b', 'c')
def __init__(self):
self.a = 1
self.b = 2
self.c = 3
d = WithDict()
s = WithSlots()
print(sys.getsizeof(d)) # ~48 bytes
print(sys.getsizeof(d.__dict__)) # 额外的 ~104 bytes(__dict__ 对象)
print(sys.getsizeof(s)) # ~48 bytes(无 __dict__ 开销)
# 创建 100000 个实例:
# WithDict: ~15 MB
# WithSlots: ~5 MB(节省约 66% 内存)__slots__ 的工作原理:
- 禁止实例的
__dict__创建(每个实例不再拥有字典) - 为每个 slot 名称在实例内存布局中预留 slot 描述符
- slot 描述符也是数据描述符,直接操作 C 结构体字段,比
__dict__查找更快
# __slots__ 的限制
class WithSlots:
__slots__ = ('a', 'b')
def __init__(self):
self.a = 1
self.b = 2
s = WithSlots()
# s.c = 3 # AttributeError: 'WithSlots' object has no attribute 'c'
# s.__dict__ # AttributeError: 没有 __dict__
# 不能动态添加属性性能考量
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| property getter 访问 | O(1) + 函数调用开销 | 描述符 __get__ + 用户函数 |
| property setter 赋值 | O(1) + 函数调用开销 | 描述符 __set__ + 用户函数 |
普通属性访问 obj.attr | O(1) 均摊 | __dict__ 哈希查找 |
| slot 属性访问 | O(1) | C 结构体字段直接索引,比 __dict__ 快约 20-30% |
名称改写 __attr | O(1) | 编译时完成,运行时无开销 |
| 创建 10000 个 slots 实例 | ~5 MB 内存 | vs dict 实例 ~15 MB |
知识关联
封装知识关联:
┌───────────────┐
│ 第 1 章:类 │
│ 实例属性 │
└───────┬───────┘
│
↓
┌───────────────┐
│ 第 4 章:封装 │
│ @property │
│ 名称改写 │
└───────┬───────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 只读属性 │ │ setter │ │ 私有属性 │
│ @property │ │ 验证逻辑 │ │ name mangling│
└───────────┘ └───────────┘ └───────────┘
│
↓
┌───────────────┐
│ 设计原则(第6章)│
│ 信息隐藏 │
└───────────────┘
│
↓
┌───────────────┐
│ 描述符协议 │
│ __get__ │
│ __set__ │ ← property 是数据描述符
│ __slots__ │ ← slot 描述符,无 __dict__
└───────────────┘自检清单
1. @property 和 @xxx.setter 的区别是什么?
@property 定义 getter(读取),@xxx.setter 定义 setter(写入)。只有 getter 就是只读属性。
2. 双下划线前缀和单下划线前缀的区别?
单下划线 _attr 是约定(不强制),双下划线 __attr 触发名称改写为 _Class__attr(强制保护)。
3. 为什么 eb.__file_size_mb = -1 不会报错但也没生效?
Python 会创建一个新属性 eb.__file_size_mb,而不是修改私有的 _EBook__file_size_mb。原属性不受影响。
4. property setter 中验证失败应该抛异常还是返回 False?
简单验证可以抛 ValueError(如格式检查);复杂业务逻辑建议用方法返回 bool,避免赋值语句意外抛异常。
5. 如何验证 oop_demo 中的封装实现?
运行 cd oop_demo && uv run pytest -k encapsul -v,共 6 个测试覆盖只读属性、私有属性、setter 验证、邮箱验证。
本章能力清单
完成本章后,你应该能够:
- [ ] 使用
@property创建只读属性 - [ ] 使用
@xxx.setter添加验证逻辑 - [ ] 使用双下划线前缀创建私有属性
- [ ] 解释名称改写机制(
__attr→_Class__attr) - [ ] 区分单下划线和双下划线的用途
- [ ] 设计带有验证逻辑的 property(如邮箱、文件大小)
- [ ] 运行
uv run pytest -k encapsul -v验证封装实现