【Swift】NSRegularExpressionの使い方【正規表現】

はじめに

今回はSwiftで正規表現を行う際に使用するNSRegularExpressionについてまとめたいと思います。

たまに使おうとすると細かい部分で「これ何だっけ?」「どうするんだっけ?」とわからなくなることがあるので、備忘録としても使えればいいなと思っています。

また、本記事では「正規表現とは?」については言及いたしません。
ある程度の正規表現についての知識がある方で、Swiftでそれをやろうとしている方を対象としています。

環境は Xcode 11.4.1 / Swift 5.2 です。

基本的な使い方のサンプル

まず最初に、基本的な使い方を示すサンプルコードを載せます。

コードはXcodeのPlaygroundで実行可能です。

// ① 検索対象文字列
let string = "Google, Amazon, Facebook, Apple"

// ② 検索するパターン
let pattern = "A[a-z]+"

// ③ NSRegularExpressionのインスタンス生成
let regex = try! NSRegularExpression(pattern: pattern, options: [])

// ④ 検索実行
let results = regex.matches(in: string, options: [], range: NSRange(0..<string.count))

// ⑤ 結果表示
for result in results {
    for i in 0..<result.numberOfRanges {
        let start = string.index(string.startIndex, offsetBy: result.range(at: i).location)
        let end = string.index(start, offsetBy: result.range(at: i).length)
        let text = String(string[start..<end])
        print(text)
    }
}

上から説明すると、

① 検索対象となる文字列(そのまんま)

② ①に対し、検索するパターンを書いた文字列(ここでは「A」から始まり、小文字のアルファベットが1つ以上続く、というパターン)

③ パターンとオプションを指定してNSRegularExpressionのインスタンスを生成(optionsについては後述)

④ 生成したインスタンスのmatchesメソッドを呼び出して検索を実行。引数として、検索対象の文字列と、その文字列の検索範囲(ここでは全範囲)を示すNSRangeオブジェクトを渡す(こちらもoptionsについては後述)。ここではmatchesだが、他にも検索や置換のメソッドがいくつかあるので、目的に応じて使い分ける

⑤ matchesの実行結果としてNSTextCheckingResultというクラスのオブジェクトが配列で得られる(パターンにマッチした数分の要素が含まれる)ので、それをループで1つずつprintしていく(NSTextCheckingResultのrangeは、検索対象文字列の中のパターンにマッチした範囲を表す)

となっています。

これを実行してみると以下のような結果が得られるかと思います。

Amazon
Apple

無事「A」から始まって小文字のアルファベットが続く「Amazon」と「Apple」の2社が抽出できました。

では細かく見ていきたいと思います。

NSTextCheckingResultのRangeについて

NSTextCheckingResultが持つrangeプロパティは「検索対象文字列の中のパターンにマッチした範囲を表す」と上述しましたが、そのプロパティには2種類存在します。

1つは引数なしのrange、もう1つは引数ありのrange(at: Int)です。

これは、前者はマッチした範囲の全体を表すNSRangeで、後者はパターンで括弧 () を使ったグループ化を設定したときの各グループの範囲を表すNSRangeとなっています。

ただし、グループ化してもしなくても、numberOfRangesプロパティは最低1となり、range(at: 0)は引数なしのrangeと等価となっているため、実際の個々のグループの範囲を示すNSRangeはrange(at: 1)からとなっています。

試しに上記サンプルコードの「② 検索するパターン」を以下のように変えて実行してみます。

// ② 検索するパターン
let pattern = "(A)([a-z]+)" // 頭文字とそれ以降とでグループ化

実行結果↓

Amazon
A
mazon
Apple
A
pple

結果、まず全体となる「Amazon」や「Apple」が得られた後、頭文字とそれ以降とをグループにした各部分文字列が出力されました。

NSRegularExpressionのインスタンス生成

init

NSRegularExpressionのinitメソッドは次の1種類だけです。

init(pattern: String, options: NSRegularExpression.Options = []) throws

無効なパターンを書いたときに例外を発するためのthrowsが付いているので、tryを付けるが必要あります。

// インスタンス生成例
let regex = try! NSRegularExpression(pattern: "^[0-9a-fA-F]+$", options: [
    .anchorsMatchLines
])

