CodeGym /課程 /Swift SELF /Swift Regex 與 NSRegularExpression

Swift Regex 與 NSRegularExpression

Swift SELF
等級 34, 課堂 1
開放

1. Swift 中的兩個 regex API

如果你第一次在同一門語言裡同時看到 Regex 和 NSRegularExpression,大腦可能會開始懷疑有陰謀:「難道不能做一個正常的 regex 嗎,像人類一樣?」其實這只是生態系統成熟的自然過程:舊 API 之所以還在,是因為很多地方都在用;而新 API 的出現,則是為了讓 regex 更 swifty——更安全、更方便,也更友善於型別。

先抓住一個簡單觀念:在我們的 LibraryCLI 範圍內(這是我們在課程中持續開發的圖書館 CLI),regex 不是應用程式的中心,而是驗證與搜尋的其中一個工具。也就是說,我們要選一種方式,讓程式碼可讀,而且錯誤可預期。否則就會變成經典場面:「它看起來是能跑,但為什麼在單字 “coffee” 上壞掉了——沒人知道。」

2. Swift Regex

Swift Regex 是標準函式庫中的現代正則表示式 API。它的設計目標是以 Swift 的風格運作:使用正常的字串索引、在 String 上提供方便的方法,並且把「沒有匹配」視為一般結果,而不是人生中的例外。

對新手來說最舒服的一點是:你會開始覺得 regex 不再是「帶咒語的字串」,而是語言中正常的工具。尤其當你使用像 /.../ 這樣的 regex 字面量時更是如此。這正是那種情況:程式碼看起來像程式碼,而不是像「有人把一團反斜線麵條灑到鍵盤上」。

最簡單的例子:「字串裡是否至少有一個數字」:

let text = "remove #42"
let hasNumber = text.contains(/\d+/)
print(hasNumber) // true

注意這個風格:contains(...) 會回傳 Bool,看起來和 contains("abc") 完全一樣,只不過從字串變成了 pattern。

第二個重要優點是:Swift 裡有不同的「匹配模式」,而且它們幾乎像人話一樣容易閱讀。例如,如果你想檢查整個字串是不是數字,你不是做「在裡面搜尋」,而是做「整體匹配」:

let input = "12345"
let ok = (input.wholeMatch(of: /^\d+$/) != nil)
print(ok) // true

這裡已經可以看出,regex 正在變成格式驗證的一部分。

3. 來自 NSRegularExpression 的 Foundation

NSRegularExpression 是 Foundation 裡的類別,它包裝了 ICU regex engine。它是在執行期從字串 pattern 建立的,從歷史上來看,它曾經是 Swift 中主要的 regex 選項。Foundation 把這個 API 提供成「透過字串使用 regex」的標準路徑。

為什麼它到現在還會出現在真實程式碼裡?

第一,許多函式庫與舊專案都是用它寫的。第二,當 pattern 來自外部時它很方便,例如使用者輸入「依 pattern 搜尋」,或 pattern 放在設定檔裡。第三,NSRegularExpression 與 Foundation 型別以及 Objective-C 世界的整合非常緊密。

但它也有代價:它使用 NSRange 這種範圍模型,這表示幾乎總會伴隨範圍轉換,以及 String 的各種細節(因為 Swift 字串是以 Unicode 為中心,而且不是用 Int 來索引)。

pattern 編譯的迷你範例:

import Foundation

