Swift Digest
SE-0350 | Swift Evolution

Regex Type and Overview

Proposal
SE-0350
Authors
Michael Ilseman
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

01 何が問題だったのか

Swift の String は Unicode を徹底的に尊重した設計になっていますが、日常的な文字列処理の用途では Collection のアルゴリズムだけでは不足する場面が多く、他言語に比べて見劣りしがちでした。特に、フィールドが 2 つ以上の空白で区切られた銀行明細のような半構造化テキストをパースしようとすると、split() とインデックス操作で力技を書くか、左から手書きでスキャンしていくしかなく、どちらも冗長で壊れやすくなります。

let statement = """
  CREDIT    03/02/2022    Payroll                   $200.23
  CREDIT    03/03/2022    Sanctioned Individual A   $2,000,000.00
  DEBIT     03/03/2022    Totally Legit Shell Corp  $2,000,000.00
  DEBIT     03/05/2022    Beanie Babies Forever     $57.33
  """

既存の選択肢として Foundation の NSRegularExpression がありますが、これにはいくつかの根本的な問題がありました。

  • パターンが文字列リテラルであるため、シンタックスハイライトや補完などのソースツールの恩恵を受けられません。
  • キャプチャの個数や型が型システムに現れず、NSNotFound を見逃すなどの取りこぼしが発生しやすくなります。
  • 正規表現エンジンが「オール・オア・ナッシング」で、サブ要素を切り出して共有したりリファクタリングしたりできません。その結果、日付・数値・通貨のように本来はきちんとしたパーサを使うべきものまで正規表現で無理にパースしがちです。
  • ICU ベースで動作するため、Swift の String が採用している grapheme cluster 単位や canonical equivalence と一致しない結果を返すことがあり、ブリッジングのオーバーヘッドも避けられません。

つまり、Swift に必要だったのは単なる「正規表現クラス」ではなく、型付きキャプチャ・リテラル・結果ビルダ・本格派パーサとの相互運用・Unicode 対応といった要素が一体となった、文字列処理のための新しい基盤でした。

02 どのように解決されるのか

この提案は、Swift の新しい正規表現機能全体の土台となる Regex<Output> 型と Regex<Output>.Match 型を導入し、関連する一連の提案(正規表現リテラル、結果ビルダ DSL、Unicode セマンティクス、文字列処理アルゴリズムなど)の全体像を示すものです。

Regex<Output>

Regex<Output> は、文字列に対して走らせる処理アルゴリズムを表します。型パラメータ Output には、マッチ全体とキャプチャが(タプルとして)まとまって現れます。慣例として Output の 0 番目の要素がマッチ全体、1 番目以降がサブパターンのキャプチャに対応します。

実行時にパターン文字列からコンパイルすることも、コンパイル時にリテラルから生成することもできます。リテラルから生成する場合は、コンパイラがキャプチャの個数と型を推論するため、Output は具体的なタプル型になります。

// 実行時コンパイル(キャプチャ型を明示)
let pattern = #"(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)"#
let regex1: Regex<(Substring, Substring, Substring, Substring, Substring)> =
  try Regex(pattern)

// 実行時コンパイル(型を明示しなければ AnyRegexOutput)
let regex2 = try Regex(pattern)
// regex2: Regex<AnyRegexOutput>

// リテラルからの生成(型はコンパイラが推論)
let regex3 = /(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)/
// regex3: Regex<(Substring, Substring, Substring, Substring, Substring)>

パターンに文法エラーがあったり、宣言した Output の型と実際のキャプチャが合わなかったりした場合は、Regex の初期化時に throw されます。

Regex<Output>.Match

マッチ結果は Regex<Output>.Match で表され、マッチした範囲(range)とキャプチャ(output)を保持します。@dynamicMemberLookup により、Output がタプルの場合はそのメンバ(番号や名前)に match.kind のような形で直接アクセスできます。

public struct Regex<Output> {
  public func wholeMatch(in s: String) throws -> Regex<Output>.Match?
  public func prefixMatch(in s: String) throws -> Regex<Output>.Match?
  public func firstMatch(in s: String) throws -> Regex<Output>.Match?
  // Substring を受け取るオーバーロードも用意されます

