Skip to content

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


概念铺垫

为什么需要封装?

问题场景

直接暴露属性会导致数据被随意修改,破坏业务规则:

python
# ❌ 没有封装 — 属性裸暴露
eb = EBook("Python", "作者", "9780000000001", 2020, 3.0)
eb.__file_size_mb = -100   # 文件大小变成负数!
eb._isbn = "fake"           # ISBN 被篡改!
python
# ✅ 用 @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_mbgetter 控制读取
密码锁(写入)@file_size_mb.settersetter 验证后放行
展示窗@property isbn只读,只能看不能改
安保系统if value <= 0: raise ValueError拒绝非法操作

L1 理解层:会用

@property:将方法伪装成属性

只读属性

BookItem.isbn 是只读 property —— 只能读取,不能修改:

python
# 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._available
python
book = 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 中包含验证逻辑:

python
# 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 = value
python
eb = 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 MB

setter 验证规则value <= 0 时拒绝,确保文件大小始终为正数。


私有属性:名称改写(Name Mangling)

双下划线前缀

EBook.__file_size_mb 被 Python 自动改写为 _EBook__file_size_mb

python
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

python
# 查看实际存储
print(eb.__dict__)
# {'title': 'Python', 'author': '作者', '_isbn': '...', 
#  '_EBook__file_size_mb': 3.0, '_format': 'PDF', ...}

单下划线 vs 双下划线

前缀机制保护强度使用场景
_attr约定:内部使用弱(仍可访问)子类需要访问的受保护属性
__attr名称改写:_Class__attr强(外部看不到原名)真正要隐藏的属性
python
# 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 验证邮箱格式:

python
# 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 email
python
m = 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

python
class Member:
    @property
    def member_id(self) -> str:
        return self._member_id

    @property
    def borrow_count(self) -> int:
        return len(self._borrowed_isbns)
python
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 章演示:

bash
cd oop_demo && uv run python -m app   # 选 4
python
# 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

反模式:不要这样做

python
# ❌ 属性裸暴露,无验证
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
python
# ❌ property 与实例属性同名导致递归
class Foo:
    @property
    def name(self):
        return self.name  # 递归调用!self.name 又触发 property

# ✅ 内部存储用不同名称
class Foo:
    @property
    def name(self):
        return self._name  # 或 self.__name
python
# ❌ 所有属性都私有化 — 过度封装
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
  - 不会在函数/方法外部改写
python
# 验证:名称改写发生在编译阶段
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__

python
# 验证: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__)
python
# 验证:数据描述符优先于实例 __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__ 可以显著减少内存:

python
# 对比:__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__ 查找更快
python
# __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.attrO(1) 均摊__dict__ 哈希查找
slot 属性访问O(1)C 结构体字段直接索引,比 __dict__ 快约 20-30%
名称改写 __attrO(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 验证封装实现