NSRegularExpression.Options

インスタンス生成時に検索の挙動を変化させるオプションを設定できます。

指定できるオプションは以下のとおりです。

とその前に、オプションの挙動を確認するため、↓のような関数をひとつ用意しました。

func matches(string: String, pattern: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 検索実行
    let results = regex.matches(in: string, options: [], range: NSRange(0..<string.count))

    if results.count == 0 {
        print("ヒットなし")
        return
    }

    print("\(results.count)件ヒット")

    // 結果表示
    for result in results {
        for i in 0..<result.numberOfRanges {
            let start = string.index(string.startIndex, offsetBy: result.range(at: i).location)
            let end = string.index(start, offsetBy: result.range(at: i).length)
            let text = String(string[start..<end])
            print(text
                .replacingOccurrences(of: "\r", with: "[CR]")
                .replacingOccurrences(of: "\n", with: "[LF]"))
        }
    }
}

これを使って見ていきたいと思います。

caseInsensitive

アルファベットの大文字と小文字の区別をなくします。

let string = "apple Apple"
let pattern = "apple"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.caseInsensitive])

実行結果↓

-- オプションなし --
1件ヒット
apple
-- オプションあり --
2件ヒット
apple
Apple

allowCommentsAndWhitespace

パターン文字列中の空白#以降の文字を無視します。

let string = "apple"
let pattern = """
apple # appleという文字を検索します!
"""

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.allowCommentsAndWhitespace])

実行結果↓

-- オプションなし --
ヒットなし
-- オプションあり --
1件ヒット
apple

オプションを付けると空白と#以降が無視されるため、結果的に「apple」がパターン文字列となってヒットするようになりました。

これは設定ファイルなど、外部にパターンを記述した場合にコメントを残す用途ですかね。

ignoreMetacharacters

パターン文字列中のメタ文字を無効化します。

let string = "[a]"
let pattern = "[a]"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.ignoreMetacharacters])

実行結果↓

-- オプションなし --
1件ヒット
a
-- オプションあり --
1件ヒット
[a]

オプションを付けると [ ] がメタ文字ではなくなるため、[a] がそのままヒットしています。

dotMatchesLineSeparators

ドット . が改行コードにもヒットするようになります。

let string = """
apple
orange
strawberry
"""
let pattern = ".+"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.dotMatchesLineSeparators])

実行結果↓

-- オプションなし --
3件ヒット
apple
orange
strawberry
-- オプションあり --
1件ヒット
apple[LF]orange[LF]strawberry

. が改行コードを含むようになったため、全体で1件のヒットになりました。

anchorsMatchLines

行頭を表す ^ や行末を表す $ が各行でヒットするようになります。

let string = """
apple
orange
strawberry
"""
let pattern = "^.+$"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.anchorsMatchLines])

print("-- .dotMatchesLineSeparators も指定してみる --")
matches(string: string, pattern: pattern, options: [.anchorsMatchLines, .dotMatchesLineSeparators])

実行結果↓

-- オプションなし --
ヒットなし
-- オプションあり --
3件ヒット
apple
orange
strawberry
-- .dotMatchesLineSeparators も指定してみる --
1件ヒット
apple[LF]orange[LF]strawberry

オプションなしの場合は、ドット . が改行コードを含まないためヒットなし。

オプションありの場合は、行ごとで行頭・行末がヒットするため3件のヒット。

加えて .dotMatchesLineSeparators を指定した場合はドットが改行コードも含むため、最終行まで入れた1件のヒットとなりました。

useUnixLineSeparators

Unix系の改行コード(Line Feed、\n) だけを改行コードとします。

let string = "apple\norange\rstrawberry\r\nlemon"
let pattern = ".+"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.useUnixLineSeparators])

↑少し見づらいですが、apple, orange, strawberry, lemonの4つをLF、CR、CRLFの3種類の改行コードで区切っています。

実行結果↓

-- オプションなし --
4件ヒット
apple
orange
strawberry
emon
-- オプションあり --
3件ヒット
apple
orange[CR]strawberry[CR][LF]
emon

オプションなしではそれぞれ改行と見なされるため、4件のヒットとなりました。

一方、オプションありではLFのみが改行となるため、CRだけの行は改行なしと見なされ、かつCRLFは2バイト目のLFが拾われて改行し、結果3件のヒットになりました。

