Swift Digest
SE-0354 | Swift Evolution

Regex Literals

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

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 を使います。拡張デリミタ #/.../# はこの制約を受けず、従来の言語モードでもすぐに使えます。

Future Directions

今後の見通しとしては、コメントに ///* ... */ を使えるようにするなどの「より Swift 風のモダンなリテラル構文」、PCRE の (?J) による名前重複キャプチャに対応した型付きキャプチャ、branch reset alternation (?|(a)|(b)) の型付けといった方向性が挙げられています。また、Regex のパーサーの AST や API をライブラリに開放し、ICU や PCRE、JavaScript などの別エンジン向けに正規表現を出し直したり、文字列のマッチ単位(ローカライズ比較や独自の grapheme 分割)を差し替えたりできるようにする構想も示されています。いずれも本Proposalのスコープ外で、実現が約束されているわけではありません。