3696 字
18 分钟
阅读量加载中...
Python 3.15 正式发布

背景#

2026 年 10 月 9 日,Python 3.15.0 正式版发布,来自 1012 位贡献者的 5643 个提交,bugfix 支持会一直持续到 2031 年 10 月

这篇只介绍 跟日常写业务代码有关 的改动来讲,底层实现和 C API 那一层就跳过了,看完你可以直接对照自己的项目决定要不要升

每次大版本发布,大家最关心的无非两件事:代码能少写几行,以及程序能跑快多少

3.15 这次给出的答案挺实在:语言层面加了 frozendict、sentinel 两个内置类型,给了 lazy import 这个新关键字,还顺手把 UTF-8 变成了默认编码

工具层面更狠,一个能 attach 到线上进程、采样率最高到 100 万 Hz 的采样剖析器直接进了标准库,以后排查线上性能问题不用再装第三方东西了

下面按“新类型 → 启动速度 → 编码 → 语法 → 线上剖析 → 类型注解 → 报错体验 → 升级兼容”的顺序展开

一、两个新内置类型#

frozendict:终于有不可变的字典了#

PEP 814 给内置命名空间加了一个 frozendict,创建之后就不能改

需要注意的是,它不是 dict 的子类,而是直接继承 object,所以别指望 isinstance(x, dict) 能通过

>>> a = frozendict(x=1, y=2)
>>> a
frozendict({'x': 1, 'y': 2})
>>> a['z'] = 3
Traceback (most recent call last):
...
TypeError: 'frozendict' object does not support item assignment
>>> b = frozendict(y=2, x=1)
>>> a == b
True
>>> hash(a) == hash(b)
True

几个要点值得记住:

  • 只要键和值都可哈希,frozendict 本身就是可哈希的,可以直接当字典的键、放进 set
  • 它保留插入顺序,但比较的时候不看顺序
  • copy、decimal、json、marshal、pickle、pprint、plistlib、xml.etree.ElementTree 都已支持它
  • eval()、exec() 的 globals,type()、str.maketrans() 的 dict 参数也接受 frozendict

放到业务里,它至少能帮你解决三个老问题

一是配置常量。以前模块级的配置字典,谁都能顺手改一下,出了 bug 很难查

DEFAULT_RETRY = frozendict(timeout=3, backoff=1.5, max_attempts=5)
DEFAULT_RETRY["timeout"] = 10 # TypeError,改不动了

二是缓存键。以前要拿一组参数当缓存键,得先 tuple(sorted(d.items())) 转一手,现在直接把 frozendict 丢进 functools.lru_cache 就行了

from functools import lru_cache
@lru_cache(maxsize=1024)
def build_query(filters: frozendict):
...

三是多线程共享只读数据。不可变意味着不需要加锁,也不会被别的线程改掉

如果你以前的代码里写着 isinstance(arg, dict),建议改成 isinstance(arg, (dict, frozendict)),或者干脆放宽成 collections.abc.Mapping,这样别的映射类型也能进来

sentinel:写哨兵值不用再自己造对象#

PEP 661 带来的 sentinel 解决的是一个老大难问题:函数默认值需要一个“绝对不是 None”的标记

以前的写法是自己 object() 一个类属性,然后被日志里的 <object object at 0x7f...> 折磨

MISSING = sentinel("MISSING")
def f(x=MISSING):
if x is MISSING:
...

它的好处是:repr 干净、拷贝后保持同一性、可以在类型表达式里用 | 参与联合类型,只要能被模块和名字导入就还能 pickle

实际写业务时,最典型的场景就是区分“没传”和“显式传了 None”

UNSET = sentinel("UNSET")
def update_config(key: str, value=UNSET):
if value is UNSET:
return # 本次不修改这个字段
if value is None:
...

另一个场景是缓存未命中,以前要么返回一个魔法值,要么用异常控制流程,现在一个哨兵对象就能表达清楚

二、lazy import:应用启动速度的解药#

这个可能是 3.15 里最能立刻看到收益的特性

过去为了加快启动速度,我们只能把 import 塞进函数里、用 importlib 手动加载,或者靠重构砍依赖,代码可读性一路下滑

