Swift Digest
SE-0354 | Swift Evolution

Regexリテラル

Regex Literals

Proposal
SE-0354
Authors
Hamish Knight, Michael Ilseman, David Ewing
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

SE-0350 で導入された Regex 型は、文字列で与えたパターンを実行時にコンパイルできる強力な仕組みでした。しかし、パターンが静的に決まっている場合には、文字列に書いた正規表現をわざわざ try! Regex(...) でくるむ次のような書き方には、いくつもの不便がつきまといます。

let pattern = #"(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)"#
let regex = try! Regex(pattern)
// regex: Regex<AnyRegexOutput>

具体的には、次のような問題があります。

  • 正規表現の構文エラーが実行時まで見つからず、明示的なエラーハンドリング(try! など)が必要になります。
  • 構文ハイライトやコード補完、リファクタリングといったエディタ支援が効きません。
  • キャプチャの型も実行時まで分からないため、結果は動的な AnyRegexOutput で扱うしかなく、型安全に取り出せません。
  • マッチ関数の引数として書くときなど、文字列リテラルと Regex(...) を組み合わせる記法は冗長です。

また、Swift には SE-0351 で導入された Regex builder DSL もありますが、DSL は明示的で強力な一方、ちょっとしたパターンをその場で書きたい場面ではやや重く、「/[$£]\d+\.\d{2}/ のようにさっと書けるリテラル表現」を組み合わせて使いたいという要求にも応えられていませんでした。

/.../ という区切り記号は、1969年の ed に始まり、sed、Perl、Ruby、JavaScript などに受け継がれてきた、50年以上の歴史を持つ正規表現の「見た目の定番」です。Swift でもこの記法を第一級のリテラルとして取り込むことで、上述の不便を一気に解消することが目指されました。

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

正規表現リテラルとして、スラッシュで囲む /.../ と、# で拡張した #/.../# の2種類を導入します。リテラルの中身はコンパイラが正規表現として解析するため、構文エラーはコンパイル時に検出され、キャプチャの型も静的に推論されます。

// "<identifier> = <hexadecimal value>" にマッチし、identifier と16進数を取り出す
let regex = /(?<identifier>[[:alpha:]]\w*) = (?<hex>[0-9A-F]+)/
// regex: Regex<(Substring, identifier: Substring, hex: Substring)>

SE-0351 の Regex builder DSL と組み合わせることもでき、軽量な箇所はリテラルで、構造化したい箇所は DSL で、という書き分けが自然に行えます。

// /[$£]\d+\.\d{2}/ と同じ形式から通貨と金額を取り出す
let regex = Regex {
  Capture { /[]/ }
  TryCapture {
    /\d+/
    "."
    /\d{2}/
  } transform: {
    Amount(twoDecimalPlaces: $0)
  }
}

型付きキャプチャ

キャプチャの型はパターンから静的に決まります。ルールは次の通りです。

  • マッチ全体を表す Substring が必ず含まれます。
  • キャプチャがある場合、全体の Substring を先頭に、以降のキャプチャ型を並べたタプルになります。順序は正規表現のキャプチャ番号に従います。
  • キャプチャ単体の型はデフォルトで Substring ですが、「成功時に必ず値が得られる」とは限らない位置(?*、下限が 0 の範囲量指定子、または | の分岐の中)に現れる場合は Substring? になります。
let regex1 = /([ab])?/
// regex1: Regex<(Substring, Substring?)>

let regex2 = /([ab])|\d+/
// regex2: Regex<(Substring, Substring?)>

ゼロ回量指定子や分岐がキャプチャの 内側 に入れ子になっている場合は、キャプチャ自体がさらに外側のゼロ回量指定子や分岐の中にない限り、オプショナルにはなりません。

let regex = /([ab]*)cd/
// regex: Regex<(Substring, Substring)>
// `*` が0回マッチしたときは空文字列がキャプチャされる

オプショナルは多重にはならず、どれだけ入れ子になっても最大1段です。

let regex = /(.)*|\d/
// regex: Regex<(Substring, Substring?)>

名前付きキャプチャ

リテラルの場合に限り、名前付きキャプチャ (?<name>...) はタプルのラベルとしても推論されます。番号によるアクセスと名前によるアクセスの両方が可能です。

func matchHexAssignment(_ input: String) -> (String, Int)? {
  let regex = /(?<identifier>[[:alpha:]]\w*) = (?<hex>[0-9A-F]+)/
  // regex: Regex<(Substring, identifier: Substring, hex: Substring)>

  guard let match = input.wholeMatch(of: regex),
        let hex = Int(match.hex, radix: 16)
  else { return nil }

  return (String(match.identifier), hex)
}

この「キャプチャ名がタプルのラベルになる」挙動は DSL にはないリテラル固有の機能です(DSL 側では named reference によって同等のことを表現します)。

拡張デリミタ #/.../#

正規表現の中でスラッシュそのものを使いたい場合に \/ とエスケープするのは煩雑です。そこで、任意個の # で両端を囲む拡張デリミタを用意しました。デリミタが変わるので、スラッシュをそのまま書けます。

let regex = #/usr/lib/modules/([^/]+)/vmlinuz/#
// regex: Regex<(Substring, Substring)>

