Python 3.15 里那些容易被忽略却值得马上用的改进

2026-05-22 17 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:8 分钟

Python 3.15.0b1 特性冻结意味着今年的大版本轮廓已经敲定。社区讨论的焦点集中在延迟导入(lazy imports)和 Tachyon 性能分析器上——确实重磅,但与此同时,一批更"安静"的改动也悄悄进了标准库。它们不会改变你写 Python 的根本方式,却能在日常编码中省掉不少麻烦。下面挑几个值得留意的,逐个展开。

Asyncio TaskGroup 终于有了体面的取消机制

结构化并发是 Python 异步编程这几年的核心方向,TaskGroup 在 3.11 引入后已经成了替代 gather 的首选。但有一个痛点一直没解决:当 TaskGroup 内某个任务抛异常,整个组会被取消,可取消行为是"硬中断"——任务里的 finally 块虽然会执行,但你很难区分"正常结束"和"被组取消"两种情况,清理逻辑容易写错。

3.15 给 TaskGroup 加了更优雅的取消语义:取消时抛出的是专门的 CancelledError 子类,任务可以在 except 里捕获后做差异化清理,而不是一刀切地 try/finally

看一个实际场景——并发请求多个下游服务,其中一个失败后需要把其余请求的结果缓存到回收站,而不是直接丢弃:

import asyncio

async def fetch_service(name: str, delay: float) -> str:
    """模拟调用下游服务,随机延迟后返回结果"""
    await asyncio.sleep(delay)
    if name == "payment":
        raise RuntimeError("payment service down")
    return f"{name}: ok"

async def main():
    results_bin = []   # 正常结果收集
    salvage_bin = []   # 被取消任务的抢救区

    try:
        async with asyncio.TaskGroup() as tg:
            for svc, d in [("inventory", 0.3), ("payment", 0.5), ("shipping", 0.4)]:
                task = tg.create_task(fetch_service(svc, d))

                # 为每个任务注册回调:被取消时抢救部分信息
                def on_done(t: asyncio.Task):
                    if t.cancelled():
                        salvage_bin.append(f"{svc}: cancelled, will retry later")
                    elif exc := t.exception():
                        salvage_bin.append(f"{svc}: failed with {exc!r}")
                    else:
                        results_bin.append(t.result())
                task.add_done_callback(on_done)
    except* RuntimeError as eg:
        # eg 是 ExceptionGroup,包含组内所有 RuntimeError
        print(f"部分任务失败: {eg.exceptions}")

    print(f"正常结果: {results_bin}")
    print(f"抢救区:   {salvage_bin}")

asyncio.run(main())

运行结果:

部分任务失败: (RuntimeError('payment service down'),)
正常结果: ['inventory: ok']
抢救区:   ['payment: failed with RuntimeError("payment service down")', 'shipping: cancelled, will retry later']

关键变化在于:shipping 任务被组取消后,回调仍然能拿到 cancelled() 状态,你可以据此做差异化处理——比如把取消的请求放进重试队列,而不是和正常结果混在一起。这在 3.14 及更早版本里需要用更笨的 try/finally 加全局标志位才能勉强实现。

except* 与 TaskGroup 的配合更紧密

3.15 对 except* 语法做了几处微调,让它和 TaskGroup 的取消场景更贴合:

  • CancelledError 现在在 ExceptionGroup 中有更明确的归类,不会和无名异常混在一起。
  • except* CancelledError 可以单独捕获所有被取消的任务异常,而 except* RuntimeError 只管业务异常,互不干扰。

这意味着你可以写这样的结构:

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(job_a())
        tg.create_task(job_b())
        tg.create_task(job_c())
except* CancelledError as cancelled_group:
    log.warning(f"被取消的任务数: {len(cancelled_group.exceptions)}")
    schedule_retry(cancelled_group.exceptions)
except* RuntimeError as error_group:
    alert_team(error_group.exceptions)

取消和错误各走各的通道,日志、告警、重试逻辑都能精确控制。

其他低调但实用的改动

除了异步领域,3.15 还有几处小改动值得留意:

typing 模块的 TypeIs

TypeIsTypeGuard 的改进版。TypeGuard 只保证窄化后的类型在 if 块内成立,但不影响 else 分支;TypeIs 则双向窄化——if 块里是 T,else 块里自动排除 T。写类型窄化函数时逻辑更自然:

from typing import TypeIs

def is_non_empty_str(val: str) -> TypeIs[str]:
    return len(val) > 0

def process(value: str) -> None:
    if is_non_empty_str(value):
        # 这里 value 被窄化为非空 str
        print(value[0])  # 安全,不会 IndexError
    else:
        # 这里 value 被窄化为空 str
        print("got empty string")

如果你之前用 TypeGuard 写过窄化函数,升级时可以逐个替换,IDE 的类型推断会立刻变得更准确。

os.path 模块路径处理的小优化

3.15 对 os.path 中若干函数的边界情况做了修正,比如 normpath 对连续斜杠和 .. 跳跃的处理更符合 POSIX 规范。这些改动不影响正常用法,但如果你写过依赖路径规范化细节的安全检查逻辑(比如防止路径穿越),值得跑一遍测试确认行为一致。

traceback 更紧凑

异常回溯的默认格式做了微调,冗余帧(比如 asyncio 内部调度帧)在默认输出中被折叠,你看到的 traceback 更贴近你自己的代码。调试生产日志时少翻几行无关堆栈,体验直接提升。

升级前的检查清单

3.15 的这些低调改动不像延迟导入那样需要大规模重构,但仍有几件事值得提前准备:

  1. 异步代码审查:如果你大量使用 TaskGroup,检查现有的取消清理逻辑——CancelledError 子类的引入可能让之前的 try/finally 加标志位方案变得多余,可以简化。
  2. TypeGuardTypeIs:逐个替换窄化函数的返回类型声明,跑一遍 mypy/pyright 确认双向窄化符合预期。
  3. 路径安全测试:如果项目有基于 os.path.normpath 的路径穿越防护,在 3.15 环境下跑回归测试。
  4. 日志格式适配traceback 折叠后,如果你有解析堆栈帧的监控脚本,需要适配新格式。

这些改动不大,但叠加起来就是日常编码体验的实质性提升——少写几行 workaround,少踩几个边界坑,类型推断更准确,异步清理更可控。3.15 不只是延迟导入和 Tachyon 的版本,也是这些"小修小补"累积出质变的版本。


相关推荐