现在有了 lazy 这个软关键字,模块顶层照常写 import,真正加载推迟到第一次使用名字的时候

lazy import json
lazy from pathlib import Path
print("Starting up...") # 此时 json 和 pathlib 都还没真正加载
data = json.loads('{"key": "value"}') # 这一行才把 json 加载进来
p = Path(".") # 这一行才把 pathlib 加载进来

对什么样的应用最有用?就是那种顶层 import 一大堆、但一次运行只用其中一小部分的

  • CLI 工具:十几个子命令,每个子命令依赖的库都不一样,以前不管跑哪个都要全部加载一遍
  • Web 服务:路由散在各个模块,只有被请求到的那条路径才需要真正加载对应依赖
  • 数据处理脚本:读 CSV、写 Excel、画图、连数据库的库全在顶上,但每次只干一件事

如果惰性导入的模块最终加载失败,异常会在第一次使用时抛出,而且 traceback 里会同时给出访问位置和原始 import 语句,排查起来不难受

不想改源码也想全局开启,可以用命令行和环境变量:

Terminal window
# 命令行
python -X lazy_imports=all app.py
# 环境变量
export PYTHON_LAZY_IMPORTS=all

all 表示所有 import 默认惰性,normal(默认值)表示尊重源码里的 lazy 关键字

运行时还能用 sys.set_lazy_imports()、sys.get_lazy_imports() 查询和切换,想做更精细的控制就用 sys.set_lazy_imports_filter()

import sys
def myapp_filter(importing, imported, fromlist):
return imported.startswith("myapp.")
sys.set_lazy_imports_filter(myapp_filter)
sys.set_lazy_imports("all")
import myapp.slow_module # 惰性,命中过滤规则
import json # 正常加载,没命中

这个思路很实用:自己的业务模块全懒加载,第三方依赖保持正常加载,因为不少第三方库依赖 import 时的副作用(比如注册插件、加载配置),全懒加载容易踩坑

限制也得知道:

  • lazy 只能出现在模块顶层,写在函数、类体、try/except/finally 里都是 SyntaxError
  • 星号导入和 __future__ 导入不能懒加载
  • 要兼容老版本解释器又要用懒加载,可以在模块里写 __lazy_modules__ = ["json", "pathlib"],普通 import 就会被当成惰性

三、UTF-8 成为默认编码#

PEP 686 终于落地:不显式传 encoding 时,Python 一律用 UTF-8,不再看系统 locale 脸色

open("flying-circus.txt") # 3.15 起就是 UTF-8
open("flying-circus.txt", encoding="locale") # 想回到 locale 行为可以这么写

这条对做数据处理的同学尤其重要。以前同一段读文件的代码,在开发机上跑得好好的,扔到 Windows 中文环境的服务器上就 UnicodeDecodeError,十有八九就是 locale 编码不一致闹的。3.15 之后这种行为统一了

想保留旧行为,关掉 UTF-8 模式即可:

Terminal window
# 环境变量方式
PYTHONUTF8=0 python app.py
# 命令行传入参数
python -X utf8=0 app.py

跨版本库和应用的兼容建议只有一句:永远显式写 encoding=,然后用编码警告把遗漏的地方扫出来

四、推导式里也能解包了#

PEP 798 把 PEP 448 的星号解包扩展到了推导式,很多嵌套推导式可以写短一截

lists = [[1, 2], [3, 4], [5]]
[*L for L in lists] # [1, 2, 3, 4, 5]
sets = [{1, 2}, {2, 3}, {3, 4}]
{*s for s in sets} # {1, 2, 3, 4}
dicts = [{'a': 1}, {'b': 2}, {'a': 3}]
{**d for d in dicts} # {'a': 3, 'b': 2}
gen = (*L for L in lists)
list(gen) # [1, 2, 3, 4, 5]

做数据清洗时这个语法很顺手,比如把一批接口返回的列表拍平

pages = [{"items": [1, 2]}, {"items": [3]}, {"items": [4, 5]}]
flat = [*p["items"] for p in pages] # [1, 2, 3, 4, 5]
merged = {**p for p in pages} # 直接把多个分页字典合成一个

