文章
学习
从 Python 到 Data/AI 一年计划成果第 5 / 10 篇 对应计划

Week 2 学习成果|闭包、decorator 与工程化重构

Week 2 的实际产出:Day 1 参数组合与闭包、Day 2 自定义 module 与 import 机制、Day 3 闭包与作用域深化、Day 4 decorator 简化版、Day 5–6 项目重构(目录拆分、logging、dataclass、type hints)的完整总结与实验记录。

PythonWeek2函数闭包decorator模块import重构dataclass类型标注logging学习成果

本篇对应 Week 2 学习计划,与一年学习计划总纲 Phase I 的 Week 2 · 函数、作用域、闭包与模块化(等级 L2)一致,按总纲 Part II「每周固定执行模板」编排。计划篇保留任务与要求,这里放实际做出来的东西和实验记录。

🎯 本周目标

L1

  • ✅ 已掌握函数参数、作用域、闭包、lambda、import / module / package

L2

  • 🟡 已完成参数组合、闭包、自定义 module;decorator 简化版整理中;项目重构(目录拆分、logging、dataclass、type hints)进行中

L3

  • 总纲本周不设 L3

📚 Core Knowledge

  • 函数进阶:positional / keyword / default 参数、*args / **kwargs、可变默认参数陷阱、lambda
  • 作用域与闭包:LEGB、global / nonlocal、闭包捕获定义时的环境
  • decorator:接受函数、返回新函数的高阶函数,本质依赖闭包
  • 模块与包:module / package / subpackage、三种 import、sys.modules 缓存、sys.path 查找、python -m、循环导入
  • 工程化:分层(storage → core → cli → config)、dataclass、type hints、logging

✍️ Human Core

  • 手写:参数组合(positional / keyword / default)
  • 手写:闭包(make_counter、outer / inner)
  • 手写:decorator 简化版
  • 手写:自定义 module(mypkg/)

🤖 AI Build

  • 将 Week 1 项目重构为 core/ / storage/ / cli/ / config/
  • 加入 dataclass
  • 加入 type hints
  • 加入 logging

🧪 Experiment

Hypothesis

  • 通过目录拆分、dataclass、type hints 与 logging,Week 1 项目能在不改变对外行为的前提下变得可维护、可测试。

Experiment

  • 将 Week 1 的 KV 项目按职责拆为 storage / core / cli / config 四层,接入 logging 与 config.json,补充 dataclass 与类型标注,并用 pytest 验证行为不变。

Result

  • 重构后行为不变,测试通过;basicConfig 实验确认「谁先被 import,谁就决定全局日志格式」——详见 Day 5 实验 A / B。

Conclusion

  • 验证了假设:重构在不改变行为的前提下提升了可维护性;basicConfig 必须只在入口调用一次。

📝 数智工程师考试映射

  • Python 函数、作用域、闭包与 decorator
  • Python 模块与包机制(import、sys.path、sys.modules)
  • 代码组织、分层与工程化重构

✅ Acceptance

总纲 Week 2 验收:

L1

  • 能独立解释:
x = outer()
x()

为什么还能访问 outer() 中定义的变量。

L2

  • 手写参数组合、闭包、decorator 简化版、自定义 module
  • 将 Week 1 项目重构为 core/ / storage/ / cli/ / config/,加入 dataclass、type hints、logging

L3

  • 总纲本周不设 L3

🧠 本周最重要的问题

为什么 x = outer(); x() 还能访问 outer() 中定义的变量?

因为闭包会把“定义时的环境”和函数对象绑在一起:inner 被返回后,它仍持有对 outer 局部作用域(enclosing)的引用,x() 时按 LEGB 先查到 E 层的 n。只要这个函数对象还活着,被它引用的外层变量就不会被回收。

❓ 尚未解决

  • 业务代码手写熟练度不足,需要不看参考从零重写 KVService 与 cli.py

📊 Final Level

L2

Day 1 成果|参数组合与闭包

对应计划:Day 1|函数进阶:参数、作用域、闭包

参数组合

写法说明
positionalf(1, 2) 按位置传
keywordf(a=1, b=2) 按名字传
defaultdef f(a, b=10) 可省略

可变默认参数陷阱:

def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))       # [1]
print(append_to(2))       # [1, 2] ← 不是 [2],默认 list 被共享了
print(append_to(3, []))   # [3]    ← 显式传新 list 才隔离

