str 是"人话" (Unicode 码点), bytes 是"电线上的字节" — encode/decode 双向管道两端 codec 必须一致, 90% 的乱码事故都发生在这条管子上
bytearray 是可变版, 做缓冲区拼装不复制encoding='utf-8'PYTHONUTF8=1 兜底 locale 差异len(str) 数码点 — 不等于字节数len(bytes) 数字节 — 存储带宽按它算把 str 想成"写在纸上的字", 把 bytes 想成"电报码": 纸上的字人人可读, 但上电线前必须按某本密码本 (codec) 编成数字, 对端再按同一本密码本解回来。90% 的乱码事故就出在两头密码本不一致: 运气差一点, 解码器直接抛 UnicodeDecodeError, 你立刻知道错在哪; 运气"好"一点, 字节恰好合法, 解出一个 '䏿\x96\x87' 不报错 — 这种静默 mojibake 才是真正难查的。记住边界纪律: 数据进门第一件事 decode 成 str, 出门最后一刻才 encode 成 bytes, 中间全流程只见"人话"。
s = "中文" # str: 码点序列, 不可变 b = s.encode("utf-8") # → b'\xe4\xb8\xad\xe6\x96\x87' buf = bytearray() # 关键: 可变缓冲区 buf += b # 反复拼装不产生新对象
ord('中') == 20013), 编码是编号的存储方案。同一字符在 UTF-8/GBK/UTF-16 里字节完全不同, 但码点只有一个。
print(ord("中")) # → 20013 码点唯一 "中".encode("utf-8") # → b'\xe4\xb8\xad' 3 字节 "中".encode("gbk") # → b'\xd6\xd0' 2 字节
10xxxxxx。兼容 ASCII 是它通吃 Web 的原因。
for ch in ["a", "中", "😀"]: print(ch, len(ch.encode())) # → a 1 / 中 3 / 😀 4 # 关键: 首字节标记长度, 续字节都是 10xxxxxx
len(s) 数码点, len(s.encode()) 数字节, 终端显示宽度是第三件事。算存储/带宽/协议长度字段, 永远用字节那个。
s = "中文😀" print(len(s), len(s.encode())) # → 3 10 (3+3+4) # 关键: 协议长度字段/带宽配额永远用字节数
b'\xe4\xb8\xad'。bytes 里"没有中文", 只有字节值; b'中' 直接 SyntaxError。
b = b"\xe4\xb8\xad" # 字节字面量 b.decode("utf-8") # → '中' # 错: b'中' → SyntaxError: bytes 里只有字节值
f"{x=}" 调试打印名字和值; {n:,} 千分位、{r:.1%} 百分比、{s:>10} 对齐 — 报表格式化一行搞定, 别手写循环插逗号。
x, n, r = 3.14159, 1234567, 0.256 f"{x=:.2f}" # → 'x=3.14' 调试直接带名字 f"{n:,}" # → '1,234,567' 千分位 f"{r:.1%}" # → '25.6%' 百分比
is 比较字符串是坏习惯, 永远用 ==。
a = "hello"; b = "hel" + "lo" # 编译期合并并驻留 c = "".join(["hel", "lo"]) # 运行期生成 a is b, a is c # → (True, False) is 看运气 a == c # → True 关键: 判等永远用 ==
split 按分隔符切列表; partition 切成 (前, 分隔符, 后) 三段, 解析 "key=value" 这种只需切一刀的场景更稳。
"key=value=1".partition("=") # → ('key','=','value=1') "a,b,c".split(",") # → ['a','b','c'] # 只切一刀用 partition, 不怕分隔符再出现
strip/lstrip/rstrip 的参数是字符集合不是子串: strip('ab') 把开头的 a、b 连续剥掉。去固定前后缀用 3.9+ 的 removeprefix/removesuffix。
"abtestab".strip("ab") # → 'test' 剥字符集合 "abtest".removeprefix("ab") # → 'test' 去固定前缀
rb'...', 两边类型必须一致, 否则 TypeError。
re.search(r"\d+", "a12") # str 模式配 str re.search(rb"\d+", b"a12") # bytes 模式配 bytes re.search(rb"\d+", "a12") # 错: → TypeError 混用
len≠1, 字节级截断必出"半个人"。
print(len("👨👩👧👦")) # → 7 4 个 emoji + 3 个 ZWJ # 关键: len≠1, 码点级截断 s[:3] 必出"半个人" # 按"字素簇"数要 grapheme 库
strict 抛错 (默认)、replace 换 U+FFFD、ignore 丢弃、surrogateescape 无损往返未知字节 — 兜底要选看得见代价的那种并计数。
b"\xff".decode("utf-8", errors="replace") # → '\ufffd' b"\xff".decode("utf-8", errors="ignore") # → '' b"\xff".decode("utf-8", errors="surrogateescape") # → '\udcff' 再 encode 同参数可无损往返
上游日志 0.01% 是坏字节, strict 让整条管道天天中断, ignore 又无声丢字。折中方案是 replace + 计数:
REPLACED = 0 def safe_decode(raw: bytes, source: str) -> str: global REPLACED text = raw.decode("utf-8", errors="replace") # U+FFFD 占位, 不中断管道 n = text.count("\ufffd") if n: REPLACED += n metrics.incr(f"etl.bad_utf8.{source}", n) # 告警带来源标签 return text # 批结束检查: 替换率超 0.1% 就挡批隔离, 别让脏数据静默入库 if total and REPLACED / total > 0.001: raise BatchQuarantined(REPLACED, total)
同一段代码 macOS 正常、Windows 全是乱码: open() 不传 encoding 时用的是 locale.getpreferredencoding(), 各平台不一样:
# 事故根因: Windows 默认 cp936(GBK); 容器 LANG=C 时默认 ANSI_X3.4(ascii) # 不传 encoding = 把编码决定权交给运行环境的 locale, 必出事 with open("data.txt", "w", encoding="utf-8", newline="\n") as f: f.write(text) # 换行符也钉死, 防 Windows 写出 \r\n import locale # 自检: 打印当前默认, CI 里断言它是 utf-8, 环境漂移当场暴露 assert locale.getpreferredencoding(False).lower() in ("utf-8", "utf8") # 容器一次性兜底: Dockerfile 里加 ENV PYTHONUTF8=1
TCP 是字节流不自带消息边界, 协议层是 bytes 的主场, 一个字节都不能含糊:
import struct def encode_frame(payload: bytes) -> bytes: # 4 字节大端长度前缀 + 内容: 对端才知道一条消息到哪结束 return struct.pack("!I", len(payload)) + payload def recv_exact(sock, n: int) -> bytes: # recv 可能短读, 必须循环凑满 buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("peer closed") buf += chunk return buf def read_frame(sock) -> bytes: (length,) = struct.unpack("!I", recv_exact(sock, 4)) if length > MAX_FRAME: # 防恶意超大帧撑爆内存 raise ProtocolError(length) return recv_exact(sock, length)
模板复用用 .format, 拼日志用 f-string; 但 SQL 拼字符串等于给攻击者开门:
# 日志: f-string 直接拼, 一次性的用完即弃 log.info(f"order={oid} paid={amount:.2f} at={ts:%H:%M:%S}") # 会被复用/翻译的模板: .format 留坑位 TEMPLATE = "user={uid} region={region} latency={ms:.1f}ms" log.debug(TEMPLATE.format(uid=u.id, region=r, ms=lat)) # SQL: 永远参数绑定 — 拼字符串 = SQL 注入 + 放弃预编译缓存 cur.execute( "SELECT id FROM orders WHERE user_id = %s AND status = %s", (uid, "paid"), ) # 表名/排序字段没法参数化: 白名单校验后再拼 assert sort_col in {"created_at", "amount"} sql = f"ORDER BY {sort_col}"
给运营导周报, 金额要千分位两位小数、占比要百分比、列要对齐 — 全在 spec 里声明:
rows = [
("华东", 12_345_678.9, 0.1872),
("华南", 9_876_543.2, 0.2419),
]
for region, amount, ratio in rows:
# 一行 spec: 左对齐 / 千分位 / 百分比, 别手写循环插逗号
print(f"{region:<4} {amount:>14,.2f} {ratio:>7.1%}")
# 华东 12,345,678.90 18.7%
# 华南 9,876,543.20 24.2%
debug = f"{dt:%Y-%m-%d %H:%M} value={value=}" # = 号连名带值一起打
千万行 CSV 里有几十行坏编码, 整批失败不可接受。跳过 + 留证 + 告警:
import csv bad_rows = [] with open("users.csv", encoding="utf-8", errors="replace", newline="") as f: # csv 模块 newline='' 必传 for lineno, row in enumerate(csv.reader(f), 1): if "\ufffd" in "".join(row): bad_rows.append((lineno, row[:3])) # 采样留证 continue ingest(row) if bad_rows: alert.fire(f"{len(bad_rows)} dirty rows", sample=bad_rows[:5]) # 妥协可见: 跳了多少行、长什么样, 都在告警里
要按"区间"抽 20GB 文本的样本, 直觉用字节切片 — 3 字节的'中'会被拦腰斩断成非法字节:
def sample_lines(path: str, lo: int, hi: int) -> list[str]: out = [] with open(path, encoding="utf-8") as f: # 按行读: 行边界永不切断码点 for line in f: if lo < len(line) <= hi: out.append(line) return out # 确实要按字节定位 (如断点续读 offset): 先记住字节偏移, # 读取后从该偏移往后找第一个行首再开始 decode — 别在字节中间下刀 # UTF-8 续字节都是 10xxxxxx: 切在它前面, decode 必炸
列表页要截 12 个字符的昵称, 旧代码按字节切, emoji 用户全变乱码:
import unicodedata def truncate(text: str, limit: int) -> str: if len(text) <= limit: return text cut = text[:limit] # 按码点切, 不按字节 — 不会出半个人 while cut and unicodedata.combining(cut[-1]): cut = cut[:-1] # 尾部不能是悬空的组合符号 return cut + "…" name = truncate("👨👩👧👦 一家四口", 4) # 坏版本 name.encode('utf-8')[:12].decode('utf-8') → UnicodeDecodeError
供应商导出文件有时 GBK 有时 UTF-8, 报错才改永远晚一步。入口先检测 + 核对:
from charset_normalizer import from_bytes def sniff(raw: bytes) -> str: # 检测 + 置信度: 只看头部 512KB, 大文件别全量喂 best = from_bytes(raw[:512_000]).best() if best is None: raise ValueError("cannot detect encoding") return str(best.encoding) raw = fetch_supplier_dump() text = raw.decode(sniff(raw)) # 检测不是真理: 双字节编码常互相兼容, 关键表抽 3 行人肉核对再入库
hashlib 只吃 bytes, 传 str 直接 TypeError; 更隐蔽的是组合字符让"看起来一样"的字符串指纹不同:
import hashlib, hmac, unicodedata # 坑: hashlib 要 bytes, 传 str 直接 TypeError: Unicode-objects must be encoded # 更深的坑: 'café' 的 é 可能是 1 个码点, 也可能是 e + 组合符 2 个码点 normalized = unicodedata.normalize("NFC", payload["name"]) digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() # 签名同理: key 和 body 都要 bytes sig = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() # 对账接口先 normalize 再算指纹, 否则"同一个名字"对出两个哈希
UnicodeEncodeError: LANG=C 时默认 codec 是 ascii。正解: 所有 open() 显式 encoding='utf-8', 容器加 ENV PYTHONUTF8=1 一劳永逸。
# 错: open("a.txt", "w") → LANG=C 容器里默认 ascii open("a.txt", "w", encoding="utf-8") # 对: 显式 # Dockerfile: ENV PYTHONUTF8=1 一劳永逸
'gbk' codec can't encode character '\U0001F600'。正解: 输出端显式 utf-8; 必须兼容 GBK 通道就 errors='replace' 并告警。
# 错: "看板😀".encode("gbk") # → 'gbk' codec can't encode character '\U0001F600' "看板😀".encode("gbk", errors="replace") # 对: 换 U+FFFD 并告警
'䏿\x96\x87', 比抛错难查十倍。正解: 入口统一 utf-8 + 关键字段断言 (如订单号正则不过就挡), 存疑用 charset-normalizer 复核。
"中文".encode().decode("latin-1") # 错: → '䏿\x96\x87' 静默乱码 raw.decode("utf-8") # 对: 入口统一, 错 codec 当场抛 # 关键字段加断言: 订单号正则不过就挡
len('中文')==2 但 UTF-8 占 6 字节, 按码点数算配额直接少算 3 倍。正解: 涉及容量一律 len(s.encode('utf-8')); 数据库字段长度先确认是字节还是字符口径。
print(len("中文")) # 错: → 2 当存储算, 少算 3 倍 print(len("中文".encode("utf-8"))) # 对: → 6 # DB 字段长度先确认是字节还是字符口径
'-' * 10**8 一步造出 100MB 不可变对象, 再拼接又复制一份。正解: 分块流式输出, 或用 bytearray/file.write 循环写, 别物化整个大字符串。
# 错: sep = "-" * 10**8 一步物化 100MB 不可变对象 for chunk in gen(): # 对: 分块流式输出 f.write(chunk) # 不物化整个大字符串
''.join(parts); 已有分段迭代器直接 io.StringIO 或生成器。
# 错: for x in items: s += x 百万次 0.1s → 30s parts = [] for x in items: parts.append(x) s = "".join(parts) # 对: 一次拼完
'abxcab'.strip('ab') 把开头连续的 a、b 全剥掉得 'xc', 不是去掉 "ab" 前缀。正解: 去固定前后缀用 removeprefix('ab'); 去中间子串用 replace 或切片。
"abxcab".strip("ab") # 错: → 'xc' 剥字符集合 "abxcab".removeprefix("ab") # 对: → 'xcab' 固定前缀
'a b' 前者按任意空白切并合并得 ['a','b'], 后者按单个空格切出空串 ['a','','b']。正解: 解析日志/CSV 用 split() 或 split(None, maxsplit); 需要保留空字段才显式 ' '。
"a b".split() # → ['a', 'b'] 合并任意空白 "a b".split(" ") # → ['a', '', 'b'] 出空串 # 解析日志用 split() 或 split(None, 1) 限次数
b'ERROR' in line 而 line 是 str, 直接 TypeError: a bytes-like object is required。正解: 两边类型对齐 — 网络层留 bytes 就用 b'ERROR', 已 decode 就用 'ERROR', 别混。
# 错: b"ERROR" in line 而 line 是 str # → TypeError: a bytes-like object is required line = raw.decode("utf-8") # 对: 已 decode 就用 str "ERROR" in line # 两边类型对齐
f"say {d["k"]}" 老版本直接 SyntaxError。正解: 先取变量 v = d["k"] 再进 f-string; 或升级 3.12+ 享受新解析器。
# 错(3.12 前): f"say {d["k"]}" → SyntaxError v = d["k"] # 对: 先取变量再进 f-string f"say {v}" # 或升级 3.12+ 新解析器
user_tpl.format(account=account) 能通过 {account.__class__} 系列读到对象内部结构, 泄露敏感信息。正解: 模板必须是自己写的常量, 用户只提供值; 或改用 string.Template.safe_substitute。
# 错: user_tpl.format(account=account) # {account.__class__} 可摸到对象内部结构 Template("$key").safe_substitute(vals) # 对 # 模板必须是自己写的常量, 用户只提供值
s[::-1] 变成 ́e (重音跑到前面)。正解: 需要按"字素簇"处理时用 unicodedata.normalize('NFC', s) 先归一, 或引入 grapheme 库切分。
s = "cafe\u0301" # e + 组合重音, 2 个码点 s[::-1] # 错: 重音跑到前面 unicodedata.normalize("NFC", s)[::-1] # 对
casefold(); 展示用途才用 title/upper。
"ß".upper() # → 'SS' 长度都变了 "straße".casefold() == "strasse" # 对: → True # 无关本地化比较统一 casefold; 展示才用 upper
b'中文...'[:4] 把 3 字节的字砍剩 1 字节, 后续 decode 必抛 UnicodeDecodeError。正解: 先 decode 成 str 再按码点切; 必须按字节定位就从行首/安全边界开始 (见场景 7)。
raw = "中文".encode() # b'\xe4\xb8\xad\xe6\x96\x87' raw[:4].decode("utf-8") # 错: → UnicodeDecodeError raw.decode()[:1] # 对: 先 decode 再切 → '中'
newline='', csv.reader 收到 \r\n 被双层处理, 每行末尾多一个空行/字段错位。正解: open(p, newline='', encoding='utf-8') 两个参数都是标配。
# 错: open(p, "w") → Windows 下 \r\n 双层处理, 字段错位 f = open(p, newline="", encoding="utf-8") # 对 csv.writer(f).writerow(["a", "b"]) # 两参数都是标配
encoding='utf-8'。
# 错: subprocess.run([b"ls"], cwd="/tmp") → TypeError # can't mix str and bytes arguments subprocess.run(["ls"], cwd="/tmp", encoding="utf-8") # 对: 全 str
type(x) 确认, 别信 repr 的相似外观。
"a" == b"a" # 错: → False 不报错只给 False d = {"key": 1} d[b"key"] # → KeyError 静默查不到 # 对: 边界处立刻 decode, 调试打印 type(x)
base64.urlsafe_b64encode (换成 -_), 且注意 padding 的 = 在部分网关也要 quote。
# 错: base64.b64encode(...) 含 +/ 拼进 query 被解析坏 b64 = base64.urlsafe_b64encode(sig) # 对: 换成 -_ urllib.parse.quote(b64.decode()) # 对: padding 的 = 也要转义
'gbk' codec can't encode。正解: 程序入口 sys.stdout.reconfigure(encoding='utf-8', errors='replace'), 日志框架单独配 utf-8 handler。
# 错: 远程/CI 终端是 GBK, print("中文") 当场崩 sys.stdout.reconfigure(encoding="utf-8", errors="replace") # 对: 入口重配
\u4e2d 六个字符, 中文日志没法看、消息体白白膨胀。正解: json.dumps(obj, ensure_ascii=False) 再 encode('utf-8'); 注意此时 write 端也必须真是 utf-8。
# 错: json.dumps({"city": "北京"}) → '{"city": "\u5317\u4eac"}' json.dumps(d, ensure_ascii=False) # 对: '{"city": "北京"}' # 再 encode("utf-8") 发送; write 端也必须真是 utf-8