Java 7から正規表現で名前つきキャプチャが使えて, Scalaのscala.util.matching.Regex.Match
でもそれに相当する機能がある(インタフェースや実装はJava標準のものとは別)けれど, ちょっと不便なところをどうにかしているうちに, インデックスによるキャプチャグループの上に独自に名前つきキャプチャグループを実装するような形になった.
使い方はREADMEを見てもらうとして, なぜこんなものがほしくなったのかという話を書いておく.
標準の名前つきキャプチャのよくないところ
キャプチャグループの実体と名前の乖離
名前つきキャプチャは正規表現中で(?<name>pattern)
のように書くことで, pattern
にマッチした部分をあとからname
で参照できる. たとえば, Webサーバのルーティング処理で, パス中に名前つきでパターンを書いておけば非常に便利そうに思える.
import java.util.regex.Pattern val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)" val pathPattern = Pattern.compile(s"/$User/bookmark") val m = pathPattern.matcher(req.pathInfo) val user = if (m.matches) Some(m.group("User")) else None
まぁこれでよいと言えなくもないけれど, 間違えやすかったり不便なところもある. ざっと以下のところが気に入らない.
val User
と(?<User>...)
で2回User
って書くのだるい(?<User>...)
のところのUser
を(たとえばUsr
とかに)書き間違えていると死ぬm.group("User")
のところのUser
を(たとえばUsre
とかに)書き間違えていると死ぬ- エンドポイントを一覧したときに
/(?<User>[a-zA-Z][a-zA-Z0-9_-]+)/bookmark
と出てきて見づらい
(/${User}/bookmark
とか書いてあってほしい)
ちなみに, Scala版のインタフェースの場合はキャプチャグループのパターンとその名前を別々に指定するので, これはこれでパターンとグループ名が離れてしまってあまりよくないし, 上に挙げた問題もとくに解決しない.
val User = "([a-zA-Z][a-zA-Z0-9_-]+)" val pathPattern = s"/$User/bookmark".r("User") val m = pathPattern.findFirstMatchIn(req.pathInfo) val user = m.map(_.group("User"))
同じ名前のグループを複数回使えない
たとえば, /$User/$Repo/compare/$Sha1...$Sha1
みたいなエンドポイントがあったときには以下のようにパターンを定義したくなる.
import java.util.regex.Pattern val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)" val Repo = "(?<Repo>[a-zA-Z0-9_-]+)" val Sha1 = "(?<Sha1>[0-9a-f]{40})" val pathPattern = Pattern.compile(s"/$User/$Repo/compare/$Sha1...$Sha1")
しかしこれはできない. パターンをコンパイルした時点で以下のようなエラーが出て怒られる.
java.util.regex.PatternSyntaxException: Named capturing group <Sha1> is already defined near index 96
ちなみにScala版ではエラーは出ないけれど最後のものしかキャプチャされない.
val User = "([a-zA-Z][a-zA-Z0-9_-]+)" val Repo = "([a-zA-Z0-9_-]+)" val Sha1 = "([0-9a-f]{40})" val pathPattern = s"/$User/$Repo/compare/$Sha1...$Sha1".r("User", "Repo", "Sha1", "Sha1") val m = pathPattern.findFirstMatchIn("/tarao/namedcap-scala/compare/da22f64f3cfaec0a2394b05810025e78ba183cdc...1a3336b05d595674d84794fea77b6beb8c680318") m.map(_.group("Sha1")) // Some(1a3336b05d595674d84794fea77b6beb8c680318)
このようなことがしたければ, 以下のように, 同じパターンでも出現ごとにグループ名を変えるしかない.
import java.util.regex.Pattern val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)" val Repo = "(?<Repo>[a-zA-Z0-9_-]+)" val Sha1 = "[0-9a-f]{40}" val Sha1A = s"(?<Sha1A>$Sha1)" val Sha1B = s"(?<Sha1B>$Sha1)" val pathPattern = Pattern.compile(s"/$User/$Repo/compare/$Sha1A...$Sha1B")
うーん, きびしい. パス中のパラメータも出現位置が異なるだけでクエリパラメータと同じだと思えば*1同じ名前のパラメータが複数あっても不思議ではないのだし, 無意味に制限されているのはつらい.
その他べんりにやりたいこと
マッチした文字列中のキャプチャされた部分を置き換えたい
正規表現にマッチした全体を置き換えるのではなく, キャプチャされた部分だけ置き換えたい. キャプチャグループの外のパターンにマッチした部分は元のままにしておきたい.
どういうときにこれが欲しいかというと, たとえばキャプチャされた部分になんらかのハイライトを施した文字列を作りたい場合. 具体的なケースとしては, Webサーバのルーティング処理でパターンマッチさせるとして, パスのパターンとしてはエンドポイントを区別できる程度の雑なものにしておいて, キャプチャされた部分がパラメータとして有効かどうかは(クエリパラメータと同様に)別途バリデーションするという場合に, バリデーションエラーをハイライトしたいことがある.
たとえば, /entry/ftp://example.com/
という文字列を/entry/(?<Url>.+)
というパターンにマッチさせて, キャプチャしたUrl
を受け入れるかどうか精査する. もしエラーになったらUrl
の部分が悪かったということを表現したいので, たとえば/entry/<strong>ftp://example.com/</strong>
というHTML文字列を作りたい.
これはべつにキャプチャグループが名前つきかどうかは関係なくて, 正規表現一般の話と言えるけれど, こういうことをやっているのはそれほど見かけない気がするし, 簡単にやれるようなインタフェースは用意されてなさそうだった.
どう解決されたか
コード中の識別子とキャプチャグループ名の一致
今回作った名前つきキャプチャのライブラリでは, 最初の例は以下のようになる.
import com.github.tarao.namedcap.Group import com.github.tarao.namedcap.Implicits._ case object User extends Group("[a-zA-Z][a-zA-Z0-9_-]+") val pathPattern = pattern"/$User/bookmark" val m = pathPattern(req.pathInfo) val user = m.get(User.name)
まず, 名前つきキャプチャグループを表すオブジェクトをcase object
で定義する*2. こうすると, Scala上の識別子User
とキャプチャグループ名が自動的に一致する(case object
ではtoString
の結果がそのまま識別子名と同じものになることを利用).
パターンオブジェクトはpattern
補間子で書くとキャプチャグループを埋め込むことができる. このパターンオブジェクト内ではキャプチャグループの名前が認識されているので, pathPattern.toString
の結果は/${User}/bookmark
となり, 煩雑な詳細パターンの代わりにグループ名で表現したものが得られる.
パターンマッチはパターンオブジェクトのapply()
で実行できて, 返ってきたMap[]
に問い合わせるとキャプチャされた値が得られる. このときm.get("User")
としても構わないけれど, m.get(User.name)
と書いておいた方がUser
の部分の書き間違いをコンパイルエラーとして検出できてより安心できる.
インデックスによるキャプチャグループの上に実装
同名グループを複数回利用する場合は以下のようにできる.
import com.github.tarao.namedcap.Group import com.github.tarao.namedcap.Implicits._ case object User extends Group("[a-zA-Z][a-zA-Z0-9_-]+") case object Repo extends Group("[a-zA-Z0-9_-]+") case object Sha1 extends Group("[0-9a-f]{40}") val pathPattern = pattern"/$User/$Repo/compare/$Sha1...$Sha1" val m = pathPattern(req.pathInfo) val Seq(from, to) = m.getAll(Sha1.name)
パターンマッチの結果は実際には独自のMultiMap
になっていて, 通常のMap[]
として使うとキャプチャした値のうち最初のものを返し, getAll()
したときは同じグループ名でキャプチャされた複数の値をすべて(出現順に)返すようになっている.
Scala版のインタフェースでも, 内部的にはインデックスによるキャプチャグループを使っていて, そこに名前を対応させて返しているだけらしい. 同じ発想で内部的にはインデックスで扱っていて, 名前と対応づけた結果をMultiMap
にしているにすぎない.
マッチした文字列中のキャプチャされた部分を置き換え
パターンオブジェクトにmapGroupsIn()
というメソッドを用意して, それで可能にした.
import com.github.tarao.namedcap.Group import com.github.tarao.namedcap.Implicits._ case object Url extends Group(".+") val pathPattern = pattern"/entry/$Url" pathPattern.mapGroupsIn("/entry/ftp://example.com/") { (g, s) => s"""<strong data-pattern-id="${g.name}">$s</strong>""" } // /entry/<strong data-pattern-id="Url">ftp://example.com/</strong>
これはべつに名前つきキャプチャグループによらず, インデックスによるキャプチャグループでもできることで, 実際インデックスによるキャプチャグループのためのメソッドを定義して利用している.
キャプチャグループには文字列のどこからどこまでにマッチしたかという情報があるので, それを使ってマッチし文字列全体をうまく切り出してくればよいだけなので実はそんなに難しくない(のでなんかこういうの既にありそうなんだけどなぁ). キャプチャグループが入れ子になっている場合も考慮しなければならない点は少しトリッキーではあるものの, やればできる.
感想
実はこれらすべての問題を考慮して独自実装していたわけではなく, 主に1つ目の問題があるために独自のやり方を進めていた. このユースケース特有の問題というか, 設計方針の問題でもあるように感じていたので, 作ったライブラリはしばらくプロジェクトのソースコードに同梱してクローズドな運用をしていた.
そもそも名前つきキャプチャグループを標準的なやり方ではどう扱うのか調べてみたらJavaの正規表現まわりにいろいろイケてないところがあるという話を見つけて, 2つ目の問題が解決されるのは有益と感じたので公開することにした.
Java文化を遠巻きに見ていた頃は, 標準ライブラリや事実上の標準となっているライブラリはちゃんとしててまともそう, という印象だったけれど, 意外とそうでもないということが最近実感できるようになってきた. 他の言語では当たり前にあるようなべんりに使えるやつも案外なかったりしてつらい. Javaはともかく, ScalaまわりはLLっぽい人たちも多そうだし, こういうところは積極的によくしていきたい.
とはいえ, 今回のライブラリもそうだけど, 小さい部品としてのライブラリを公開するのもあまりJava/Scala文化っぽくないように感じられる. もちろん, 依存関係地獄になりたくないからあまり有象無象の小さなライブラリに依存したくないというのもわかる. でもそれはビルドツールの問題だとおもうのでなんとかなってほしい. あとは個人で作った小さなライブラリだと, 名前空間がいかにも個人用っぽくて, 人を集めてドメインをとって大きく作っているものと比べてデファクトスタンダード感を出しづらい, というのもある気がする.
とにかく文化のせいにするのももったいないので, あえて空気は読まずに, べんりそうなものはどんどん公開していこう, とおもっている.