零基础入门python08:用类和对象重构个人记账本

一、上一篇课后练习讲解

上一篇要求给 JSON 账目增加 kind 字段。参考实现是新增记录时强制要求 kindincomeexpense,读取旧文件时用 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)

更多推荐