攝影或3C

別把中文洗掉:Python `isalnum()` vs `[^A-Za-z0-9]` 含/不含 CJK中日韓

這篇的目的很直接:幫你判斷在 token 清洗時,
什麼情況該用 `isalnum()`,
什麼情況不該再直覺寫 `[^A-Za-z0-9]`,
避免把中文或其他有效字元誤刪掉。

這份文件專門解釋:

1. `ch.isalnum()` 到底會保留什麼。

2. `re.sub(r”[^A-Za-z0-9]”, “”, token)` 真正會刪掉什麼。

3. 為什麼在目前 Open_Test這種 token 清洗情境下,
`isalnum()` 通常比 ASCII-only regex 更安全。

## 1. 結論先講

如果你的目的只是:

– 去掉 `_`、`#`、`-`、`.` 這類符號

– 保留真正有內容的字元

– 不要誤刪中文

那通常比較適合寫成:

“`python

alnum_only = “”.join(ch for ch in token if ch.isalnum())

“`

而不是:

“`python

re.sub(r”[^A-Za-z0-9]”, “”, token)

“`

原因很簡單:

– `isalnum()` 是 **Unicode-aware**(包含 CJK)

– `[^A-Za-z0-9]` 是 **ASCII-only**(不含 CJK)

CJK 一般就是指:

C = Chinese

J = Japanese

K = Korean

中文通常會說「中日韓」。

這裡先補一個最短版本:

– `ASCII`:字元集很小,主要是英文、數字、常見符號

– `Unicode`:字元集很大,包含中文、韓文、日文、重音字母、全形數字等多語言字元

所以:

– `[^A-Za-z0-9]` 是典型的 ASCII 思維,只認英文半形英數

– `isalnum()` / `isalpha()` 這類 Python 字串方法,通常是用 Unicode 規則判斷

所以 `isalnum()` 會保留中文,ASCII regex 不會。

## 2. `isalnum()` 是什麼意思

`isalnum` 是 `alphanumeric` 的縮寫,也就是「字母或數字」。

所以:

“`python

ch.isalnum()

“`

意思是:

這個字元是不是字母或數字?

注意:這裡的「字母或數字」不是只看英文和 `0-9`。

Python 的 `str.isalnum()` 會依 Unicode 規則判斷,因此中文也算。

例如:

print("a".isalnum())
print("7".isalnum())
print("".isalnum())
print("_".isalnum())
print("#".isalnum())

輸出:

補充:`isalpha()` 也不是只認英文。

Python 的 `str.isalpha()` 同樣是 Unicode-aware,所以中文、韓文、日文這類文字通常也會回傳 `True`。

例如:

print("A".isalpha())
print("".isalpha())
print("".isalpha())
print("".isalpha())
print("7".isalpha())

輸出:

`isdigit()` 則只看是不是數字。

所以可以先把三者粗略理解成:

– `isalpha()`:只看是不是字母,中文/日文/韓文也算

– `isdigit()`:只看是不是數字

– `isalnum()`:字母或數字都算,中文/日文/韓文也算

如果只看概念層級,也可以把 `isalnum()` 近似理解成:

“`python

ch.isalpha() or ch.isdigit()

“`

也就是:

– 是字母,就算 `True`

– 是數字,也算 `True`

– 兩者都不是,才是 `False`

最小對照範例:

samples = ["A", "", "", "", "7", "_"]

for ch in samples:
    print(
        repr(ch),
        "isalpha=", ch.isalpha(),
        "isdigit=", ch.isdigit(),
        "isalnum=", ch.isalnum(),
    )

你會看到:

– `”大”`、`”韓”`、`”한”` 都是 `isalpha=True`

– `”7″` 是 `isdigit=True`

– `isalnum()` 會把前兩類都算進去

## 3. `[^A-Za-z0-9]` 是什麼意思

這是正則表達式中的一個字元集合。

“`python

[^A-Za-z0-9]

“`

意思是:

匹配任何一個「不是 A-Z、不是 a-z、也不是 0-9」的字元。

所以如果你這樣寫:

“`python

re.sub(r”[^A-Za-z0-9]”, “”, token)

“`

就等於:

把所有不是英文或數字的字元全部刪掉。

這種寫法的最大問題是:

**中文也會被刪掉。**

例如:

