第7章 7.2 封装、属性装饰器 @property

🎯 学这个之前,先听我讲个故事

上节课我们给小明写了一个「学生信息卡」程序:

class Student:
def __init__(self, name, score):
    self.name = name
    self.score = score

# 用起来很爽
xiaoming = Student("小明", 85)
print(xiaoming.score)  # 85

但是!你有没有想过一个问题:如果有人手滑写了 xiaoming.score = -50 呢?

分数居然能是负数?这不科学!

再比如,你写了一个「银行账户」类:

class BankAccount:
def __init__(self, balance):
    self.balance = balance

account = BankAccount(1000)
account.balance = -999999  # 我的账户居然变成负债了?

这就是「封装」要解决的问题 —— 不是把所有东西藏起来,而是「让对的人用对的方式访问数据」

学完这章,你就能写出「智能的数据访问」:分数自动校验、余额不能乱改、访问有记录……


🧱 基础 25 分钟:封装是什么? @property 怎么用?

1. 封装到底是啥?

说白了:封装就是给数据加「门卫」

想象你家小区的大门:
- 门禁卡(getter)→ 刷卡看信息
- 密码锁(setter)→ 输入密码才能改信息
- 访客系统 → 记录谁进来了

类比到程序世界:
- 直接访问属性obj.name → 像敞开的大门,谁都能进
- 用 getter 访问obj.get_name() → 像门禁,要刷卡
- 用 setter 修改obj.set_name("新名字") → 像密码锁,要验证

那问题来了:Python 明明可以直接 obj.name,为什么要多此一举?

答案:数据验证 + 逻辑保护。你想限制分数只能在 0-100 之间?用 @property 就能做到。

2. 最简单的例子:把分数锁死

class Student:
def __init__(self, name, score):
    self.name = name
    self.score = score  # 这里会自动调用 setter

@property
def score(self):
    """读取分数时会自动调用这个方法"""
    return self._score  # 读取的是私有属性 _score

@score.setter
def score(self, value):
    """修改分数时会自动调用这个方法"""
    if value < 0:
        value = 0  # 不能为负数,自动改成 0
    elif value > 100:
        value = 100  # 不能超过100,自动改成100
    self._score = value  # 真正存储的地方是 _score

# 测试一下
xiaoming = Student("小明", 85)
print(xiaoming.score)  # 85 ✅

xiaoming.score = 150  # 试着改成150
print(xiaoming.score)  # 100 ✅ 自动修正了!

xiaoming.score = -20  # 试着改成负数
print(xiaoming.score)  # 0 ✅ 自动修正了!

这3行代码在干嘛:
- @property 装饰器把 self.score 变成「受保护的读操作」
- @xxx.setter 装饰器把 self.score = xxx 变成「受保护的写操作」
- self._score 才是真正存数据的地方(前面加下划线表示私有)

3. 生活类比:为什么需要这个「包装盒」?

想象你在网店买水果:
- 没有封装:你直接伸手进箱子摸 → 可能摸到烂的、不够秤
- 有封装:店家给你装好的盒装水果 → 店家已经挑过、称过、封好了

@property 就是那个「封装好的盒子」:
- 读取时 → 给你的是处理好的值
- 写入时 → 先过一遍检验,合格了才收下

配图1 - 配图1

4. 私有属性:单下划线 _ 和双下划线 __

Python 里有个「潜规则」:

class Student:
def __init__(self, name, score):
    self.name = name        # 公有属性:随便访问
    self._score = score      # 受保护属性:约定不直接访问
    self.__private = 100     # 私有属性:名字被改写了(名字重整)

xiaoming = Student("小明", 85)
print(xiaoming.name)      # ✅ 小明 - 公有属性
print(xiaoming._score)    # ⚠️ 85 - 能访问,但不建议
print(xiaoming.__private) # ❌ AttributeError! 报错!
print(xiaoming._Student__private)  # ✅ 100 - 硬要找也能找到,但不推荐

一句话总结:
- _x = 「兄弟,这个我私用的,你别直接动」
- __x = Python 帮你把名字改了,碰碰运气看能不能找到

