Regex lookbehind assertions
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 の両方をサポートし、それぞれ Lookbehind と NegativeLookbehind という型として提供されます。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 として伝えるのが難しく、主要言語にもあまり例がないため、いったん見送られています。