  @dynamicMemberLookup
  public struct Match {
    public var range: Range<String.Index> { get }
    public var output: Output { get }
    public subscript<T>(dynamicMember keyPath: KeyPath<Output, T>) -> T { get }
  }
}

名前付きキャプチャを使うと、Output のタプル要素にラベルが付き、match.kind のように意味のある名前でキャプチャを取り出せます。

func processEntry(_ line: String) -> Transaction? {
  // 拡張リテラルなので空白やコメントが自由に書けます
  let regex = #/
    (?<kind>    \w+)                \s\s+
    (?<date>    \S+)                \s\s+
    (?<account> (?: (?!\s\s) . )+)  \s\s+
    (?<amount>  .*)
  /#
  // regex: Regex<(Substring, kind: Substring, date: Substring,
  //               account: Substring, amount: Substring)>

  guard let match = line.wholeMatch(of: regex),
        let kind = Transaction.Kind(match.kind),
        let date = try? Date(String(match.date), strategy: dateParser),
        let amount = try? Decimal(String(match.amount), format: decimalParser)
  else { return nil }

  return Transaction(
    kind: kind, date: date, account: String(match.account), amount: amount)
}

結果ビルダと他のパーサとの連携

Regex は結果ビルダ DSL でも記述でき、Regex 自身も DSL のコンポーネントとして再利用できます。これにより、同じ表現を「リテラル」「DSL」「実行時コンパイル」と使い分けられ、リテラルから DSL へのリファクタリングのようなソースツールが成立します。

さらに、別提案の CustomMatchingRegexComponent を通じて、Date.ParseStrategyDecimal.FormatStyle.Currency のような本格派のパーサを正規表現のコンポーネントとしてそのまま組み込めます。これにより、「緩い正規表現でだいたい切り出してから後段で検証する」という定番の悪手を避け、パース失敗をその場で正規表現のバックトラックとして扱えます。TryCapture でキャプチャ時に値を変換し、Reference<T> 経由で型付きの値を取り出すパターンが典型です。

let kind = Reference<Transaction.Kind>()
let date = Reference<Date>()
let amount = Reference<Decimal>()

let regex = Regex {
  TryCapture(as: kind) { OneOrMore(.word) } transform: { Transaction.Kind($0) }
  fieldSeparator
  TryCapture(as: date) { dateParser }
  fieldSeparator
  Capture { /* account */ }
  fieldSeparator
  TryCapture(as: amount) { decimalParser }
}

guard let match = line.wholeMatch(of: regex) else { return nil }
let transaction = Transaction(
  kind: match[kind], date: match[date], account: ..., amount: match[amount])

Unicode セマンティクス

Swift の String における Character は extended grapheme cluster で、等価性は canonical equivalence で判定されます。Regex もこのモデルに沿って動作し、既定では . が 1 つの grapheme cluster にマッチし、正準等価な文字列同士もマッチ対象になります。\bUTS#29 に基づく単語境界で、”don’t” のような縮約やスクリプトの切り替わりも正しく扱われます。既定では UTS#18 Level 2 相当ですが、オプションで Unicode スカラ単位の処理や互換文字クラスへ切り替えることもできます。詳細は別提案「Unicode for String Processing」で扱われます。

キャンセル対応

正規表現の実行は長時間に及ぶことがあるため、Regex のアルゴリズムは親 Task のキャンセルを検知し、途中で処理を終了することがあります。

関連提案との関係

この提案は、以降に続く一連の正規表現関連提案(正規表現構文、リテラル、結果ビルダ、Unicode セマンティクス、文字列処理アルゴリズム)の入口にあたる位置づけです。それぞれの提案は独立に評価されるべきで、本提案の採択が他の提案の採択を自動的に意味するわけではありません。

今後の展望

将来的には、正規表現のバイトコードを部分的にコンパイル時に解決して起動時のコストを減らすこと、単純な正規表現を DFA に静的コンパイルすること、CustomMatchingRegexComponent を土台にしたパーサコンビネータ的な発展、Regex に裏打ちされた enum(RawRepresentable に似た位置づけ)などが見通しとして言及されています。これらはいずれも現時点ではスコープ外で、実現を約束するものではありません。