原因:默认参数只在函数定义时求值一次,target=[] 这个 list 被所有调用共享。修复:target=None,函数内再 if target is None: target = []。

闭包计数器

def make_counter():
    n = 0
    def counter():
        nonlocal n
        n += 1
        return n
    return counter

counter = make_counter()
print(counter())   # 1
print(counter())   # 2
print(counter())   # 3

a = make_counter()
b = make_counter()
print(a(), a())    # 1 2
print(b())         # 1 ← 两个计数器相互独立

关键理解:counter 捕获的是 make_counter 的局部变量 n(enclosing 层),而不是它当时的值。每次调用 make_counter() 都会新建一个独立的 n,所以 a、b 互不影响。nonlocal 让 counter 内部可以修改外层的 n。

验收

  • 默认参数定义时求值一次 → 可变默认值被共享
  • *args 收集位置参数成 tuple;**kwargs 收集关键字参数成 dict
  • 闭包捕获的是变量(环境),不是值(所以要用 nonlocal 修改)
  • “函数是对象”意味着:可赋值、可当参数传、可当返回值

Day 2 成果|自定义 module 与 import 机制

对应计划:Day 2|自定义 module 与包

模块 / 包 / 子包

概念对应什么例子
模块 module一个 .py 文件core.py → 模块 mypkg.core
包 package一个目录,含 __init__.pymypkg/ → 包 mypkg
子包 subpackage包里面的包mypkg/utils/ → 子包 mypkg.utils

实验用的结构:

mypkg/
├── __init__.py
├── core.py
└── utils/
    ├── __init__.py
    └── text.py

一句话:目录 = 包 / 子包;.py 文件 = 模块;点号表示层级。

三种 import 写法

# 1. import x —— 绑定包名
import mypkg.core
mypkg.core.hello()      # 当前命名空间只绑定 mypkg,必须写全 mypkg.core

# 2. from x import y —— 绑定里面的名字
from mypkg import core
core.hello()

from mypkg.core import hello
hello()

# 3. from x import y as z —— 重命名
from mypkg.core import hello as hi
hi()
写法当前绑定谁调用方式
import mypkg.coremypkgmypkg.core.hello()
from mypkg import corecorecore.hello()
from mypkg.core import hellohellohello()
from mypkg.core import hello as hihihi()

__name__ 与 main 守卫

  • 直接运行文件:__name__ == "__main__"
  • 被导入时:__name__ 是模块导入路径,如 "mypkg.core"
if __name__ == "__main__":
    hello()
运行方式core.py 里的 __name__
python mypkg/core.py"__main__"
python -m mypkg.core"__main__"
import mypkg.core"mypkg.core"

python file.py vs python -m pkg.mod

对比项python mypkg/core.pypython -m mypkg.core
sys.path[0]mypkg/当前工作目录
包上下文无有
相对导入失败成功
适合独立脚本包内模块

相对导入 from .utils import text:python mypkg/core.py 报 attempted relative import with no known parent package;python -m mypkg.core 正常。

__init__.py 与 re-export

# mypkg/__init__.py
from .core import hello
# 外部
from mypkg import hello
hello()

好处:对外隐藏内部结构,重构内部文件时不影响外部调用。

import 查找顺序与缓存

  1. sys.modules 缓存 —— 先看这个模块之前有没有被导入过,有就直接用,不再重新执行。
  2. sys.path —— 缓存里没有,就按 sys.path 里的目录顺序查找。

结论:第一次导入会执行模块顶层代码并放入 sys.modules;第二次导入直接复用缓存,不再执行。

循环导入

成因:两个模块在顶层互相 import,其中一个还没执行完,另一个就试图使用它还没定义好的名字。解法:把 import 移到函数内部(延迟导入),或抽公共模块解耦。

重点实验:from x import y 到底绑定了什么?

# counter.py
count = 0

def inc():
    global count
    count += 1
# main.py
import counter
from counter import count

counter.inc()
print(counter.count, count)

输出 1 0。from counter import count 绑定的是导入那一刻 count 指向的整数 0,不是变量名本身;counter.inc() 改的是 counter 模块自己的全局变量。

