攝影或3C

Python 正規表示式進階:一次搞懂「零寬斷言」四大象形心法 (?=…) 正向前瞻 (Positive Lookahead) ; (?!…) 負向前瞻 (Negative Lookahead) ; (?<=…) 正向回顧 (Positive Lookbehind) ; (?<!…) 負向回顧 (Negative Lookbehind) ; re.sub() ; re.split()

開篇:為什麼我們需要斷言?
在日常的文本處理中,我們常常遇到這樣的困境:

我們想要找到字串中的某個「特定位置」,但不想破壞或消耗原本的字串內容。
如果用傳統的 (群組) 把字元抓出來,替換時還得把這些字元再補回去,容易顯得囉唆。
這時,斷言(Assertion,或稱零寬度斷言) 就像是一把隱形的游標手術刀!它允許你在字串中設置「守衛」,它只負責檢查該位置的左右兩邊符不符合條件,但絕不會吃掉實際的字元。

核心速查表與象形記憶法
斷言的符號組合看起來就像顏文字般複雜 (?<=…),但其實這套語法藏著非常直觀的象形含義:

?:特殊指令起手式(這不是普通的群組括號!)
<:箭頭朝左,代表向左回顧 (沒有箭頭就是向右前瞻)
=:代表條件必須成立
!:代表條件絕對不能成立
四大斷言家族

 實戰對決:如何拆解駝峰式命名 (CamelCase)?

假設我們有一個連續的程式碼字串 DauntlessFwUpdateProcessStep
我們的目標是把它拆開,變成有空格分隔的:Dauntless Fw Update Process Step

為此,我們需要找到小寫字母與大寫字母之間的「交界處」,並在那裡塞入一個空格。

🔪 老派做法:捕獲群組法 (Capturing Groups)

傳統做法是直接找字元,把它們吃進肚子裡,然後重新吐出來。

import re

text = "DauntlessFwUpdate"

# 尋找左邊小寫右邊大寫的字元組合找到後把兩個字都吃掉
# 替換:\1 吐出第一個吃掉的字母,\2 吐出第二個吃掉的字母中間補空格
result = re.sub(r'([a-z])([A-Z])', r'\1 \2', text)

print(result) # Dauntless Fw Update

限制:這種寫法步驟繁瑣,且因為涉及記憶體的存取與反向引用 (\1\2),有時在複雜的替換條件中會顯得笨重。

🪄 現代做法:零寬度斷言法 (Lookaround Assertions)

利用斷言語法,我們不再尋找「字元」,而是精準尋找「字元中間的無形縫隙」!

import re

text = "DauntlessFwUpdate"

# 尋找一個沒有寬度的縫隙
# 條件是游標往左看必須是小寫 (?<=[a-z]),並且往右看必須是大寫 (?=[A-Z])
# 替換直接在這個隱形縫隙中硬塞入一個空白字元 ' '
result = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', text)

print(result) # Dauntless Fw Update

優點:意圖極度精準且優雅!原字串的字母自始至終都沒有被「消耗」,我們只是找到那個完美的接縫處,將空白插入。

總結

掌握正則表達式的斷言 (Lookaround) 功能後,你處理字串的思維會從「操作具體字元」,昇華為「操作隱形空間與邏輯條件」。下次當你發現自己寫了一堆括號 () 以及 \1\2\3 在做回補組裝時,不妨停下來回想這張「象形速查表」——試著用斷言,寫出更像頂尖駭客的優雅程式碼吧!

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

進階實戰:當遇到連續大寫字母 (Acronyms) 該怎麼辦?
在處理真實的程式碼或測試紀錄時,我們經常會遇到首字母縮寫 (Acronym) 與 CamelCase 混用的情況,例如:AAPLCheckProcessStep 或 XMLHTTPRequest。

如果我們只使用基礎版的正則表達式 (?<=[a-z])(?=[A-Z]),會發現它「切不開」連續大寫字母:

✅ Check 與 Process 之間 (k 接 P) -> 成功切開
❌ AAPL 與 Check 之間 (L 接 C) -> 失敗!因為兩邊都是大寫字母。
這時候,我們需要升級我們的 Regex 武器,利用 | (OR) 運算子,將「兩種切換條件」組合起來:

進階正則表示式

pattern = r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'

語法拆解與定位原理