ここで、CRLFのLFが検索結果に含まれていたり、 lemon の l が削れてしまったりしていますが、これはCRとLFが合わせて1文字(1 character)として扱われる仕様のためのようです。

※こちらの記事を参考にさせていただきました。
Qiita「Swift の改行コードの挙動」
https://qiita.com/codelynx/items/b5e19aaf75e8a7b746b6

なので、改行コードを含む、それでいて使われている改行コードが不明確な文字列を検索する場合は、まず最初に改行コードを揃える置換処理をやってあげるのが無難ですね。

useUnicodeWordBoundaries

このオプションは単語区切りを検出するためのメタ文字 \b の挙動に影響します。

通常、\b は語を表す文字(word、\w)とそれ以外(non-word、\W)の間にヒットしますが、このオプションを付けるとUnicodeのTechnical Reports #29で定義されている単語境界が使われるようになります。

let string = "日本の首都は東京?"
let pattern = "\\b"

print("-- オプションなし --")
matches(string: string, pattern: pattern, options: [])

print("-- オプションあり --")
matches(string: string, pattern: pattern, options: [.useUnicodeWordBoundaries])

実行結果↓(※ここだけ、結果の出力をヒットした内容ではなくRangeの中身にしています)

-- オプションなし --
2件ヒット
location: 0, length: 0
location: 8, length: 0
-- オプションあり --
7件ヒット
location: 0, length: 0
location: 2, length: 0
location: 3, length: 0
location: 5, length: 0
location: 6, length: 0
location: 8, length: 0
location: 9, length: 0

オプションなしの場合、行頭と「日」の間、「京」と「?」の間の2件がヒットしています。
※「?」はnon-wordです。

一方のオプションありの場合は、
|日本||首都||東京||
の | で示した箇所にヒットし、7件となりました。

おもしろいですね。

NSRegularExpressionの検索メソッド

matches

func matches(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange) -> [NSTextCheckingResult]

ここまでずっと見てきたとおりですが、基本的な検索メソッドです。

パターンにマッチした箇所がNSTextCheckingResultの配列として、すべて返却されます。

引数は先頭から順に、in: 検索対象文字列、options: マッチングオプション、 range: 検索対象文字列中の検索対象範囲、となっています。

ただし、マッチングオプションで指定できるものはすべて後述するenumerateMatchesメソッドでのみ有効なものとなっているため、それ以外のメソッドでは無指定([])固定で構いません。(もしくは、デフォルト値として[]が付いているので、options自体を省略してもOK)

func matches(string: String, pattern: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 検索実行
    let results = regex.matches(in: string, options: [], range: NSRange(0..<string.count))

    if results.count == 0 {
        print("ヒットなし")
        return
    }

    print("\(results.count)件ヒット")

    // 結果表示
    for result in results {
        for i in 0..<result.numberOfRanges {
            print("location: \(result.range(at: i).location), length: \(result.range(at: i).length)")
            let start = string.index(string.startIndex, offsetBy: result.range(at: i).location)
            let end = string.index(start, offsetBy: result.range(at: i).length)
            let text = String(string[start..<end])
            print(text)
        }
    }
}

matches(string: "12345", pattern: "\\d", options: [])

numberOfMatches

func numberOfMatches(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange) -> Int

パターンにヒットした件数のみ取得するメソッドです。

引数はmatchesと同じです。

func numberOfMatches(string: String, pattern: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 検索実行
    let result = regex.numberOfMatches(in: string, options: [], range: NSRange(0..<string.count))

    // 結果表示
    print("\(result)件ヒット")
}

numberOfMatches(string: "12345", pattern: "\\d", options: [])

firstMatch

func firstMatch(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange) -> NSTextCheckingResult?

パターンにヒットした最初の1件のみを取得するメソッドです。

引数はmatchesと同じです。

func firstMatch(string: String, pattern: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 検索実行
    guard let result = regex.firstMatch(in: string, options: [], range: NSRange(0..<string.count)) else {
        print("ヒットなし")
        return
    }

    // 結果表示
    for i in 0..<result.numberOfRanges {
        let start = string.index(string.startIndex, offsetBy: result.range(at: i).location)
        let end = string.index(start, offsetBy: result.range(at: i).length)
        let text = String(string[start..<end])
        print(text)
    }
}

