返回首页
## 引言
前四篇把第二章的类型地图铺完了:名字与绑定、int/float/complex、布尔与真值、字符串字面量与转义。现在到了「输出面」的问题——**把变量的值变成人类可读的字符串**。这是第二章的第五篇,也是前半章最后的纯语法主题,它把前面所有类型串成一个共同的出口:不管数值、布尔还是字符串,最终都要格式化进一句人话里。
Python 的字符串格式化有三代语法,同一个功能演进出了三种写法:
1. **%-format**:Python 诞生起就有的老语法,直接继承 C 语言的 printf;
2. **str.format()**:Python 2.6 / 3.0 引入,用 {} 占位符解决顺序与命名问题;
3. **f-string**:PEP 498,Python 3.6 引入,把表达式直接写进字符串,Python 3.8 增加调试语法,3.12(PEP 701)重写了解析器、放宽引号限制并显著提速。
这三代在今天的生产代码里并存——老项目、框架源码、标准库文档、新代码各不相同。读懂三代各自的语法与局限,你才能既看懂别人的代码,又写出最现代的代码。本篇将给出三代的完整语法、真实输出与同一个基准场景下的实测性能对比(本机 Python 3.12 实测,非拍脑袋数字),最后给出「什么时候用哪一个」的工程判断。
为什么需要三代?看同一需求的三次写法就明白:% 时代把「模板」与「参数」分开,参数一多就得回头数占位符;format 时代允许命名与编号,但表达式得先算好传进去,模板与数据仍是两层;f-string 让模板直接引用作用域里的名字与表达式,人脑到代码的翻译距离最短。三代不是推翻重来,而是各自解决了前一代最痛的问题——存量代码里 % 与 format 都还活着,看懂它们是工程必修,写新代码则直接上 f-string。
## 概念与原理
### 第一代 %-format:printf 的直系后代
% 格式化沿袭 C 的 printf 惯例:字符串里的 %s、%d、%.2f 等占位符,由右侧的元组按顺序填充。`%s` 调用 str() 格式化任意对象,`%r` 调用 repr()(带引号的调试表示),`%d` 要求整数(传入浮点会静默截断,传入字符串报 TypeError),`%f` 是浮点,`%.2f` 固定两位小数,`%%` 转义出字面百分号。还能用字典做命名填充 %(name)s。
它有两个硬伤:其一,占位符与参数的对应关系只能靠「数位置」,参数一多容易错位,而且错位不会报错、只会出脏数据;其二,类型由占位符说了算,%s 和 %d 的宽容程度不一致,行为靠记忆。今天的官方文档明确建议新代码不再使用 % 格式化。
### 第二代 str.format():顺序与命名
.format() 用 {} 代替 %:`"{} {}".format(a, b)` 按顺序填充,`"{0} {1}".format(a, b)` 按编号,`"{name}".format(name=...)` 按关键字。它把格式规约(format spec)统一为字符串语法:`{值:规约}`,规约的完整形态为 `[[填充]对齐][符号][#][0][宽度][,][.精度][类型]`,比如 `{:.2f}`、`{:,}`、`{:>10}`。逐段解读:对齐符 < 左对齐、> 右对齐、^ 居中、= 只对数字生效(把填充放到符号之后);填充符默认空格,可换成任意字符;符号标志 + 让正数也带正号、- 是默认、空格让正数前留一个空格;# 让 0b/0o/0x 进制输出带上前缀;0 表示宽度不足补零;宽度可带千分位逗号;精度对浮点指小数位数、对字符串指截取的最大字符数;最后的类型字母决定呈现形式。这套规约在 format 与 f-string 中完全通用,% 只支持它的一个子集——背下结构,比背下每种字母的用法更重要。字面花括号要写 {{}} 转义。相比 %,它解决了顺序与命名,但表达式仍要预先算好传出,写法依然绕。
### 第三代 f-string:表达式内联
f-string 在字面量上直接求值:f"{expr}" 把表达式的结果填进去,表达式可以是算术、函数调用、属性访问、切片。Python 3.8 起 f"{x=}" 输出 `x=值` 的调试写法;转换标志 !r / !s / !a 分别对应 repr() / str() / ascii();格式规约与 format 完全同源。3.12 的 PEP 701 把 f-string 从「重新分词」的旧实现改成常规解析器直接参与,允许在 3.10/3.11 里非法的同引号嵌套与反斜杠(如 f"{d["k"]}"),并且消除了旧的性能惩罚。标准库还有两个「表亲」:string.Template($name 风格)适合对用户提供的模板做安全替换,logging 模块则内置 % 风格(log.info("x=%s", x))支持延迟到实际输出时再格式化——它们各有专门场景,将在标准库与工程化章节再会。
### 性能差异的真实来源
三代在语法糖之外,机器层面也不同:% 和 f-string 在编译期把模板「定型」——f-string 编译为一条条 FORMAT_VALUE 后再用一条 BUILD_STRING 合并的指令序列(本机可用 dis 模块核验),运行期没有模板解析步骤;.format() 则每次都要在运行时重新解析模板字符串、构建字段解析器。因此热点循环里通常 f-string 最快、% 次之、.format() 最慢。当然,日志、单次格式化这类场景,三者差距在百纳秒级,正确性与可读性才是首要决策依据——性能结论只对「每秒数十万次的格式化循环」有意义。
## 操作与实现
先用同一份数据跑通三代,验证输出完全一致。
```python
name, score = "Ada", 98.5
print("打分:%s,得分:%.1f" % (name, score))
print("打分:{},得分:{:.1f}".format(name, score))
print(f"打分:{name},得分:{score:.1f}")
```
三行输出都是 `打分:Ada,得分:98.5`。同一件事三种写法,这就是演进的成果:第三种最接近自然语言,表达式中还能直接写算术——`f"及格线是 {score - 60} 分"`。
%-format 的完整语法与边角。
```python
print("整数 %d,浮点 %.2f,字符串 %s" % (10, 3.14159, "ok")) # 整数 10,浮点 3.14,字符串 ok
print("%s vs %r" % ("文本", "文本")) # 文本 vs '文本'
print("完成度 %d%%" % 87) # 完成度 87%:%% 才是字面百分号
print("%(name)s 得 %(score)d 分" % {"name": "李雷", "score": 92}) # 字典命名填充
print("%d" % 1.5) # 1:%d 对浮点静默截断
try:
"%d" % "abc"
except TypeError as e:
print("TypeError:", e) # %d format: a real number is required, not str
```
注意最后一个块:%d 收浮点会截断(1.5 → 1),收字符串直接 TypeError——这就是第一代「类型由占位符说了算」的写照。
str.format 的顺序、命名与规约。
```python
print("{} -- {}".format("左", "右")) # 左 -- 右:按顺序
print("{1} 在前,{0} 在后".format("A", "B")) # B 在前,A 在后:按编号
print("{name} 今年 {age} 岁".format(name="小王", age=18)) # 小王 今年 18 岁:按名字
print("{:.2f}".format(3.14159)) # 3.14
print("{:,}".format(1234567)) # 1,234,567:千分位
print("{:>8}|{:<8}|{:^8}".format("右", "左", "中")) # 右对齐|左对齐|居中
print("花括号要转义:{{字面内容}}".format()) # 花括号要转义:{字面内容}
print("十六进制 {0:x},二进制 {0:b}".format(255)) # 十六进制 ff,二进制 11111111
```
对齐演示的真实输出是 ` 右|左 | 中 `——> 8 右对齐、< 8 左对齐、^ 8 居中,这是打印对齐报表(表格、日志列)的标准工具。
f-string 的表达式能力与调试语法。
```python
price, count, width = 3.14159, 3, 10
print(f"单价 {price:.2f} 元 × {count} 件 = {price * count:.2f} 元") # 表达式直接求值
print(f"{price=}") # price=3.14159:3.8+ 调试写法
print(f"{price!r}") # 3.14159:!r 强制 repr 表示
print(f"{price:>{width}}") # 用变量控制宽度,输出 " 3.14159"
book = {"title": "Python 基础", "pages": 320}
print(f"《{book['title']}》共 {book['pages']} 页") # 字典下标取值(3.10 经典写法)
from datetime import datetime
now = datetime(2026, 8, 31, 10, 30, 0)
print(f"{now:%Y-%m-%d %H:%M}") # 2026-08-31 10:30:datetime 直接进规约
```
f"{now:%Y-%m-%d %H:%M}" 是 f-string 的招牌特性:格式规约直接透传给对象的 __format__ 方法,datetime 借此把「转字符串」写成一行。f"{price=}" 在调试日志里能省下大量手写 `price=` 前缀的工夫。
规约还支持嵌套:把宽度与精度本身交给变量,`f"{3.14159:{width}.{prec}f}"` 在 width=8、prec=2 时输出 ` 3.14`(本机验证)——报表系统里根据列宽动态生成格式串时,这是不二之选。
3.12 的 PEP 701 引号放宽,单独验证。
```python
d = {"k": 1}
print(f"{d["k"]}") # 1:Python 3.12+ 允许与外部相同的引号(PEP 701)
print(f"{'a'}{"b"}") # ab:单双引号混搭亦可
```
这两行在 3.10/3.11 里是 SyntaxError,3.12 起合法。如果你维护的代码要兼容 3.11 及更早版本,请继续使用 f"{d['k']}" 的写法——兼容性判断本身就是工程能力的一部分。
性能对比:本机 Python 3.12.10 实测,一百万次格式化同一句文案。
```python
import timeit
setup = "name = 'Ada'; score = 98.5"
n = 1_000_000
for label, stmt in [
("%-format", '"打分:%s,得分:%.1f" % (name, score)'),
("str.format", '"打分:{},得分:{:.1f}".format(name, score)'),
("f-string", 'f"打分:{name},得分:{score:.1f}"'),
]:
t = timeit.timeit(stmt, setup=setup, number=n)
print(f"{label:>12}: {t:.4f} 秒")
```
本机一次采样输出(不同机器、不同运行会有波动,关注量级而非小数):
```text
%-format: 0.2865 秒
str.format: 0.3440 秒
f-string: 0.2768 秒
```
量级结论:同一场景下 f-string 与 %-format 名列前茅、互有胜负(多次采样中 % 偶尔更快),str.format 稳定地慢约两成——它每次都要重新解析模板。这不是「f-string 比 format 快 100 倍」的夸张说法:单次格式化的差距在百纳秒级,只有写入热点循环才值得在意。
## 易错点与陷阱
### 陷阱一:% 与元组的括号错位
`"%s %s" % name, age` 是新手第一坑:% 优先级高于逗号,这句等价于 `("%s %s" % name), age`,先对单个字符串 name 取模,报 "not enough arguments for format string"。正确写法永远是 `"%s %s" % (name, age)`。同理,`%d` 对非数字直接 TypeError,别指望它像 %s 那样宽容地转字符串。
### 陷阱二:花括号的转义与表达式的闭包
format 和 f-string 里,字面花括号必须写成 {{ 和 }},`f"{{1}}"` 输出 `{1}` 而不是报错——这常常让「想打一对花括号」的人抓狂。另一个边界在 3.12 之前:f-string 的表达式里不能出现反斜杠(f"{'\n'}" 是 SyntaxError),字典键必须用另一套引号。升级到 3.12 后这些限制解除,但老代码里踩过的坑不会自动消失。
### 陷阱三:规约里的类型错误静默化
`f"{3.14:.2f}"` 得到 3.14,而 `f"{'abc':.2f}"` 会抛 ValueError: Unknown format code 'f' for object of type 'str';`f"{3.14159:d}"` 则抛 ValueError 因为 d 要求整数。规约本身是「类型越界即报错」,但 % 时代留下的 `%d % 1.5` 静默截断习惯,会让新手把格式化的锅甩给「数值被改了」。记住:**规约不转换类型,只格式化类型**,类型不匹配时它宁可报错也不将就。
### 陷阱四:把性能结论用错场景
看到上面的实测就「全项目弃 format 改 f-string」是过度解读:日志输出、用户界面拼文案,每秒几百次,三种写法差距无感知,而 %-format 在存量的日志框架(logging)里仍是默认参数风格。真正该用 f-string 的场合是「新代码 + 可读性优先」;真正该跑 timeit 的场合是「确认过热点」之后。性能优化的第一原则永远是先测再改,这与第六节的原则一致。
## 小结
三代格式化各有定位:%-format 是 C 语言 printf 的遗产,语法古老、类型靠占位符猜,官方已不推荐新代码使用但存量庞大;str.format 解决顺序与命名,规约语法完整;f-string 把表达式内联进字面量,3.8 有调试语法、3.12 经 PEP 701 重写后语法更强、性能更快。同一文案三种写法输出一致,差异在可读性与微基准性能:本机百万次实测 f-string 与 %-format 同档互有胜负(一次采样 0.277/0.287 秒),.format 稳定慢约两成(0.344 秒)。工程判断:新代码写 f-string,兼容 3.11 及以下时避开新引号语法,热点循环再谈性能。下一篇,第二章进入「类型转换」,把 int("...")、str(...) 这些转换函数与隐式转换的坑一次讲清。
## 练习与思考题
1. 用三种语法分别将元组 (2026, 8, 31) 格式化为 "2026-08-31",要求月、日补零到两位,并运行验证三行输出一致。
2. 写出 `"{:>10,.2f}".format(1234567.891)` 的输出并解释规约每一段的含义(对齐、千分位、精度、类型)。
3. 运行 `f"{3.14159:d}"` 与 `"%d" % 3.14159`,对比两者的行为差异,并用「规约不转换类型」解释为什么。
4. 思考题:为什么 3.12 之前 f"{d["k"]}" 是语法错误?提示:旧实现把 f-string 当作「字符串 + 重新解析表达式」,词法上无法区分第一个双引号是表达式定界符还是内部字符串定界符;PEP 701 让解析器像解析普通代码一样解析表达式后,这个问题自然消失。