Swift Digest
SE-0448 | Swift Evolution

Regex lookbehind assertions

Proposal
SE-0448
Authors
Jacob Hearst, Michael Ilseman
Review Manager
Steve Canon
Status
Accepted

01 何が問題だったのか

Swift の Regex はすでに先読み (lookahead) のアサーションをサポートしていますが、後読み (lookbehind) のアサーションはサポートしていませんでした。後読みとは、現在の位置の「直前」に特定のパターンが現れているかどうかをチェックする、幅 0 (zero-width) のアサーションです。

実際、次のように書けそうに見えるコードも、これまでは正規表現のコンパイル時点でエラーになっていました。

let regex = /(?<=a)b/
// error: Cannot parse regular expression: lookbehind is not currently supported

Perl・PCRE2・Python・Java などの現代的な正規表現エンジンは、固定長の後読みを、.NET や JavaScript は任意長の後読みをサポートしています。Swift の Regex が後読みを欠いていると、これらのエンジンに書けるパターンが Swift では書けず、表現力の面で見劣りしてしまいます。Regex builder の DSL 側にも、前方アサーションに対応する Lookahead / NegativeLookahead はある一方で、後方アサーションに対応する型がない、というギャップもありました。

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

Regex エンジンに任意長の後読みアサーションを追加します。実装としては、後読み部分だけマッチングを右から左に逆向きに走らせることで、固定長に限定せず任意のパターンを受け付けます。先読みと同じく後読みも zero-width なので、マッチ位置そのものは進みません。

正規表現リテラルでの記法

これまでコンパイルエラーになっていた (?<=...)(positive lookbehind)と (?<!...)(negative lookbehind)がそのまま使えるようになります。PCRE 互換の (*plb:...) / (*positive_lookbehind:...)(*nlb:...) / (*negative_lookbehind:...) も同様に受け付けます。

"abc".firstMatch(of: /a(?<=a)bc/)  // "abc" にマッチ
"abc".firstMatch(of: /a(?<=b)c/)   // マッチしない
"abc".firstMatch(of: /a(?<=.)./)   // "ab" にマッチ
"abc".firstMatch(of: /ab(?<=a)c/)  // マッチしない
"abc".firstMatch(of: /ab(?<=.a)c/) // マッチしない
"abc".firstMatch(of: /ab(?<=a.)c/) // "abc" にマッチ

たとえば 1 つめの例では、a をマッチした直後に (?<=a) が「直前の 1 文字が a か」を確認しますが、マッチ位置は進めません。そのまま続きの bc がマッチするので、全体として "abc" がマッチします。

Regex builder での対応

Regex builder でも positive / negative の両方をサポートし、それぞれ LookbehindNegativeLookbehind という型として提供されます。Lookahead / NegativeLookahead と同じ感覚で、組み立て中の Regex の任意の位置に挟めます。

import RegexBuilder

// positive lookbehind: 直前が "b" のときだけマッチ
Regex {
  "a"
  Lookbehind { "b" }
  "c"
}

// negative lookbehind: 直前が "b" でないときだけマッチ
Regex {
  "a"
  NegativeLookbehind { "b" }
  "c"
}

後読み内部のキャプチャと貪欲性

後読みは右から左に向かってマッチングが走るため、後読みの内部にキャプチャを置いた場合、貪欲な量指定子の「取り分」が通常の左から右へのマッチングと変わることがあります。マッチするかどうかは変わりませんが、キャプチャの中身が変わり得る、という点に注意が必要です。

"abcdefg".wholeMatch(of: /(.+)(.+)/)
// ("abcdefg", "abcdef", "g") を返す
// 左から右への通常のマッチでは、最初の (.+) が可能な限り長く取る

"abcdefg".wholeMatch(of: /.*(?<=(.+)(.+))/)
// ("abcdefg", "a", "bcdefg") を返す
// 後読み内部は右から左へ走るため、右側の (.+) の方が長く取る

最初の例は通常のマッチングで、先に現れる (.+) が可能な限り長く消費するため "abcdef""g" に分かれます。後者は後読みの内部なので逆向きにマッチングが進み、右端側の (.+) が貪欲に "bcdefg" を取り、左端側は残った "a" だけになります。

Future Directions

本提案のスコープ外として、今後の方向性がいくつか示されています。いずれも speculative で、実現を約束するものではありません。

  • PCRE の \K への対応: 現在位置までのマッチ結果をリセットする機能。後読みと並んで、マッチ結果の切り出しを柔軟にするためのもの。
  • 逆向きマッチング API: 文字列の末尾から逆向きに Regex を適用する API。以前のピッチでは検討されたものの、逆向きマッチングの意味論を API として伝えるのが難しく、主要言語にもあまり例がないため、いったん見送られています。