# の個数はバランスが取れていれば増やせて、例えば /# をリテラル中に含めたければ ##/.../## を使うといった運用ができます。

生文字列リテラル #"..."# と似ていますが、重要な違いが2点あります。

  • バックスラッシュは リテラル化されません#/\n/#\n(改行エスケープ)として扱われ、\s\w\p{...} などの正規表現エスケープもそのまま機能します。これは、外部の正規表現をコピー&ペーストしたときにエスケープを書き換えなくて済むようにするためです。
  • 開きデリミタの直後に改行がある場合、複数行リテラル として扱われます。

複数行リテラル

#/ に続けて改行を入れると、複数行リテラルになります。このモードでは、いわゆる extended syntax (?x) が有効になり、空白は非意味的(文字クラスの中でも同様)になり、行末の # ... はコメントとして扱われます。

let regex = #/
  usr/lib/modules/ # Prefix
  (?<subpath> [^/]+)
  /vmlinuz          # The kernel
/#
// regex: Regex<(Substring, subpath: Substring)>
let regex = #/
  # "DEBIT  03/03/2022  Totally Legit Shell Corp  $2,000,000.00" のような行にマッチ
  (?<kind>    \w+)                \s\s+
  (?<date>    \S+)                \s\s+
  (?<account> (?: (?!\s\s) . )+)  \s\s+ # 口座名には空白が含まれることもある
  (?<amount>  .*)
/#

閉じデリミタは必ず新しい行に置く必要があります。改行そのものをマッチさせたいときは \n を使うか、行末でバックスラッシュによりリテラルな改行をエスケープします。extended syntax は (?-x) で無効化することはできませんが、(?-x:...)\Q...\E でグループや引用範囲ごとに限って止めることはできます(ただしこれらは単一行に収まっている必要があります)。

/.../ の曖昧性と制限

スラッシュは既存の演算子やコメントと衝突するため、/.../ リテラルには次の制約があります。

  • 先頭または末尾が スペース・タブ のリテラルは /.../ としては解析されません。先頭に空白が欲しい場合は \ でエスケープするか、#/.../# を使います。この制約は、result builder 内で digit / [+-] / digit のような書き方が演算子チェーンと誤解されないようにするためのものです。
  • // で始まるコメントや /* */ ブロックコメントは従来どおりコメントです(空リテラルは #//# と書けますが、実用上ほぼ出番はありません)。
  • 中置演算子 +/ のように空白なしで / が続くケースは、演算子として解釈されます。x + /y/ のように空白を空けるか、x+#/y/# を使う必要があります。
  • /.../ リテラルは式位置の / で始まり、同じ行に閉じ / があり、かつ内部に閉じていない ) を含まない場合にのみリテラルとして解釈されます。この「閉じていない ) を含まない」条件は、bar(/x)/2 のようなソース互換性を保つためのヒューリスティックです。

これらの曖昧性を回避できない既存コードは、丸括弧や空白の追加で曖昧さを解消できます。また、/.../ の導入自体はソース互換性に影響するため、Swift 6 言語モード で解禁されます。Swift 5 系でも試したい場合は、コンパイラフラグ -enable-bare-slash-regex あるいは upcoming feature flag BareSlashRegexLiterals を使います。拡張デリミタ #/.../# はこの制約を受けず、従来の言語モードでもすぐに使えます。

03 今後の見通し

よりモダンなリテラル構文

正規表現リテラルの中で、コメントを ///* ... */ で書けるようにしたり、引用範囲を "..." で表せるようにしたりといった、より Swift らしい構文を取り入れる案が示されています。ただしこれらは本Proposalで採用した既存の正規表現構文のスーパーセットとは両立しないため、別種のリテラルとして導入する必要があり、デリミタの選択にも明確な答えがない状況です。標準的な正規表現の親しみやすさを失うリスクや、DSL とリテラルの組み合わせで似たことが達成できることもあり、あくまで方向性として述べられているにとどまります。

名前重複キャプチャや branch reset alternation の型付け

PCRE では (?J) を指定することで同じ名前のキャプチャグループを複数定義できますが、これはタプルのラベルが重複できないという Swift の制約とは相容れません。また、PCRE や Perl が持つ branch reset 構文 (?|(a)|(b)) は、複数の枝でキャプチャ番号を共有する仕組みで、型付きキャプチャを与えるには各枝の型を統一する必要があります。本Proposalではどちらの構文もサポートしないため、これらを取り入れる際の型付け方針は将来の課題として残されています。

ライブラリによる Regex の拡張

正規表現リテラルは「文字列に対する処理アルゴリズム」を記述するもので、対象となる文字列のモデル(grapheme cluster 単位か Unicode スカラ単位か、ローカライズ比較を使うかなど)はライブラリ側で差し替えたい余地があります。ExpressibleBy* 系プロトコルではアルゴリズムの構造そのものに触れられないため力不足で、Regex のパーサーが持つ AST や API、AST に対するアクションをライブラリへ開放する案が示されています。

これが実現すれば、ICU や PCRE、JavaScript といった別エンジン向けに Swift の正規表現を出力したり、URL のコンポーネント単位でパターンを評価する独自の高水準構造に正規表現リテラルを埋め込んだりといった応用が考えられます。いずれも本Proposalのスコープ外で、将来の方向性として挙げられているにすぎず、実現が約束されているわけではありません。