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

Week 2 学习成果|模块机制、异常体系与工程化重构

Week 2 的实际产出:Day 2 模块与 import 机制、Day 3 异常处理的完整总结与实验记录。

PythonWeek2模块import异常处理EAFP自定义异常学习成果

本篇是 Week 2 的学习成果,与 学习计划 分开成文:计划篇保留当时的任务与要求,这里放实际做出来的东西和实验记录。目前有书面成果的是 Day 2(模块与 import 机制)和 Day 3(异常处理),Day 4–7 完成后再补进来。

成果Day 2 模块与包Day 3 异常处理

Day 2 成果|模块与包:import 机制

对应计划Day 2|模块与包:import 机制

模块 / 包 / 子包

概念对应什么例子
模块 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

mypkg 是顶层包,mypkg.core 是模块,mypkg.utils 是子包,mypkg.utils.text 是子包里的模块。

一句话:目录 = 包 / 子包;.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()            # 当前命名空间绑定 core

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__

  • 直接运行文件:__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
__name__"__main__""__main__"
sys.path[0]mypkg/当前工作目录
包上下文
相对导入失败成功
适合独立脚本包内模块

相对导入实验:

from .utils import text
  • python mypkg/core.py → 报错 attempted relative import with no known parent package
  • python -m mypkg.core → 正常

原因:python file.py 把文件当独立脚本,没有父包;python -m pkg.mod 把模块放在包上下文里运行,有父包。

__init__.py 与 re-export

__init__.py 的作用:

  • 导入包时执行初始化代码
  • 准备包对象上要暴露的名字
  • re-export,让外部可以直接 from mypkg import hello
  • 定义 __all__,控制 from mypkg import *
  • 组织公开接口,隐藏内部结构
# mypkg/__init__.py
from .core import hello
# 外部
from mypkg import hello
hello()

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

import 查找顺序与缓存

导入时的顺序:

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

sys.path 通常包括:当前目录或脚本目录、PYTHONPATH 里的目录、Python 标准库目录、site-packages 第三方库目录。

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

循环导入

成因:两个模块在顶层互相 import,其中一个还没执行完,另一个就试图使用它还没定义好的名字。

# a.py
import b

def a_func():
    print("a_func")
# b.py
import a

def b_func():
    print("b_func")

a.a_func()   # 此时 a 可能还没定义完 a_func

常见解法:

  • 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

解释:

  • import counter 后,counter.count 是 0。
  • from counter import countcounter.count 当时指向的整数 0 绑定到 main.pycount
  • counter.inc() 修改的是 counter 模块自己的全局变量 count,使其变成 1。
  • main.py 里的 count 仍指向旧整数 0

结论:from x import y 绑定的是导入那一刻 y 指向的对象,不是变量名本身。整数不可变,重新赋值不会影响已绑定的名字。

验收四题

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

Day 2 一句话总结:模块是 .py 文件,包是含 __init__.py 的目录。import 先查 sys.modules 缓存,再按 sys.path 查找。from x import y 绑定的是对象,不是变量名。直接运行用 main 守卫;包内模块用 python -m 跑,相对导入才有效。循环导入靠延迟导入或解耦解决。

Day 3 成果|异常处理

对应计划Day 3|异常处理:健壮性基础

基本结构:try / except / else / finally

try:
    ...        # 可能出错的代码
except ValueError:
    ...        # 捕获并处理 ValueError
else:
    ...        # try 中没有异常时执行
finally:
    ...        # 无论有没有异常,最后都执行
情况执行顺序
try 成功try → else → finally
try 失败并被捕获try → except → finally
try 失败但没被捕获try → finally,异常继续往上抛

关键点:

  • else:只有 try 没抛异常时才执行。
  • finally:无论成功、失败、return,都会执行。
  • finally 里的 return 会覆盖 try 里的 return,所以不要在 finally 里随便写 return

except 匹配规则

  • except 从上往下匹配,第一个匹配上的就执行。
  • 父类可以捕获子类,所以子类异常要写在父类异常前面。
BaseException
 ├── KeyboardInterrupt
 ├── SystemExit
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── TypeError
      ├── KeyError
      ├── OSError
      └── ...

except: vs except Exception:

写法捕获范围危险点
except Exception:Exception 及其子类不抓 KeyboardInterruptSystemExit
except:所有 BaseException 子类会抓 Ctrl+C、sys.exit(),程序可能停不下来