5. 只读属性:不给改的机会

有时候数据只能读、不能改,比如「学号」:

class Student:
def __init__(self, name, student_id):
    self.name = name
    self._student_id = student_id

@property
def student_id(self):
    """学号只能读,不能改"""
    return self._student_id

# 测试
xiaoming = Student("小明", "A2024001")
print(xiaoming.student_id)  # A2024001

xiaoming.student_id = "A2024999"  # ❌ AttributeError!

6. @property 还能干点别的:计算属性

有时候属性不是存着的,而是「算出来」的:

class Rectangle:
def __init__(self, width, height):
    self.width = width
    self.height = height

@property
def area(self):
    """面积不需要存,每次用的时候算"""
    return self.width * self.height

@property
def perimeter(self):
    """周长也不需要存"""
    return 2 * (self.width + self.height)

# 测试
rect = Rectangle(10, 5)
print(rect.area)       # 50
print(rect.perimeter)   # 30

关键点:面积和周长没有存 self.area,而是每次根据宽高「算」出来的!

配图2 - 配图2

7. setter 还能做更多:日志记录

class BankAccount:
def __init__(self, owner, balance):
    self.owner = owner
    self.balance = balance

@property
def balance(self):
    return self._balance

@balance.setter
def balance(self, value):
    print(f"[日志] {self.owner} 的账户被修改了!")
    print(f"[日志] 旧值: {self._balance if hasattr(self, '_balance') else 'N/A'}, 新值: {value}")
    self._balance = value

# 测试
account = BankAccount("小明", 1000)
account.balance = 2000  # 会打印日志!

🔥 实战 35 分钟:3 个递进项目

项目 1(5 分钟):智能学生成绩管理

目标:巩固 @property 的基本用法

class Student:
def __init__(self, name, chinese, math, english):
    self.name = name
    self.chinese = chinese
    self.math = math
    self.english = english

@property
def average(self):
    """计算平均分"""
    return (self.chinese + self.math + self.english) / 3

@property
def chinese(self):
    return self._chinese

@chinese.setter
def chinese(self, value):
    self._chinese = max(0, min(100, value))  # 自动限制在 0-100

@property
def math(self):
    return self._math

@math.setter
def math(self, value):
    self._math = max(0, min(100, value))

@property
def english(self):
    return self._english

@english.setter
def english(self, value):
    self._english = max(0, min(100, value))


# 测试
xiaoming = Student("小明", 85, 90, 78)
print(f"学生: {xiaoming.name}")
print(f"平均分: {xiaoming.average:.1f}")

# 试着输入异常分数
xiaoming.math = 120  # 超出范围
xiaoming.english = -10  # 低于范围
print(f"数学成绩(修正后): {xiaoming.math}")  # 应该是 100
print(f"英语成绩(修正后): {xiaoming.english}")  # 应该是 0
print(f"平均分(修正后): {xiaoming.average:.1f}")

预期输出:

学生: 小明
平均分: 84.3
数学成绩(修正后): 100
英语成绩(修正后): 0
平均分(修正后): 87.7

一句话解释:@property 自动把越界的分数「拽」回合法范围


项目 2(15 分钟):个人记账本(带数据验证)

目标:综合运用 getter/setter + 计算属性 + 数据验证

import json
from datetime import datetime

class ExpenseRecord:
"""个人记账记录"""

def __init__(self, category, amount, description=""):
    self.category = category
    self.amount = amount
    self.description = description
    self.date = datetime.now().strftime("%Y-%m-%d")

@property
def category(self):
    return self._category

@category.setter
def category(self, value):
    valid_categories = ["餐饮", "交通", "住宿", "购物", "娱乐", "其他"]
    if value not in valid_categories:
        print(f"⚠️ 警告: 类别 '{value}' 不在预设中,已自动设为 '其他'")
        value = "其他"
    self._category = value

@property
def amount(self):
    return self._amount

