编程学习网 > 编程语言 > Python > Python 3.15 超乎你想象的7个特性!
2026
09-16

Python 3.15 超乎你想象的7个特性!


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

第二段赋值会直接失败。

对于构造完成后就不应该再变化的配置、元数据和缓存键,这个语义比把普通字典传下去,然后希望所有人都自觉清楚得多。

不过它也不是深度冻结。如果值里面还嵌着 listdict 之类的可变对象,那些对象仍然要单独处理。只有键和值本身满足可哈希条件时,整个 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教程欢迎持续关注编程学习网。 

扫码二维码 获取免费视频学习资料

Python编程学习

查 看2022高级编程视频教程免费获取