异步生成器表达式同样支持,(*a async for a in agen()) 等价于 (x async for a in agen() for x in a)

以前要写 itertools.chain 或者双层 for 的地方,现在一行就够了

五、Tachyon:不用重启就能给线上进程做体检#

PEP 799 把内置剖析工具收到一个统一命名空间下:

  • profiling.tracing:确定性函数调用追踪,其实就是从 cProfile 搬过来的,cProfile 仍作为别名保留
  • profiling.sampling:全新的统计采样剖析器,代号 Tachyon
  • 老的 profile 模块已弃用,将在 3.17 移除

Tachyon 是这次最值得玩的东西,它的定位是几乎零开销地剖析运行中的进程,采样率最高可达 100 万 Hz,不用改代码、不用重启服务

这意味着排查线上问题的流程变了。以前服务突然变慢,要么加日志重新发版,要么在本地复现,要么上第三方 APM。现在是直接按 PID 贴上去看

几种用法:

Terminal window
# 直接跑脚本
python -m profiling.sampling run app.py
# 跑模块
python -m profiling.sampling run -m mypackage
# attach 到正在运行的进程
python -m profiling.sampling attach --pid 12345
# 一次性抓一份所有线程的栈,排查卡死很好用
python -m profiling.sampling dump --pid 12345

dump 这个子命令值得单独说:线上服务卡住了、接口一直不返回,直接用它在原地抓一份所有线程的调用栈,立刻能看出大家卡在哪一行

四种剖析模式各有用途:

模式测什么什么时候用
--mode wall(默认)真实耗时,含 I/O 和阻塞想知道程序的时间到底花在哪
--mode cpu只算 CPU 执行时间找 CPU 密集型瓶颈
--mode gil持锁时间多线程里谁在抢 GIL
--mode exception只采有活跃异常的线程分析异常处理开销

输出格式也给得很全:--pstats 出传统表格,--collapsed 出折叠栈(喂给火焰图工具),--flamegraph 直接生成可交互 HTML,--gecko 可以传到 Firefox Profiler,--heatmap 出行级热力图

再配上 --live 实时 TUI、--async-aware 异步任务栈重建、--opcodes 字节码级剖析,基本覆盖了从开发到线上的全部场景

做异步服务的同学可以重点看 --async-aware,它能把协程之间的等待关系还原出来,谁在等谁一目了然

其他模块与“更多颜色”#

大概 60 个模块有改动,argparse、asyncio、ast、dataclasses、functools、hashlib、json、os、pathlib、pdb、re、sqlite3、ssl、subprocess、threading、tomllib、typing、unittest、urllib.parse、venv、zlib 都在名单里

还有个不算大但很讨喜的变化:命令行输出颜色更多了。python --help、不可捕获异常、PyREPL 补全,以及 argparse、ast、calendar、difflib、http.server、pickletools、sqlite3、timeit、tokenize 等一批 CLI 帮助都上了色,颜色行为可以用环境变量控制

六、类型注解三连#

  • PEP 728:TypedDict 支持带类型的额外项(typed extra items),以前 **kwargs 的写法终于能被类型检查器理解了
  • PEP 747:用 TypeForm 注解类型形式
  • PEP 800:类型系统里的不相交基类(disjoint bases)

写类型注解比较重的项目,这三条都值得单独去翻 PEP

其中第一条最贴近日常。假设你在写一个 HTTP 客户端封装,以前想表达“必填参数固定、还允许额外的 header”,只能写成 **kwargs: str,类型检查器完全帮不上忙,现在可以精确声明了

七、错误信息:日常体感最强的一块#

这部分的改动看着小,用起来是真的舒服

属性访问出错时,解释器会沿着成员去找相似的名字,并给出链路建议

AttributeError: 'Container' object has no attribute 'area'. Did you mean '.inner.area' instead of '.area'?

对内置类型,它会拿一张 JS、Java、Ruby、C# 常见方法名对照表来提示 Python 的等价写法

