你明明看見兩段一模一樣的文字,Python 卻說它們不相等。
left = "Café"
right = "Cafe\u0301"
print(left == right) # False
不是 Python 壞掉,也不是字型在惡作劇。
畫面上的「é」可以是一個字元,也可以由 e 加上一個組合重音符號構成。
人眼看到同一件事,電腦看到的 code point 序列卻不同。
這種差異會偷偷跑進:
- 使用者名稱去重
- 搜尋與自動完成
- CSV 匯入清理
- 跨平台檔名
- API payload 比對
- 測試資料與快取 key
今天拍拍君要用標準庫 unicodedata,把這些「看起來一樣」的文字整理成可預期的資料。
一. 安裝:不用裝,它就在標準庫裡 #
unicodedata 是 Python 標準庫的一部分。
直接 import 就能使用:
import unicodedata
print(unicodedata.unidata_version)
unidata_version 會告訴你目前 Python 內建的 Unicode Character Database 版本。
不同 Python 版本可能綁定不同 Unicode 資料版本,所以資料處理服務升級 Python 時,也要把 Unicode 行為放進回歸測試。
先準備一個小工具,看看字串裡到底放了什麼:
import unicodedata
def inspect_text(text: str) -> None:
for char in text:
code = f"U+{ord(char):04X}"
name = unicodedata.name(char, "<unknown>")
print(f"{char!r:6} {code:10} {name}")
inspect_text("é")
inspect_text("e\u0301")
第一段只有一個 code point:
'é' U+00E9 LATIN SMALL LETTER E WITH ACUTE
第二段則有兩個:
'e' U+0065 LATIN SMALL LETTER E
'́' U+0301 COMBINING ACUTE ACCENT
這就是問題的入口:視覺相同,不代表底層表示相同。
二. normalize:先把等價文字放到同一條軌道 #
unicodedata.normalize() 支援四種常見形式:
| 形式 | 核心概念 | 常見用途 |
|---|---|---|
| NFC | canonical decomposition 後再組合 | 一般文字儲存與比較 |
| NFD | 保留 canonical 分解形式 | 分析組合符號 |
| NFKC | compatibility 分解後再組合 | 搜尋鍵、帳號比對 |
| NFKD | 保留 compatibility 分解形式 | 低階文字分析 |
先從最常用的 NFC 開始:
import unicodedata
left = "Café"
right = "Cafe\u0301"
normalized_left = unicodedata.normalize("NFC", left)
normalized_right = unicodedata.normalize("NFC", right)
print(normalized_left == normalized_right) # True
NFC 會把 canonical equivalent 的表示整理成一致形式。 對一般應用程式來說,它是很好的預設值:
def normalize_text(text: str) -> str:
return unicodedata.normalize("NFC", text)
在資料進入系統邊界時做一次,比每次比較前才補救更可靠:
def clean_display_name(raw: str) -> str:
text = unicodedata.normalize("NFC", raw)
return text.strip()
這裡故意只做兩件事:Unicode 正規化與去除首尾空白。 不要順手把所有標點、大小寫與空格都刪掉,否則你可能會改變使用者真正想顯示的名稱。
三. NFC、NFD、NFKC 到底差在哪裡? #
NFC 與 NFD 處理的是 canonical equivalence。 NFKC 與 NFKD 還會處理 compatibility equivalence。
看一個全形字元:
import unicodedata
text = "ABC123"
print(unicodedata.normalize("NFC", text))
print(unicodedata.normalize("NFKC", text))
輸出:
ABC123
ABC123
NFC 保留全形外觀。 NFKC 則把 compatibility characters 轉成較常見的形式。
再看另一個例子:
samples = ["①", "K", "ffi"]
for sample in samples:
converted = unicodedata.normalize("NFKC", sample)
print(repr(sample), "->", repr(converted))
NFKC 很適合建立搜尋鍵與比對鍵,因為它能減少一些只有排版形式不同的變體。 但它不是無損轉換。 原始字元的視覺或語意細節可能被折疊,所以不要直接覆蓋使用者輸入的展示文字。
拍拍君習慣把「展示值」與「比對值」分開:
from dataclasses import dataclass
import unicodedata
@dataclass(frozen=True)
class Label:
display: str
key: str
def make_label(raw: str) -> Label:
display = unicodedata.normalize("NFC", raw).strip()
key = unicodedata.normalize("NFKC", display).casefold()
return Label(display=display, key=key)
這樣畫面仍顯示原本合理的字形,比對時則使用穩定 key。
四. casefold:跨語言大小寫比對的好搭檔 #
只用 .lower() 做不分大小寫比對,對英文字母通常夠用,但跨語言時不一定完整。
casefold() 是專門為 caseless matching 設計的轉換。
print("Straße".lower())
print("Straße".casefold())
輸出大致會是:
straße
strasse
把 NFKC 與 casefold() 組合起來,可以建立實用搜尋鍵:
import unicodedata
def search_key(text: str) -> str:
normalized = unicodedata.normalize("NFKC", text)
return " ".join(normalized.casefold().split())
這個版本做了三件事:
- 用 NFKC 折疊 compatibility variants。
- 用
casefold()做 Unicode-aware 大小寫折疊。 - 用
split()再 join,把連續空白整理成單一空格。
values = [
" Café Latte ",
"Cafe\u0301 Latte",
"CAFÉ LATTE",
]
for value in values:
print(search_key(value))
請注意:正規化不會自動移除重音。
café 與 cafe 是否要視為相同,是產品規則,不是 Unicode 替你決定的事。
五. 組合字元:移除重音前先想清楚 #
有些搜尋功能希望輸入 cafe 也能找到 café。
可以先做 NFD 分解,再移除 combining marks:
import unicodedata
def remove_diacritics(text: str) -> str:
decomposed = unicodedata.normalize("NFD", text)
filtered = "".join(
char for char in decomposed
if not unicodedata.combining(char)
)
return unicodedata.normalize("NFC", filtered)
print(remove_diacritics("Café")) # Cafe
unicodedata.combining(char) 會回傳 canonical combining class。
一般字元通常是 0,組合符號則可能是其他數值。
但不要把這段函式套在所有資料上。 在某些語言裡,附加符號不是可有可無的裝飾;移除後可能造成資訊損失、誤配或冒犯性的結果。
比較安全的做法是:
- 原始值保留 NFC 版本
- 只有搜尋索引額外建立 accent-insensitive key
- UI 明確顯示真正命中的原文
- 帳號唯一性與安全識別不要只靠去重音 key
def accent_insensitive_key(text: str) -> str:
folded = unicodedata.normalize("NFKC", text).casefold()
return remove_diacritics(folded)
這是搜尋體驗工具,不是萬用身份驗證規則。
六. category:用 Unicode 屬性理解字元 #
unicodedata.category() 會回傳 Unicode General Category。
第一個字母是大類,第二個字母是子類。
import unicodedata
for char in "A9! 中\n":
print(repr(char), unicodedata.category(char))
常見大類包括:
| 前綴 | 類型 | 例子 |
|---|---|---|
L |
Letter | A、中 |
M |
Mark | 組合重音 |
N |
Number | 9、羅馬數字 |
P |
Punctuation | !、, |
S |
Symbol | 貨幣、數學符號 |
Z |
Separator | 空白分隔 |
C |
Other | 控制字元、未指派字元 |
如果要拒絕不可見控制字元,可以針對政策做檢查:
def find_control_characters(text: str) -> list[str]:
return [
char for char in text
if unicodedata.category(char).startswith("C")
and char not in {"\n", "\t"}
]
這裡沒有粗暴地刪除所有 C 類字元。
因為換行與 tab 在某些欄位是合法的,而格式控制字元也可能出現在正當文字中。
應用程式應該先定義欄位政策,再決定接受、警告或拒絕。
七. 實戰:建立搜尋與去重管線 #
假設我們要匯入一批文章標籤,希望保留顯示文字,同時找出正規化後的重複項目。
from dataclasses import dataclass
import unicodedata
@dataclass(frozen=True)
class NormalizedTag:
display: str
key: str
def normalize_tag(raw: str) -> NormalizedTag:
display = unicodedata.normalize("NFC", raw).strip()
if not display:
raise ValueError("tag 不能是空字串")
key = unicodedata.normalize("NFKC", display).casefold()
key = " ".join(key.split())
return NormalizedTag(display=display, key=key)
接著做去重:
def deduplicate_tags(values: list[str]) -> list[NormalizedTag]:
seen: set[str] = set()
result: list[NormalizedTag] = []
for raw in values:
tag = normalize_tag(raw)
if tag.key in seen:
continue
seen.add(tag.key)
result.append(tag)
return result
測試資料故意混入不同表示:
tags = [
"Python",
"Python",
"Café",
"Cafe\u0301",
" Machine Learning ",
]
for tag in deduplicate_tags(tags):
print(tag)
這個設計有幾個好處:
- UI 可以使用
display - 資料庫 unique index 可以使用
key - 原始匯入資料仍可另外保存供稽核
- 規則集中在一個函式,不會散落在 route、form 與 SQL 裡
如果正規化規則將來改變,記得為既有資料安排 migration,而不是只改新資料的寫入邏輯。
八. 寫測試:把肉眼看不出的差異固定下來 #
Unicode bug 很適合用參數化測試處理。
import pytest
@pytest.mark.parametrize(
("left", "right"),
[
("Café", "Cafe\u0301"),
("Python", "Python"),
("Straße", "STRASSE"),
],
)
def test_search_key_equivalence(left: str, right: str) -> None:
assert search_key(left) == search_key(right)
也要測試「不應該相等」與 idempotence:
def test_search_key_keeps_meaningful_differences() -> None:
assert search_key("resume") != search_key("résumé")
def test_search_key_is_idempotent() -> None:
value = " Cafe\u0301 Latte "
once = search_key(value)
twice = search_key(once)
assert once == twice
九. 常見陷阱 #
- 以為 normalize 會處理所有相似字: 不同 script 裡長得像的字元,仍可能是完全不同的 code point;normalization 不是帳號防冒充系統。
- 正規化後直接丟掉原始值: 搜尋 key 與展示文字需求不同,保留 NFC display value 通常更容易除錯與遷移。
- 整個系統只用一種規則: 人名、密碼、搜尋字串與 SKU 不該共用同一個 aggressive normalizer。
- 把正規化當安全消毒: 它不會阻止 SQL injection、XSS、路徑穿越或釣魚名稱。
- 升級 Python 不跑回歸測試: Unicode database 會演進,重要的比對 key 應保留代表性 fixture。
結語 #
unicodedata 的 API 不多,但它處理的是很深的資料邊界問題。
今天最值得帶走的做法是:
- 一般展示文字先用 NFC 統一 canonical representation。
- 搜尋與去重可以考慮 NFKC 加
casefold()。 - 原始展示值與 comparison key 分開保存。
- 去重音、刪控制字元與檔名清理都要由欄位政策決定。
- 用看不出差異的測試案例,固定你真正想要的行為。
文字看起來一樣,不代表資料真的一樣。 先把正規化規則寫清楚,之後的搜尋、去重與跨平台交換就會少很多玄學 bug。