结论:裸 except: 更危险,因为它连退出信号都吞。

raise 与异常链

raise ValueError("值不对")        # 主动抛异常
except ValueError:
    ...
    raise                         # 重新抛出当前异常
try:
    int("abc")
except ValueError as e:
    raise RuntimeError("配置解析失败") from e

traceback 会出现:

The above exception was the direct cause of the following exception:

含义:对外暴露 RuntimeError("配置解析失败"),对内保留 ValueError 作为直接原因。

一句话:raise ... from e = 上层业务异常包装 + 保留底层技术根因。

自定义异常

class AppError(Exception):
    pass

class ConfigError(AppError):
    pass

class DataError(AppError):
    pass
Exception
 └── AppError
      ├── ConfigError
      └── DataError

作用:按业务语义区分错误类型、让调用方精准捕获、可以附加额外信息。

值得自定义的场景:需要区分「配置错误 / 数据错误 / 网络错误」等业务类型;需要让上层只捕获 AppError;需要给异常附加字段,比如 keypath

不值得的场景:

class MyError(ValueError):
    pass

只换名字、不加信息、不做层级区分,就不值得。

EAFP vs LBYL

LBYL(先检查,再操作):

if key in config:
    return config[key]
else:
    return "default"

EAFP(先操作,出错再处理):

try:
    return config[key]
except KeyError:
    return "default"

EAFP 好处:不用自己写复杂预检查;代码更短、更直接;避免检查和操作之间状态变化。

EAFP 风险:try 范围太大,出错难定位;except 太宽,容易吞掉不该吞的异常;捕获后不处理,程序带伤继续跑。

一句话:EAFP 省去预检查,靠异常处理;但 try 要小,except 要具体。

反模式

except:

try:
    do_something()
except:
    pass

危险:会吞掉 KeyboardInterruptSystemExit

吞异常不记录:

try:
    do_something()
except Exception:
    pass

危险:错误被隐藏,调试无线索。

正确做法:

except Exception:
    logging.exception("出错了")
    raise

或者只捕获具体异常,并明确处理。

今天完成的实验

实验 1:parse_int(s)

处理了 int("abc")ValueErrorint(None)TypeError;成功时走 else 并返回结果,出错时打印提示,隐式返回 None

实验 2:try / else / finally

观察到成功时 try → else → finally,失败时 try → except → finally

实验 3:自定义异常层级

class AppError(Exception): pass
class ConfigError(AppError): pass
class DataError(AppError): pass

验证了 except AppError 能捕获 ConfigError

实验 4:LBYL 改 EAFP

def get_config_EAFP(config, key):
    try:
        return config[key]
    except KeyError:
        return "default"

重点实验:raise ... from ...

观察到两个异常同时出现在 traceback 中,以及:

The above exception was the direct cause of the following exception:

附加实验:finally 里的 return

def f():
    try:
        return "try"
    finally:
        return "finally"

最终返回 "finally",说明 finally 里的 return 会覆盖 try 里的 return

验收四题

  1. except Exception 和裸 except: 的区别? except Exception 只抓普通异常;裸 except:KeyboardInterruptSystemExit 都抓,更危险。
  2. else 什么时候执行?finally 呢? else 在 try 无异常时执行;finally 无论有没有异常都执行。
  3. 什么时候值得自定义异常类? 需要按业务语义区分错误、精准捕获、附加额外信息时。
  4. EAFP 是什么?相比 LBYL 的好处和风险? EAFP 是先做,出错再处理。好处是省去复杂预检查;风险是 try 和 except 范围失控会吞错。

Day 3 一句话总结:try 放可能出错的代码,except 处理已知错误,else 走成功分支,finally 做兜底清理。异常要么明确处理,要么记录后 raise,不要吞。需要分层时用 raise ... from e。优先 EAFP,但 try 要小,except 要具体。

来源与延伸阅读

  1. 01Week 1 学习计划|Python 对象模型与数据结构week-1计划
  2. 02学习计划:从 Python开始到认识Data/AI计划
  3. 03Week 1 学习成果|复杂度实测、对象模型与数据结构实战week-1成果
  4. 04Week 2 学习计划|Python 函数、抽象与模块化week-2计划
  5. 05Week 2 学习成果|模块机制、异常体系与工程化重构week-2成果