>>> [1, 2, 3].push(4)
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
>>> 'hello'.toUpperCase()
AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?
>>> {}.put("a", 1)
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.
>>> (1, 2, 3).append(4)
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?

delattr() 失败时也会提示拼写相近的属性名,ImportError / ModuleNotFoundError 的 repr 还会把 name= 和 path= 按关键字形式展示出来

这几条对经常在 Python 和别的语言之间来回切的人特别友好

八、升级前要处理的兼容问题#

第一件,UTF-8 默认编码带来的行为变化。任何没写 encoding 的文件读写,行为都会变。老项目升级前建议先跑一遍编码警告:

Terminal window
python -X warn_default_encoding your_script.py

第二件,一批老接口在 3.15 里被移除了,升级时会直接报错,提前扫一遍能省不少事

已移除替代写法
glob.glob0 / glob.glob1glob.glob(..., root_dir=...)
platform.java_ver()已无替代,直接删掉
pathlib.PurePath.is_reserved()os.path.isreserved()
http.server 的 CGIHTTPRequestHandler 和 --cgi换用 WSGI/ASGI 框架
Loader.load_module()、zipimporter.load_module()exec_module()
importlib.resources.files(package=...)位置参数或 files("pkg")
wave 的 getmark() / setmark() / getmarkers()自行记录帧位置
typing.NamedTuple 的关键字参数写法类语法或功能语法
TypedDict("TD") 零字段写法class TD(TypedDict): pass
sre_compile / sre_constants / sre_parse直接用 re
CodeType.co_lnotabco_linetable
sysconfig.is_python_build(check_home=...)去掉该参数
datetime.strptime 里只有 %d 没有年份补上年份指令

第三件,re.match 被软弃用了。3.15 新增了语义更直白的 re.prefixmatch() 和 Pattern.prefixmatch(),因为别的语言里 match 通常指的是 Python 的 search

官方明确说不会移除 match,所以老代码不用急着改,新代码建议用 prefixmatch

第四件,一批模块的 __version__ 被弃用了,包括 json、re、logging、pickle、platform、argparse、csv、ctypes、http.server、imaplib、ipaddress、optparse、socketserver、tarfile、zlib 等,将在 3.20 移除,需要版本号请统一用 sys.version_info

第五件,macOS 27.0 上的 Tk/tkinter 用户要留神:IDLE 等 GUI 在打开对话框(比如 About IDLE、Settings、Open Module)时可能卡死,出现旋转彩球只能强制退出。原因是 macOS 27.0 的系统行为变化,据说影响所有当前 Tk 版本,也就是影响所有 Python 版本。如果你依赖 Tk 应用,建议暂缓升级 macOS 27.0,进度可以盯 issue #158053

总结#

  1. 新内置类型:frozendict(配置常量、缓存键、只读共享数据)和 sentinel(区分“没传”和“传了 None”)可以直接替换掉手搓的轮子
  2. lazy import(PEP 810) 给启动速度提供了解药,业务模块懒加载、第三方保持正常加载是比较稳的用法
  3. UTF-8 成为默认编码(PEP 686),跨版本代码请一律显式写 encoding=
  4. 推导式支持 * 和 ** 解包(PEP 798),能替掉不少 itertools.chain 和双层 for,做数据清洗时尤其顺手
  5. profiling 包 + Tachyon 是这次最实用的工具更新,百万级采样率、可 attach 线上进程、火焰图和热力图开箱即用,卡死时用 dump 抓栈特别香
  6. 错误信息与命令行配色全面变好,这是升级后第一天就能感觉到的变化
  7. 升级前先扫兼容:编码警告跑一遍,对照移除清单查一下,re.match 和 __version__ 这两类弃用可以先放着

顺带一提,官方还放出了一个由 Barry Warsaw 做的 TUI 文字冒险游戏 whatsnewt,18 道谜题全部要靠真写 3.15 新特性的代码才能过,其中类型相关的题还会用 pyrefly 判分

Terminal window
uvx --python 3.15 whatsnewt

据说里面还有彩蛋

历史相关文章#


以上是自己实践中遇到的一些问题,分享出来供大家参考学习,欢迎关注微信公众号:DataShare ,不定期分享干货

微信公众号