import re

print(repr(re.sub(r"[^A-Za-z0-9]", "", "大吉岭") ) )

輸出:

也就是整個中文 token 被清空了。

如果你想再往前走一步,手動把常見漢字主區段補進去,也可以寫成:

re.sub(r'[^a-z0-9\u4e00-\u9fff]+', '', token.lower())

上面這條是建立在「輸入 token 已先做 `lower()`」的前提下,所以只需要保留 `a-z`。

如果不先轉小寫、而是直接對原始 token 清洗,
為了更符合本篇前面 `[^A-Za-z0-9]` 的比較場景,通常應寫成:

re.sub(r'[^A-Za-z0-9\u4e00-\u9fff]+', '', token)

這條規則的意思是:

– 保留英文小寫 `a-z`

– 保留數字 `0-9`

– 保留常見 CJK 主漢字區 `\u4e00-\u9fff`

– 其他字元都刪掉

若使用未先 `lower()` 的版本,則上面第一點要改成:

– 保留英文大小寫 `A-Z` / `a-z`

這比純 `[^A-Za-z0-9]` 更接近「保留中英文數字」的需求,但它仍然和 `isalnum()` 不一樣:

– 它是手動白名單範圍

– `isalnum()` 是 Unicode 類別判斷

– 這條 regex 會保留常見漢字,
但不會自然涵蓋所有 Unicode 字母數字

所以可以把它們想成三個層次:

1. `[^A-Za-z0-9]`:只保留 ASCII 英數

2. `[^A-Za-z0-9\u4e00-\u9fff]+` 或 `[^a-z0-9\u4e00-\u9fff]+`:手動擴成英文 + 數字 + 常見漢字

3. `ch.isalnum()`:依 Unicode 規則保留字母或數字

## 4. 兩者直接對比

下面這段最值得直接在 Jupyter 跑:

import re

samples = [
    "_###",
    "bmc_ip",
    "大吉岭",
    "測試123",
    "abc-123",
    "éclair",
]

for token in samples:
    keep_by_isalnum = "".join(ch for ch in token if ch.isalnum())
    keep_by_regex = re.sub(r"[^A-Za-z0-9]", "", token)
    print(f"token={token!r}")
    print(f"  isalnum -> {keep_by_isalnum!r}")
    print(f"  regex   -> {keep_by_regex!r}")
    print()

預期重點:

– `_###`:兩者都能清掉

– `bmc_ip`:兩者都能拿掉底線

– `大吉岭`:只有 `isalnum()` 保留中文

– `測試123`:`isalnum()` 保留完整內容,regex 只剩數字

– `éclair`:`isalnum()` 會保留重音字母 `é`,ASCII regex 會把它刪掉;

這不是在示範西班牙文,而是在示範「非 ASCII 的拉丁字母」

這就是兩者最核心的差別。

## 5. 為什麼 `isalnum()` 比較適合目前專案

在 `expand_synonyms` 的情境裡,
我們不是要做「只接受英文 token」的清洗。

我們真正想做的是:

– 把明顯噪聲符號拿掉

– 看 token 去掉符號後,還有沒有實質內容

– 不要誤傷中文或其他有效文字

因此:

alnum_only = "".join(ch for ch in token if ch.isalnum())
if not alnum_only:
    # 幾乎可視為 noise / placeholder
    ...

這種邏輯就很合理。

例如:
– `_###` -> `””` -> 可視為噪聲

– `bmc_ip` -> `”bmcip”` -> 不是噪聲

– `大吉岭` -> `”大吉岭”` -> 不是噪聲

如果改用 ASCII-only regex:
– `_###` -> `””`

– `bmc_ip` -> `”bmcip”`

– `大吉岭` -> `””`
最後一筆就會誤判。

## 6. `replace()` 為什麼不是更好的主方案

有時候會想到這樣寫:

token.replace("_", "").replace("#", "").replace("-", "")

這不是不能用,但它更適合:

– 你明確知道只要刪幾個固定符號

– 而且未來不太會出現其他新符號

缺點是:

1. 要手動列出每個要刪的字元

2. 很容易漏掉其他符號

3. 寫久了會變成很長一串 `replace()`

4. 表達不出真正規則是「只保留有內容的字元」

反過來說:

"".join(ch for ch in token if ch.isalnum())

