你一定遇過這種事:
兩段字看起來一模一樣,結果程式就是比對失敗。
然後你盯著螢幕五分鐘,開始懷疑自己是不是瞎了。
通常不是你瞎了。
是電腦太誠實。
對人類來說,兩個字「看起來一樣」,大多就會自動被腦補成同一件事。
對程式不是。
程式不看感覺,它看的是:
- code point
- byte sequence
- 正規化形式
- 有沒有混進不可見字元
只要其中一項不同,它就很有可能判定:
不一樣就是不一樣。
很冷酷,但也沒毛病。
一個最經典的例子:é
先看這兩段字:
s1 = "café"
s2 = "cafe\u0301"
print(s1 == s2)
很多人直覺會以為輸出是 True。
實際上通常是:
False
因為這兩個 é,雖然長得一樣,但底層不是同一種表示法:
é:單一 code pointe+◌́:字母e加上 combining acute accent
畫面看起來差不多。
但對字串比較來說,它們不是同一串東西。
為什麼會這樣?
因為 Unicode 並不是「一個字長怎樣」那麼簡單。
它更像是一套規則,告訴你:
- 字元怎麼編號
- 字元怎麼組合
- 不同平台怎麼表示它們
這裡有三個層次要分清楚。
1. Code point
Unicode 會替每個字元指派一個編號,例如:
A→U+0041é→U+00E9
這是最基本的身分證號。
2. Grapheme
使用者眼中看到的一個「字」,不一定只由一個 code point 組成。
像剛才的 e + 重音符號,就是一個很典型的例子。
人類看到的是一個字。
程式看到的可能是兩個成分。
3. Encoding
等到字串真的要存成 bytes 時,又會有 UTF-8、UTF-16 之類的編碼問題。
所以「看起來一樣」這件事,在不同層次上都可能失手。
常見地雷,不只重音符號
這類問題不只發生在法文或特殊字元,很多平常資料都會踩到。
一、全形與半形
s1 = "ABC123"
s2 = "ABC123"
print(s1 == s2) # False
對人類來說,這只是字比較胖。
對程式來說,是完全不同的字元。
二、不可見字元
最討厭的通常不是長得不一樣的字,而是你看不到的字。
例如:
- zero-width space
- non-breaking space
- directional marks
- 文字從網頁複製時帶進來的控制字元
這些東西混進資料後,畫面還是很乾淨,只有你的比對結果開始發瘋。
三、大小寫不是你想的那麼簡單
很多人以為 case-insensitive compare 只要 lower() 就好。
不一定。
某些語言的大小寫轉換規則沒那麼樸素,Unicode 也不是全世界都只講英文。
如果你真的要做 Unicode 層級的大小寫無關比較,通常會更偏向使用:
text.casefold()
而不是只靠 lower()。
解法:先正規化,再談比較
這種問題的標準處理方式叫做 Unicode normalization。
Python 內建的 unicodedata 就能做:
import unicodedata
s1 = "café"
s2 = "cafe\u0301"
n1 = unicodedata.normalize("NFC", s1)
n2 = unicodedata.normalize("NFC", s2)
print(n1 == n2) # True
這時候兩邊就會先被整理成相同的表示形式,再做比較。
終於肯講人話了。
NFC、NFD、NFKC、NFKD 到底差在哪?
這四個名字第一次看很像亂碼,實際上只是在回答兩個問題:
- 要不要拆開?
- 要不要做相容性轉換?
1. NFC
Canonical Composition
傾向把可合併的字元組合回去。
例如:
e+ accent →é
這通常是最保守也最常用的選擇。
如果你的需求是:
- 儲存一般文字
- 做穩定比對
- 保留原始語意
那大多數情況下,先試 NFC 就對了。
2. NFD
Canonical Decomposition
把可組合字元拆開。
比較常見於某些文字分析流程,或你真的需要逐個組件處理字元時。
一般業務系統不太會把它當預設格式。
3. NFKC
Compatibility Composition
除了標準正規化之外,還會做「相容性」層級的轉換。
例如某些:
- 全形字
- 相容字元
- 視覺上接近但語意被 Unicode 視為可折疊的形式
都可能被收斂成更統一的結果。
這很有用。
也很危險。
因為它做得比較多,所以適合:
- 搜尋索引
- 使用者輸入清理
- 帳號、識別碼這種你想盡量收斂格式的欄位
但如果你處理的是:
- 法律文本
- 排版敏感內容
- 必須保留原貌的資料
那就不要隨手上 NFKC。
4. NFKD
拆開版的 compatibility normalization。
除非你真的知道自己在做什麼,不然大部分時候不會先選它。
一個比較像樣的清理流程
實務上,比對文字通常不只做 normalization。
還會一起處理:
- Unicode normalization
- case folding
- 空白整理
- 不可見控制字元移除
例如:
import re
import unicodedata
def normalize_text(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
text = text.casefold()
text = re.sub(r"[\u200b-\u200f\u202a-\u202e\u2060-\u206f]", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
s1 = " Docsaid\u00A0Lab "
s2 = "docsaid lab"
print(normalize_text(s1) == normalize_text(s2)) # True
這個版本已經比單純 strip().lower() 靠譜很多。
至少不會一邊自信,一邊出錯。
但不要什麼都正規化
這裡有個很常見的過度工程:
「反正 normalization 很好用,那我全部欄位都先做一遍。」
別。
有些資料不能亂動。
例如:
- 密碼
- token
- 簽章資料
- 雜湊前原文
- 需要逐 byte 保真的欄位
這些東西只要你先正規化,後面就可能整串對不起來。
有些系統甚至不是壞在比較,而是壞在你「好心幫它整理過」。
工程界有很多 bug,就是這樣被做出來的。
什麼時候該用哪一種?
如果你懶得記規格,可以先記這個粗暴版本:
- 一般文字儲存 / 顯示:先考慮
NFC - 搜尋、帳號、使用者輸入比對:考慮
NFKC+casefold() - 安全敏感資料:不要亂正規化
- 看到明明一樣卻比對失敗:先懷疑 Unicode,再懷疑人生
這個順序比較省時間。
怎麼快速排查?
當你懷疑字串有鬼,不要只 print(text)。
那通常沒有用。
請直接看它的表示方式:
text = "cafe\u0301"
print(repr(text))
print([hex(ord(ch)) for ch in text])
輸出會像這樣:
'cafe\u0301'
['0x63', '0x61', '0x66', '0x65', '0x301']
這時你就知道,不是資料庫在針對你,也不是 Python 今天心情不好。
是字串裡真的多了一個 combining mark。
最後
字串比對失敗,很多時候不是邏輯太複雜。
而是你以為「看起來一樣」就等於「底層一樣」。
這個假設對人類合理,對電腦不合理。
電腦不會幫你腦補。
它只會安靜地回你一個 False,然後看你自己崩潰。
所以,如果你有以下症狀:
- 從網頁貼過來的字一直對不起來
- 使用者名稱明明一樣卻查不到
- 多語系文本在搜尋和去重時怪怪的
- 比對前你只做了
lower().strip()然後很有信心
那你現在該做的事情大概不是再加一個 if。
而是先去把 Unicode 正規化補上。
這比較像在修 bug,不像在祈禱。
參考資料
- Unicode Standard Annex #15: Unicode Normalization Forms
- Python
unicodedataDocumentation - Python
str.casefold
☕ 一杯咖啡,就是我創作的燃料!
贊助我持續分享 AI 實作、全端架構與開源經驗,讓好文章不斷更新。
AI / 全端 / 客製 一次搞定
從構想到上線,涵蓋顧問、開發與部署,全方位支援你的技術實作。
包含內容
- 顧問服務 + 系統建置 + 客製開發
- 長期維運與擴充規劃
🚀 你的專案準備好了嗎?
如果你需要客製服務或長期顧問,歡迎與我聯繫!