firstMatch(string: "12345", pattern: "\\d", options: [])

rangeOfFirstMatch

func rangeOfFirstMatch(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange) -> NSRange

パターンにヒットした最初の1件の範囲を取得するメソッドです。

引数はmatchesと同じです。

func rangeOfFirstMatch(string: String, pattern: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 検索実行
    let range = regex.rangeOfFirstMatch(in: string, options: [], range: NSRange(0..<string.count))

    if range.location == NSNotFound {
        print("ヒットなし")
        return
    }

    // 結果表示
    print("location: \(range.location), length: \(range.length)")
}

rangeOfFirstMatch(string: "12345", pattern: "\\d", options: [])

enumerateMatches

func enumerateMatches(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange, using: (NSTextCheckingResult?, NSRegularExpression.MatchingFlags, UnsafeMutablePointer) -> Void)

検索処理自体はmatchesメソッドと同じですが、こちらは1件ずつマッチするたびにusingパラメータで渡したクロージャが呼ばれます。

そのため、例えばある長文に対し正規表現でマッチした部分を順次ハイライト表示していく、といった要件に使用することができます。

また、オプション指定により次のマッチまでの間にもクロージャが呼ばれるようにすることもできるため(progressとして)、想定以上に時間がかかっている場合に処理をキャンセルする、といったことも可能です。

usingで渡すクロージャが取る引数は以下の3つです。

NSTextCheckingResult?
1件ごとのマッチ結果。ヒット以外のタイミングではnilが渡される

NSRegularExpression.MatchingFlags
クロージャが呼ばれた理由を示すフラグ

UnsafeMutablePointer
ObjCBool型へのポインタ。これに対しtrueの値をセットすると、検索処理がキャンセルされる

func enumerateMatches(string: String, pattern: String, range: NSRange? = nil, options: NSRegularExpression.Options = [], matchingOptions: NSRegularExpression.MatchingOptions) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    let range = range ?? NSRange(0..<string.count)

    // 検索実行
    regex.enumerateMatches(in: string, options: matchingOptions, range: range, using: { (result, flags, stop) in

        switch flags {
            case .progress:
                print("* progress - \(Date().timeIntervalSinceReferenceDate)")
            case .completed:
                print("* completed")
            case .hitEnd:
                print("* hitEnd")
            case .requiredEnd:
                print("* requiredEnd")
            case .internalError:
                print("* internalError")
            default:
                print("* hit")
        }

        // 結果表示
        if let result = result {
            for i in 0..<result.numberOfRanges {
                print("location: \(result.range(at: i).location), length: \(result.range(at: i).length)")
                let start = string.index(string.startIndex, offsetBy: result.range(at: i).location)
                let end = string.index(start, offsetBy: result.range(at: i).length)
                let text = String(string[start..<end])
                print(text)
            }
        }

        // 中止したい場合は以下のようにする
        //stop.pointee = true
    })
}

オプションで渡せる値は以下のとおり。

reportProgress

進行中(progress)のステータスとして、次のマッチ箇所が見つかるまでの間、細かくクロージャを呼び出すようにします。

※試した感じだと数ミリ秒からマイクロ秒単位で呼ばれたので、ループで処理中の間は常に呼び続けるのだと思われます

let string = "Google, Amazon, Facebook, Apple"
let pattern = "A[a-z]+"

print("--- reportProgress ---")
enumerateMatches(string: string, pattern: pattern, matchingOptions: [.reportProgress])

実行結果↓

--- reportProgress ---
* progress - 609059730.994546
* progress - 609059730.9946
* progress - 609059730.994632
* progress - 609059730.994659
* progress - 609059730.994684
* progress - 609059730.994715
* progress - 609059730.994743
* progress - 609059730.994767
* hit
location: 8, length: 6
Amazon
* progress - 609059730.995134
* progress - 609059730.995192
* progress - 609059730.995238
* progress - 609059730.995276
* progress - 609059730.995303
* progress - 609059730.99533
* progress - 609059730.995356
* progress - 609059730.995381
* progress - 609059730.995404
* progress - 609059730.99544
* progress - 609059730.995465
* progress - 609059730.995487
* hitEnd
location: 26, length: 5
Apple