表達的是:

**只保留字母或數字,其他都忽略。**

這比列舉一堆要刪掉的符號,更接近我們要的規則語意。

## 7. 最推薦的包裝方式

如果你覺得這一行太長,
可以包成小函式,會比直接寫 regex 更好懂:

def keep_alnum_chars(token: str) -> str:
    return "".join(ch for ch in token if ch.isalnum())

主程式就可以寫:

alnum_only = keep_alnum_chars(token)
if not alnum_only:
    return True

如果名稱想更貼近用途,也可以叫:

def strip_non_alnum_chars(token: str) -> str:
    return "".join(ch for ch in token if ch.isalnum())

## 8. Jupyter 驗證範例

### 範例 A:最基本對比

import re

samples = ["_###", "bmc_ip", "大吉岭", "測試123", "abc-123"]

for token in samples:
    kept = "".join(ch for ch in token if ch.isalnum())
    cleaned = re.sub(r"[^A-Za-z0-9]", "", token)
    print(repr(token), "-> isalnum:", repr(kept), ", regex:", repr(cleaned))

### 範例 B:逐字檢查 `isalnum()`

samples = ["_###", "bmc_ip", "大吉岭", "測試123", "abc-123"]

for token in samples:
    print("token:", repr(token))
    for ch in token:
        print(f"  {ch!r}: isalnum={ch.isalnum()}")
    print()

### 範例 C:做成 noise 判斷

def keep_alnum_chars(token: str) -> str:
    return "".join(ch for ch in token if ch.isalnum())


def is_noise_placeholder(token: str) -> bool:
    alnum_only = keep_alnum_chars(token)
    return not alnum_only


samples = [
    "_###",
    "__##_--",
    "bmc_ip",
    "大吉岭",
    "smc0_ip",
    "abc-123",
]

for token in samples:
    print(
        f"{token!r:12} | "
        f"alnum_only={keep_alnum_chars(token)!r:15} | "
        f"is_noise={is_noise_placeholder(token)}"
    )

這段會證明:

– `_###`、`__##_–` 這類純符號垃圾會被抓出來

– `大吉岭` 不會被誤判成噪聲

– `bmc_ip`、`smc0_ip` 也不會被誤判

## 9. 需要知道的 caveat

`isalnum()` 比 ASCII regex 安全很多,但它不是「只保留英文 + 半形數字」。

它還可能保留:

– 全形數字

– 重音字母

– 某些 Unicode 數字字元

– 其他語系字母

例如:

samples = ["123", "éclair", "ⅠⅡ", "大吉岭"]

for token in samples:
    kept = "".join(ch for ch in token if ch.isalnum())
    print(repr(token), "->", repr(kept))

所以正確說法不是:

– `isalnum()` 完美無敵

而是:

– 在「保留有效文字、移除符號垃圾」這個需求上,
`isalnum()` 比 `[^A-Za-z0-9]` 更符合目前專案

再補一個很實務的點:

– `isalnum()` 會保留重音字母,因為像 `é` 這種字元本身就是合法的 Unicode 字母

– 但如果你的需求不是「保留原字形」,而是想把重音拿掉、做更一致的 ASCII-ish 正規化,那就不能只靠 `isalnum()`,而應該另外搭配 `unicodedata`

例如:

import unicodedata


def strip_accents(text: str) -> str:
    normalized = unicodedata.normalize("NFKD", text)
    return "".join(ch for ch in normalized if not unicodedata.combining(ch))


token = "éclair"
print(strip_accents(token))

輸出:

也就是說:

– `isalnum()` 解的是「哪些字元算有效字母/數字」

– `unicodedata.normalize(…)` + 移除 combining marks 解的是「要不要把重音折平」

這兩件事是不同層的需求,不應混成同一件事。

## 10. 一句話總結

– `[^A-Za-z0-9]`:只認 ASCII 英數,中文會被刪掉

– `ch.isalnum()`:Unicode-aware,中文也會保留

– 在目前 `expand_synonyms` / token noise 判斷情境下,優先用 `isalnum()` 比較安全

推薦hahow線上學習python: https://igrape.net/30afN

儲蓄保險王

儲蓄險是板主最喜愛的儲蓄工具,最喜愛的投資理財工具則是ETF,最喜愛的省錢工具則是信用卡