Python 现代开发学习笔记

(十八)综合示例:可以导入的阅读统计包

1. 任务、数据流与文件职责

任务(自拟):从启动参数接收每天的非负整数分钟数,输出统计天数、总时长、平均时长、达标天数和最长时长。--goal 设置非负整数目标,默认 30;--only-met 表示先保留达标记录,再统计保留下来的数据。没有选中记录时,各项计数与总量为 0,平均值显示为 0.00。

先用 20、35、0、45 手算:全部保留时,总量 100、条数 4、平均 25.00、达标 2、最长 45;打开 --only-met 后,剩下 35、45,总量 80、条数 2、平均 40.00、达标 2、最长 45。

把目录保存为 reading_project:

reading_project/
└── readingkit/
    ├── __init__.py
    ├── __main__.py
    ├── cli.py
    └── core.py

core 负责筛选和统计,cli 负责启动参数和输出,__main__ 负责调用入口。cli 可以导入 core,计算模块不需要反过来导入命令行入口。

参数解析、生成器筛选与单次遍历统计的配合

图中的筛选与统计沿同一来源前进。统计函数每请求下一项,筛选函数才继续读取输入;筛选掉 20 和 0 后,统计状态只更新两次。

2. 计算模块:筛选与统计

# 项目文件:reading_project/readingkit/core.py
# Iterable 表示可遍历的输入,Iterator 表示函数调用返回的逐项来源。
from collections.abc import Iterable, Iterator

type Summary = dict[str, int | float]


def select_minutes(
    values: Iterable[int], *, goal: int = 30, only_met: bool = False
) -> Iterator[int]:
    # 关闭筛选时交出全部数据,打开时只交出达标项。
    for minutes in values:
        if not only_met or minutes >= goal:
            yield minutes


def summarize(values: Iterable[int], *, goal: int = 30) -> Summary:
    """在一次遍历中生成阅读统计。"""
    count = total = completed = longest = 0
    for minutes in values:
        count += 1
        total += minutes
        if minutes >= goal:
            completed += 1
        longest = max(longest, minutes)
    # 空来源也直接形成一份完整报告,显示层不需要补齐字段。
    average = total / count if count else 0.0
    return {
        "count": count,
        "total": total,
        "average": average,
        "completed": completed,
        "longest": longest,
    }

select_minutes 不建立筛选结果列表,summarize 不会先用 sum 消耗来源、再重新遍历。四个累计状态与平均值已经对应第二章的手算表;函数包装没有改变统计规则,只把数据交入和结果交出的地方写明确。

从这一接口看,Summary 是字符串键、整数或浮点值的字典。每个键的具体含义由返回内容说明;后面的面向对象章节会用数据类把一条报告的各字段集中起来。

3. 命令行模块与包入口

# 项目文件:reading_project/readingkit/cli.py
# 命令行层依赖计算层;计算层不依赖输入方式。
import argparse

from .core import select_minutes, summarize


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="阅读时长统计")
    parser.add_argument("minutes", nargs="*", type=int, help="每天的分钟数")
    parser.add_argument("--goal", type=int, default=30, help="达标分钟数")
    parser.add_argument("--only-met", action="store_true", help="只统计达标记录")
    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()
    # 解析器负责把错误反馈给命令行用户;下面继续使用转换后的整数。
    if args.goal < 0:
        parser.error("目标分钟数不能为负数")
    if any(minutes < 0 for minutes in args.minutes):
        parser.error("每天的分钟数不能为负数")

    selected = select_minutes(
        args.minutes, goal=args.goal, only_met=args.only_met
    )
    report = summarize(selected, goal=args.goal)
    print("统计天数", report["count"])
    print("总时长", report["total"])
    print(f"平均时长 {report['average']:.2f}")
    print("达标天数", report["completed"])
    print("最长时长", report["longest"])

build_parser 返回解析器,让配置启动规则与实际执行分开。main() -> None 表示入口通过显示结果完成工作,不返回一份报告;真正要复用报告时,直接调用 summarize。

# 项目文件:reading_project/readingkit/__init__.py
# 导入 readingkit 时只建立包,启动动作由 __main__ 承担。
"""阅读时长统计包。"""
# 项目文件:reading_project/readingkit/__main__.py
# 包入口只调用命令行层,不重复计算或解析逻辑。
from .cli import main

if __name__ == "__main__":
    main()

4. 运行、复用与工作量

在 reading_project 文件夹中,用第一章准备的 Python 3.12 环境执行:

# 先统计全部四天,再只统计达标记录,比较相同数据的两种结果。
python -m readingkit 20 35 0 45
python -m readingkit 20 35 0 45 --only-met
# 调高目标,或查看解析器生成的帮助。
python -m readingkit 20 35 0 45 --goal 40
python -m readingkit --help

第一条输出:

统计天数 4
总时长 100
平均时长 25.00
达标天数 2
最长时长 45

第二条输出:

统计天数 2
总时长 80
平均时长 40.00
达标天数 2
最长时长 45

在同一目录还可以另建 use_core.py,只导入计算函数:

# 项目文件:reading_project/use_core.py
# 导入计算模块不会解析启动参数,也不会主动显示命令行报告。
from readingkit.core import summarize

report = summarize(iter([20, 35, 0, 45]))
print(report["total"], report["completed"])

执行 python use_core.py 得到 100 2。后面从文件、数据库或网页取得数据,都可以继续调用计算函数,不必同时启动命令行。

设有 n 条输入,解析、检查与筛选分别线性读取,合计工作量仍是 O(n)O(n)。命令行解析器保存的分钟数列表占 O(n)O(n) 空间;筛选生成器和统计函数自身只额外保存当前进度与几个累计值,为 O(1)O(1)。逐项处理减少的是中间结果存储,整条命令并没有因此变成常数空间。

笔记正文与全部题解 · Ctrl / ⌘ K