用 Python 学计算机编程:编写你的第一个实用小程序

用 Python 学计算机编程:编写你的第一个实用小程序

用 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 小程序。试着先用自己的非敏感样例数据运行,再故意加入错误日期、空类别和不合法金额,观察程序如何响应。能够说明程序适用于什么、遇到什么会拒绝,以及它没有解决什么问题,就是从“让代码运行”迈向“写出可用程序”的重要一步。

© 版权声明

相关文章