验收四题

  1. import 同一模块两次,模块代码执行几次? 只执行一次,第二次复用 sys.modules 缓存。
  2. __init__.py 能做什么? 执行包初始化、re-export、定义 __all__、组织公开接口。
  3. python file.py 和 python -m pkg.mod 的区别? 前者当独立脚本,无包上下文;后者当包内模块,有包上下文,相对导入成功。
  4. 循环导入为什么发生?怎么解? 顶层互相导入,一方没执行完另一方就使用它。解法:延迟导入到函数内,或抽公共模块解耦。

Day 3 成果|闭包与作用域深化

对应计划:Day 3|闭包与作用域深化

总纲 Week 2 的验收就落在这一条:

x = outer()
x()

为什么 x() 还能访问 outer() 中定义的变量?

def outer():
    n = 0
    def inner():
        nonlocal n
        n += 1
        return n
    return inner

x = outer()
print(x())   # 1
print(x())   # 2

outer() 返回的是 inner 这个函数对象,它带着 outer 的作用域环境一起被返回(闭包)。x() 执行时按 LEGB 找 n:L 层没有 → E 层(outer 的局部作用域)找到 n。只要 x 还引用着 inner,n 就不会被回收。nonlocal n 让 inner 能修改这个外层变量,而不只是读取。

LEGB 查找顺序

层含义
L Local当前函数内部
E Enclosing外层嵌套函数
G Global模块顶层
B Built-in内置(print、len…)

从 L 往外逐层找,找到即停。

global / nonlocal

关键字声明效果
global xx 属于全局 → 函数内赋值改的是全局变量
nonlocal xx 属于外层函数 → 函数内赋值改的是外层变量

不加声明却赋值,又想读写外层/全局变量 → 报 UnboundLocalError(赋值让名字变局部,局部还没赋值就被读取)。

Day 4 成果|decorator 简化版

对应计划:Day 4|decorator 简化版

def log(func):
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__} args={args} kwargs={kwargs}")
        return func(*args, **kwargs)
    return wrapper

@log
def add(a, b):
    return a + b

print(add(1, 2))

输出:

call add args=(1, 2) kwargs={}
3

关键理解:

  • @log 等价于 add = log(add):把原函数传进 log,拿回被包一层的新函数。
  • wrapper 用 *args / **kwargs 透传任意参数,这样装饰器对任何签名都通用。
  • 装饰器本质是闭包:wrapper 记住了外层的 func,调用时先执行附加逻辑再执行原函数。
  • 简化版故意没用 functools.wraps,所以 add.__name__ 会变成 "wrapper"——这正是“简化”的代价,正式版需要 wraps 保留元信息。

Day 5 成果|L2 重构:目录拆分与 logging

对应计划:Day 5|L2 重构(一):目录拆分 + logging

目录结构

kv_project/
├── app/
│   ├── __init__.py
│   ├── storage/
│   │   ├── __init__.py
│   │   ├── list_store.py     # ListKeyValueStore
│   │   └── dict_store.py     # DictKeyValueStore
│   ├── core/
│   │   ├── __init__.py
│   │   └── kv_service.py     # KVService
│   ├── cli.py                # main(),程序入口
│   └── config.py             # AppConfig + load_config()
├── tests/
│   ├── __init__.py
│   ├── test_list_store.py
│   └── test_dict_store.py
├── config.json
└── .venv/

四层职责

层文件职责例子
storagelist_store.py / dict_store.py只管数据怎么存、怎么取set / get / delete / exists / keys
corekv_service.py业务逻辑,调 storage容量限制:满了拒绝
clicli.py用户入口,解析命令,组装一切set name alice
configconfig.py读 JSON → AppConfigcapacity=100

依赖方向:

cli → core → storage
config ← 谁需要谁读(实际只有 cli 读)

关键点:core 不读配置(配置值由 cli 读好后传进去);core 不知道底层是 list 还是 dict,只调 store.set / store.get——这叫依赖注入。

配置分离

config.json 只放值:

{
  "storage_backend": "dict",
  "capacity": 100,
  "log_level": "INFO"
}

config.py 放“怎么读”:

import json
from dataclasses import dataclass


@dataclass
class AppConfig:
    storage_backend: str = "dict"
    capacity: int = 100
    log_level: str = "INFO"


def load_config(path: str = "config.json") -> AppConfig:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    return AppConfig(**data)

转换链路:config.json → json.load() → dict → AppConfig(**data) → AppConfig 实例。

