社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

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

IT服务圈儿 • 2 月前 • 263 次点击  

来源丨经授权转自 数据STUDIO

作者丨 云朵君

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 仍然更适合按预发布版本来对待。

如果只是想知道“值不值得试”,我觉得答案已经很明确:值得。但别把“值得试”直接等同于“值得把生产环境全部切过去”。

比较稳妥的顺序是:

  1. 用 uv 建独立环境,不碰系统 Python。
  2. 先跑项目现有测试,看看第三方依赖能不能正常安装、导入。
  3. 启动慢,就优先测 lazy import,顺便检查导入副作用。
  4. 配置经常被误改,就试 frozendict;接口需要区分“没传”和 None,再试 sentinel。
  5. 经常踩编码差异,就先检查现有文件格式契约,再决定能不能依赖默认 UTF-8。
  6. 只有手里真有 Python-heavy workload,再去测 profiler 和 JIT。

这 7 项变化没必要一次性全上。更好的方式,是把它们当成 7 个独立实验入口:哪儿最痛,就先测哪儿。

Python 3.15 现在最有意思的地方,也不在于它终于凑出了一份“必须升级”的理由。

恰恰相反,它开始把一些我们过去长期靠约定、经验和手工绕路解决的问题,慢慢变成更明确的语言能力和运行时行为。对我来说,这比单纯多几个语法特性更值得关注。

1、C++ 容器背后的隐性代价

2、盘点本周 TOP 14 的 GitHub 开源项目,看看能用到哪些。

3、程序员最狠的报复:代码不写注释,然后离职,差点把其他同事“送走”!!

4、再见 Jupyter,又一个 Python Notebook 封神了!

5、返回success 却空指针:一次 Redis 撞锁返回的坑

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/201047