reportCompletion

検索が完了した際にクロージャを呼び出すようにします。

let string = "Google, Amazon, Facebook, Apple"
let pattern = "A[a-z]+"

print("--- reportCompletion ---")
enumerateMatches(string: string, pattern: pattern, matchingOptions: [.reportCompletion])

実行結果↓

--- reportCompletion ---
* hit
location: 8, length: 6
Amazon
* hitEnd
location: 26, length: 5
Apple
* hit

……いや、呼ばれませんけど?

最後のhitは何?

anchored

マッチングを検索範囲の開始位置からのみに限定します。

パターンに常に ^ が付いているイメージ。

let string = "Google, Amazon, Facebook, Apple"
let pattern = "A[a-z]+"
let range = NSRange(8..<string.count)

print("--- anchored ---")
enumerateMatches(string: string, pattern: pattern, matchingOptions: [.anchored])

print("--- anchored (範囲限定) ---")
enumerateMatches(string: string, pattern: pattern, range: range, matchingOptions: [.anchored])

print("--- anchored (範囲限定) + reportCompletion ---")
enumerateMatches(string: string, pattern: pattern, range: range, matchingOptions: [.reportCompletion, .anchored])

実行結果↓

--- anchored ---
--- anchored (範囲限定) ---
* hit
location: 8, length: 6
Amazon
--- anchored (範囲限定) + reportCompletion ---
* hit
location: 8, length: 6
Amazon
* completed

全範囲だとヒットなしで、ちょうど「Amazon」から始まるようにRangeを指定してあげたら1ヒットしました。

そして、試しにここでreportCompletionも付けてみたら、こちらではcompletedが呼ばれました…(謎)

withTransparentBounds

単語区切りの検出や先読みのため、検索範囲を一部超えて文字列を検査することを許容します。

ただし、検索範囲が検索対象文字列の全範囲となっている場合、このオプションは無視されます。

ここではuseUnicodeWordBoundariesを付けて、単語区切りで試してみます。

let string = "北海道はでっかい道"
let pattern = "\\b"
let range = NSRange(1..<string.count - 2)

print("--- 全範囲 ---")
enumerateMatches(string: string, pattern: pattern, options: [.useUnicodeWordBoundaries], matchingOptions: [])

print("--- オプションなし ---")
enumerateMatches(string: string, pattern: pattern, range: range, options: [.useUnicodeWordBoundaries], matchingOptions: [])

print("--- オプションあり ---")
enumerateMatches(string: string, pattern: pattern, range: range, options: [.useUnicodeWordBoundaries], matchingOptions: [.withTransparentBounds])

実行結果↓

--- 全範囲 ---
* hit
location: 0, length: 0
* hit
location: 3, length: 0
* hit
location: 4, length: 0
* hit
location: 8, length: 0
* hitEnd
location: 9, length: 0
--- オプションなし ---
* hit
location: 3, length: 0
* hit
location: 4, length: 0
* hitEnd
location: 7, length: 0
--- オプションあり ---
* hit
location: 3, length: 0
* hit
location: 4, length: 0

参考として、全範囲で単語区切りを探した場合は、
|北海道||でっかい||
の5件のヒットとなりました。

一方、限定した文字列「海道はでっか」を対象とした場合、

オプションなしでは
北海道||でっか|い道
の3件がヒットしたのに対し、

オプションありでは
北海道||でっかい道
の2件のヒットとなりました。

頭の方は、オプションの有無に関係なくいずれも「海道」の前ではヒットしませんでした。
(オプションなしの場合はヒットしても良さそうなのに)

お尻の方は、オプションなしではそのまま「でっか」の後にヒットしていますが、オプションありでは範囲を超えて見た場合に「い道」と続いてまだ区切りじゃないと判断してか、ヒットなしになっています。

withoutAnchoringBounds

検索対象文字列の検索対象範囲を絞った場合に、その中の先頭と末尾を ^$ でヒットしないようにします。

検索範囲が検索対象文字列の全範囲となっている場合、このオプションは無視されます。

let string = "Google, Amazon, Facebook, Apple"
let pattern = "^.+$"
let range = NSRange(8..<string.count - 7)