logging

# 1. 每个模块顶部
import logging
logger = logging.getLogger(__name__)

# 2. 打日志
logger.debug("细节")
logger.info("正常流程")
logger.warning("警告")
logger.error("出错")

# 3. 入口只配一次
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(name)s %(levelname)s %(message)s",
)

日志级别:DEBUG < INFO < WARNING < ERROR < CRITICAL。

basicConfig 铁律:basicConfig() 只能由应用入口调一次。底层模块只 getLogger,绝不 basicConfig。

实验 A:在 dict_store.py 里也加 basicConfig(STORAGE: ...)

结果格式变成 STORAGE: ...、级别变成 DEBUG,连 cli.py 的日志也被改成 STORAGE: 格式。原因:python -m app.cli 先从 cli.py 顶部 import app.storage.dict_store,dict_store.py 的 basicConfig 抢先生效,main() 里的 basicConfig 被静默忽略。

实验 B:删掉 storage 里的 basicConfig —— 恢复成 cli.py 配的格式。

结论:谁先被 import,谁就决定了全局日志格式和级别;入口想统一配置,就会被架空。

测试

uv run pytest -v

结果:10 passed in 0.02s。重构没有改变原有行为。

Day 6 成果|L2 重构:dataclass 与 type hints

对应计划:Day 6|L2 重构(二):dataclass 与 type hints

@dataclass 是什么

标准库 dataclasses 里的装饰器,自动为“数据类”生成样板方法。

from dataclasses import dataclass, field

@dataclass
class Record:
    id: int
    name: str
    tags: list[str] = field(default_factory=list)
    created_at: str = ""
方法何时生成
__init__默认
__repr__默认
__eq__默认
__setattr__ / __delattr__仅 frozen=True 时

原理:@dataclass 读类里的类型标注(存在 __annotations__),据此生成 __init__ 等方法。

默认值规则

类型写法
不可变(int / str / bool)count: int = 0
可变(list / dict / set)tags: list[str] = field(default_factory=list)

原因:可变默认值会被所有实例共享,和 Day 1 的函数默认参数是同一个坑。

frozen=True 与 __post_init__

@dataclass(frozen=True)
class Item:
    name: str
  • 实例创建后字段不可改,赋值抛 FrozenInstanceError
  • __post_init__ 在 __init__ 执行完后自动调用,用来做数据校验:
def __post_init__(self):
    if self.price < 0:
        raise ValueError("price 不能为负数")

类型标注

运行时不做检查,只是“说明”,给编辑器、mypy、AI、未来的你看。

写法意思
int / str / bool基本类型
list[int]整数列表
dict[str, int]键 str、值 int
Optional[int]int 或 None
Union[int, str]int 或 str(3.10+ 可写 int | str)
Callable[[int], str]接收 int、返回 str 的函数

用在哪:

x: Optional[int] = None            # 变量
def f(a: int, b: str) -> bool: ... # 参数 + 返回值
def g() -> Optional[Record]: ...   # 返回值可能是 None
name: Optional[str] = None         # dataclass 字段

mypy

静态类型检查工具,运行前检查,不执行代码。能查参数 / 返回值类型不对、Optional 没判空就用、访问不存在的属性等;不能查具体值是否合理、业务逻辑错误。

本周为 storage、core 的公开函数补全类型标注后运行 mypy 作为重构的验证手段。

来源与延伸阅读

  1. 01Week 1 学习计划|Python 对象模型与数据结构week-1计划
  2. 02一年学习计划:从 Python 到 Data/AI 系统的 52 周计划
  3. 03Week 1 学习成果|复杂度实测、对象模型与数据结构实战week-1成果
  4. 04Week 2 学习计划|Python 函数、作用域、闭包与模块化week-2计划
  5. 05Week 2 学习成果|闭包、decorator 与工程化重构week-2成果
  6. 06Week 3 学习计划|异常、测试与 Debuggingweek-3计划
  7. 07Week 3 学习成果|异常、traceback 与 Debug 起步week-3成果
  8. 08Week 4 学习成果|Big-O 与四类基础算法week-4成果
  9. 09Week 5 学习成果|Stack、Queue 与 MinHeapweek-5成果
  10. 10Week 6 学习成果|图算法与 Python Engineering Toolkitweek-6成果