do {
    let re = try NSRegularExpression(pattern: #"^\d+$"#)
    print("Compiled: \(re)") // Compiled: <NSRegularExpression: ...>
} catch {
    print("Invalid pattern") // if the pattern is broken
}

這裡的重要觀念是:錯誤不只可能是「字串不符合」,也可能是「pattern 本身就無效」。也就是說,try 是針對 pattern,而不是針對使用者輸入。

4. CLI 中的核心概念與陷阱

談到「字串模型」時很容易想打呵欠,但真正最煩人的 bug 往往就從這裡誕生。ICU 引擎(也就是 NSRegularExpression 背後的引擎)使用的字串模型,和 Swift String 並不一樣。這可能導致「看起來是同一個 pattern」,但結果卻不同,特別是在 Unicode 字元、組合字形,以及現實世界的其他樂趣上。

Swift Regex कोशिश更貼近 Swift String 和它的索引。而 NSRegularExpression 歷史上則是以 Foundation/ObjC 風格的範圍來思考,而且常常依賴 UTF‑16 表示。

對我們的 LibraryCLI 而言,實務上的結論是:如果你在解析像 add --title "Les Misérables" 這樣的命令,你不希望因為名稱帶了變音符號,解析器就開始「稍微不一樣地」運作。所以預設情況下,我們會優先使用 Swift Regex;而 NSRegularExpression 則留給我們真的需要「把 pattern 當成字串」的情境,例如使用者自訂搜尋 pattern。

NSRange 與 Swift 中安全的範圍

當你使用 NSRegularExpression 時,得到的匹配結果會是 NSTextCheckingResult,裡面包含 NSRange。而這時新手常常會想做類似「直接用 range 切字串就好了」的事,但 Swift String 不是這樣運作的。

正確的做法雖然看起來比較囉嗦,但很安全:先取得整個字串的 NSRange,搜尋 match,然後把特定群組的 NSRange 轉成 Range<String.Index>。

範例:在字串中找第一個數字(內部搜尋,不是格式驗證):

import Foundation

let text = "remove 42 please"
let re = try NSRegularExpression(pattern: #"\d+"#)

let full = NSRange(text.startIndex..<text.endIndex, in: text)
if let match = re.firstMatch(in: text, range: full),
   let swiftRange = Range(match.range, in: text) {
    let numberText = String(text[swiftRange])
    print(numberText) // 42
}

注意兩件事。第一,我們不是用 Int 來切字串。第二,即使在 firstMatch 之後,我們仍然要小心地寫 if let,因為可能根本沒有匹配。這是正常情況,不是災難。

如果你忽略範圍轉換,試圖直接做些「直接操作」的事,你不是無法編譯,就是得到難看的錯誤,或者更糟——在 Unicode 字元上拿到錯誤的字串片段。

兩層跳脫

Regex 本身會使用 \d、\s、\w 等等。但 Swift 字串也使用 \ 作為控制字元。因此,當你把 pattern 直接寫成字串時,會遇到「雙重跳脫」:先給 Swift,再給 regex。

比較一下:同樣是「只有數字」的意思,可以用不同方式寫出來。

選項 A —— 一般字串(必須把 \ 加倍):

let pattern = "^\\d+$"

選項 B —— raw string,雜訊更少:

let pattern = #"^\d+$"#

選項 C —— regex 字面量(根本不是字串,通常也是最容易讀的寫法):

let re = /^\d+$/

在 LibraryCLI 裡,我們幾乎總是希望 pattern 能成為程式碼的一部分,而且要能正常閱讀。因此對固定規則(年份、ID、slug),用 Swift Regex 字面量,或者至少使用 raw 字串,會比把程式碼變成 \\ 的嘉年華更舒服。

「找到子字串」和「字串有效」是兩個不同問題

這是連「大致懂 regex」的人在趕時間時也會犯的錯。他們用 contains 做格式驗證,卻應該用 wholeMatch(或者在 NSRegularExpression 中用 ^ 和 $ 錨點)。

我們用年份來比較兩種做法。

錯誤的驗證(搜尋內部):

let input = "year=2025"
let hasYearLike = input.contains(/\d{4}/)
print(hasYearLike) // true (but this does NOT mean input == "2025")

正確的格式驗證(整個字串):

let yearText = "2025"
let isFormatOK = (yearText.wholeMatch(of: /^\d{4}$/) != nil)
print(isFormatOK) // true

在 CLI 中這件事非常重要,因為使用者輸入的不是教科書裡的測試字串,而是各種東西。包括多餘空格、隨機字元,以及「我以為它會自己懂」。

不要在不必要時重複編譯同一個 pattern

無論是 Regex(pattern) 還是 NSRegularExpression(pattern:) 都是在編譯 pattern。如果你在迴圈裡,或每次使用者輸入時都這樣做,而沒有必要,就會浪費時間,還會製造額外的失敗點。

對我們的 LibraryCLI 來說,觀念很簡單:如果 pattern 是固定的(例如「書籍 ID 是數字」),就把它當作 let 放在解析程式碼旁邊,並重複使用。

以下是「迷你 pattern 儲存區」的風格範例(這還不是架構,只是讓可讀性更好):

struct Patterns {
    static let digitsOnly = /^\d+$/
    static let commandName = /^[a-z]+$/  // simplified for the example
}

然後:

let token = "remove"
let ok = (token.wholeMatch(of: Patterns.commandName) != nil)
print(ok) // true

5. API 比較與選擇規則

為了不把所有東西都記在腦中,最好能一次在「紙上」看懂。表格不會替你做決定,但它可以幫你避免選錯工具。

Criterion Swift Regex NSRegularExpression
Where it lives Swift standard library Foundation
How the pattern is specified Literal
/.../
or
try Regex("...")
String
pattern
Pattern compilation error Only with
try Regex(patternString)
try NSRegularExpression(pattern: ...)
Matches and indexes In Swift
String
style
Often
NSRange
→ needs conversion
Convenience for fixed patterns Very high (literals are readable) Medium (template string)
Where it is especially suitable Validation/search in code, CLI patterns Pattern comes from outside, legacy code
Typical traps Mostly logical (contains vs wholeMatch) NSRange, Unicode/UTF‑16, range conversions

The takeaway: in “new” Swift code, we usually prefer Regex, and use NSRegularExpression as a compatibility and dynamic-scenario tool. The Foundation engine is important and real, but it requires more discipline around ranges and strings.

To avoid depending on mood or lunar phase, it helps to agree on a simple decision rule for our app. We are building a CLI, so we often have fixed formats: "id=123", "year=2025", "remove 42", "find \"swift basics\"", and so on.

If the pattern is fixed and lives in code, use Swift Regex (literal). If the pattern may come from outside (for example, the user enters a filter as a string, or it comes from config), use

try Regex(patternString)
or NSRegularExpression — but always handle compilation errors.

For clarity, here is a small flowchart:

flowchart TD
    A["Need regex"] --> B{"Is the pattern fixed<br/>and lives in code?"}
    B -- "Yes" --> C["Swift Regex literal /.../"]
    B -- "No" --> D{"Does the pattern come as String?"}
    D -- "Yes" --> E["try Regex(patternString)<br/>and do/catch"]
    D -- "No/Legacy" --> F["NSRegularExpression<br/>+ NSRange conversions"]

In a real project, legacy sometimes wins: you open someone else’s code, and everything is built on NSRegularExpression. In that case, your job is not to rewrite the whole world, but to understand the ranges carefully and avoid slicing the string incorrectly.

6. Template: pattern compilation error as a separate case

We are not extracting capture groups yet (that will be in the next lecture), but it is already useful to separate two situations: “the pattern is broken” and “the input did not match”. In LibraryCLI, this helps a lot when writing proper error messages.

Here is a small function we can later reuse in validators:

enum RegexBuildError: Error {
    case invalidPattern(String)
}

func buildRegex(_ pattern: String) -> Result<Regex<AnyRegexOutput>, RegexBuildError> {
    do {
        let re = try Regex(pattern)
        return .success(re)
    } catch {
        return .failure(.invalidPattern(pattern))
    }
}

The important style point here is that we do not use try!, because a broken pattern is not a reason to crash. In CLI, crashing is almost always a bad UX: the user understands nothing, and we lose control over the message.

7. Typical mistakes

Mistake #1: using NSRegularExpression and then trying to “slice the string by Int”.
This usually happens out of habit from languages where strings are indexed by numbers. In Swift String that does not work, and that is actually good: otherwise Unicode would become a constant source of incorrect slices. The right path is NSRange → Range<String.Index> → String(slice). Spending 2 lines of code on that is cheaper than later hunting a bug that only appears on the surname “Soderstrom”.

Mistake #2: thinking contains(/\d{4}/) validates a year.
contains answers the question “is there a match somewhere?”. For a CLI argument format, you almost always need the question “does the whole string match?”. So validation should use wholeMatch (or the equivalent with ^...$ in NSRegularExpression). Otherwise you will suddenly start accepting "year=2025" where you expected just "2025".

Mistake #3: mixing input normalization and regex into one huge chain.
Sometimes people write something like text.trimmingCharacters(in: ...).lowercased().wholeMatch(of: ...) and then wonder why it is hard to debug. It is much calmer to first normalize the input into a separate variable, and only then validate it. In CLI code, this improves readability and reduces the chance of “oops, I forgot trim”.

Mistake #4: compiling the same pattern in every call.
This is especially painful in a command parser that runs on every input line. If the pattern is fixed, store it as static let or simply let near the place where it is used. This is not premature optimization; it is a way to reduce noise and avoid extra failure points.

Mistake #5: using try! or forced unwraps ! on user input.
By definition, user input is uncontrolled. If today the user enters "2025", and tomorrow "20O5" (the letter O instead of zero), your program should not crash with a mysterious stack trace. Validation should return a controlled error: “Expected a year in YYYY format.”

1
任務
Swift SELF, 等級 34, 課堂 1
上鎖
文字中的數字
文字中的數字
1
任務
Swift SELF, 等級 34, 課堂 1
上鎖
完整年份
完整年份
1
任務
Swift SELF, 等級 34, 課堂 1
上鎖
第一個數字
第一個數字
1
任務
Swift SELF, 等級 34, 課堂 1
上鎖
模式測試器
模式測試器
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION