Python 编程基础与工程实践(六):异常、资源与模块系统

可靠程序不是“从不出错”,而是能区分哪些错误可以恢复、哪些必须向上传播,并保证失败时资源仍被正确释放。与此同时,模块边界决定依赖方向,日志和配置决定程序能否被运维。

本文以 CPython 3.14 为基线,把异常、上下文管理、模块、包、导入、日志和配置组织成一套工程方法。

1. 异常是控制流,不是返回值替代品

Python 使用异常表示无法按契约完成操作:

1
2
3
4
5
6
7
8
9
def parse_port(raw: str) -> int:
    try:
        port = int(raw)
    except ValueError as error:
        raise ValueError(f"端口不是整数:{raw!r}") from error

    if not 1 <= port <= 65_535:
        raise ValueError(f"端口超出范围:{port}")
    return port

这里既保留原始异常,又补充业务上下文。raise ... from error 建立显式异常链,日志能同时展示转换失败的根因和更高层语义。

不要用异常处理正常且高频的分支,如果一个结果本来就可能缺失,返回 None 或明确的结果对象可能更合适;反过来,也不要用魔法值 -1 隐藏真正错误。

2. 精确捕获,保持异常边界

1
2
3
4
try:
    config = load_config(path)
except FileNotFoundError:
    config = default_config()

只捕获你能处理的异常。下面的写法会掩盖退出信号以外的几乎所有异常,包括大量本应暴露的程序错误:

1
2
3
4
5
# 反例
try:
    run_job()
except Exception:
    pass

except Exception 在应用最外层用于记录失败、转换协议响应或隔离单个任务时可以合理,但通常要记录完整堆栈并决定是否重新抛出。不要捕获 BaseException,除非你明确知道如何处理 KeyboardInterruptSystemExit 等控制异常。

try 语句的四部分职责不同:

1
2
3
4
5
6
7
8
try:
    value = parse_value(raw)
except ValueError as error:
    report(error)
else:
    persist(value)
finally:
    release_temporary_state()
  • except 处理匹配异常;
  • else 只在 try 正常完成时执行,能缩小被捕获代码范围;
  • finally 无论成功、异常或提前返回都会执行,适合清理;
  • finally 中不要随意 return,否则可能压制原异常或返回值。Python 3.14 会对从 finally 代码块跳出的 returnbreakcontinue 发出 SyntaxWarning;这些写法在未来版本或其他实现中可以成为语法错误。

3. 自定义异常表达领域语义

1
2
3
4
5
6
7
8
class DeviceError(Exception):
    """设备操作失败的基类。"""


class DeviceOfflineError(DeviceError):
    def __init__(self, device_id: str) -> None:
        super().__init__(f"设备离线:{device_id}")
        self.device_id = device_id

调用方可以捕获稳定的领域异常,而不依赖底层串口、Socket 或驱动库的具体异常类型。转换时保留异常链:

1
2
3
4
try:
    transport.send(command)
except OSError as error:
    raise DeviceOfflineError(device_id) from error

异常类型应能帮助调用者决定处理策略。仅仅为了改一段消息而建立几十个没有语义差异的异常类,反而增加负担。

4. 多个并发错误:ExceptionGroup

并发任务可能同时失败。Python 3.11 引入 ExceptionGroupexcept*,允许按类型拆分处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
errors = ExceptionGroup(
    "批处理失败",
    [ValueError("坏数据"), TimeoutError("超时")],
)

try:
    raise errors
except* ValueError as group:
    print(f"数据错误:{len(group.exceptions)}")
except* TimeoutError as group:
    print(f"超时错误:{len(group.exceptions)}")

except* 不是按顺序只选一个分支;一个异常组可被拆分后由多个分支处理。日常代码通常通过 asyncio.TaskGroup 等结构化并发 API 间接遇到它,不必手工把所有异常都包装成组。

5. 资源释放优先使用上下文管理器

文件、锁、事务和临时目录都有生命周期。相比手写 try/finallywith 更直接:

1
2
3
4
5
6
from pathlib import Path


path = Path("config.json")
with path.open("r", encoding="utf-8") as file:
    content = file.read()

多个资源可以写在同一个 with 中:

1
2
3
with source.open("rb") as input_file, target.open("wb") as output_file:
    while chunk := input_file.read(64 * 1024):
        output_file.write(chunk)

动态数量的资源使用 ExitStack

1
2
3
4
5
6
from contextlib import ExitStack


with ExitStack() as stack:
    files = [stack.enter_context(path.open(encoding="utf-8")) for path in paths]
    contents = [file.read() for file in files]

资源所有权要明确:谁创建,谁关闭。函数若接收调用者提供的文件对象,通常不应擅自关闭它;函数自己打开的文件则应在返回前关闭。

6. 模块与包如何组织代码

一个 .py 文件通常是一个模块;包是可包含子模块的组织单元。推荐的小型项目结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
temperature-service/
├── pyproject.toml
├── src/
│   └── temperature_service/
│       ├── __init__.py
│       ├── domain.py
│       ├── config.py
│       ├── service.py
│       └── cli.py
└── tests/
    └── test_domain.py

src 布局能降低测试误用仓库根目录源码、而不是已安装包的风险。它不是语言强制要求,但适合可发布包和中大型项目。

导入模块会经历查找、创建模块对象和执行模块顶层代码。成功导入后通常会缓存在 sys.modules 中,因此同一进程里的普通重复导入不会每次重新执行模块。

1
from temperature_service.domain import Measurement

包内可使用显式相对导入:

1
from .domain import Measurement

应用入口和公共代码通常优先绝对导入,关系更清楚。采用 src 布局时,包不会仅因终端位于项目根目录就自动变得可导入;需要先把项目以普通或可编辑方式安装到当前环境。完成安装后,再从项目根目录使用 python -m temperature_service.cli,不要通过直接运行包内部文件来绕过导入模型。

7. __init__.py 与公共 API

常规包通常包含 __init__.py。没有它时也可能形成命名空间包,但那是为跨多个目录分发同一逻辑包等场景设计的,不应因为漏文件偶然触发。

__init__.py 可以重导出稳定接口:

1
2
3
from .domain import Measurement

__all__ = ["Measurement"]

保持包初始化轻量。不要在导入时连接数据库、启动线程或读取远程配置;导入副作用会影响测试发现、命令行工具和子进程启动。

循环导入通常说明模块职责或依赖方向不清晰。可选修法包括:

  • 把共享抽象移到更底层模块;
  • 在应用装配层连接对象;
  • 仅为类型检查的导入放进 if TYPE_CHECKING:
  • 必要时在函数内部延迟导入,但不要用它长期掩盖结构问题。

8. 配置:把来源与领域模型分开

环境变量适合部署时注入少量字符串配置,但读取后必须解析和验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from dataclasses import dataclass
import os


@dataclass(frozen=True, slots=True)
class Settings:
    endpoint: str
    timeout_seconds: float


def load_settings() -> Settings:
    endpoint = os.environ.get("APP_ENDPOINT", "http://localhost:8080")
    raw_timeout = os.environ.get("APP_TIMEOUT_SECONDS", "5")

    try:
        timeout = float(raw_timeout)
    except ValueError as error:
        raise ValueError("APP_TIMEOUT_SECONDS 必须是数字") from error

    if timeout <= 0:
        raise ValueError("APP_TIMEOUT_SECONDS 必须大于零")

    return Settings(endpoint=endpoint, timeout_seconds=timeout)

不要在业务模块导入时到处读取环境变量;集中加载后传入依赖,测试更容易控制。密钥不应写入源码、默认配置或日志。复杂配置格式要选择明确的解析器,并在边界转换为有约束的领域对象。

9. 日志:记录事件,不拼接秘密

标准库 logging 支持级别、处理器和格式化:

1
2
3
4
5
6
7
8
import logging


logger = logging.getLogger(__name__)


def process(device_id: str) -> None:
    logger.info("processing device_id=%s", device_id)

库代码只获取命名 logger,不应擅自调用 basicConfig() 改变宿主应用配置。应用入口统一配置:

1
2
3
4
5
6
7
8
import logging


def configure_logging() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )

使用参数化日志而不是先拼 f-string,能让未启用的日志级别避免不必要的字符串格式化,也便于日志系统保留结构。记录异常堆栈可使用:

1
2
3
4
5
try:
    run_job()
except DeviceError:
    logger.exception("job failed")
    raise

不要记录密码、访问令牌、完整连接串或不必要的个人数据。日志级别也不是越高越好:可恢复的预期输入错误不一定需要 ERROR,无法继续的系统故障也不应只记 DEBUG。

10. 一个清晰的应用边界

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import logging

from .config import load_settings
from .service import ApplicationService


logger = logging.getLogger(__name__)


def main() -> int:
    try:
        settings = load_settings()
        service = ApplicationService(settings)
        service.run()
    except ValueError as error:
        logger.error("invalid configuration: %s", error)
        return 2
    except OSError:
        logger.exception("infrastructure failure")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

这个入口把配置解析、对象装配、异常到退出码的转换放在边界,领域模块不需要知道命令行退出码或日志配置。

11. 小结

  • 只捕获能够处理的异常,转换层级时使用 raise ... from ... 保留根因。
  • 清理放进上下文管理器或 finally,并明确资源所有权。
  • 模块导入会执行顶层代码;包初始化应轻量,避免连接外部资源等副作用。
  • 集中加载、解析和校验配置,再把设置对象传入业务层。
  • 库获取 logger,应用配置日志;任何层都不应泄露秘密。

下一篇将为这些模块和接口补上现代类型标注,解释静态检查能证明什么、又不能证明什么。

参考资料

Licensed under CC BY-NC-SA 4.0