🐪

PascalCase, camelCaseを正規表現を用いて分割する

2022/09/01に公開

はじめに

JavaでGsonを使っていた際、 FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORESaURL -> a_u_r_l と変換する使えない子で自作する必要があったのでメモしておきます。
(記事自体は正規表現についてなので、Java以外の言語でも使えるはずです。)

使えない子のJavaDocはこちら。

https://www.javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/FieldNamingPolicy.html#LOWER_CASE_WITH_UNDERSCORES

正規表現

次の正規表現で正常に分割できます。
あくまで分割なので snake_case に変換するには小文字にしたりアンダースコアで結合したりする必要がありますがそれは後ほど。

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^0-9])|(?<=[^0-9])(?=[0-9])|(?<=[^A-Za-z])(?=[a-z])|(?<=[A-Za-z])(?=[^A-Za-z])

パフォーマンスを重視する場合、文字数は増えますが次の方が良いです。
※アルファベットの方がその他の文字より多い通常の文字列の場合
(正規表現エンジンの実装に依存しますが、こちらの方が悪いことはないはず…?)

変更部分は、一部の角括弧内の範囲を狭めたのと、先読み/後読みの順番を一部入れ替えたのみです。正規表現の意味は変わりません。
これらについては後述します。

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^A-Z0-9])|(?=[0-9])(?<=[^0-9])|(?<=[^A-Za-z0-9])(?=[a-z])|(?=[^A-Za-z0-9])(?<=[A-Za-z])

分割例

aURL -> a, URL
JavaDBOptionMAIN -> Java, DB, Option, MAIN
StringBuilder -> String, Builder
UUID2txt0123 -> UUID, 2, txt, 0123 // 数字は1語
LOWER_case4_小文字 -> LOWER, _ , case, 4, _小文字 // 記号(ABCと数字以外)の連続も1語

参考

この記事に書かれている正規表現を元に弄らせていただきました。

https://akisute3.hatenablog.com/entry/20111217/1324109628

正規表現の基礎についてはこちらで。

https://murashun.jp/article/programming/regular-expression.html

先読み/後読み

(?=[a-Z])(?<=[A-Z]) は正規表現の先読み、後読み、と呼ばれるものです。
解説は次の記事がわかりやすかったので読んでみてください。

https://abicky.net/2010/05/30/135112/

記事中でアンカーという説明がありますが、ゼロ幅の正規表現と理解した方が次の説明がわかりやすいかもです。

先後読みのAND

(?<=[^A-Z])(?=[A-Z])

この正規表現は、大文字以外の文字の直後 & 大文字の直前となり、abcABC のcAの間にマッチします。

正規表現において、(a)(b)ab を示しますが、先後読みではゼロ幅の連続となるので実質的に「AND/かつ」を表すと理解しました。

解説

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^0-9])|(?<=[^0-9])(?=[0-9])|(?<=[^A-Za-z])(?=[a-z])|(?<=[A-Za-z])(?=[^A-Za-z])

|(OR/または) で区切って1つずつ見ていくと次のようになります。

  1. (?<=[A-Z])(?=[A-Z][a-z])
    大文字の後、かつ、大文字と小文字の連続(例:Ab)の前
    すべて大文字の単語と、その後の先頭のみ大文字の単語を分割
    DBOption -> DB, Option

  2. (?<=[^A-Z])(?=[A-Z])
    大文字以外の文字の後、かつ、大文字の前
    通常のcamelCaseを分割
    toString -> to, String 314Pi -> 314, Pi

  3. (?<=[0-9])(?=[^0-9])
    数字の後、かつ、数字以外の文字の前
    (大文字の前は2で分割されるので、(?<=[0-9])(?=[^A-Z0-9]) でも同様)
    4と合わせて数字とその他の文字を分割
    12+ -> 12, + 34あ -> 34, あ

  4. (?<=[^0-9])(?=[0-9])
    数字以外の文字の後、かつ、数字の前
    String2 -> String, 2 URL2 -> URL, 2 +12 -> +, 12 あ34 -> あ, 34

  5. (?<=[^A-Za-z])(?=[a-z])
    アルファベット以外の文字の後、かつ、小文字の前
    (数字の後は3で分割されるので、(?<=[^A-Za-z0-9])(?=[a-z]) でも同様)
    +a -> +, a あa -> あ, a

  6. (?<=[A-Za-z])(?=[^A-Za-z])
    アルファベットの後、アルファベット以外の文字の前
    (数字の前は4で分割されるので、(?<=[A-Za-z])(?=[^A-Za-z0-9]) でも同様)
    a+ -> a, + aあ -> a, あ

パフォーマンス重視型

正規表現は文字列の頭からチェックを繰り返すので、この正規表現のようにチェックする対象が多いものだとパフォーマンスが低下します。
そこで、チェックをできるだけ減らすことでパフォーマンスを向上させることができます。

元の正規表現は次の通りです。

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^0-9])|(?<=[^0-9])(?=[0-9])|(?<=[^A-Za-z])(?=[a-z])|(?<=[A-Za-z])(?=[^A-Za-z])

まず、解説で同様とした正規表現に置き換えてマッチする文字の範囲を狭め、チェックの回数を減らします。

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^A-Z0-9])|(?<=[^0-9])(?=[0-9])|(?<=[^A-Za-z0-9])(?=[a-z])|(?<=[A-Za-z])(?=[^A-Za-z0-9])

次に、先読みと後読みをマッチする文字の範囲の狭い方が先になるよう入れ替えます。
アルファベットの方がアルファベット以外の文字より多い文字列の方が一般的なので、[^A-Za-z][A-Za-z] では前者の範囲が狭いと考えます。
先に説明したようにゼロ幅の連続はANDと同じなので、先にある先読み/後読みがマッチした場合のみ、次の先読み/後読みがチェックされます。
なので、先にマッチしにくい先読み/後読みを置いた方がパフォーマンスの向上が見込めます。

(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^A-Z0-9])|(?=[0-9])(?<=[^0-9])|(?<=[^A-Za-z0-9])(?=[a-z])|(?=[^A-Za-z0-9])(?<=[A-Za-z])

参考

正規表現の評価のステップ数を見ることができるサイトです。パフォーマンスの比較に使用しました。

https://regex101.com/

Javaでの実装例

PascalCaseまたはcamelCaseをsnake_caseに変換するメソッドです。

static final Pattern splitRegex = Pattern.compile("(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[0-9])(?=[^A-Z0-9])|(?=[0-9])(?<=[^0-9])|(?<=[^A-Za-z0-9])(?=[a-z])|(?=[^A-Za-z0-9])(?<=[A-Za-z]))";

static String toSnakeCase(String pascalOrCamelCase) {
  return splitRegex.splitAsStream(pascalOrCamelCase).collect(Collectors.joining("_"));
}
GitHubで編集を提案

Discussion