
用 Python 学计算机编程:编写你的第一个实用小程序
学习编程不必从复杂的算法题开始。一个更容易获得反馈的办法,是先解决身边的小问题:比如每个月的支出分散在一张表里,想快速知道各类别花了多少钱。这篇教程将用 Python 编写一个命令行程序,读取 CSV 记账文件,统计总支出和类别支出,并支持按月份筛选。
程序只使用 Python 标准库,不需要安装第三方包。完成后,你会接触到变量、函数、循环、条件判断、文件读取、异常处理和命令行参数等基础概念。更重要的是,你会看到一个小程序需要明确哪些输入条件、怎样处理坏数据,以及它不适合被当成什么工具。
这个程序适合做什么
如果你能把支出记录保存为 CSV 文件,就可以用它快速生成简单汇总。例如,给定一份包含日期、类别、金额和备注的记录,程序可以回答:
- 记录中的支出合计是多少?
- 餐饮、交通等类别分别花了多少?
- 某个月的支出合计和分类结果是什么?
它适合个人学习、整理少量本地记录,或作为后续扩展的起点。它不是完整记账软件:没有账户、收入、退款、预算、汇率或图形界面,也不会校验账目是否真实。程序的统计结果只反映文件中符合规则的记录。
准备 Python 和工作目录
先确认系统中有 Python 3。打开终端或命令提示符,运行:
python --version
部分系统使用 python3 命令:
python3 --version
如果显示 Python 3.x 版本,就可以继续。不同操作系统安装方式不同;安装时如果有“将 Python 添加到 PATH”选项,通常可以勾选。下面的代码使用 Python 3 的标准库接口,不需要通过 pip 安装依赖。
新建一个工作目录,例如 python_expense。在其中创建两个文件:expenses.csv 和稍后要编写的 expense_report.py。CSV 是一种普通文本表格格式,一行一条记录,字段之间用逗号分隔。
先准备一份输入文件
把下面内容保存为 expenses.csv。建议使用 UTF-8 编码;用电子表格软件另存为 CSV 时,也要留意它选择的编码和分隔符。
date,category,amount,description
2025-03-02,餐饮,36.50,午餐
2025-03-03,交通,8.00,公交
2025-03-05,餐饮,52.00,晚餐
2025-04-01,购物,120.00,日用品
程序要求表头包含 date、category 和 amount 三列。description 是可选的,程序不会读取或输出备注。日期使用 YYYY-MM-DD 格式,金额使用非负数字,最多两位小数。这里把一笔支出记作正数;如果你的文件用负数表示支出,就需要先转换规则,不能直接套用这个程序的统计含义。
编写程序
将以下完整代码保存为 expense_report.py。代码中的错误提示会指出文件行号;遇到格式不合规的记录时,程序会跳过该行并继续处理其他记录。
import argparse
import csv
import json
import sys
from collections import defaultdict
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
REQUIRED_COLUMNS = {"date", "category", "amount"}
CENT = Decimal("0.01")
def parse_month(value):
"""检查月份参数是否符合 YYYY-MM 格式。"""
try:
parsed = datetime.strptime(value, "%Y-%m")
except ValueError as exc:
raise argparse.ArgumentTypeError(
"月份必须使用 YYYY-MM 格式,例如 2025-03"
) from exc
if parsed.strftime("%Y-%m") != value:
raise argparse.ArgumentTypeError(
"月份必须使用 YYYY-MM 格式,例如 2025-03"
)
return value
def parse_date(value):
"""解析严格的 YYYY-MM-DD 日期。"""
parsed = datetime.strptime(value, "%Y-%m-%d")
if parsed.strftime("%Y-%m-%d") != value:
raise ValueError("日期必须使用 YYYY-MM-DD 格式")
return parsed.date()
def load_expenses(csv_path):
"""读取文件,返回有效记录及错误行数。"""
totals_by_category = defaultdict(Decimal)
total = Decimal("0")
invalid_rows = 0
with csv_path.open(
"r", encoding="utf-8-sig", newline=""
) as csv_file:
reader = csv.DictReader(csv_file)
if reader.fieldnames is None:
raise ValueError("文件为空,或缺少 CSV 表头")
columns = set(reader.fieldnames)
missing = REQUIRED_COLUMNS - columns
if missing:
names = ", ".join(sorted(missing))
raise ValueError(f"CSV 缺少必需列:{names}")
for row in reader:
line_number = reader.line_num
try:
date_text = (row.get("date") or "").strip()
category = (row.get("category") or "").strip()
amount_text = (row.get("amount") or "").strip()
if not date_text:
raise ValueError("日期不能为空")
if not category:
raise ValueError("类别不能为空")
if not amount_text:
raise ValueError("金额不能为空")
expense_date = parse_date(date_text)
try:
amount = Decimal(amount_text)
except InvalidOperation as exc:
raise ValueError("金额不是有效数字") from exc
if not amount.is_finite():
raise ValueError("金额必须是有限数字")
if amount < 0:
raise ValueError("金额不能为负数")
if amount != amount.quantize(CENT):
raise ValueError("金额最多只能有两位小数")
yield expense_date, category, amount
except (ValueError, InvalidOperation) as exc:
invalid_rows += 1
print(
f"警告:第 {line_number} 行已跳过:{exc}",
file=sys.stderr,
)
return invalid_rows
def main():
parser = argparse.ArgumentParser(
description="汇总 CSV 文件中的支出,可选择按月份筛选。"
)
parser.add_argument("csv_file", help="CSV 文件路径")
parser.add_argument(
"--month",
type=parse_month,
help="只统计指定月份,例如 2025-03",
)
args = parser.parse_args()
csv_path = Path(args.csv_file)
try:
total = Decimal("0")
totals_by_category = defaultdict(Decimal)
invalid_rows = 0
for expense_date, category, amount in load_expenses(csv_path):
if args.month and expense_date.strftime("%Y-%m") != args.month:
continue
total += amount
totals_by_category[category] += amount
except (OSError, UnicodeError, csv.Error, ValueError) as exc:
print(f"无法处理文件:{exc}", file=sys.stderr)
return 1
if args.month:
print(f"月份:{args.month}")
else:
print("月份:全部")
print(f"支出合计:{total:.2f}")
print("类别汇总:")
if not totals_by_category:
print(" (没有符合条件的有效记录)")
else:
for category in sorted(totals_by_category):
# 使用 JSON 字符串形式显示类别,避免控制字符直接输出到终端。
safe_category = json.dumps(category, ensure_ascii=False)
print(f" {safe_category}: {totals_by_category[category]:.2f}")
if invalid_rows:
print(f"无效记录:{invalid_rows} 行(详情见警告)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
理解程序的几个关键部分
1. 用模块完成专门的工作
csv 负责读取 CSV,argparse 负责解析命令行参数,datetime 用于解析日期,Decimal 用于金额计算。标准库模块已经随 Python 提供,不需要额外安装。
金额使用 Decimal,而不是常见的浮点数 float。浮点数以二进制近似表示很多十进制小数,在精确金额计算中可能出现不符合直觉的结果。Decimal 更适合这种场景,但它也不会自动替你定义记账、舍入、退款等业务规则;这里采取的是“最多两位小数,否则拒绝”的明确规则。
2. 用函数分开职责
parse_month 检查筛选月份,parse_date 验证记录日期,load_expenses 读取和校验 CSV,main 负责组织参数、筛选和输出。拆成小函数后,发现问题时更容易判断发生在输入校验、文件处理还是统计逻辑中。
defaultdict(Decimal) 是一个按类别保存金额的字典。遇到新的类别时,它会自动以零作为初始金额,于是可以直接累加:totals_by_category[category] += amount。
3. 为什么读到无效行会跳过
程序把空日期、空类别、非法日期、负数金额等视为单行数据错误,输出警告后继续。这样,一行录入错误不会让整份文件都无法统计。文件打不开、编码无法解码、CSV 结构错误或缺少必要表头,则属于无法正常处理输入文件的错误,会以非零退出状态结束。
要注意,“跳过”不代表错误记录可以忽略。汇总结果可能因此少算。实际使用前应查看警告和无效记录数;如果完整性比便利更重要,可以把策略改为遇到第一条错误就停止,而不是继续统计。
代码通过 utf-8-sig 读取 UTF-8 文件,并兼容文件开头带有 UTF-8 BOM 的情况。它不会自动猜测任意编码。如果文件实际是其他编码,程序会报编码错误,而不是保证能正确读出内容。
运行程序
在终端中进入保存文件的目录。macOS 或 Linux 常见命令如下:
python3 expense_report.py expenses.csv
Windows 上如果 python3 不可用,可以尝试:
python expense_report.py expenses.csv
对上面的示例文件,输出大致如下:
月份:全部
支出合计:216.50
类别汇总:
"交通": 8.00
"购物": 120.00
"餐饮": 88.50
统计某一个月时,添加 --month 参数:
python expense_report.py expenses.csv --month 2025-03
此时只统计日期落在 2025 年 3 月的记录。如果把月份写成 2025-13 或 2025/03,程序会在参数解析阶段提示格式错误。月份筛选只接受 YYYY-MM 格式。
文件路径既可以是相对路径,也可以是绝对路径。例如:
python expense_report.py ./data/expenses.csv
python expense_report.py "/Users/alex/Documents/monthly expenses.csv"
路径中有空格时,通常要用引号括起来。命令提示符、PowerShell、macOS 和 Linux 终端对引号与路径的处理存在差异,遇到问题时先确认当前目录和文件名是否正确。
用一份错误数据检查边界
可以复制一份 CSV,加入几种不合规记录,确认程序的反应。例如:
date,category,amount,description
2025-03-02,餐饮,36.50,正常记录
2025-02-30,交通,8.00,无效日期
2025-03-05,购物,-2.00,负数金额
2025-03-06,,12.00,类别为空
2025-03-07,其他,1.234,小数位过多
正常记录会计入合计,其余行会显示警告并被跳过。若表头把 amount 拼错,程序会指出缺少必需列,而不会把错误表格误当成有效输入。
空白行可能被 CSV 读取器作为空记录处理。此时必需字段为空,程序会给出警告并跳过。这是有意采取的保守做法;如果你的数据中空白行很多,可以进一步调整代码,让它在检测到整行为空时不输出警告。
参数与数据限制
- 列名:必需列名严格为
date、category、amount,区分大小写。程序没有提供自定义列名参数。 - 日期:使用真实有效的
YYYY-MM-DD日期。日期与月份都用固定格式校验;程序不会识别“昨天”或其他自然语言日期。 - 金额:必须是有限、非负的十进制数,最多两位小数。负数和超过两位小数的数会被拒绝;程序不会自动舍入,也不会处理货币符号、千位分隔符或货币单位换算。
- 货币:输入文件中的金额会直接相加。若文件混有人民币、美元等不同货币,结果没有实际意义;程序不会检测币种。
- 类别:类别文本按原样统计,仅去除首尾空格。大小写不同、繁简不同或写法不同的类别会被当成不同类别。
- 记录规模:程序适合普通个人 CSV 文件。它没有为大型数据仓库或超大文件设计性能、并发和恢复机制;CSV 字段大小也受 Python CSV 模块自身限制。
上述限制不是 Python 的全部能力,而是这个示例程序主动选择的输入契约。明确规则可以避免程序悄悄猜测数据含义,也能让使用者知道统计结果的适用范围。
安全与隐私:它能做什么,不能保证什么
程序只打开你指定的 CSV 文件,读取内容并在终端打印汇总;示例代码没有网络请求、文件删除或修改输入文件的操作。备注列不会输出,类别输出时也会转义控制字符,避免把其中的控制字符直接写入终端。
但这不等于可以把它称为经过安全审计的金融软件。运行 Python 程序仍然需要信任程序代码和运行环境。请不要把来源不明的脚本直接运行在包含敏感文件的账户下,也不要仅凭汇总结果作重要财务决策。程序不会加密输入文件,不提供访问控制、备份、审计记录或防篡改能力。
文件若存放在同步盘、共享目录或备份服务中,可能会被相应服务上传或共享;“程序本身没有联网”不意味着文件所在环境完全离线。包含账户、身份或交易信息的数据,应根据自己的隐私要求选择存储位置,并避免将真实敏感数据上传到不可信的在线工具或代码仓库。
这里没有把 CSV 内容拼进命令执行,也没有把数据写回 CSV,因此不涉及由本程序生成表格时常见的电子表格公式注入问题。如果以后添加“导出分类汇总为 CSV”功能,类别文本可能被电子表格软件解释为公式;届时需要单独设计导出时的转义策略。不能因为当前程序没有导出,就推断任何 CSV 输入输出场景都天然安全。
常见错误与排查办法
提示找不到文件
检查终端当前所在目录、文件名和扩展名。某些系统会隐藏扩展名,实际文件可能是 expenses.csv.txt。可以先运行目录列表命令确认文件是否存在,或直接传入绝对路径。
提示缺少必需列
检查第一行表头是否拼写正确,是否多了空格,以及 CSV 的分隔符是否为逗号。某些地区设置或电子表格软件会使用分号作为分隔符;本程序按标准逗号分隔读取,遇到分号文件时可能只看到一个表头字段。需要这类输入时,应明确调整 CSV 读取参数,而不是随意改动数据后假定结果正确。
出现编码错误或乱码
把文件另存为 UTF-8 编码后再试。程序兼容带 BOM 的 UTF-8 文件,但不负责猜测并转换任意编码。若使用表格软件导出,确认其导出选项,避免只根据文件扩展名判断编码。
金额明明有数字,却被拒绝
检查金额是否包含货币符号、逗号、空格或超过两位小数。例如 ¥36.50 和 1,200.00 不符合当前格式;可先在数据源中统一为不带货币符号和千位分隔符的数字。逗号本身是 CSV 分隔符,字段内如果确实需要逗号,必须按 CSV 规则正确引用。
统计结果和预期不一致
先看警告和无效记录数,再检查日期范围、类别的空格和写法,以及金额的正负约定。程序筛选月份使用日期字段,不会根据文件名或备注判断月份。不同币种、退款和收入也不会自动区别处理。
可以怎样扩展
当基础版本运行正常后,可以逐步增加功能,但每次只改变一件事并重新检查边界:
- 增加
--category参数,只统计指定类别,并明确类别比较是否区分大小写。 - 支持收入与支出类型,为数据表增加必需的
type列,避免仅靠金额正负猜测记录含义。 - 输出每月汇总或排序结果,并为排序、空文件和重复记录编写测试。
- 生成报告文件时,采用明确的输出编码和文件覆盖策略;导出给电子表格的软件时,还要考虑文本单元格的安全处理。
扩展前先写清楚规则,通常比立刻添加代码更省时间。例如,“退款算支出还是冲减原支出”“空类别是否允许”“输入重复记录怎么办”,都没有唯一答案,取决于使用场景。程序应执行明确约定,而不是替使用者猜。
从这个小程序学到什么
这个例子展示了编程的一个基本工作方式:把需求拆成输入、校验、处理和输出。先定义 CSV 结构与金额规则,再读取数据,逐行处理异常,最后生成统计结果。错误处理不是附加装饰,而是程序行为的一部分;参数和边界也不是文档里的小字,而是结果是否可信的前提。
你现在已经有一个可以直接运行的 Python 小程序。试着先用自己的非敏感样例数据运行,再故意加入错误日期、空类别和不合法金额,观察程序如何响应。能够说明程序适用于什么、遇到什么会拒绝,以及它没有解决什么问题,就是从“让代码运行”迈向“写出可用程序”的重要一步。









