第 05 章 · 字符串与格式化输出
这一章要解决什么:把电芯的编号、批次和容量打印成一张对齐、别人能直接读的小表。 这一章要打掉两个坑:字符串方法全都返回新串、原串不动;以及最让人抓狂的中文全角标点。
本章你需要完成的代码:demos/ch05-string.py
5.1 字符串就是一串字符
cell_id = "C001"
batch = "A"字符串(str)是一串有序的字符。关键词是"有序" —— 因为有序,所以能按下标取、能切片 (和列表同一套规则,第 06 章会看到完全一样的写法)。
| 你看到的 | Python 眼里 |
|---|---|
"C001" | 4 个字符:C、0、0、1 |
" C001 " | 8 个字符,前后各两个空格(空格也是字符) |
"" | 空字符串,长度 0,也是合法的字符串 |
字符串是不可变的(immutable):一旦创建,里面的字符就改不了。这一条会在 5.6 节变成真实的行为差异 —— 所有"看起来在改字符串"的方法,其实都返回了一个新字符串。
5.2 引号与转义
print('单引号 "内容" 双引号都可以')
print("换行用 \\n,制表用 \\t:")
print("第一行\n第二行\t← Tab 之后")
print("原始字符串 r'' 里 \\n 不再是换行:", r"C:\Users\jini\data")| 写法 | 效果 |
|---|---|
"abc" / 'abc' | 两者完全等价,"C001" == 'C001' 是 True |
\n / \t | 换行 / 制表符(Tab) |
\\ | 一个反斜杠要写成两个 |
r"..." | 原始字符串:里面的 \ 不再是转义符 |
引号只要成对,单双都行;引号里要放同种引号就用 \ 转义:"他说 \"你好\""。
为什么路径一定要加 r:\t 是 Tab、\U 是 Unicode 转义的开关。 你写 "C:\user\temp",Python 会把 \t 变成一个 Tab 字符 —— 路径废了,而且不一定报错。 看到路径就加 r,这是肌肉记忆。(Linux / macOS 用 /,不涉及这个问题。)
5.3 三引号:写多行文本
report = """批次: A
电芯: C001
结论: 健康
"""
print(report, end="")"""...""" 里的换行原样保留,不用写 \n。两个正式用途: 多行文本(报告片段、SQL、HTML 模板)和文档字符串 docstring(函数底下第一段字符串, help() 会显示它,第 10 章)。
注意 end="":print 默认在末尾补换行,而三引号字符串自带了结尾换行, 不加 end="" 就会多出一个空行。
5.4 索引与切片:和列表同一套规则
cell_id = "C001"
print(len(cell_id)) # 4
print(cell_id[0]) # 'C' ← 第 1 个,下标 0
print(cell_id[-1]) # '1' ← 最后一个
print(cell_id[1:]) # '001' ← 从下标 1 到结尾
print(cell_id[:1]) # 'C' ← 只要第 1 个把字符串想成一排字符,每个字符下面有一个编号:
C 0 0 1
0 1 2 3 ← 正向下标
-4 -3 -2 -1 ← 反向下标| 写法 | 结果 | 记忆点 |
|---|---|---|
s[0] | 第 1 个字符 | 下标从 0 开始 |
s[-1] | 最后 1 个字符 | 负数从右往左数 |
s[1:] | 从第 2 个到结尾 | 起点省略默认 0 |
s[1:3] | 下标 1、2 | 左闭右开,右端取不到 |
切片"左闭右开"是本书出现频率最高的三个坑之一(第 06 章列表、第 19 章 NumPy 会原封不动再讲一遍)。 字符串和列表的切片唯一区别:caps[1:3] 得到列表,"C001"[1:3] 得到字符串 —— 类型跟着原对象走。
5.5 拼接与重复:+ 和 *
print("用 + 拼接:", "电芯" + cell_id) # 电芯C001
print("用 * 重复:", cell_id * 3) # C001C001C001
print("推荐用 f-string:", f"电芯{cell_id}(批次{batch})")| 运算 | 作用 | 注意 |
|---|---|---|
"a" + "b" | 拼接 | 两边必须都是字符串,"a" + 1 报 TypeError |
"a" * 3 | 重复 | 'aaa';不是乘法 |
"a" "b" | 字面量自动拼接 | 只对写死在代码里的字符串生效 |
* 是重复不是乘法,这是第 04 章埋下的伏笔:
raw = "0.9921" # 从 CSV 里读出来的是字符串
print(raw * 2) # '0.99210.9921' ← 不报错!但完全不是你要的
print(float(raw) * 2) # 1.9842 ← 先转类型才对这是"静默失败"的典型:程序不崩,结果错得离谱。凡是看到"数字"长得像字符串,先 float()。 推荐 f-string 而不是一串 +:+ 容易漏空格、容易把数字和字符串加起来报错,长表达式还不好折行。
5.6 常用方法:全都返回新字符串
s = " C001,Batch-A "
print(s.strip()) # 'C001,Batch-A'
print(s.strip().split(',')) # ['C001', 'Batch-A']
print(s.strip().upper()) # 'C001,BATCH-A'
print(s.strip().replace('-', '_'))# 'C001,Batch_A'
print(cell_id.startswith('C')) # True
print(s.find(',')) # 6
print(s) # ' C001,Batch-A ' ← 原串一点没变!| 方法 | 作用 | 返回 |
|---|---|---|
s.strip() | 去掉首尾空白(lstrip/rstrip 去一侧) | 新字符串 |
s.split(',') | 按分隔符切开 | 列表 ['C001', 'Batch-A'] |
s.replace('a','b') | 替换全部(不是正则) | 新字符串 |
s.upper() / s.lower() | 转大写 / 小写 | 新字符串 |
s.startswith('C') | 判断开头(endswith 判断结尾) | bool |
s.find(',') | 第一次出现的下标,找不到返回 -1 | int |
','.join(['a','b']) | 用分隔符把列表拼成字符串 | 新字符串 |
点开:strip() 不改原串 —— 为什么说这是最重要的一条
s = " C001 "
s.strip()
print(repr(s)) # ' C001 ' ← 白算了!要写 s = s.strip()所有字符串方法都不会改原变量,因为字符串不可变(第 03 章"变量是标签"的直接后果): s.strip() 造了一个新对象 'C001',但没人给它贴标签,随即被回收。
这条规律同样适用于 round(x, 2)(第 04 章)、sorted(x)(第 06 章)—— 都返回新对象。 反过来,list.append() 这类返回 None 的方法才是"原地修改":
| 返回什么 | 行为 | 例子 |
|---|---|---|
| 返回新对象 | 原对象不变 | s.strip()、sorted(x)、x.copy() |
返回 None | 原地修改了 | lst.append()、lst.sort()、lst.pop() |
find 返回 -1 也要留意:if s.find(',') 在找不到时是 -1(真值!), 判断"有没有"要用 if ',' in s:。
split 和 join 是一对:line.split(',') 把一行文本变成列表(第 12 章读 CSV 的核心动作), ",".join(parts) 再拼回去。注意 "a b".split() → ['a','b'](连续空白当一个分隔符), 而 "a b".split(' ') 会留下空串 —— 处理人工输入优先用不带参数的 split()。
5.7 f-string 格式化迷你语言
前面几章已经在用 f"{soh:.2f}",现在把它讲全。格式符的结构是固定的:
{ 值 : [填充字符][对齐符][宽度][.精度][类型] }caps = {"C001": 2.50, "C002": 2.29, "C003": 2.314159}
print(f"{'电芯':<6}{'容量':>10}{'SOH':>8}")
print("-" * 24)
for cid, cap in caps.items():
print(f"{cid:<6}{cap:>10.3f}{cap / 2.5:>8.1%}")输出:
电芯 容量 SOH
------------------------
C001 2.500 100.0%
C002 2.290 91.6%
C003 2.314 92.6%| 写法 | 输出 | 说明 |
|---|---|---|
{x:.2f} | 0.50 | 两位小数 |
{x:.1%} | 50.0% | 百分比(自动乘 100 加 %) |
{x:>8} / {x:<8} / {x:^8} | ab / ab / ab | 右 / 左 / 居中,总宽 8 |
{x:08.2f} | 00003.14 | 用 0 填充到 8 位 |
{x:,} | 1,234,567 | 千分位 |
{x:+.2f} | +3.14 | 强制显示正负号 |
三个必须记住的点:
- 顺序不能乱:写成
{x:.3f>8}会直接报ValueError; - 宽度是"总宽度",不是"补几个空格"(
"C001"已占 4 格,所以只补 4 个空格); - 一张排版单的记忆法:
{ '电芯' : < 6 }= "把『电芯』装进宽 6 格的框里,靠左放"。 数字默认右对齐,因为个位对齐才能一眼比大小。
中文对齐会"看起来不齐":中文字在终端占 2 个显示宽度,但 len('电芯') 是 2。 严格排版要用 tabulate 之类的库。
5.8 f-string 的 = 调试语法
n_cycles = 862
print(f"{n_cycles=}") # n_cycles=862
print(f"{n_cycles * 2=}") # n_cycles * 2=1724
print(f"{soh=:.4f}") # soh=0.9160 ← 加格式符也有效Python 3.8+ 支持在 f-string 里加 =:它会自动把表达式本身也打印出来。 以前排查问题要写 print("n_cycles =", n_cycles),变量名是手打的字符串,改一次变量名就忘了改字符串, 日志立刻开始骗人。f"{x=}" 的"名字"是从代码里取的,永远不会和值对不上。
5.9 完整示例与输出
cd code
python demos/ch05-string.py真实输出(节选):
=== ① 引号 ===
单引号 "内容" 双引号都可以
换行用 \n,制表用 \t:
第一行
第二行 ← Tab 之后
原始字符串 r'' 里 \n 不再是换行: C:\Users\jini\data
=== ③ 索引与切片 ===
cell_id = 'C001',长度 4
cell_id[0] = 'C' ← 第一个字符
cell_id[-1] = '1' ← 最后一个字符
cell_id[1:] = '001' ← 从第 2 个到结尾
=== ④ 拼接与重复 ===
用 * 重复: C001C001C001 ← 不是编号翻倍!字符串乘法是重复
=== ⑤ 常用方法(字符串不可变) ===
原始 : ' C001,Batch-A '
strip() : 'C001,Batch-A'
split(',') : ['C001', 'Batch-A']
find(',') : 6
注意:所有方法都返回新字符串,原变量没变 → ' C001,Batch-A '
=== ⑥ f-string 格式化迷你语言 ===
电芯 容量 SOH
------------------------
C001 2.500 100.0%
C002 2.290 91.6%
C003 2.314 92.6%
=== ⑦ f-string 的调试语法 ===
n_cycles=862 ← 自动打印「表达式=值」,排查问题时省事
=== ⑧ 中文标点是新手第一大坑 ===
错:print("你好") ← 全角括号
错:print("你好“) ← 全角引号
对:print("你好")5.10 常见坑
坑 ①:中文全角标点 —— 新手第一大语法错误。
print("你好") # ❌ SyntaxError: invalid character '('
print("你好“) # ❌ 引号没配对编辑器里 ( ) , “ ” : 和 ( ) , " " : 长得极像,但 Python 只认半角。 中文输入法开着写代码 = 迟早踩。
坑 ②:以为 strip() 改了原串。 要写 s = s.strip(),理由见 5.6。
坑 ③:split() 返回的是列表,不是字符串。
parts = "C001,2.50".split(',')
parts.upper() # ❌ AttributeError: 'list' object has no attribute 'upper'
parts[0].upper() # ✅ C001方法用错对象时,先 print(type(x))。
坑 ④:数字字符串做运算。 "2.50" + "2.50" 是 '2.502.50'、"2.50" * 2 是 '2.502.50', 都不报错。要算数先 float()。
坑 ⑤:find 返回 -1 被当成真值。
if s.find("A"): # -1 是真值!条件成立 → 逻辑全反用 if "A" in s: 或 if s.find("A") != -1:。
坑 ⑥:f-string 里嵌套引号。 Python 3.12 之前内外不能同种引号, 写 f"{cell['id']}"(外双内单)最安全。
坑 ⑦:拼接时忘了类型统一。 "SOH = " + 0.92 报 TypeError; 用 f"SOH = {0.92}" 一次解决。
本章练习
练习 1:把电芯信息排版成一张三列表格
用 f-string 的格式化迷你语言打印一张表:
- 三列:编号(左对齐宽 8)、容量(右对齐宽 12、保留 3 位小数)、SOH(右对齐宽 8、百分比 1 位);
- 表头用同样的宽度写;分隔线用
"-" * 32生成; - 数据两行:
C001 2.50、C002 2.29(额定 2.5 Ah); - 最后单独打印组合编号,形如
A-C001。
期望输出(节选):
电芯 容量 (Ah) SOH
--------------------------------
C001 2.500 100.0%
C002 2.290 91.6%
组合编号:A-C001参考答案
cell_id = "C001"
batch = "A"
cap_rated = 2.5
rows = [("C001", 2.50), ("C002", 2.29)]
# ① 表头:和数据行用完全相同的宽度,这是"对齐"的全部秘密
print(f"{'电芯':<8}{'容量 (Ah)':>12}{'SOH':>8}")
# ② 分隔线:用 * 重复,别手打 32 个减号
print("-" * 32)
# ③ 数据行:对齐符、宽度、精度写在同一个格式符里
for cid, cap in rows:
print(f"{cid:<8}{cap:>12.3f}{cap / cap_rated:>8.1%}")
# ④ 组合编号:f-string 直接拼,不用 +
print(f"组合编号:{batch}-{cell_id}")要点解释:
- 对齐的秘诀是"表头和数据用同一个宽度"。列宽 8 就都写 8。
<>后面是总宽度(含数字本身),不是"补几个空格"。 - 格式符顺序固定:
{值:[填充][对齐][宽度][.精度][类型]}, 写成{x:.3f>8}会报ValueError: Invalid format specifier。 :.1%自动乘 100:2.50 / 2.5 = 1.0→100.0%。- 中文列宽的小陷阱:
"电芯"的len()是 2,但显示占 4 格, 中英混排会"看起来差一点",严格排版要用tabulate。
完整练习在 exercises/ex04-05-string (仓库的 solution.py 用的是 :<10 / :>12,宽度不同、输出也就略不同 —— 宽度是排版选择,不是语法)。
练习 2:从一行文本里抽出字段
给定一行"从 CSV 里读出来"的字符串 line = " C001,2.50,0.92 ",依次打印:
- 用
strip()去掉首尾空格后的字符串; - 用
split(',')切出来的列表; - 把第 2 个字段转成
float后乘 1000(Ah → mAh),形如2500.0 mAh; - 逗号第一次出现的下标(用
find)。
期望输出:
'C001,2.50,0.92'
['C001', '2.50', '0.92']
2500.0 mAh
逗号第一次出现的下标:4参考答案
line = " C001,2.50,0.92 "
# ① strip 返回新串,必须接收返回值
clean = line.strip()
print(repr(clean))
# ② split 返回的是列表
parts = clean.split(',')
print(parts)
# ③ 从"文件"读出来的一定是字符串,算数前必须转类型
cap_ah = float(parts[1])
print(f"{cap_ah * 1000} mAh")
# ④ find 返回下标(找不到是 -1)
print(f"逗号第一次出现的下标:{clean.find(',')}")三个要点:
- 每个方法都返回新对象:
clean = line.strip()、parts = clean.split(',')。 直接写line.strip()不接收返回值 = 白算 —— 这是本章最容易犯的错。 float(parts[1])不能省:parts[1]是'2.50',乘以 1000 会得到'2.502.50...'(重复 1000 次!),不报错但完全错。- 打印字符串用
repr()或!r,能看见引号和空格 —— 调试"空格到底删掉没有"时,print(s)根本看不出来。
练习 3:预测输出(不要运行,先想)
s = "C001"
print(s[1:3])
print(s[-2:])
print(s * 2)
print(len(s))
raw = "3.5"
print(raw + raw)
print(float(raw) + float(raw))
x = 0.916
print(f"{x:.1%}")
print(f"{x=}")要求:先写下每一行的输出,再运行验证。不一致的地方,说出原因。
参考答案
预测输出:
00
01
C001C001
4
3.53.5
7.0
91.6%
x=0.916| 表达式 | 结果 | 为什么 |
|---|---|---|
s[1:3] | '00' | 左闭右开,取下标 1、2,不含 3 |
s[-2:] | '01' | 从倒数第 2 个到结尾 —— 负下标从右往左数(画一遍下标就不会错) |
s * 2 | 'C001C001' | * 对字符串是重复 |
raw + raw | '3.53.5' | + 对字符串是拼接,不是加法 |
float(raw)+float(raw) | 7.0 | 先转类型才是数学加法 |
f"{x:.1%}" | '91.6%' | 自动乘 100 加 %,保留 1 位 |
f"{x=}" | 'x=0.916' | = 语法自动带上表达式名 |
最容易错的两行:
s[-2:]是'01'不是'C0':负下标从右往左数,-2指倒数第 2 个字符。raw + raw是拼接:它和float(...) + float(...)都合法、都不报错, 但一个是字符串拼接、一个是数值加法 —— 同一段代码都不报错,结果却完全不同, 这正是"读进来的数字必须先转类型"的原因。
面向 AI 开发:字符串是"模型与数据之间的接口"
在 AI / 数据分析项目里,字符串几乎不是"给人看的文字",而是数据的载体和接口:
① 日志与实验结果全是字符串。
line = "epoch=12 train_loss=0.2341 val_loss=0.3102"
d = dict(kv.split("=") for kv in line.split())
print(float(d["val_loss"])) # 0.3102训练日志、实验记录都要用 split / find / startswith 解析。 f"{x=}" 就是为这个场景生的:print(f"{loss=:.4f}") 直接产出 loss=0.2341, 可以整段丢进实验记录,不会出现"变量名和值对不上"的日志。
② 提示词(prompt)就是字符串拼接。
prompt = f"""你在检查一批电池数据。
批次: {batch}
SOH 下限: {soh_min:.2f}
请指出哪几支电芯需要复检。
"""三引号(多行)+ f-string(插值)+ 格式符(精度)是一套组合拳。 数值一定要统一精度:{soh:.2f} 比 {soh} 稳定得多,否则同一个值可能打印成 0.9199999999999999,白白让日志和模型困惑。
③ 字段值往往是字符串,类型转换是第一道工序。
| 场景 | 典型字符串 | 要做什么 |
|---|---|---|
| 读 CSV | "0.9921" | float(),否则 * 2 变重复 |
| 读配置 | "true" / "0.1" | == "true"、float() |
| 命令行参数 | "42" | int() |
| JSON / API 返回 | "2023-01-01" | pd.to_datetime()(第 29 章) |
④ 文件名用 f-string 生成,是批量导出的标准动作。
for ch in (1, 2, 3):
out = f"soh_ch{ch:02d}.csv" # soh_ch01.csv ← 02d 自动补零,文件排序才对没有 02d,soh_ch10.csv 会排在 soh_ch2.csv 前面(字符串排序按字符比较)—— 这是批量导出时的经典坑。
一句话总结:字符串看似"只是打印好看一点",其实同时管着 输入解析(split/find/strip)、输出规范(f-string 格式符)、接口拼接(prompt/路径) —— 而这三件事里的每一个错误,通常都不报错。