@amount.setter
def amount(self, value):
    if value < 0:
        raise ValueError("金额不能为负数!")
    self._amount = round(value, 2)  # 保留2位小数

def __str__(self):
    return f"[{self.date}] {self.category} - ¥{self.amount:.2f} ({self.description})"


class AccountBook:
"""记账本容器"""

def __init__(self):
    self._records = []

def add(self, category, amount, description=""):
    """添加一条记录"""
    record = ExpenseRecord(category, amount, description)
    self._records.append(record)
    return record

@property
def total(self):
    """计算总支出"""
    return sum(r.amount for r in self._records)

@property
def by_category(self):
    """按类别分组统计"""
    result = {}
    for r in self._records:
        result[r.category] = result.get(r.category, 0) + r.amount
    return result

def show_summary(self):
    print("\n" + "=" * 40)
    print(f"📊 记账本汇总(共 {len(self._records)} 条记录)")
    print("=" * 40)
    print(f"💰 总支出: ¥{self.total:.2f}")
    print("\n📂 分类明细:")
    for cat, amt in self.by_category.items():
        print(f"   {cat}: ¥{amt:.2f}")
    print("=" * 40)


# 测试
book = AccountBook()

# 添加一些记录
book.add("餐饮", 35.5, "午餐")
book.add("交通", 8.0, "地铁")
book.add("购物", 299.9, "日用品")
book.add("娱乐", 88.0, "电影")
book.add("餐饮", 42.0, "晚餐")
book.add("餐饮", 200, "请客吃饭")
book.add("娱乐", 50.0, "无效类别测试")  # 会触发警告

try:
book.add("餐饮", -100, "这不应该发生")
except ValueError as e:
print(f"\n✅ 捕获到异常: {e}")

book.show_summary()

# 展示单条记录
print("\n📝 记录明细:")
for record in book._records:
print(f"   {record}")

预期输出:

⚠️ 警告: 类别 '娱乐' 不在预设中,已自动设为 '其他'

✅ 捕获到异常: 金额不能为负数!

========================================
📊 记账本汇总(共 6 条记录)
========================================
💰 总支出: ¥723.40

📂 分类明细:
饮: ¥277.50
通: ¥8.00
物: ¥299.90
他: ¥138.00
========================================

📝 记录明细:
2026-06-26] 餐饮 - ¥35.50 (午餐)
..

一句话解释:金额负数直接抛异常,类别不对自动归到「其他」


项目 3(15 分钟):命令行待办清单(文件持久化)

目标:组合多个类 + 属性装饰器 + 文件读写

import json
from pathlib import Path

class TodoItem:
"""单个待办事项"""

def __init__(self, title, priority=3):
    self.title = title
    self._priority = priority
    self._done = False

@property
def priority(self):
    return self._priority

@priority.setter
def priority(self, value):
    if not 1 <= value <= 5:
        raise ValueError("优先级必须在 1-5 之间")
    self._priority = value

@property
def done(self):
    return self._done

@done.setter
def done(self, value):
    self._done = bool(value)

def __str__(self):
    status = "✅" if self._done else "⬜"
    stars = "⭐" * self._priority
    return f"{status} {self.title} {stars}"

def to_dict(self):
    return {"title": self.title, "priority": self._priority, "done": self._done}

@classmethod
def from_dict(cls, data):
    item = cls(data["title"], data["priority"])
    item._done = data["done"]
    return item


class TodoList:
"""待办清单管理器"""

def __init__(self, filename="todo_data.json"):
    self._filename = filename
    self._items = []
    self._load()

def _load(self):
    """从文件加载数据"""
    if Path(self._filename).exists():
        with open(self._filename, "r", encoding="utf-8") as f:
            data = json.load(f)
            self._items = [TodoItem.from_dict(d) for d in data]