print("--- オプションなし ---")
enumerateMatches(string: string, pattern: pattern, range: range, matchingOptions: [])

print("--- オプションあり ---")
enumerateMatches(string: string, pattern: pattern, range: range, matchingOptions: [.withoutAnchoringBounds])

実行結果↓

--- オプションなし ---
* hit
location: 8, length: 16
Amazon, Facebook
--- オプションあり ---

オプションなしでは範囲を絞った文字列の先頭と末尾が ^ と $ でヒットするため、1件のヒットとなりました。

一方のオプションありでは、先頭と末尾はあくまで元の文字列の先頭と末尾とされるため、0件のヒットです。

NSRegularExpressionの置換メソッド

replaceMatches

func replaceMatches(in: NSMutableString, options: NSRegularExpression.MatchingOptions, range: NSRange, withTemplate: String) -> Int

replaceMatchesは、matches検索によりヒットした部分を置換しつつ、ヒットした件数を戻り値として受け取れるメソッドです。

引数は、in: 検索・置換の対象となる文字列(NSMutableStringで、これ自体が書き換えられる)、options: マッチングオプション、 range: 検索対象文字列中の検索対象範囲、withTemplate: テンプレート文字列、となっています。

テンプレート文字列というのは基本的には「置換後の文字列」のことですが、ここではヒットした部分文字列全体を取得する $0 や、グループ化した時の $1, $2 … などのメタ文字を使用することができます。($をエスケープするためのバックスラッシュ \ も)

また、マッチングオプションはmatchesと同じで、省略または [] 固定でOKです。

func replaceMatches(string: String, pattern: String, template: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 置換実行
    let mstring = NSMutableString(string: string)
    let count = regex.replaceMatches(in: mstring, options: [], range: NSRange(0..<string.count), withTemplate: template)

    // 結果表示
    print(mstring)
    print("\(count)件置換しました")
}

let string = "Google, Amazon, Facebook, Apple"
let pattern = "[B-Z][a-z]+"
let template = "Apple"

replaceMatches(string: string, pattern: pattern, template: template, options: [.useUnicodeWordBoundaries])

実行結果↓

Apple, Amazon, Apple, Apple
2件置換しました

stringByReplacingMatches

func stringByReplacingMatches(in: String, options: NSRegularExpression.MatchingOptions, range: NSRange, withTemplate: String) -> String

単純に文字列置換を行うメソッドです。
StringをNSMutableStringに変換しなくていい分、置換した件数を取得することはできません。

in: 以外の引数についてはreplaceMatchesと同じです。

func stringByReplacingMatches(string: String, pattern: String, template: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 置換実行
    let result = regex.stringByReplacingMatches(in: string, options: [], range: NSRange(0..<string.count), withTemplate: template)

    // 結果表示
    print(result)
}

let string = "Google, Amazon, Facebook, Apple"
let pattern = "[B-Z][a-z]+"
let template = "Apple"

stringByReplacingMatches(string: string, pattern: pattern, template: template, options: [])

実行結果↓

Apple, Amazon, Apple, Apple

replacementString

func replacementString(for: NSTextCheckingResult, in: String, offset: Int, template: String) -> String

replacementStringは上2つと少し用途が異なります。

これは独自の置換処理を実装したい場合に使うもので、検索した結果(NSTextCheckingResult)とその他引数を渡すことで、置換後の文字列を取得するメソッドとなっています。

少々分かりづらいので、具体例を示します。

例えば↓のような感じ。

func replacementString(string: String, pattern: String, template: String, options: NSRegularExpression.Options) {

    // NSRegularExpressionのインスタンス生成
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // ① まずは検索
    let results = regex.matches(in: string, options: [], range: NSRange(0..<string.count))

    var resultString = string
    var offset = 0

    // ② 検索結果を使って順次置換していく
    for result in results {

        // ③ 置換後文字列の取得
        let replacementString = regex.replacementString(for: result, in: resultString, offset: offset, template: template)

        // ④ 置換後文字列の確定版作成(3の倍数の数字だったら、前に○を付ける)
        let fixedReplacementString: String
        if let number = Int(replacementString), number.isMultiple(of: 3) {
            fixedReplacementString = "○\(replacementString)"
        }
        else {
            fixedReplacementString = replacementString
        }

        // ⑤ 置換範囲を求める(グループ化した際の個別の範囲は使わない)
        var range = result.range(at: 0)
        range.location += offset

        // ⑥ 置換
        let start = resultString.index(resultString.startIndex, offsetBy: range.location)
        let end = resultString.index(start, offsetBy: range.length)
        resultString = resultString.replacingCharacters(in: start..<end, with: fixedReplacementString)

        // ⑦ オフセット更新
        offset += fixedReplacementString.count - range.length
    }

    // 結果表示
    print(resultString)
}

