零基础入门python08:用类和对象重构个人记账本
·
零基础入门python08:用类和对象重构个人记账本
一、上一篇课后练习讲解
上一篇要求给 JSON 账目增加 kind 字段。参考实现是新增记录时强制要求 kind 为 income 或 expense,读取旧文件时用 record.get('kind', 'expense') 兼容历史数据,而不是直接假设每条旧数据都有新字段。
可执行验收答案
def normalize_record(record: dict) -> dict:
kind = record.get("kind", "expense")
if kind not in {"income", "expense"}:
raise ValueError("kind 只能是 income 或 expense")
amount = float(record["amount"])
if amount < 0:
raise ValueError("金额不能为负")
return {**record, "kind": kind, "amount": amount}
old = {"amount": 12.5, "note": "午餐"}
new = {"amount": 100, "kind": "income", "note": "工资"}
assert normalize_record(old)["kind"] == "expense"
assert normalize_record(new)["kind"] == "income"
try:
normalize_record({"amount": 1, "kind": "gift"})
except ValueError as exc:
print(exc)
else:
raise AssertionError("非法 kind 没有失败")
升级旧 JSON 时先逐条调用 normalize_record,发现损坏记录要报告编号并停止写回;不要读取一半就用空列表覆盖原文件。
二、本篇为什么要学习类
当账本只有几个函数时还不难;随着读取、添加、统计、保存越来越多,函数之间需要共享 records 和文件路径。类把“数据”和“操作数据的函数”放在一起,形成一个有边界的对象。

三、完整实现
from dataclasses import dataclass, asdict
from pathlib import Path
import json
@dataclass
class Record:
"""一条账目;使用 dataclass 自动生成初始化和可读表示。"""
amount: float
kind: str
note: str
class Ledger:
"""管理账目生命周期,调用方不需要知道 JSON 文件的细节。"""
def __init__(self, path: Path):
self.path = path
self.records: list[Record] = []
self.load()
def load(self) -> None:
if not self.path.exists():
return
data = json.loads(self.path.read_text(encoding='utf-8'))
self.records = [Record(float(x['amount']), x.get('kind', 'expense'), x.get('note', '')) for x in data]
def add(self, amount: float, kind: str, note: str) -> None:
if amount <= 0 or kind not in {'income', 'expense'}:
raise ValueError('金额必须大于0,类型必须是income或expense')
self.records.append(Record(amount, kind, note.strip()))
def summary(self) -> dict[str, float]:
income = sum(x.amount for x in self.records if x.kind == 'income')
expense = sum(x.amount for x in self.records if x.kind == 'expense')
return {'income': income, 'expense': expense, 'balance': income - expense}
def save(self) -> None:
payload = [asdict(record) for record in self.records]
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
Ledger 隐藏了文件读写细节,未来换成 SQLite 时,命令行层不必重写;Record 表达一条数据,Ledger 表达一组数据和业务操作。这就是封装的实际价值。
四、运行示例
ledger = Ledger(Path('records.json'))
ledger.add(5000, 'income', '工资')
ledger.add(35, 'expense', '午餐')
ledger.save()
print(ledger.summary())
预期:{'income': 5000, 'expense': 35, 'balance': 4965}。课后练习:增加按月份筛选统计,下一篇拆分模块并加入日志调试。
实战补充:从数据类到账本对象
当账本同时拥有记录、文件路径和保存规则时,类可以把状态和操作放进一个边界。
from dataclasses import dataclass, asdict
@dataclass
class Record:
amount: Decimal
kind: str
note: str
class Ledger:
def __init__(self, path: Path):
self.path = path
self.records: list[Record] = []
self.load()
def add(self, amount: Decimal, kind: str, note: str) -> None:
if amount <= 0 or kind not in {'income', 'expense'}:
raise ValueError('金额必须为正,类型只能是 income 或 expense')
self.records.append(Record(amount, kind, note.strip()))
类的价值不是把函数改成方法,而是限制谁能修改内部状态;外部只调用 add、summary 和 save,未来把 JSON 换成 SQLite 时命令行不必重写。
验收与课后练习
测试新增收入、支出、非法类型和重新加载;课后增加按月份过滤,下一篇拆分模块并加入日志。
本篇结束:完整模块文件
本节不是代码片段,而是本篇结束时该模块的完整版本。请先备份旧文件,再整体替换;替换后重新运行本篇命令和测试。阅读时重点看本篇新增的函数、事务边界和错误处理,未涉及的代码先不要自行删减。
main.py
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
items: list[str] = field(default_factory=list)
def add(self, item):
self.items.append(item)
order = Order(1)
order.add('Python Book')
print(order)
更多推荐



所有评论(0)