
Python 3.15 的这次有些变化挺有意思。不是又多了几个语法糖,而是一些我们写 Python 很久以后已经习惯了的“小麻烦”,开始被正式收进语言和运行时里:想延迟导入,不用再把 import 塞进函数;想表达“这个配置不该再改”,终于不只是靠团队约定;想区分“没传参数”和“明确传了 None”,也有了标准工具。
再往底层一点,还有 UTF-8 默认编码、采样 profiler,以及继续往前走的 JIT。
所以 Python 3.15 真正值得看的,不是“新特性够不够多”,而是这些变化分别碰到了日常开发里的哪一类问题。它们的成熟度和适用范围也不一样:有些今天就能拿小项目试,有些必须放到自己的工作负载里测,还有些要先等第三方依赖跟上。
我更建议先用 uv 建一个随时能删掉的环境,再挑和自己最相关的几个点验证,先不着急。下面这 7 个变化,就是比较值得先看的。
01先把实验环境隔离
试新版本最怕的不是代码报错,而是顺手把原来的环境也折腾坏了。
所以别急着动系统 Python。单独建一个目录就够了:
uv init python315 --no-package
cd python315
uv python install 3.15
uv python pin 3.15
uv run python --version
uv add --dev ruff
项目也最好明确声明 Python 版本:
[project]
requires-python = ">=3.15"
这样至少有两件事是确定的:实验不会污染已有项目,后面跑测试时也知道自己到底用了哪个解释器。
需要注意,3.15 的发布状态会继续变化。上面这组命令解决的是“怎么把实验隔离出来”,不代表你的第三方依赖已经全部兼容。
02lazy import
终于不用为了延迟加载把 import 藏起来
大型 CLI、插件系统,或者可选依赖很多的应用,经常有一个挺烦的问题:程序一启动,就先导入一大批这次根本用不到的模块。
过去想延迟加载,常见做法是把 import 移进函数,或者自己调 importlib。能用,但代价也很直观——模块顶部看不全依赖关系,代码读起来会有点“藏东西”。
Python 3.15 引入了显式的 lazy import 语法。导入声明仍然放在顶部,只是模块会等到名称第一次真正被使用时再解析:
import sys
lazy import json
def parse_dataset(raw_data: str) -> list[dict[str, object]]:
return json.loads(raw_data)
raw_data = '[{"product": "Laptop", "price": 1299.0}]'
print("Before first use:", "json" in sys.modules)
dataset = parse_dataset(raw_data)
print("After first use: ", "json" in sys.modules)
print(dataset)
我觉得它比“启动快一点”更有价值的地方,是依赖终于还能老老实实待在读者预期的位置。以前为了延迟加载而移动 import,本质上是在拿代码结构换启动时机。
当然,lazy import 也不是免费优化。模块导入时机一变,导入副作用的发生时间也会变。如果某段代码默认依赖“模块一 import 就完成注册、初始化或打补丁”,那行为就可能跟着变化。
所以这类特性最适合局部试,不适合上来就全局开。当前构建到底提供了什么开关、准确名称是什么、作用范围多大,先看解释器自己的帮助信息;没有明确入口,就只用逐项语法验证。真正落地前,还是要把依赖的初始化顺序过一遍。
03frozendict
配置不该靠“大家记得别改”
Python 很早就有不可变的 tuple 和 frozenset,但配置映射长期还是普通 dict。
结果就是:这个配置“按理说不该再动”,可代码层面并没有真的禁止谁去动它。
Python 3.15 提供了内置 frozendict:
MODEL_CONFIG = frozendict(
model="xgboost",
max_depth=8,
learning_rate=0.05,
)
MODEL_CONFIG["max_depth"] = 12
第二段赋值会直接失败。
对于构造完成后就不应该再变化的配置、元数据和缓存键,这个语义比“把普通字典传下去,然后希望所有人都自觉”清楚得多。
不过它也不是“深度冻结”。如果值里面还嵌着 list、dict 之类的可变对象,那些对象仍然要单独处理。只有键和值本身满足可哈希条件时,整个 frozendict 才能拿去做字典键:
cache: dict[frozendict, float] = {}
config = frozendict(model="xgboost", max_depth=8)
cache[config] = 0.934
所以我更倾向于从配置边界开始试,而不是看到 dict 就批量替换。它解决的是“这个映射本来就应该不可变”的场景,不是来接管所有字典的。
04sentinel
终于把“没传”和“传了 None”说清楚
很多 API 实际上不止两个状态。
比如更新一个阈值时,常见语义是:
没传:保持原值;
传 None:明确清空;
传具体值:更新成新值。
过去通常会自己造一个对象:
_MISSING = object()
def update_threshold(value: float | None = _MISSING) -> None:
...
Python 3.15 的 sentinel() 把这个老模式正式标准化了:
MISSING = sentinel("MISSING")
def update_threshold(
value: float | None | MISSING = MISSING,
) -> None:
if value is MISSING:
print("Keep existing value")
return
print(f"Set threshold to {value}")
update_threshold()
update_threshold(None)
update_threshold(0.75)
它真正解决的不是少写一行 object(),而是接口语义终于更明确了:None 可以继续表示“明确传入空值”,不用再顺便承担“调用者根本没传参数”这层含义。
但如果函数本来就只有两种状态,普通默认值反而更简单。新特性不需要为了“用上”而用上。
05推导式也能直接展开
以前要展平一组批次,通常得写两层 for:
def flatten_batches(batches: list[list[int]]) -> list[int]:
return [
row
for batch in batches
for row in batch
]
Python 3.15 扩展了推导式里的展开表达:
def flatten_batches(batches: list[list[int]]) -> list[int]:
return [*batch for batch in batches]
batches = [
[101, 102, 103],
[104, 105],
[106, 107, 108],
]
print(flatten_batches(batches))
# [101, 102, 103, 104, 105, 106, 107, 108]
集合和字典推导式也有对应形式:
def collect_columns(column_groups: list[set[str]]) -> set[str]:
return {*columns for columns in column_groups}
def merge_config(
config_parts: list[dict[str, object]],
) -> dict[str, object]:
return {**part for part in config_parts}
比如三组列集合:
column_groups = [
{"customer_id", "revenue"},
{"country", "revenue"},
{"customer_id", "segment"},
]
columns = collect_columns(column_groups)
print(sorted(columns))
# ['country', 'customer_id', 'revenue', 'segment']
重复列名最后只保留一份。
配置合并也是同样的展开方式,但同键时仍然是后值覆盖前值:
config_parts = [
{"host": "db01", "port": 5432},
{"ssl": True, "timeout": 30},
{"timeout": 10},
]
config = merge_config(config_parts)
print(config)
# {'host': 'db01', 'port': 5432, 'ssl': True, 'timeout': 10}
也就是说,展开表达式只是把已有语义写得更紧凑,并没有偷偷发明一套新的合并规则:集合还是去重,字典还是按出现顺序覆盖同名键。
我觉得它适合那种“容器里就是一堆容器”的代码。如果嵌套关系本身已经很复杂,再塞一堆 *、**,未必比原来的双层循环更好读。最后还是看代码评审的人能不能一眼看懂。
06UTF-8 成为默认值,但显式编码还是值得写
编码问题很典型:平时没事,一换机器、一进容器、一次 CI,就突然冒出来。
Python 3.15 调整了未显式指定 encoding 时的默认行为,让 UTF-8 成为默认编码,减少平台 locale 带来的差异。
这类代码的默认行为会更稳定:
with open("customers.csv") as file:
text = file.read()
但如果文件格式本身已经约定了编码,我还是建议明确写出来:
with open("customers.csv", encoding="utf-8") as file:
text = file.read()
原因也很简单:显式 encoding 不只是给解释器看的,它还是输入契约的一部分。
UTF-8 成为默认值,解决的是“忘写时各平台表现不一致”;它并不等于“从此所有文本文件都可以假定是 UTF-8”。
07采样 profiler
别先猜瓶颈,先看时间到底花在哪
“应该是这个函数慢”“大概率是这里循环太多”——很多时候改了半天,最后发现真正的热点根本不在那。
Python 3.15 新增的 profiling 能力里包含高频采样 profiler。它和逐函数插桩的 deterministic profiler 思路不一样:程序运行时定期采样调用栈,再从大量样本里判断时间主要消耗在哪些路径上。
这里不建议直接照搬一组未经核验的命令。先看当前 3.15 构建的帮助信息,确认采样模式、输出格式和权限要求,再决定能不能生成 flame graph。帮助信息里没有对应入口,就只记录当前构建真正支持的模式,不要硬套旧命令。
实验可以先从一段纯 Python 负载开始:
def calculate_scores(size: int) -> int:
return sum(value * value for value in range(size))
if __name__ == "__main__":
score = calculate_scores(15_000_000)
print(f"Score: {score:,}")
测的时候也别一下堆太多变量。固定输入、固定运行环境,先记录正常运行的基线,再看采样得到的调用栈。确认采样模式、权限和输出位置以后,再比较不同实现。
它适合回答的是:“程序大概把时间花在了哪些调用栈上?”
它不是 benchmark,也不能只凭一张 flame graph 就推出“优化后会快多少”。它更像一次定位:先找到热点,再决定是改代码、换算法,还是干脆换工具。
这部分原稿里没有本次运行生成的 profile 文件,所以任何具体耗时或 flame graph 都不应该被当作这次环境下的实测结果。
08JIT
值得测,但千万别理解成“性能开关”
JIT 很容易被讲成一句话:打开后 Python 会更快。
问题是,这句话太容易误导。
更适合拿来测试的,其实是那些大量时间真正在 Python 层消耗的代码,比如循环、分支、整数运算,而不是早就把主要工作丢给 NumPy 或其他原生库的任务。
例如:
from time import perf_counter
def calculate_score(size: int) -> int:
total = 0
for value in range(size):
if value % 2 == 0:
total += value * value
return total
def benchmark(runs: int = 10, size: int = 5_000_000) -> None:
durations: list[float] = []
for _ in range(runs):
start = perf_counter()
calculate_score(size)
durations.append(perf_counter() - start)
print(f"Fastest run: {min(durations):.3f} s")
print(f"Average: {sum(durations) / len(durations):.3f} s")
if __name__ == "__main__":
benchmark()
比较时,先用同一个构建、同一组输入测默认配置,再按照当前构建的官方说明启用 JIT 做对照。
第一轮运行可能带着编译成本,所以不能只跑一次就下结论;输入太小,也可能测到的主要是启动和编译开销,而不是稳定执行阶段。
所以 JIT 现在更适合“拿自己的 Python-heavy workload 去测”,而不是看到一个开关就默认打开。它到底有没有收益,取决于代码形态、解释器构建和实际工作负载。
原稿里的具体秒数没有在本次环境里复现,也就不该被当成通用结论。
09还有一个低成本变化:错误提示更具体
这类变化没那么吸睛,但日常写代码时反而很实用。
Python 3.15 继续改进错误消息。某些包装对象上的属性写错后,解释器可能会进一步提示嵌套对象里的候选路径。
比如对象里其实有 inner,但你写了不存在的 container.shape,错误信息可能会提醒你检查 inner.shape。
它不会让程序跑得更快,也不会改变语言模型,但能少掉一些“明明只写错了一个属性,却要来回翻对象结构”的排查时间。
这种体验提升很值得欢迎,不过也没必要单独因为它升级生产解释器。
10写在最后
截至本文核对时,Python 3.15 仍然更适合按预发布版本来对待。
如果只是想知道“值不值得试”,我觉得答案已经很明确:值得。但别把“值得试”直接等同于“值得把生产环境全部切过去”。
比较稳妥的顺序是:
用 uv 建独立环境,不碰系统 Python。
先跑项目现有测试,看看第三方依赖能不能正常安装、导入。
启动慢,就优先测 lazy import,顺便检查导入副作用。
配置经常被误改,就试 frozendict;接口需要区分“没传”和 None,再试 sentinel。
经常踩编码差异,就先检查现有文件格式契约,再决定能不能依赖默认 UTF-8。
只有手里真有 Python-heavy workload,再去测 profiler 和 JIT。
这 7 项变化没必要一次性全上。更好的方式,是把它们当成 7 个独立实验入口:哪儿最痛,就先测哪儿。
Python 3.15 现在最有意思的地方,也不在于它终于凑出了一份“必须升级”的理由。
恰恰相反,它开始把一些我们过去长期靠约定、经验和手工绕路解决的问题,慢慢变成更明确的语言能力和运行时行为。对我来说,这比单纯多几个语法特性更值得关注。
以上就是“Python 3.15 超乎你想象的7个特性!”的详细内容,想要了解更多Python教程欢迎持续关注编程学习网。
扫码二维码 获取免费视频学习资料

- 本文固定链接: http://www.phpxs.com/post/14521/
- 转载请注明:转载必须在正文中标注并保留原文链接
- 扫码: 扫上方二维码获取免费视频资料