這個進階 Pattern 由 | 分成左右兩個條件(只要滿足其中一個就會切開):

  1. 條件一:(?<=[a-z])(?=[A-Z]) (一般 CamelCase)
    • Lookbehind:左邊是小寫字母 [a-z]
    • Lookahead:右邊是大寫字母 [A-Z]
    • 🎯 作用點CheckProcess 之間的邊界。
  2. 條件二:(?<=[A-Z])(?=[A-Z][a-z]) (縮寫詞接一般單字)
    • Lookbehind (?<=[A-Z]):左邊是一個大寫字母(代表縮寫詞的結尾,例如 AAPLL)。
    • Lookahead (?=[A-Z][a-z]):右邊必須是**「一個大寫字母緊接著一個小寫字母」**(代表新單字的開頭,例如 CheckCh)。
    • 🎯 作用點:精準定位在 AAPLLCheckC 之間的隱形邊界!

Python 實戰程式碼

import re

def advanced_camel_split(text):
    # 組合兩個斷詞條件
    pattern = r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'
    
    # 在符合條件的隱形邊界上插入空格
    return re.sub(pattern, ' ', text)

# 測試各種棘手的命名
test_cases = [
    "AAPLCheckProcessStep",
    "GetUutIpProcessStep",
    "XMLHTTPRequest",
    "parseJSONData"
]

for name in test_cases:
    print(f"{name:25} -> {advanced_camel_split(name)}")

輸出結果:

💡 提示:為什麼不會把 AAPL 切成 A A P L?
因為 (?=[A-Z][a-z]) 這個條件非常聰明地限定了:右邊的大寫字母後面必須跟著小寫字母。所以 A 後面是 A (沒有小寫),P 後面是 L (沒有小寫),就不會被誤切;直到 L 後面遇到 Ch (大寫接小寫),才會發動切割!

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

改用 re.split

import re

def advanced_camel_split(text):
    # 組合兩個斷詞條件
    pattern = r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'
    
    # 在符合條件的隱形邊界上插入空格
    return re.sub(pattern, ' ', text)

# 測試各種棘手的命名
test_cases = [
    "AAPLCheckProcessStep",
    "GetUutIpProcessStep",
    "XMLHTTPRequest",
    "parseJSONData"
]

for name in test_cases:
    print(f"{name:25} -> {advanced_camel_split(name)}")

print("\n--- 換用 re.split 示範 ---\n")

def list_camel_split(text):
    pattern = r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'
    # 直接在縫隙處切一刀直接輸出 List
    return re.split(pattern, text)

for name in test_cases:
    print(f"{name:25} -> {list_camel_split(name)}")

📝 教學:為什麼 re.split 也能「切縫隙」?

在標準的字串處理中,我們習慣用具體的字元來切割,例如 text.split(',') 用逗號切。但在正規表達式中,re.split() 允許我們把「隱形邊界(零寬度)」當作下刀的位置

1. 什麼是「零寬度 (Zero-width)」?

我們使用的 Regex (?<=[a-z])(?=[A-Z]) 是由 Lookbehind(回溯向後看)與 Lookahead(前瞻向前看)組成的。
這段寫法沒有捕捉任何實體字母,它只捕捉「右邊是大寫、左邊是小寫」的那個虛擬卡榫(縫隙)

2. re.sub vs re.split 的差異

既然都找到了這個「縫隙」,你有兩種做法:

  • 做法 A:塞東西進去 (re.sub)
    也就是您上面原本的寫法,把找到的縫隙填入一個實體的空白 " ",會得到加工後的新字串 "AAPL Check Process Step"
  • 做法 B:直接斬斷 (re.split)
    re.split() 遇到這個縫隙時,它會直接從該處把字串一分為二(或多份),並直接回傳一個 List。這免去了您先用 re.sub 塞空白,事後又要呼叫 .split(' ') 的兩道工序。

3. 陷阱:如果你加了外層括號 ()

在 re.split() 中有一個特殊規則:如果你在 Regex 裡面使用了實體的捕獲群組 (),切割時會把「被當作刀子的那個字串」也一起保留在 List 結果裡
但因為我們使用的是 (?<=...) 和 (?=...) 這種非捕獲的零寬度斷言,它本身就是空氣,所以切出來的 List 看起來非常乾淨:
['AAPL', 'Check', 'Process', 'Step']

總結
當你需要最終結果是「字串(例如供前端顯示)」,用 re.sub 補空白;
當你需要最終結果是「陣列(例如供模型做 Tokenization 餵資料)」,直接用 re.split 效能最好,最乾脆!

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

儲蓄保險王

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