Unicode for String Processing
01 何が問題だったのか
Swift の String は既定で Character(拡張書記素クラスタ)の列として扱われ、canonical equivalence(正準等価)で比較されます。これにより、"Cafe\u{301}" と "Café" は等価に扱え、str.dropLast() で最後の "é" を安全に落とすことができます。
let str = "Cafe\u{301}" // "Café"
str == "Café" // true
str.last == "é" // true
str.last == "e\u{301}" // true
一方、一般的な regex エンジンは Unicode scalar 単位(あるいはバイト単位)で動作し、. や \w といったメタキャラクタは grapheme cluster を理解しません。Python など他言語では、"Cafe\u{301}" を正規表現で扱う際に正準等価を前提にできず、明示的に NFC 正規化をかけなければ ^.{4}$ や .+é$ がマッチしない、といった挙動になります。
もし Swift の Regex がこうした従来型のセマンティクスをそのまま採用すると、String の既定の振る舞いと一致せず、次のような違和感が発生します。
/.+é/が、合成済み"é"にはマッチするが分解済み"e\u{301}"にはマッチしない。/Caf./が"Café"の末尾の"é"を1文字としてマッチしてくれない(scalar 単位で途中を切ってしまう)。- 絵文字や旗、ZWJ シーケンス、国旗のような複数 scalar からなる文字を「1文字」と扱えない。
加えて、regex にはもともと実運用で欠かせないオプション群があります。
- 大文字・小文字を区別しないマッチ(
(?i)) .を改行にもマッチさせる single line モード((?s))や、^/$を各行頭・行末にマッチさせる multi-line モード((?m))\d/\s/\wなどの組み込み文字クラスを ASCII 範囲のみに制限する切り替え\bの単語境界を Unicode の「default word boundary」にするか「simple word boundary」にするか- 量化子の既定挙動(eager / reluctant / possessive)の切り替え
これらのオプションや文字クラスは、regex リテラルの内部構文((?i) など)だけでなく、RegexBuilder で組み立てるコードからも自然に扱える API が必要でした。さらに、\d や \s といったクラスが grapheme cluster セマンティクスと Unicode scalar セマンティクスのそれぞれで何を意味するか、\p{...} のような Unicode プロパティをどう拡張して Character に適用するかといった意味論を、Swift の String モデルと整合する形で定義し直す必要がありました。
02 どのように解決されるのか
Regex に、マッチングのセマンティックレベルとオプション群、そして組み込みの文字クラス群を整備します。既定では String の振る舞いに合わせて grapheme cluster セマンティクス かつ canonical equivalence で比較し、必要に応じて Unicode scalar セマンティクスへ明示的に切り替えられるようにします。Regex は Unicode Technical Standard #18 の level 2 を目標に、Unicode 文字クラス、canonical equivalence、grapheme cluster マッチング、level 2 の単語境界を既定で備えます。
let str = "Cafe\u{301}" // "Café"
str.contains(/Café/) // true
str.contains(/Caf./) // true
str.contains(/.+é/) // true
str.contains(/\w+é/) // true
セマンティックレベルの切り替え
matchingSemantics(_:) で regex 全体、あるいは RegexBuilder で作った一部分のマッチングレベルを切り替えられます。
public struct RegexSemanticLevel: Hashable {
public static var graphemeCluster: RegexSemanticLevel // 既定
public static var unicodeScalar: RegexSemanticLevel
}
grapheme cluster モードでは . や \w は Character 1つにマッチし、比較も canonical equivalence を使います。Unicode scalar モードでは Unicode scalar 1つ単位にマッチし、正準等価は使いません。
let composed = "qué"
let decomposed = "que\u{301}"
let queRegex = /^q..$/
composed.contains(queRegex) // true
decomposed.contains(queRegex) // true
let queScalar = queRegex.matchingSemantics(.unicodeScalar)
composed.contains(queScalar) // true
decomposed.contains(queScalar) // false (. が scalar 1つにしかマッチしない)
Unicode scalar モードの内側に grapheme cluster モードのセクションを挟むと、そのセクションの先頭に 暗黙の grapheme cluster 境界アサーション が挿入され、grapheme cluster セマンティクス側で得られるインデックスが必ず Character 境界に揃うよう保証されます。regex 全体が Unicode scalar モードのままマッチが終わる場合は、先頭や末尾に境界アサーションは入らないため、キャプチャ範囲が Character の途中で切れる可能性があります。その場合は String 上で使う前に境界へ丸める必要があります。
オプション群
オプションは regex リテラルの内部構文((?i) など)とメソッドチェーンの両方で設定でき、どちらで書いても等価です。メソッド呼び出しは (?:...) で囲むのと同じく、対象となった regex の範囲にのみ適用され、内側でさらに上書きできます。
| オプション | 内部構文 | メソッド | 既定 |
|---|---|---|---|
| 大文字・小文字を無視 | (?i) |
ignoresCase() |
無効 |
| 組み込みクラスを ASCII のみに | (?DSWP) |
asciiOnlyClasses(_:) |
.none |
| 単語境界アルゴリズム | (?w) |
wordBoundaryKind(_:) |
.default |
| マッチングレベル | なし | matchingSemantics(_:) |
.graphemeCluster |
| 量化子の既定挙動 | なし | defaultRepetitionBehavior(_:) |
.eager |
. を改行にマッチ |
(?s) |
dotMatchesNewlines() |
無効 |
^ / $ を行頭・行末に |
(?m) |
anchorsMatchLineEndings() |
無効 |
| eager/reluctant を反転 | (?U) |
なし | 無効 |
| 拡張構文 | (?x) / (?xx) |
なし | 複数行 regex リテラルでは xx 有効 |
| 名前付きキャプチャのみ | (?n) |
なし | 無効 |
let regex1 = /(?i)banana/
let regex2 = Regex { "banana" }.ignoresCase()
let regex3 = /banana/.ignoresCase()
// メソッド呼び出しはスコープを持つ
let regex4 = Regex {
"ba"
Regex { "na" }.ignoresCase(false)
"na"
}.ignoresCase()
"banana".contains(regex4) // true
"BAnaNA".contains(regex4) // true
"BANANA".contains(regex4) // false (中央の "na" は区別される)
大文字・小文字を無視するマッチは case folding を用いて行われ、canonical equivalence も維持されます。
asciiOnlyClasses(_:) には RegexCharacterClassKind(OptionSet)を渡します。.digit / .whitespace / .wordCharacter / .all / .none があり、\d / \s / \w や POSIX クラスを ASCII 範囲に絞り込めます。
let str = "0x35AB"
str.contains(/0x(\d+)/.asciiOnlyClasses())
単語境界の扱い(wordBoundaryKind(_:))
既定の .default は Unicode の「default word boundary」(level 2)を使い、アポストロフィを含む語、スクリプトの切り替わり、絵文字や国旗などを適切に扱います。.simple は「\w\W か \W\w の位置」という素朴な定義で、他エンジンに近い挙動になります。
let str = "Don't look down!"
str.firstMatch(of: /D\S+\b/) // "Don't"
str.firstMatch(of: /D\S+\b/.wordBoundaryKind(.simple)) // "Don"
level 2 では "🔥😊👍" や "🇨🇦🇺🇸🇲🇽" のような絵文字列は1文字ずつに分かれ、"I can't" は "can't" が1語として扱われる、といった違いが生まれます。
single line / multi-line モード
dotMatchesNewlines() は regex 構文中の . を改行にもマッチさせます。CharacterClass.any は元から改行も含むすべてにマッチし、改行を除外したい場合は CharacterClass.anyNonNewline を使います。
anchorsMatchLineEndings() を有効にすると regex 構文の ^ / $ が各行頭・行末にもマッチします。RegexBuilder の Anchor.startOfInput / Anchor.startOfLine / Anchor.endOfInput / Anchor.endOfLine は役割が最初から明確に分かれているため、このオプションの影響を受けません。
既定の量化子挙動
defaultRepetitionBehavior(_:) は、明示的に ? や + を付けずに使った量化子の既定挙動を切り替えます。
public struct RegexRepetitionBehavior {
public static var eager: RegexRepetitionBehavior { get } // 既定
public static var reluctant: RegexRepetitionBehavior { get }
public static var possessive: RegexRepetitionBehavior { get }
}
RegexBuilder の OneOrMore などは初期化子に behavior を受け取れ、nil を渡すと(または省略すると)このオプションの値を使います。明示的な behavior を渡した場合はそちらが優先されます。regex 構文だけの (?U) は eager と reluctant を反転させるオプションで、こちらは独立して働きます。
文字クラス
RegexBuilder モジュールに CharacterClass 型が追加され、regex 構文の文字クラスと対応します。
let regex1 = /\w+\s?\d{,3}/
let regex2 = Regex {
OneOrMore(.word)
Optionally(.whitespace)
Repeat(.digit, ...3)
}
主な組み込みクラスは以下の通りです。
.any/.anyNonNewline:(?s:.)と(?-s:.)に対応。dotMatchesNewlines()の影響を受けず、改行を含むか含まないかが API 側で明確。.anyGraphemeCluster(\X): 現在のセマンティックレベルに関わらず、grapheme cluster 1つにマッチ。.digit(\d):Numeric_Type=Decimalの scalar。grapheme cluster モードでは単一 scalar からなる文字のみマッチ。.hexDigit: 10進数字、または Halfwidth and Fullwidth Forms ブロックのA〜F/a〜f。.word(\w):Alphabetic/Digit/Join_Controlプロパティ、または general categoryMark/Connector_Punctuationの scalar。grapheme cluster モードでは先頭 scalar がこの条件を満たす文字にマッチ。.whitespace(\s) /.horizontalWhitespace(\h) /.verticalWhitespace(\v) /.newlineSequence(\R): それぞれ対応する Unicode プロパティに従います。grapheme cluster モードでは\sがCR+LFペアにもマッチする一方、Unicode scalar モードでは\sは片方の scalar だけにマッチし、\R/.newlineSequenceを使うとCR+LFをひとまとめに扱えます。
反転は regex 構文の \D / \W / \S / \H / \V、または CharacterClass の inverted プロパティで得られます。
Unicode プロパティと POSIX クラス
\p{PROPERTY} / \p{PROPERTY=VALUE} で Unicode プロパティにマッチでき、反転は \P{...} です。RegexBuilder からは CharacterClass の static メソッドで利用できます。
CharacterClass.generalCategory(.uppercaseLetter)
CharacterClass.binaryProperty(\.isAlphabetic)
CharacterClass.name("LATIN SMALL LETTER A")
CharacterClass.age(.v14_0)
CharacterClass.numericType(.decimal)
CharacterClass.numericValue(0.5)
CharacterClass.canonicalCombiningClass(.above)
CharacterClass.lowercaseMapping("ss")
grapheme cluster モードへの拡張方針は、プロパティの性質ごとに single-scalar(単一 scalar の文字のみマッチ)、first-scalar(先頭 scalar が条件を満たせばマッチ)、any-scalar、all-scalars を使い分けます。たとえば \p{Decimal} や \p{Hex_Digit} は single-scalar、\p{Whitespace} や \p{Alphabetic} / \p{Emoji} は first-scalar、\p{Lowercase_Mapping} / \p{Changes_When_Casefolded} のようにシーケンスに自然に適用できるものは all-scalars、\p{Cased} や \p{Emoji_Presentation} は any-scalar です。
POSIX クラス([:lower:] / [:upper:] / [:alpha:] / [:alnum:] / [:word:] / [:digit:] / [:xdigit:] / [:punct:] / [:blank:] / [:space:] / [:cntrl:] / [:graph:] / [:print:])は、対応する Unicode プロパティクラスの別名として定義されます。(?D) などの ASCII-only オプションが有効なときは、ASCII 範囲のみを対象にします。
カスタム文字クラスとセット演算
カスタム文字クラス [...] は、個別文字・scalar、範囲、組み込みクラス、POSIX クラス、他のカスタムクラスの和集合として働きます。範囲は安全性のために次の制約があります。
- 端点は単一 Unicode scalar でなければなりません(ソース上の複数 scalar 表記は正規合成形に変換されます)。
- grapheme cluster モードでの範囲マッチは、単一 scalar の文字のみが対象です。これにより、
[0-9]が"5️⃣"や"3̠̄"のような複合文字に意図せずマッチすることを防ぎます。
let allDigits = /^[0-9]+$/
"1230".contains(allDigits) // true
"123̠̄0".contains(allDigits) // false
"5️⃣".contains(allDigits) // false
let cafeExtended = /Caf[à-ÿ]/
"Café".contains(cafeExtended) // true
"Cafe\u{301}".contains(cafeExtended) // true
RegexBuilder の CharacterClass は、組み込みクラスと文字・範囲を合成して作れるほか、集合演算にも対応します。
let charClass = CharacterClass(.digit, "a"..."h").ignoresCase()
extension CharacterClass {
public func union(_ other: CharacterClass) -> CharacterClass
public func intersection(_ other: CharacterClass) -> CharacterClass
public func subtracting(_ other: CharacterClass) -> CharacterClass
public func symmetricDifference(_ other: CharacterClass) -> CharacterClass
}
// 範囲リテラルから CharacterClass を作る演算子
public func ...(lhs: Character, rhs: Character) -> CharacterClass
public func ...(lhs: UnicodeScalar, rhs: UnicodeScalar) -> CharacterClass
また、任意の文字・scalar 列から組み立てる CharacterClass.anyOf(_:) / CharacterClass.noneOf(_:) も用意され、それぞれ [abcd] / [^abcd] に対応します。