let string = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15"
let pattern = "[0-9]+"
let template = "$0"

replacementString(string: string, pattern: pattern, template: template, options: [])

実行結果↓

1 2 ○3 4 5 ○6 7 8 ○9 10 11 ○12 13 14 ○15

文字列の中から数字を探し、それが“3の倍数の数字だったら頭に○を付ける”、ということをしています。

関数の中身についてざっと説明すると、

① まずはパターンにマッチする箇所を matchesメソッドにて取得します

② 次にそれをループで回し、順次処理にかけます

③ ここでreplacementStringを使用しています。各パラメータを渡して呼び出すと、その結果(result)に対して置き換えられる文字列を得ることができます。この例では「$0」をテンプレート文字列として指定していますので、ヒットした各数字(1, 2, 3…)が返却されます

④ ③で得られた文字列に対し、「数字で3の倍数だったら頭に○を付ける」ということをしています

⑤ 変換する文字列(resultString)の中の、置換が発生する範囲を求めています。ループの2周目以降はずれが生じている可能性があるため、offsetをlocationに加えます

⑥ 実際の文字列置換はここで行われます

⑦ resultStringを変更していくと、当然元の検索結果と位置がずれていきますので、ここでその補正のためのoffsetを計算しています。具体的には、置換後の文字列の長さから置換前の長さを引いて、offsetにその増減を加えています。そして更新されたoffsetを持って、次の処理へと続いていきます

といった感じです。

これと同じことをもう少し簡単にやりたい場合、NSRegularExpressionのサブクラス化を検討することができます。

↓サンプルその2

class MyRegularExpression: NSRegularExpression {
    override func replacementString(for result: NSTextCheckingResult, in string: String, offset: Int, template templ: String) -> String {

        // 置換対象範囲を求める(グループ化した際の個別の範囲は使わない)
        var range = result.range(at: 0)
        range.location += offset

        // 置換対象文字列の抜き出し
        let substring = NSString(string: string).substring(with: range)

        // テンプレートをいじる(3の倍数の数字だったら、前に○を付ける)
        let template: String
        if let number = Int(substring), number.isMultiple(of: 3) {
            template = "○\(templ)"
        }
        else {
            template = templ
        }

        // 置換後文字列の返却
        return super.replacementString(for: result, in: string, offset: offset, template: template)
    }
}

func replacementString2(string: String, pattern: String, template: String, options: NSRegularExpression.Options) {

    // MyRegularExpressionのインスタンス生成
    guard let regex = try? MyRegularExpression(pattern: pattern, options: options) else {
        print("無効なパターン")
        return
    }

    // 置換実行
    let result = regex.stringByReplacingMatches(in: string, options: [], range: NSRange(0..<string.count), withTemplate: template)

    // 結果表示
    print(result)
}

let string = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15"
let pattern = "[0-9]+"
let template = "$0"

replacementString2(string: string, pattern: pattern, template: template, options: [])

実行結果↓

1 2 ○3 4 5 ○6 7 8 ○9 10 11 ○12 13 14 ○15

NSRegularExpressionを継承したMyRegularExpressionクラスを作成し、その中でreplacementStringをオーバーライドしています。

MyRegularExpressionのインスタンスのstringByReplacingMatchesを呼ぶと、中で随時replacementStringが呼ばれますので、そこでテンプレート文字列を変えてやることで置換処理そのものを変化させています。

こちらの場合、offset計算は不要なため(おそらくstringByReplacingMatchesの中身は、1つ目のサンプルとはまた違ったやり方で置換処理をしているのだと思います)、多少すっきりします。

ただし、こちらのやり方は場所によってクラスの使い分けが必要になるため、どちらがいいとは一概には言えないかと思います。

