f-string 是 Python 3.6 以来最受欢迎的特性,没有之一。写 f"hello {name}" 比手动 "hello " + name 或者 "hello {}".format(name) 舒服太多,可读性和性能也都好。这几年用下来几乎没有人怀念老写法。
但用得越久,越会撞到同一件事:f-string 是即时求值并拼成 str。它对插值表达式的处理到此为止——值被算出来、被转成字符串、被拼在一起,中间那点结构信息全部丢掉了。绝大多数场景这样挺好,可有些场景偏偏需要保留那点结构。
举个例子。日志里写 f"user={uid} action={act} cost={ms}",输出是一整行字符串。想把它变成结构化日志,让日志系统按字段过滤,得再写一次 {"user": uid, "action": act, "cost": ms}。两处重复。SQL 里写 f"SELECT * FROM {table} WHERE id = {uid}",你得自己记得去参数化,忘了就是注入。HTML 里写 f"<div>{comment}</div>",你得记得对 comment 转义,忘了就是 XSS。
这些问题的根都在一处:f-string 把「模式」和「值」直接搅成了字符串,没有给中间层留任何位置。Python 3.14 加进来的 t-string(PEP 750)就是来补这个缺口的。
一、t-string 到底返回什么
语法上只差一个字母:f-string 用 f,t-string 用 t。
name = "alice"
score = 42.5
f_string = f"player={name} score={score}"
t_string = t"player={name} score={score}"
print(type(f_string)) # <class 'str'>
print(type(t_string)) # <class 'string.templatelib.Template'>
差别就在这。t-string 求值得到的不是 str,是一个 Template 对象。它把插值过程拆成了几个可读的部分:
strings:静态文本片段组成的元组。长度总比插值多一个。interpolations:插值对象组成的元组。values:插值值的元组,等价于每个插值的.value。
拿上面那行 t_string 举例:
print(t_string.strings)
# ('player=', ' score=', '')
print(len(t_string.interpolations)) # 2
for interp in t_string.interpolations:
print(repr(interp.expression), '=>', interp.value)
# 'name' => alice
# 'score' => 42.5
注意 strings 的最后一个元素是空字符串。这是有意为之的——它保证了「n 个插值对应 n+1 个静态片段」,这样按顺序拼接的时候不用做特殊的边界处理。
每个 Interpolation 对象上存的东西比想象中多:
value:表达式求值后的结果,原始对象,不经过任何字符串化。expression:表达式的源码字符串。t"{user.name}"里这个字段就是"user.name"。这在做结构化输出的时候特别有用。conversion:格式转换字符,比如!r会得到'r',没写就是None。format_spec:冒号后面的格式描述。t"{x:.2f}"这里就是".2f"。
普通 f-string 会把这些信息吃掉。f"{x:.2f}" 出来就是一个字符串 "3.14",没有任何办法知道它原来是浮点数、用的是什么格式、源表达式是什么。t-string 把这些全部保留了下来,交给处理者(也就是你)去决定怎么用。
二、把它变成字符串:str 与 format
不处理的话,Template 也能当字符串用:
t = t"total = {price:.2f} yuan"
print(str(t)) # total = 3.14 yuan
print(f"{t}") # total = 3.14 yuan
print(format(t)) # total = 3.14 yuan
__str__ 和 __format__ 的行为和 f-string 完全一致,包括 !r、!s、格式描述符全都照常生效。这意味着 t-string 不会破坏那些只需要字符串的地方,可以直接传进去。
但要小心一个反直觉的点:t == "..." 永远为 False。Template 不等于任何字符串,哪怕 str(t) 看起来一样。因为它不是 str 的子类。这点在设计 API 时要考虑到——如果一个函数的参数标注是 str,传 Template 进去不会立即报错,但后续比较、正则匹配、切片之类都会出问题。要用就显式 str() 一下,或者改成接受 Template | str。
三、开始写第一个处理器:SQL 参数化
先从最有价值的一个场景开始。规则很简单:静态部分原样保留,插值部分全部变成占位符 ?,值收集到一个参数元组里。
from string.templatelib import Template, Interpolation
def to_sql(template: Template) -> tuple[str, tuple]:
parts = []
params = []
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
parts.append("?")
params.append(template.interpolations[i].value)
return "".join(parts), tuple(params)
怎么用:
uid = 42
status = "active"
sql, args = to_sql(t"SELECT * FROM users WHERE id = {uid} AND status = {status}")
print(sql) # SELECT * FROM users WHERE id = ? AND status = ?
print(args) # (42, 'active')
cur.execute(sql, args)
关键点在于,这个 to_sql 函数是值无关的。不管 uid 里塞的是 42 还是 "1 OR 1=1 --",函数都会把它变成 ? 丢进参数元组,数据库驱动负责安全绑定。注入在这里不可能发生,因为整个流程里根本没有任何字符串拼接的机会。
这比用 sqlite3 那种 ? 或者 %s 的手写方式好在哪?好在目标是 不可能写错的。当 t"..." 出现在代码里,它就已经不是字符串了,必须过 to_sql 才能用。如果哪天有人不小心把它直接传给 cursor.execute,Template 对象不是 str,驱动会立刻抛类型错误。这就是类型系统在帮忙兜底。
需要标识符插值怎么办
表名、列名不能参数化,只能直接拼,因为 SQL 占位符只对值有效。这是个实际项目中必须处理的问题,方法是对标识符做白名单校验:
import re
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
def safe_ident(value: str) -> str:
if not isinstance(value, str) or not _IDENTIFIER_RE.match(value):
raise ValueError(f"非法标识符: {value!r}")
return value
def to_sql_v2(template: Template) -> tuple[str, tuple]:
from string.templatelib import convert
parts = []
params = []
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
interp = template.interpolations[i]
# 用 format_spec 里的特殊标记区分标识符
if interp.format_spec == "sql:ident":
parts.append(safe_ident(str(interp.value)))
else:
parts.append("?")
params.append(interp.value)
return "".join(parts), tuple(params)
调用方式就说 t"SELECT * FROM {table:sql:ident} WHERE id = {uid}"。表名走白名单,ID 走参数化。这里 format_spec 从「控制格式」变成了「控制插值语义」,虽然有点超出 PEP 750 的原始意图,但社区里已经在用类似的做法了。要是觉得太 hacky,也可以定义一个新的转换字符,比如把 !i 用作标识符标记,Interpolation.conversion 里会拿到 'i'。
四、第二个处理器:HTML 自动转义
HTML 生成的问题和 SQL 一样:随手一写就有 XSS 漏洞。t-string 的思路是让转义成为默认行为,只有明确标记为安全的片段才原样输出。
from html import escape
class SafeHTML:
"""显式标记为不需要转义的内容。"""
__slots__ = ("_html",)
def __init__(self, html: str):
self._html = html
def __str__(self):
return self._html
def __html__(self):
return self._html
def to_html(template: Template) -> SafeHTML:
parts = []
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
value = template.interpolations[i].value
if isinstance(value, SafeHTML):
parts.append(str(value))
else:
parts.append(escape(str(value), quote=True))
return SafeHTML("".join(parts))
用法:
username = "<script>alert(1)</script>"
card = to_html(t"<div class='user'>{username}</div>")
print(card)
# <div class='user'><script>alert(1)</script></div>
值里的尖括号被转义了。再看一个显式标记为安全的例子:
logo = SafeHTML("<img src='/logo.png' alt='logo'>")
header = to_html(t"<header>{logo}<h1>{title}</h1></header>")
print(header)
# <header><img src='/logo.png' alt='logo'><h1>...</h1></header>
logo 里的标签保留了下来。因为它被 SafeHTML 包装过,处理器明确知道这是「开发者自己写的可信内容」,跳过转义。
这个模式和 Jinja2 的 Markup、Django 的 mark_safe 是同一个思想。t-string 的好处在于它把这个思想集成到了语法级别——你没法「忘了转义」或者「忘了标记安全」,因为转义与否取决于值的类型,而不取决于你记得写 escape() 还是 mark_safe()。
五、第三个处理器:结构化日志字段
这个最有意思。用插值表达式的源码字符串当字段名,可以把一行 t-string 直接变成「模板 + 字段」的两部分。
import re
import json
_FIELD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")
def to_log(template: Template) -> tuple[str, dict]:
parts = []
fields = {}
counter = 0
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
interp = template.interpolations[i]
expr = (interp.expression or "").strip()
if _FIELD_RE.fullmatch(expr):
key = expr.split(".")[-1] # user.id -> id
else:
counter += 1
key = f"arg{counter}"
# 同名冲突就加后缀,避免丢字段
base_key = key
suffix = 1
while key in fields:
suffix += 1
key = f"{base_key}_{suffix}"
fields[key] = interp.value
parts.append("{" + key + "}")
return "".join(parts), fields
用法:
user = "alice"
order_id = 9981
amount = 199.00
msg, extra = to_log(t"order paid user={user} order={order_id} amount={amount}")
print(msg) # order paid user={user} order={order_id} amount={amount}
print(extra) # {'user': 'alice', 'order': 9981, 'amount': 199.0}
logger.info(msg, extra=extra)
这个输出可以直接喂给 logging 或者 structlog。日志系统拿格式化消息做展示,拿 extra 做过滤、聚合、报警。一行代码同时满足人类可读和机器可读两个需求。
字段名的推导有一点小细节。t"{user.id}" 里表达式是 "user.id",直接当 key 会带上点号,很多日志系统不喜欢。上面代码里取了最后一段,变成 "id"。如果表达式不是简单的名字(比如 t"{len(items)}"),fullmatch 匹配不上,就退化成 arg1 这样的编号,不丢数据只是不够美观。
六、组合多个处理器
有时候需要同时做几件事。比如生成一份「既安全又带结构化字段」的日志载荷,或者「插入数据库时需要参数化,同时也要保留人类可读的原始值」。可以把处理器串起来:
def composed(*handlers):
def apply(template: Template):
result = template
for h in handlers:
result = h(result)
return result
return apply
不过我不太推荐这种做法。每个处理器有自己的返回形态(一段字符串、一个二元组、一个标记对象),串起来之后类型会变得很混乱。更好的方式是为每个场景单独写一个处理器,把逻辑放在里面。好处是每个处理器的输入输出契约都明确,也就是那句老话——「宁可每个函数简单,不要让组合变得复杂」。
七、几个必须提前知道的事
版本和依赖。t-string 是 Python 3.14 正式引入的。如果你现在跑的是 3.12 或 3.13,用不了。3.14 现在是新版本,部署环境要提前评估。有些团队会在 CI 里跑多个 Python 版本,别忘了在 pyproject.toml 里把 requires-python 升上去。
t-string 不是 str 的子类。上面提过,但值得再说一遍。isinstance(t"...", str) 是 False。任何接受 str 的 API,直接传 Template 进去都可能踩雷——json.dumps 会报错、re.match 会报错、.upper() 没有这个方法。用之前先明确知道对方需要什么类型。
表达式不是惰性的。t-string 在创建的那一刻就把插值表达式全求值了,和 f-string 一样。它改变的是「求值之后怎么处理」,不是「什么时候求值」。想做惰性求值,还是得用 lambda 或者字符串加 eval——两个都不推荐用到生产里。
不要在处理器里做副作用。处理器就是个函数,接收 Template 返回结果。在里面发 HTTP、写文件、改全局变量都能做,但一旦处理器有副作用,测试就不好写了。除非有一个特别明确的理由,否则处理器保持纯函数,副作用留给调用方。
性能。处理器比直接 f-string 慢——多了一层函数调用、多了一次循环、多了一次字符串拼接。在热路径上(一秒钟几十万次调用)要谨慎。但绝大多数场景,比如构造 SQL、生成 HTML 片段、组装日志,都不在这个量级上。先测一遍再决定要不要优化,不要为了理论上的性能损失提前放弃。
别把处理器的复杂性藏在深处。t-string 的卖点是「插值的处理方式一目了然」。如果一个处理器里有一堆分支、一堆正则、层层嵌套,那和当年 f-string 拼一拼再处理本质上没区别,只是换了个地方放。保持每个处理器短小、职责单一。
八、其它能用的场景
除了这三个,其实还有不少地方适合上 t-string。
i18n。把 t"Welcome, {name}" 交给翻译层,翻译器可以对消息做替换、复数处理、占位符重排,而不用担心值已经被拼进去了。gettext 那套现在只能吃字符串,未来支持 Template 之后误用会少很多。
路径拼接。校验每一段都是合法片段、防止 ../ 逃逸,逻辑和 SQL 参数化几乎一样。
Shell 命令构造。本来想让 t-string 做自动转义,但我想了想还是劝退。Shell 语义太复杂(引号、变量展开、通配符),t-string 抽象拿不住。真要拼命令就用 subprocess 的 list 形式,绕开 shell。
邮件与通知模板。一个处理器负责转义用户数据到 HTML,一个处理器负责纯文本版本,一个处理器负责提取字段做追踪。三份输出,一个数据源,不会前后不一致。
九、写在最后
t-string 不是 f-string 的替代品,它是 f-string 的补充。日常拼字符串该怎么写还是怎么写,f-string 依然是最顺手的工具。t-string 的价值在于那些「值不能随便拼」的场景——SQL、HTML、结构化输出,这些场景过去要么靠人记得写转义,要么靠一层专门的小 DSL,现在可以在语言级别表达出来。
如果手头有个项目正被 XSS 或者 SQL 注入的审计追着跑,可以拿一个模块先试点:把其中构造 HTML 或者 SQL 的 f-string 挑出来,改成 t-string 加处理器。改完之后再让静态分析工具跑一遍,看看漏洞是不是自己就少了。这种「写错会直接报类型错误」的安全感,试过一次就回不去了。

