1 项目总览 —— 命令行记账本(阶段三收官)
是什么?为什么学?
今天是阶段三收官日,没有新语法——把所有知识串起来做一个命令行记账本。前 20 天学了语法、函数、文件、面向对象、标准库,今天把它组装成一个「真正能用的」命令行记账本:要有清晰的类设计、能交互操作、数据关机不丢。做完这个项目,你就具备了「独立把一个小需求做成程序」的能力。
阶段知识地图:
Day 15 类与对象(class/属性/方法/__init__) ────────┐
Day 16 继承与多态(子类复用/重写/鸭子类型) ─────────┐│
Day 17 魔法方法(__str__/__len__/__eq__/运算符重载) ─┐││
Day 18 迭代器、生成器与装饰器(惰性求值/@语法糖) ────┐│││
Day 19 常用标准库(datetime/collections/itertools/re)┐││││
Day 20 第三方库(pip/venv/requests/beautifulsoup4) ──┐│││││
└─┴┴┴┴┴┴──→ 阶段项目:命令行记账本
一句话串法:类把数据和函数打包 → 继承让类复用 → 魔法方法让对象「像内置类型」→ 生成器装饰器让代码更优雅 → 标准库提供现成工具 → 第三方库扩展无限能力。记账本就是把这些零件按顺序组装起来:类当骨架、魔法方法当「打印门面」、datetime 记时间、json 做保险柜、异常处理当安全气囊。
注意事项总结
1 项目拆解表(四大功能 → 知识点):
| 功能 | 用到的知识点 | 具体写法 | 来自哪一天 |
|---|---|---|---|
| 添加收支 | 类与对象、魔法方法、datetime、异常处理 | Record(rtype, category, amount, note)、datetime.now() 打时间戳、try/except ValueError 拦非法金额 | Day 15/17/19/13 |
| 查看流水 | 类与对象、魔法方法 | for r in book.records: print(r) 触发 Record.__str__ | Day 15/17 |
| 统计余额 | 类与对象、生成器表达式 | sum(r.amount for r in self.records if r.rtype == "支出") | Day 15/18 |
| 分类统计 | 类与对象、列表推导式 | [r for r in self.records if r.category == category] | Day 15/6 |
2 持久化与交互 → 知识点:
| 功能 | 用到的知识点 | 具体写法 | 来自哪一天 |
|---|---|---|---|
| 保存数据 | 标准库 json、文件读写 | json.dump(data, f, ensure_ascii=False, indent=2) | Day 19/12/11 |
| 加载数据 | 文件判断、json、异常处理 | os.path.exists + json.load + except (json.JSONDecodeError, OSError) | Day 10/12/13 |
| 序列化转换 | 魔法方法、类方法 | Record.to_dict()(转字典)、Record.from_dict()(@classmethod 转回对象) | Day 17/15 |
| 主菜单 | while 循环、条件判断 | while True: + if/elif + break | Day 4/5 |
| 导出 CSV | 标准库 csv、编码 | csv.DictWriter + encoding="utf-8-sig" | Day 19/12 |
易错点
- 错误写法:一口气写完 150 行代码再运行(「憋大招」) → 问题:一运行全是错,不知道从哪改起 → 正确写法:三步走(骨架 → 填空腹 → 加持久化防护),每步都能运行验证。
- 错误写法:项目做不出来就怀疑是今天的问题 → 问题:往往是前面某天基础没打牢 → 正确写法:对照拆解表自查「这一步用到了哪个知识点」,回去翻那一天的总结。
记忆口诀:类当骨架,魔法方法当门面;datetime 记时间,json 做保险柜;拆解表对号入座,小步迭代不憋大招。
2 需求分析与类设计 —— Record 与 Book
是什么?为什么学?
先想清楚要什么功能:添加收入/支出、查看全部流水、统计余额、按类别筛选、保存数据。程序里有两个天然的概念,拆成两个类:Record(一条收支记录)和 Book(整个账本)。一个类管「个体」,一个类管「整体」,职责清晰。
注意事项总结
1 Record 类——一条收支记录:
class Record:
"""一条收支记录"""
def __init__(self, rtype, category, amount, note="", date=None):
if rtype not in ("收入", "支出"):
raise ValueError("类型只能是 收入 或 支出")
self.rtype = rtype # 收入 / 支出
self.category = category # 餐饮、交通、工资……
self.amount = float(amount) # 金额
self.note = note # 备注
self.date = date or "2026-08-03 09:00:00"
def __str__(self):
symbol = "+" if self.rtype == "收入" else "-"
return f"[{self.date}] {self.rtype} {self.category} {symbol}{self.amount:.2f} {self.note}"
r = Record("支出", "餐饮", 25.5, "午饭")
print(r)
运行结果:
[2026-08-03 09:00:00] 支出 餐饮 -25.50 午饭
__init__ 里先校验类型(非法直接 raise ValueError);__str__ 决定打印格式,f"{self.amount:.2f}" 金额两位小数,symbol 用条件表达式决定 + 还是 -。
2 Book 类——添加、统计、筛选:
class Book:
def __init__(self):
self.records = []
def add_record(self, record):
self.records.append(record)
def balance(self):
"""收入加起来减支出,得到余额"""
total = 0.0
for r in self.records:
if r.rtype == "收入":
total += r.amount
else:
total -= r.amount
return total
def filter_by_category(self, category):
return [r for r in self.records if r.category == category]
balance() 遍历所有记录,收入加、支出减;filter_by_category 用列表推导式筛出同类记录。
易错点
- 错误写法:一个类装所有东西(一条记录和整个账本混在一个类里) → 问题:职责混乱,改一个地方影响全局 → 正确写法:拆成
Record(个体)和Book(整体),各管各的。
记忆口诀:个体 Record,整体 Book,一个管一条、一个管全本;字段校验放构造,打印交给 __str__。
3 魔法方法与生成器 —— 对象的「门面」和「统计」
是什么?为什么学?
__str__ 决定「打印一条记录」长什么样——查看流水时 print(r) 直接输出可读的一行;生成器表达式让「筛选 + 求和」一行搞定,不产生中间列表。
注意事项总结
1 str 让「打印一条记录」一行搞定:
class Record:
def __init__(self, rtype, category, amount, note="", date="2026-08-04 09:30:00"):
self.rtype = rtype
self.category = category
self.amount = float(amount)
self.note = note
self.date = date
def __str__(self):
symbol = "+" if self.rtype == "收入" else "-"
return f"[{self.date}] {self.rtype} {self.category} {symbol}{self.amount:.2f} {self.note}"
r = Record("支出", "餐饮", 25.5, "午饭")
print(r) # 触发 __str__
运行结果:
[2026-08-04 09:30:00] 支出 餐饮 -25.50 午饭
没有 __str__,打印对象只会看到 <__main__.Record object at 0x...>,完全没法看。
2 生成器表达式统计支出总额(惰性、一行):
class Book:
def __init__(self):
self.records = []
def expense_total(self):
return sum(r.amount for r in self.records if r.rtype == "支出")
book = Book()
book.records = [Record("支出", "餐饮", 25.5), Record("收入", "工资", 8000)]
print("支出总额:", book.expense_total())
运行结果:
支出总额: 25.5
(r.amount for r in self.records if r.rtype == "支出") 是生成器表达式——边遍历边过滤,不产生中间列表;sum() 消费它算出总和。
3 datetime 自动打时间戳:
from datetime import datetime
class Record:
def __init__(self, rtype, category, amount, note=""):
self.rtype = rtype
self.category = category
self.amount = float(amount)
self.note = note
self.date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
datetime.now() 获取当前时刻,strftime("%Y-%m-%d %H:%M:%S") 格式化成字符串存进 self.date——每条记录自动盖上时间戳,不用手动输入时间。
易错点
- 错误写法:忘了实现
__str__直接print(r)→ 问题:输出内存地址,完全没法看 → 正确写法:任何自定义类都实现__str__。 - 错误写法:手写
for+if+ 累加做统计 → 问题:代码长且容易错 → 正确写法:生成器表达式 +sum()一行搞定。
记忆口诀:str 定门面,打印变人话;生成器一行统计,datetime 自动打戳。
4 持久化与序列化 —— 对象 ↔ 字典 ↔ JSON
是什么?为什么学?
程序关了数据就没了,这不行。用 json 模块把记录列表转成文本存进文件,启动时再读回来。但 JSON 只认 dict/list/str/num,不认对象——所以需要 Record.to_dict()(存之前转字典)和 Record.from_dict()(读出来转回对象),打通「对象 ↔ 字典 ↔ JSON」。
注意事项总结
1 保存和加载的基本姿势:
import json
records = [
{"rtype": "收入", "category": "工资", "amount": 8000, "note": "月薪"},
{"rtype": "支出", "category": "餐饮", "amount": 25.5, "note": "午饭"},
]
# 保存:ensure_ascii=False 让中文正常显示,indent=2 格式化
with open("records.json", "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
# 加载
with open("records.json", "r", encoding="utf-8") as f:
loaded = json.load(f)
print("记录数:", len(loaded))
2 to_dict / from_dict 配合(@classmethod 工厂方法):
import json
class Record:
def __init__(self, rtype, category, amount, note=""):
self.rtype = rtype
self.category = category
self.amount = float(amount)
self.note = note
def to_dict(self):
return {"rtype": self.rtype, "category": self.category,
"amount": self.amount, "note": self.note}
@classmethod
def from_dict(cls, data):
return cls(data["rtype"], data["category"], data["amount"], data.get("note", ""))
records = [Record("收入", "工资", 8000)]
data = [r.to_dict() for r in records]
with open("records.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
with open("records.json", "r", encoding="utf-8") as f:
back = [Record.from_dict(item) for item in json.load(f)]
print(back[0].rtype, back[0].category, back[0].amount)
运行结果:
收入 工资 8000.0
to_dict() 把对象转成字典(存文件前调用);from_dict() 把字典转回对象(加载时调用)。@classmethod 让 from_dict 能不创建实例就通过类调用(Record.from_dict(data)),返回的是 cls(...) 构造的新对象——这就是 Day 18 的「工厂方法」。
易错点
- 错误写法:直接把
Record对象丢给json.dump→ 问题:报TypeError: Object of type Record is not JSON serializable→ 正确写法:先to_dict()转成字典再存。 - 错误写法:读写文件没指定编码 → 问题:中文乱码或报
UnicodeDecodeError→ 正确写法:读写都加encoding="utf-8"。
记忆口诀:JSON 只认字典,对象先 to_dict;读回用 from_dict,工厂方法 @classmethod。
5 动手实践 —— 完整命令行记账本
把四个部分组合成完整程序,直接保存为 account_book.py 运行即可。功能包含:添加收支、查看全部流水、统计余额、按类别筛选、保存到 JSON、导出 CSV、退出时自动保存。
# account_book.py —— 命令行记账本
import csv
import json
import os
from datetime import datetime
DATA_FILE = "records.json"
CATEGORIES = ["餐饮", "交通", "购物", "娱乐", "工资", "其他"]
class Record:
"""一条收支记录"""
def __init__(self, rtype, category, amount, note="", date=None):
if rtype not in ("收入", "支出"):
raise ValueError("类型只能是 收入 或 支出")
self.rtype = rtype
self.category = category
self.amount = float(amount)
self.note = note
self.date = date or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def to_dict(self):
return {
"rtype": self.rtype,
"category": self.category,
"amount": self.amount,
"note": self.note,
"date": self.date,
}
@classmethod
def from_dict(cls, data):
return cls(
data["rtype"],
data["category"],
data["amount"],
data.get("note", ""),
data.get("date", ""),
)
def __str__(self):
symbol = "+" if self.rtype == "收入" else "-"
return f"[{self.date}] {self.rtype} {self.category} {symbol}{self.amount:.2f} {self.note}"
class Book:
"""账本:管理所有收支记录"""
def __init__(self):
self.records = []
def add_record(self, record):
self.records.append(record)
def balance(self):
total = 0.0
for r in self.records:
if r.rtype == "收入":
total += r.amount
else:
total -= r.amount
return total
def filter_by_category(self, category):
return [r for r in self.records if r.category == category]
def save_json(self, path=DATA_FILE):
data = [r.to_dict() for r in self.records]
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"已保存 {len(self.records)} 条记录到 {path}")
def save_csv(self, path="records.csv"):
with open(path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["date", "rtype", "category", "amount", "note"]
)
writer.writeheader()
for r in self.records:
writer.writerow({
"date": r.date,
"rtype": r.rtype,
"category": r.category,
"amount": r.amount,
"note": r.note,
})
print(f"已导出 {len(self.records)} 条记录到 {path}")
def load_json(self, path=DATA_FILE):
if not os.path.exists(path):
print("没有找到存档文件,从空账本开始")
return
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = [Record.from_dict(item) for item in data]
print(f"已加载 {len(self.records)} 条历史记录")
def main():
book = Book()
book.load_json()
while True:
print("\n===== 命令行记账本 =====")
print("1. 添加收入")
print("2. 添加支出")
print("3. 查看全部流水")
print("4. 查看余额")
print("5. 按类别筛选")
print("6. 保存到 JSON")
print("7. 导出 CSV")
print("0. 退出(自动保存)")
choice = input("请选择操作: ").strip()
if choice in ("1", "2"):
rtype = "收入" if choice == "1" else "支出"
category = input(f"类别({','.join(CATEGORIES)}): ").strip()
try:
amount = float(input("金额: ").strip())
except ValueError:
print("金额必须是数字,请重新操作")
continue
note = input("备注(可留空): ").strip()
book.add_record(Record(rtype, category, amount, note))
print("添加成功")
elif choice == "3":
if not book.records:
print("暂无记录")
else:
for r in book.records:
print(r)
elif choice == "4":
print(f"当前余额: {book.balance():.2f}")
elif choice == "5":
category = input(f"输入类别({','.join(CATEGORIES)}): ").strip()
found = book.filter_by_category(category)
if not found:
print("该类别暂无记录")
else:
for r in found:
print(r)
elif choice == "6":
book.save_json()
elif choice == "7":
book.save_csv()
elif choice == "0":
book.save_json()
print("再见,欢迎下次使用")
break
else:
print("无效选择,请重试")
if __name__ == "__main__":
main()
运行一次完整流程(第一次运行):
没有找到存档文件,从空账本开始
===== 命令行记账本 =====
1. 添加收入
2. 添加支出
3. 查看全部流水
4. 查看余额
5. 按类别筛选
6. 保存到 JSON
7. 导出 CSV
0. 退出(自动保存)
请选择操作: 1
类别(餐饮,交通,购物,娱乐,工资,其他): 工资
金额: 8000
备注(可留空): 8月工资
添加成功
请选择操作: 3
[2026-08-03 15:30:00] 收入 工资 +8000.00 8月工资
[2026-08-03 15:32:00] 支出 餐饮 -25.50 午饭
请选择操作: 4
当前余额: 7974.50
请选择操作: 0
已保存 2 条记录到 records.json
再见,欢迎下次使用
再次运行程序会自动加载 records.json,历史记录都在。CSV 导出后可以用 Excel 打开(utf-8-sig 编码保证 Excel 中文不乱码)。
升级挑战:加「删除某条记录」功能(用序号删);加「按月统计」功能(把 Record.date 前 7 位 2026-08 作为月份分组统计)。
6 开发流程 —— 三步搭起记账本
是什么?为什么学?
不要一口气写完 150 行代码——新手最常见的失败方式就是「憋大招」:写了几十行没运行过一次,一运行全是错,根本不知道从哪改起。正确做法是三步走,每步都能运行、都能验证。
注意事项总结
1 第 1 步:搭骨架——先让菜单跑起来:
class Record:
pass
class Book:
pass
def main():
while True:
choice = input("请选择操作(0 退出): ")
if choice == "0":
print("再见")
break
elif choice == "1":
print("(功能开发中)")
else:
print("无效选择,请重试")
if __name__ == "__main__":
main()
pass 是空语句,让类能通过语法检查。这一步的意义:先把「程序的形状」立起来,错误范围最小。
2 第 2 步:填空腹——先做「添加」和「查看」:把 Record 和 Book 的 pass 换成真实逻辑,加上添加、查看、余额三个功能,跑一遍:能记一笔、能看流水、余额算得对。
3 第 3 步:加持久化和防护——保存/加载 + 异常处理:最后补上 save_json / load_json / save_csv,再给金额输入加上 try / except ValueError。这步是「锦上添花 + 加固」。
易错点
- 错误写法:一次写完所有功能再运行 → 问题:出错范围大,难以定位 → 正确写法:每完成一步就跑一次、验证一次——出问题范围小、好定位。
- 错误写法:跳过骨架直接写功能 → 问题:菜单逻辑和功能逻辑混在一起 → 正确写法:先让菜单循环跑通(用 pass 占位),再逐个填空。
记忆口诀:先骨架后填空,再加固;每步都运行,小步快迭代。
今日总结
阶段知识地图:类 → 继承多态 → 魔法方法 → 生成器装饰器 → 标准库 → 第三方库,一环扣一环
需求分析先行:区分「一条记录」(Record)和「整个账本」(Book),职责清晰
__str__ 定打印门面:print(r) 直接输出可读流水;没有它只能看到内存地址
生成器表达式一行统计:sum(r.amount for r in self.records if r.rtype == "支出")
datetime.now().strftime(...) 自动打时间戳,不用手动输入时间
持久化闭环:Record.to_dict()(转字典)→ json.dump 保存 → json.load 加载 → Record.from_dict()(@classmethod 转回对象)
金额输入用 try/except ValueError 拦非法输入,程序不闪退
导出 CSV 用 utf-8-sig 编码,Excel 打开中文不乱码
开发流程:三步走(骨架 → 填空腹 → 加持久化防护),每步都能运行验证
独立完成命令行记账本,测试非法输入、重启加载、CSV 导出等场景
| 报错 | 原因 | 修复 |
|---|---|---|
ValueError: could not convert string to float: 'abc' | 金额输入了非数字 | 用 try/except 包住 float(input(...)),输错提示重来 |
json.decoder.JSONDecodeError | 存档文件被改坏或为空 | 删除 records.json 重新开始;保存时确认用了 json.dump |
| 中文乱码 | 读写文件没指定编码 | 读写都加 encoding="utf-8";导 CSV 用 utf-8-sig |
TypeError: 'float' object is not subscriptable | 把金额当列表用了(如 amount[0]) | 金额是数字,检查是否误用下标 |
| 数据没保存 | 忘了调用 save_json,或退出分支没触发保存 | 在 0 退出分支里调用 book.save_json() |
TypeError: Object of type Record is not JSON serializable | 直接把 Record 对象丢给 json.dump | 先 to_dict() 转成字典再存 |