NSRegularExpressionのエスケープメソッド

escapedTemplate

class func escapedTemplate(for: String) -> String

replaceMatchesメソッドなどのwithTemplateパラメータに渡す文字列に含まれるメタ文字をエスケープしてくれるメソッドです。

let string = "Google, Amazon, Facebook, Apple"
let pattern = "([B-Z])[a-z]+"
let template = "$1pple"

let escapedTemplate = NSRegularExpression.escapedTemplate(for: template)

print("* template: \(template)")
print("* escapedTemplate: \(escapedTemplate)")

print("--- エスケープなし ---")
stringByReplacingMatches(string: string, pattern: pattern, template: template, options: [])

print("--- エスケープあり ---")
stringByReplacingMatches(string: string, pattern: pattern, template: escapedTemplate, options: [])

実行結果↓

* template: $1pple
* escapedTemplate: \$1pple
--- エスケープなし ---
Gpple, Amazon, Fpple, Apple
--- エスケープあり ---
$1pple, Amazon, $1pple, Apple

テンプレート文字列「$1pple」の $ の前に \ が加えられ、「\$1pple」に変換されました。

そのため、エスケープありの置換処理では $1 がそのまま生かされて「$1pple」となっています。

escapedPattern

class func escapedPattern(for: String) -> String

escapedTemplateと似てますが、こちらはパターンで指定する文字列用のエスケープとなっています。

let string = "Google, Amazon, Facebook, Apple, [B-Z][a-z]+"
let pattern = "[B-Z][a-z]+"
let template = "Apple"

let escapedPattern = NSRegularExpression.escapedPattern(for: pattern)

print("* pattern: \(pattern)")
print("* escapedPattern: \(escapedPattern)")

print("--- エスケープなし ---")
stringByReplacingMatches(string: string, pattern: pattern, template: template, options: [])

print("--- エスケープあり ---")
stringByReplacingMatches(string: string, pattern: escapedPattern, template: template, options: [])

実行結果↓

* pattern: [B-Z][a-z]+
* escapedPattern: \[B-Z]\[a-z]\+
--- エスケープなし ---
Apple, Amazon, Apple, Apple, [B-Z][a-z]+
--- エスケープあり ---
Google, Amazon, Facebook, Apple, Apple

パターン文字列「[B-Z][a-z]+」がそれぞれメタ文字無効となるようエスケープされ、「\[B-Z]\[a-z]\+」になりました。

そのため、エスケープ適用後はGoogleなどの文字がヒットしなくなった一方、[B-Z][a-z]+ がヒットしてAppleに置き換えられました。

参考

参考として、同じ文字列をそれぞれescapedTemplateとescapedPatternにかけてみたらどうなるかやってみたいと思います。

let pattern = "[B-Z][a-z]+"
let template = "$1pple"

print("--- escapedTemplate で template をエスケープ  ---")
print(NSRegularExpression.escapedTemplate(for: template))

print("--- escapedTemplate で pattern をエスケープ  ---")
print(NSRegularExpression.escapedTemplate(for: pattern))

print("--- escapedPattern で template をエスケープ  ---")
print(NSRegularExpression.escapedPattern(for: template))

print("--- escapedPattern で pattern をエスケープ  ---")
print(NSRegularExpression.escapedPattern(for: pattern))

実行結果↓

--- escapedTemplate で template をエスケープ  ---
\$1pple
--- escapedTemplate で pattern をエスケープ  ---
[B-Z][a-z]+
--- escapedPattern で template をエスケープ  ---
\$1pple
--- escapedPattern で pattern をエスケープ  ---
\[B-Z]\[a-z]\+

templateの方はどちらも同じようにエスケープされたのに対し、patternの方はescapedPatternでのみエスケープされました。

[] や + はテンプレート文字列としてはエスケープの必要はないと判断しているようです。(そりゃそうだ)

参考リンク

公式ドキュメント:
https://developer.apple.com/documentation/foundation/nsregularexpression

おわりに

いかがでしたでしょうか?

軽い気持ちでまとめ始めたら、思いの外ボリュームが多くなってしまいました。
ともあれ、この記事でどなたかのNSRegularExpressionに関する疑問が払拭されたなら幸いです。

それでは。