def _save(self):
    """保存数据到文件"""
    data = [item.to_dict() for item in self._items]
    with open(self._filename, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def add(self, title, priority=3):
    """添加待办"""
    item = TodoItem(title, priority)
    self._items.append(item)
    self._save()
    print(f"✅ 已添加: {item}")

def done(self, index):
    """标记为完成"""
    if 0 <= index < len(self._items):
        self._items[index].done = True
        self._save()
        print(f"✅ 已完成: {self._items[index]}")
    else:
        print(f"❌ 无效序号: {index}")

def delete(self, index):
    """删除待办"""
    if 0 <= index < len(self._items):
        removed = self._items.pop(index)
        self._save()
        print(f"🗑️ 已删除: {removed}")
    else:
        print(f"❌ 无效序号: {index}")

def show(self):
    """显示所有待办"""
    if not self._items:
        print("📝 清单是空的!")
        return

    pending = [item for item in self._items if not item.done]
    completed = [item for item in self._items if item.done]

    print(f"\n📋 待办事项(共 {len(pending)} 条未完成):")
    for i, item in enumerate(pending):
        print(f"  {i}. {item}")

    if completed:
        print(f"\n✅ 已完成(共 {len(completed)} 条):")
        for i, item in enumerate(completed, len(pending)):
            print(f"  {i}. {item}")

def clear_done(self):
    """清空已完成"""
    self._items = [item for item in self._items if not item.done]
    self._save()
    print("🧹 已清空所有已完成项目")


# 演示
if __name__ == "__main__":
todo = TodoList()

# 演示命令
print("=== 添加待办 ===")
todo.add("完成 Python 作业", priority=5)
todo.add("给妈妈打电话", priority=4)
todo.add("整理房间", priority=2)
todo.add("invalid priority test", priority=10)  # 会报错

print("\n=== 显示清单 ===")
todo.show()

print("\n=== 标记完成 ===")
todo.done(1)

print("\n=== 最终清单 ===")
todo.show()

预期输出:

=== 添加待办 ===
✅ 已添加: ⬜ 完成 Python 作业 ⭐⭐⭐⭐⭐
✅ 已添加: ⬜ 给妈妈打电话 ⭐⭐⭐⭐
✅ 已添加: ⬜ 整理房间 ⭐⭐
❌ 优先级必须在 1-5 之间

=== 显示清单 ===
📋 待办事项(共 3 条未完成):
0. ⬜ 完成 Python 作业 ⭐⭐⭐⭐⭐
1. ⬜ 给妈妈打电话 ⭐⭐⭐⭐
2. ⬜ 整理房间 ⭐⭐

=== 标记完成 ===
✅ 已完成: ⬜ 给妈妈打电话 ⭐⭐⭐⭐

=== 最终清单 ===
📋 待办事项(共 2 条未完成):
0. ⬜ 完成 Python 作业 ⭐⭐⭐⭐⭐
1. ⬜ 整理房间 ⭐⭐

✅ 已完成(共 1 条):
2. ✅ 给妈妈打电话 ⭐⭐⭐⭐

一句话解释:数据会自动保存到 JSON 文件,关闭程序再打开,数据还在


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:property 和实例属性的「名字冲突」

# ❌ 错误示例
class Student:
def __init__(self, score):
    self.score = score  # 这里触发了 setter,但此时 self.score 还不存在!

@property
def score(self):
    return self._score

@score.setter
def score(self, value):
    print("正在设置分数")
    self._score = value
# ✅ 正确示例
class Student:
def __init__(self, score):
    self.score = score  # 没问题!setter 已经在类定义时创建好了

@property
def score(self):
    return self._score

@score.setter
def score(self, value):
    print("正在设置分数")
    self._score = value

坑 2:不要在 setter 里调用同一个 property

# ❌ 错误示例 - 无限递归!
class Student:
@property
def score(self):
    return self.score  # 递归调用自己!

@score.setter
def score(self, value):
    self.score = value  # 递归调用 setter!

坑 3:property 会「覆盖」子类同名属性

# ⚠️ 小心这个陷阱
class Parent:
@property
def name(self):
    return "Parent"

class Child(Parent):
pass

print(Child().name)  # "Parent" - 子类没有自己的 name!

坑 4:__slots__ 和 property 的配合

# 使用 __slots__ 可以节省内存,但要注意写法
class Student:
__slots__ = ("_name", "_score")

def __init__(self, name, score):
    self.name = name
    self.score = score

@property
def name(self):
    return self._name

@name.setter
def name(self, value):
    self._name = value

@property
def score(self):
    return self._score

@score.setter
def score(self, value):
    self._score = value

# ✅ 正常用
s = Student("小明", 85)
print(s.name, s.score)

坑 5:property 不是真正的「限制」

# ⚠️ 重要提醒:property 只是约定,不是硬限制
class Student:
def __init__(self, score):
    self.score = score

@property
def score(self):
    return self._score

@score.setter
def score(self, value):
    self._score = max(0, min(100, value))

s = Student(85)
s.score = 50          # ✅ 通过 property 修改
s._score = 999        # ⚠️ 绕过了限制!直接访问私有属性
print(s.score)        # 999 - 限制失效了

一句话总结:property 是「防君子不防小人」的约定,主要目的是让代码更清晰、逻辑更安全,而不是硬加密


调试技巧:用 __repr__ 看清楚对象状态

class Student:
def __init__(self, name, score):
    self.name = name
    self.score = score

def __repr__(self):
    return f"Student(name='{self.name}', score={self.score})"

s = Student("小明", 85)
print(repr(s))  # Student(name='小明', score=85)

✏️ 练习题

练习 1(2 分钟):添加一个验证

# 输入:在 BankAccount 类中添加一个 minimum 属性,保证余额不低于这个数
# 预期输出:设置 minimum 后,取款不能使余额低于 minimum

练习 2(2 分钟):在项目 1 里加判断

# 输入:给 Student 类加一个 @property,输出"及格"或"不及格"
# 预期输出:平均分<60输出"不及格",否则"及格"

练习 3(3 分钟):处理新数据

# 输入:用项目 2 的记账本统计一份新的消费记录
# 提示:直接把 add() 的调用换成新数据

练习 4(3 分钟):串接两个项目

# 输入:用项目 2 的 ExpenseRecord 类替代项目 1 里的简单属性
# 预期输出:每科成绩有分类(测验/期中/期末),分类汇总显示

练习 5(2 分钟):看图找错

# 下面这段代码运行后会报错,找出问题所在
class Product:
def __init__(self, name, price):
    self.name = name
    self.price = price

@property
def price(self):
    return self._price

@price.setter
def price(self, value):
    if value < 0:
        raise ValueError("价格不能为负")
    self._price = value

p = Product("苹果", -5)
print(p.price)

作业:做一个「个人健康数据记录器」

需求描述:记录每天的体重、运动时长、喝水量等健康数据,带统计和分析功能。

功能点
1. 记录每天的数据(日期、体重、运动时长、喝水量)
2. 所有数值都有验证(体重 20-300kg,运动 0-24小时,喝水量 0-10L)
3. 计算周平均值、显示趋势(体重上升/下降箭头)
4. 数据保存到本地 JSON 文件

加分项
1. BMI 计算(自动根据身高体重算)
2. 目标达成提醒(比如"今天运动量达标")

验收标准
- 能运行不报错
- 输入异常数据能正确处理
- 统计结果准确


📚 总结

本文学了 3 件事:
1. 封装:用 _x__x 表示私有属性,给数据加门卫
2. @property:把方法变成「像属性一样访问」,读写时自动校验
3. setter 验证:修改数据前先过一遍检查,保证数据合法

延伸资源:
- 官方文档:https://docs.python.org/3/library/functions.html#property
- 《Python 编程:从入门到实践》第 9 章 - 类
- 视频:B 站「小甲鱼」Python 教程第 38 讲

互动钩子:你在做项目时有没有遇到过「明明不该修改的数据被人改乱了」的情况?评论区聊聊你是怎么解决的!老粉优先回复哦~


下章剧透:学会了封装,你的类就像一个个「独立的房间」……但如果两个房间有「共同之处」呢?比如「学生」和「老师」都是「人」?下一章我们来聊继承——让代码复用的魔法!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。