背景
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)>>> afrozendict({'x': 1, 'y': 2})>>> a['z'] = 3Traceback (most recent call last): ...TypeError: 'frozendict' object does not support item assignment
>>> b = frozendict(y=2, x=1)>>> a == bTrue>>> 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 jsonlazy 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 语句,排查起来不难受
不想改源码也想全局开启,可以用命令行和环境变量:
# 命令行python -X lazy_imports=all app.py
# 环境变量export PYTHON_LAZY_IMPORTS=allall 表示所有 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-8open("flying-circus.txt", encoding="locale") # 想回到 locale 行为可以这么写这条对做数据处理的同学尤其重要。以前同一段读文件的代码,在开发机上跑得好好的,扔到 Windows 中文环境的服务器上就 UnicodeDecodeError,十有八九就是 locale 编码不一致闹的。3.15 之后这种行为统一了
想保留旧行为,关掉 UTF-8 模式即可:
# 环境变量方式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 贴上去看
几种用法:
# 直接跑脚本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 12345dump 这个子命令值得单独说:线上服务卡住了、接口一直不返回,直接用它在原地抓一份所有线程的调用栈,立刻能看出大家卡在哪一行
四种剖析模式各有用途:
| 模式 | 测什么 | 什么时候用 |
|---|---|---|
--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 的文件读写,行为都会变。老项目升级前建议先跑一遍编码警告:
python -X warn_default_encoding your_script.py第二件,一批老接口在 3.15 里被移除了,升级时会直接报错,提前扫一遍能省不少事
| 已移除 | 替代写法 |
|---|---|
glob.glob0 / glob.glob1 | glob.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_lnotab | co_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
总结
- 新内置类型:
frozendict(配置常量、缓存键、只读共享数据)和sentinel(区分“没传”和“传了 None”)可以直接替换掉手搓的轮子 lazy import(PEP 810) 给启动速度提供了解药,业务模块懒加载、第三方保持正常加载是比较稳的用法- UTF-8 成为默认编码(PEP 686),跨版本代码请一律显式写
encoding= - 推导式支持
*和**解包(PEP 798),能替掉不少itertools.chain和双层 for,做数据清洗时尤其顺手 profiling包 + Tachyon 是这次最实用的工具更新,百万级采样率、可 attach 线上进程、火焰图和热力图开箱即用,卡死时用dump抓栈特别香- 错误信息与命令行配色全面变好,这是升级后第一天就能感觉到的变化
- 升级前先扫兼容:编码警告跑一遍,对照移除清单查一下,
re.match和__version__这两类弃用可以先放着
顺带一提,官方还放出了一个由 Barry Warsaw 做的 TUI 文字冒险游戏 whatsnewt,18 道谜题全部要靠真写 3.15 新特性的代码才能过,其中类型相关的题还会用 pyrefly 判分
uvx --python 3.15 whatsnewt据说里面还有彩蛋
历史相关文章
以上是自己实践中遇到的一些问题,分享出来供大家参考学习,欢迎关注微信公众号:DataShare ,不定期分享干货