【超入門】Kotlinで正規表現(Regex)を使う方法


【超入門】Kotlinで正規表現(Regex)を使う方法

はじめに:Kotlinと正規表現の世界へようこそ

プログラミングにおいて、文字列の扱いは避けて通れません。ユーザーからの入力値の検証、テキストファイルからの特定データの抽出、ウェブサイトからの情報収集(スクレイピング)、ログの解析、コードの自動生成など、様々な場面で文字列処理が必要になります。

文字列処理の中でも特に強力で柔軟なツールとして知られているのが「正規表現 (Regular Expression)」です。正規表現を使うと、「特定のパターンを持った文字列を探す」「文字列の一部を別の文字列に置き換える」「文字列が特定の形式に合っているかチェックする」といった複雑な処理を、短い記述で実現できます。

例えば、

  • 「メールアドレスの形式に合っているか?」
  • 「電話番号がハイフン区切りになっているか?」
  • 「HTMLのタグを取り除きたい」
  • 「ログの中から特定のエラーメッセージと日時を抜き出したい」

といった処理も、正規表現を使えば効率的に行うことができます。

この記事は、「正規表現って聞いたことはあるけどよく分からない」「Kotlinでどうやって使うの?」というKotlin初心者の方のために書かれています。正規表現の基本的な考え方から、KotlinのRegexクラスを使った具体的な操作方法までを、豊富なコード例とともに丁寧に解説します。この記事を読み終える頃には、Kotlinで正規表現を使った文字列処理ができるようになっているはずです。

さあ、強力な文字列処理ツール、正規表現の世界に踏み出しましょう!

正規表現の超基本:文字とメタ文字

正規表現は、文字列のパターンを記述するための特殊な記法です。この記法は、大きく分けて二つの要素から構成されます。

  1. リテラル文字: その文字そのものにマッチします。例えば aa に、11 にマッチします。
  2. 特殊文字 (メタ文字): 特別な意味を持ち、文字の種類、繰り返し、位置などを指定するために使われます。正規表現の強力さは、このメタ文字によって実現されています。

まずは、よく使われる基本的なメタ文字から見ていきましょう。

文字そのもの:リテラル文字

正規表現パターンの中で、特殊な意味を持たない文字は、その文字自身にマッチします。例えば、パターン hello は、文字列中の hello という並びにそのままマッチします。

“`kotlin
fun main() {
val text = “hello world, hello Kotlin”
val pattern = “hello” // リテラル文字のパターン

// Kotlinでの使い方(後述)
val regex = pattern.toRegex()
println(regex.containsMatchIn(text)) // true

}
“`

魔法の記号:メタ文字とは

メタ文字は、パターン記述において特別な役割を果たします。正規表現の学習は、これらのメタ文字の意味と使い方を理解することから始まります。

任意の一文字:. (ドット)

. (ドット) は、改行文字 (\n) 以外の任意の一文字にマッチします。

“`kotlin
fun main() {
val text = “cat. rat. bat. hat.”
val pattern = “.at” // 任意の一文字 + ‘a’ + ‘t’

val regex = pattern.toRegex()
val matches = regex.findAll(text).toList()

println("Found ${matches.size} matches:")
matches.forEach { match ->
    println("  - '${match.value}' at range ${match.range}")
}
// 出力:
// Found 4 matches:
//   - 'cat' at range 0..2
//   - ' rat' at range 4..6  <-- スペースもマッチ
//   - ' bat' at range 9..11 <-- スペースもマッチ
//   - ' hat' at range 14..16 <-- スペースもマッチ

}
``
この例では、
.atcat,rat,bat,hatにマッチしていますが、実際にはスペースを含んだ rat, bat, hatにマッチしています。これは、.` が改行以外の任意の一文字にマッチするため、スペースにもマッチするからです。

繰り返しの指定:*, +, ?

これらのメタ文字は、直前の要素が何回出現するかを指定します。

  • *: 直前の要素が 0回以上 繰り返す
  • +: 直前の要素が 1回以上 繰り返す
  • ?: 直前の要素が 0回または1回 繰り返す

“`kotlin
fun main() {
val text = “color colour coloor coooool”

// 'o' が0回以上繰り返す
val regexStar = "colou*r".toRegex()
println("'o*' matches:")
regexStar.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'o*' matches:
//   - color
//   - colour
//   - coloor
//   - coooool

println()

// 'o' が1回以上繰り返す
val regexPlus = "colou+r".toRegex()
println("'o+' matches:")
regexPlus.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'o+' matches:
//   - colour
//   - coloor
//   - coooool

println()

// 'u' が0回または1回繰り返す
val regexQuestion = "colou?r".toRegex()
println("'u?' matches:")
regexQuestion.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'u?' matches:
//   - color
//   - colour

}
“`

回数を厳密に指定:{} (量指定子)

波括弧 {} を使うと、直前の要素の繰り返し回数をより詳細に指定できます。

  • {n}: ちょうど n 回繰り返す
  • {n,m}: n 回以上 m 回以下繰り返す
  • {n,}: n 回以上繰り返す

“`kotlin
fun main() {
val text = “a aa aaa aaaa aaaaa”

// 'a' がちょうど3回
val regex3 = "a{3}".toRegex()
println("'a{3}' matches:")
regex3.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'a{3}' matches:
//   - aaa
//   - aaa

println()

// 'a' が2回以上4回以下
val regex2to4 = "a{2,4}".toRegex()
println("'a{2,4}' matches:")
regex2to4.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'a{2,4}' matches:
//   - aa
//   - aaaa
//   - aaaa

println()

// 'a' が3回以上
val regex3orMore = "a{3,}".toRegex()
println("'a{3,}' matches:")
regex3orMore.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'a{3,}' matches:
//   - aaa
//   - aaaa
//   - aaaaa

}
“`

文字の集合を指定:[] (文字クラス)

角括弧 [] で囲まれた文字のリストは、そのリストの中の いずれか一文字 にマッチします。

  • [abc]: a, b, c のいずれか一文字
  • [a-z]: a から z までの小文字アルファベットのいずれか一文字 (ハイフンで範囲を指定)
  • [A-Z]: A から Z までの大文字アルファベットのいずれか一文字
  • [0-9]: 0 から 9 までの数字のいずれか一文字
  • [a-zA-Z0-9]: アルファベットまたは数字のいずれか一文字

文字クラスの先頭に ^ を付けると、そのリストに含まれていない 否定 にマッチします。

  • [^abc]: a, b, c 以外の任意の一文字
  • [^0-9]: 数字以外の任意の一文字

“`kotlin
fun main() {
val text = “Hello 123 World !?”

// アルファベットのいずれか一文字
val regexAlpha = "[a-zA-Z]".toRegex()
println("[a-zA-Z] matches (first 5):")
regexAlpha.findAll(text).take(5).forEach { println("  - ${it.value}") }
// 出力:
// [a-zA-Z] matches (first 5):
//   - H
//   - e
//   - l
//   - l
//   - o

println()

// 数字のいずれか一文字
val regexDigit = "[0-9]".toRegex()
println("[0-9] matches:")
regexDigit.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// [0-9] matches:
//   - 1
//   - 2
//   - 3

println()

// 数字以外のいずれか一文字
val regexNotDigit = "[^0-9]".toRegex()
println("[^0-9] matches (first 5):")
regexNotDigit.findAll(text).take(5).forEach { println("  - ${it.value}") }
// 出力:
// [^0-9] matches (first 5):
//   - H
//   - e
//   - l
//   - l
//   - o

}
“`

複数のパターンどれか:| (OR)

| は、複数のパターンのうち、いずれか一つ にマッチします。これは「または (OR)」を意味します。

“`kotlin
fun main() {
val text = “apple orange banana grape”

// 'apple' または 'banana' にマッチ
val regexFruit = "apple|banana".toRegex()
println("'apple|banana' matches:")
regexFruit.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'apple|banana' matches:
//   - apple
//   - banana

}
“`

まとめて扱う:() (グループ化)

丸括弧 () は、複数の要素をまとめて一つの単位として扱います。これは以下の目的で使われます。

  • 繰り返しや量指定子の適用範囲を指定する
    • 例: (ab)+ab という並び全体が1回以上繰り返す (abab, ababab などにマッチ)。単に ab+ とすると abb, abbb など、b だけが繰り返すことになる。
  • OR条件の適用範囲を指定する
    • 例: (cat|dog) foodcat food または dog food にマッチ。単に cat|dog food とすると cat または dog food にマッチしてしまう。
  • マッチした部分文字列を後から参照できるようにする (キャプチャグループ)
    • これについては後述の「キャプチャグループ」のセクションで詳しく説明します。

“`kotlin
fun main() {
val text = “ababab abcabc”

// ab という並び全体が1回以上繰り返す
val regexGroupPlus = "(ab)+".toRegex()
println("'(ab)+' matches:")
regexGroupPlus.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '(ab)+' matches:
//   - ababab

println()

val textColors = "red blue green redish bluish greenish"

// red または blue または green に ish がついたもの
val regexColorSuffix = "(red|blue|green)ish".toRegex()
println("'(red|blue|green)ish' matches:")
regexColorSuffix.findAll(textColors).forEach { println("  - ${it.value}") }
// 出力:
// '(red|blue|green)ish' matches:
//   - redish
//   - bluish
//   - greenish

}
“`

特殊文字を文字として扱う:\ (エスケープ)

正規表現のメタ文字 (., *, +, ?, [, ], (, ), |, ^, $, \, etc.) を、メタ文字ではなく単なる文字として扱いたい場合があります。その際は、直前に \ (バックスラッシュ) を付けてエスケープします。

“`kotlin
fun main() {
val text = “Price: $10.99 (USD)”

// ドル記号 '$' と ピリオド '.' はメタ文字なのでエスケープが必要
// 丸括弧 '(' と ')' もメタ文字なのでエスケープが必要
val pattern = "Price: \\\$[0-9]+\\.[0-9]{2} \\([A-Z]{3}\\)"

val regex = pattern.toRegex()
println("'$pattern' matches:")
println(regex.containsMatchIn(text)) // true

}
``
この例では、
\$,.,(,)のようにエスケープしています。Kotlinの文字列リテラルの中で` を書く場合、さらにKotlin自身の文字列エスケープとして \\ と重ねて書く必要があることに注意してください (\ という文字そのものを表すために \\ と書くのと同じです)。これは後述の「三重引用符文字列」を使うことで避けることができます。

位置を指定する:アンカー

アンカーは、特定の文字ではなく、「位置」にマッチするメタ文字です。

行頭にマッチ:^

^ は、文字列または行の先頭にマッチします。

“`kotlin
fun main() {
val text = “””
Hello World
Hello Kotlin
Goodbye
“””.trimIndent()

// 行頭に "Hello" がある行を探す
// デフォルトでは文字列全体の先頭にマッチ
val regexStart = "^Hello".toRegex()
println("'^Hello' matches (default mode):")
println(regexStart.findAll(text).map { it.value }.toList()) // [Hello]

// MULTILINE オプションを使うと各行の先頭にマッチ
val regexStartMultiline = "^Hello".toRegex(RegexOption.MULTILINE)
println("'^Hello' matches (MULTILINE mode):")
regexStartMultiline.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '^Hello' matches (MULTILINE mode):
//   - Hello
//   - Hello

}
``
デフォルトでは文字列全体の先頭にのみマッチしますが、後述する
RegexOption.MULTILINEオプションを使うと、各行の先頭 (\n` の直後) にマッチするようになります。

行末にマッチ:$

$ は、文字列または行の末尾にマッチします。

“`kotlin
fun main() {
val text = “””
Hello World.
Goodbye Kotlin.
Finished.
“””.trimIndent()

// ピリオド '.' で終わる行を探す
// デフォルトでは文字列全体の末尾にマッチ
val regexEnd = "\\.\$".toRegex() // . はメタ文字なのでエスケープ
println("'\\.\$' matches (default mode):")
println(regexEnd.findAll(text).map { it.value }.toList()) // [.] <-- 最後の行末のピリオドにマッチ

// MULTILINE オプションを使うと各行の末尾にマッチ
val regexEndMultiline = "\\.\$".toRegex(RegexOption.MULTILINE)
println("'\\.\$' matches (MULTILINE mode):")
regexEndMultiline.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '\.$' matches (MULTILINE mode):
//   - .
//   - .
//   - .

}
``
こちらも
MULTILINEオプションで各行の末尾 (\n` の直前) にマッチさせることができます。

単語の区切りにマッチ:\b

\b は「単語の境界」にマッチします。単語の境界とは、文字クラス \w (アルファベット、数字、アンダースコア) の文字と、そうでない文字 (\W) の間の位置を指します。文字列の先頭と末尾も単語境界とみなされます。

“`kotlin
fun main() {
val text = “cat catastrophe category”

// 単語の境界にある "cat" にマッチ
val regexWordBoundary = "\\bcat\\b".toRegex()
println("'\\bcat\\b' matches:")
regexWordBoundary.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '\bcat\b' matches:
//   - cat

println()

// \b を使わない場合、"cat" を含むすべての文字列にマッチ
val regexNoBoundary = "cat".toRegex()
println("'cat' matches:")
regexNoBoundary.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// 'cat' matches:
//   - cat
//   - cat  <-- catastrophe の中の cat
//   - cat  <-- category の中の cat

}
``\bを使うことで、catastrophecategoryの中に含まれるcatではなく、独立した単語としてのcat` にのみマッチさせることができます。

よく使う文字クラスの省略形

いくつかの一般的な文字クラスには、短縮記法が用意されています。これを使うとパターンをより簡潔に記述できます。

  • \d: 数字 (Digit)。[0-9] と同じ。
  • \D: 数字以外 (Non-digit)。[^0-9] と同じ。
  • \s: 空白文字 (Whitespace)。スペース、タブ (\t), 改行 (\n, \r), フォームフィード (\f) などにマッチ。
  • \S: 空白文字以外 (Non-whitespace)。[^\s] と同じ。
  • \w: 単語を構成する文字 (Word character)。アルファベット (a-zA-Z), 数字 (0-9), アンダースコア (_) にマッチ。[a-zA-Z0-9_] と同じ。
  • \W: 単語を構成する文字以外 (Non-word character)。[^\w] と同じ。

“`kotlin
fun main() {
val text = “Hello World 123 !?”

// 数字にマッチ
val regexDigit = "\\d+".toRegex() // 1桁以上の数字
println("'\\d+' matches:")
regexDigit.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '\d+' matches:
//   - 123

println()

// 空白文字にマッチ
val regexWhitespace = "\\s+".toRegex() // 1つ以上の空白文字
println("'\\s+' matches:")
regexWhitespace.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '\s+' matches:
//   - " "  <-- Hello と World の間のスペース
//   - " "  <-- World と 123 の間のスペース
//   - " "  <-- 123 と !? の間のスペース

println()

// 単語を構成する文字にマッチ
val regexWord = "\\w+".toRegex() // 1つ以上の単語構成文字
println("'\\w+' matches:")
regexWord.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// '\w+' matches:
//   - Hello
//   - World
//   - 123

}
“`

これらの省略形を使うと、パターン記述がより短く、読みやすくなることが多いです。\ はKotlinの文字列リテラル内でエスケープが必要なので、これらの省略形を使う際も \\d, \\s, \\w のようにバックスラッシュを二つ重ねて書く必要があることを覚えておいてください。

Kotlinで正規表現を使う準備

Kotlinで正規表現を扱うには、標準ライブラリに含まれる Regex クラスを使用します。

Regexクラスとは

kotlin.text.Regex クラスは、正規表現パターンをコンパイルして保持し、そのパターンを使って文字列に対して様々な操作(マッチング、検索、置換、分割など)を行うための機能を提供します。

Regexオブジェクトの作り方

Regexオブジェクトを作成するには、いくつかの方法があります。

  1. コンストラクタを使う (Regex("pattern"))
    最も基本的な方法です。正規表現パターンを文字列としてコンストラクタに渡します。
    kotlin
    val pattern = "abc"
    val regex = Regex(pattern)

    この方法を使う場合、正規表現パターン中のメタ文字をエスケープする際に、さらにKotlinの文字列エスケープも考慮する必要があります。例えば、バックスラッシュ \ をパターンに含めたい場合は \\ と書き、正規表現のメタ文字である . を文字として扱いたい場合は \. と書き、さらにKotlinの文字列リテラルの中で \ を書くために \\. と重ねて書く必要があります。

    “`kotlin
    // 正規表現パターン: \d+ (1桁以上の数字)
    // Kotlin文字列では \d+ と書く必要がある
    val pattern1 = “\d+”
    val regex1 = Regex(pattern1)

    // 正規表現パターン: foo.bar (foo.bar という文字列)
    // Kotlin文字列では foo\.bar と書く必要がある
    val pattern2 = “foo\.bar”
    val regex2 = Regex(pattern2)
    “`
    このように、エスケープが多くなると可読性が低下します。

  2. 三重引用符文字列を使う (Regex("""pattern"""))
    Kotlinには、三重引用符 """...""" で囲まれた文字列リテラルがあります。この文字列リテラルの中では、バックスラッシュ \ を含め、ほとんどの文字をエスケープせずにそのまま記述できます(三重引用符自体を含める場合は特殊な書き方が必要ですが、通常の正規表現パターンでは問題になりません)。

    正規表現パターンはバックスラッシュを多用するため、三重引用符文字列を使うと、Kotlinのエスケープを気にせずに正規表現パターンを直感的に記述できます。これが最も推奨される方法です。

    “`kotlin
    // 正規表現パターン: \d+ をそのまま書ける
    val pattern1 = “””\d+”””
    val regex1 = Regex(pattern1)

    // 正規表現パターン: foo.bar をそのまま書ける
    val pattern2 = “””foo.bar”””
    val regex2 = Regex(pattern2)

    // 正規表現パターン: (.?) (丸括弧で囲まれた任意の文字)
    // Kotlin文字列では (.
    ?) と書く必要があるが、三重引用符ならそのまま書ける
    val regex3 = Regex(“””(.*?)”””)
    “`
    今後は、特に指定がない限り、三重引用符文字列を使って正規表現パターンを記述することにします。

  3. toRegex()拡張関数を使う ("pattern".toRegex())
    Stringクラスには toRegex() という拡張関数が用意されています。これも手軽に Regex オブジェクトを作成できる方法です。

    kotlin
    // こちらも三重引用符文字列と組み合わせるのが一般的
    val regex = """\d+""".toRegex()

    これは Regex(pattern) と機能的には同じですが、よりKotlinらしい記述と言えます。

正規表現の振る舞いを変更:オプション (RegexOption)

Regexオブジェクトを作成する際に、正規表現の検索やマッチングの振る舞いを変更するためのオプションを指定できます。オプションは RegexOption 列挙型で定義されており、コンストラクタや toRegex() 関数に引数として渡します。複数のオプションを指定する場合は、setOf() で集合として渡します。

kotlin
val regex = Regex(pattern, RegexOption.IGNORE_CASE) // オプションを一つ指定
val regex = Regex(pattern, setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)) // 複数のオプションを指定

よく使うオプションは以下の通りです。

  • IGNORE_CASE: 大文字と小文字を区別せずにマッチングを行います。
    kotlin
    val regex = "kotlin".toRegex(RegexOption.IGNORE_CASE)
    println(regex.containsMatchIn("Hello Kotlin")) // true
    println(regex.containsMatchIn("hello kotlin")) // true
    println(regex.containsMatchIn("KOTLIN")) // true

  • MULTILINE: ^$ のアンカーが行全体の先頭・末尾だけでなく、各行の先頭・末尾 (改行文字の直後・直前) にもマッチするようになります。前述のアンカーの例で示しました。

  • LITERAL: パターン文字列中の全ての文字を、特殊文字としてではなくリテラル文字として扱います。つまり、パターン中のメタ文字が特別な意味を持たなくなり、文字そのものにマッチするようになります。パターンにメタ文字が含まれる可能性がなく、単に固定文字列を効率的に検索したい場合などに使えますが、限定的な用途です。

  • COMMENTS: パターン中の空白文字や、# から行末までのテキストがコメントとして無視されるようになります。複雑なパターンを記述する際に、パターンの中に説明を入れたり、パターンを整形したりするのに役立ちます。

    “`kotlin
    val regex = Regex(“””
    ^ # 行頭
    \d{4} # 数字4桁 (年)
    -? # ハイフンは省略可能
    \d{2} # 数字2桁 (月)
    -? # ハイフンは省略可能
    \d{2} # 数字2桁 (日)
    $ # 行末
    “””.trimIndent(), setOf(RegexOption.COMMENTS, RegexOption.MULTILINE))

    println(regex.matches(“2023-10-27”)) // true
    println(regex.matches(“20231027”)) // true
    println(regex.matches(“2023/10/27”)) // false
    ``trimIndent()` は三重引用符文字列のインデントを取り除くためのKotlinの関数です。

Kotlinで正規表現を「使う」:基本的な操作

Regexオブジェクトを作成したら、それを使って文字列に対して様々な操作を行うことができます。主な操作は以下の通りです。

マッチング (Matching)

文字列全体が正規表現パターンに完全に一致するかどうかを確認します。

  • matches(input: CharSequence): Boolean
    入力文字列の全体が正規表現パターンにマッチする場合に true を返します。文字列の先頭から末尾まで、パターンと完全に一致する必要があります。

    “`kotlin
    fun main() {
    val phoneNumber = “090-1234-5678”
    // 厳密な電話番号形式のパターン例 (簡易版)
    val phoneRegex = Regex(“””\d{3}-\d{4}-\d{4}”””)

    println(phoneRegex.matches(phoneNumber))        // true
    println(phoneRegex.matches("090-1234-5678 ext 100")) // false (全体が一致しない)
    println(phoneRegex.matches("12345678901"))      // false (形式が異なる)
    
    val email = "[email protected]"
    // 簡単なメールアドレス形式のパターン例
    val emailRegex = Regex("""\w+@\w+\.\w+""")
    
    println(emailRegex.matches(email))           // true
    println(emailRegex.matches("[email protected]")) // true (簡単なパターンなので許容)
    println(emailRegex.matches("test@example"))   // false
    println(emailRegex.matches("[email protected]"))     // false
    

    }
    ``matches()` は文字列の先頭から末尾までをチェックするため、入力値の形式検証によく使われます。

検索と取得 (Finding and Retrieving)

文字列中にパターンに一致する部分があるかどうかを探したり、実際の一致部分を取得したりします。

  • containsMatchIn(input: CharSequence): Boolean
    入力文字列の中に正規表現パターンに一致する部分が一つでも存在する場合に true を返します。matches() と異なり、文字列全体がパターンと一致する必要はありません。

    “`kotlin
    fun main() {
    val text = “Hello world! Kotlin is fun.”
    val keywordRegex = Regex(“Kotlin”)

    println(keywordRegex.containsMatchIn(text))        // true
    println(keywordRegex.containsMatchIn("Java is also fun.")) // false
    

    }
    “`
    特定のキーワードやパターンが文字列に含まれているかを手軽に確認したい場合に便利です。

  • find(input: CharSequence, startIndex: Int = 0): MatchResult?
    入力文字列の指定された開始位置から、正規表現パターンに一致する最初の部分を探します。一致が見つかった場合は、その一致に関する情報を含む MatchResult オブジェクトを返します。見つからなかった場合は null を返します。

  • findAll(input: CharSequence, startIndex: Int = 0): Sequence<MatchResult>
    入力文字列の指定された開始位置から、正規表現パターンに一致するすべての部分を探します。見つかったすべての一致は MatchResult オブジェクトの Sequence として返されます。Sequence は要素を必要になったときに遅延評価で生成するため、大量の一致がある場合でも効率的に処理できます。

    MatchResult オブジェクトには、マッチした部分に関する以下の情報が含まれます。
    * value: マッチした部分文字列全体。
    * range: マッチした部分文字列が元の文字列中のどのインデックス範囲にあるか (IntRange オブジェクト)。
    * groups: パターン中のキャプチャグループにマッチした部分文字列のコレクション (MatchGroupCollection)。これについては後述します。

    “`kotlin
    fun main() {
    val text = “Date: 2023/10/27, Time: 14:30:00, Date: 2024/01/15”
    val dateRegex = Regex(“””\d{4}/\d{2}/\d{2}”””)

    // find() の例: 最初の一致を見つける
    val firstMatch = dateRegex.find(text)
    if (firstMatch != null) {
        println("First date found: ${firstMatch.value}") // 2023/10/27
        println("  at range: ${firstMatch.range}")     // 6..15
    } else {
        println("No date found.")
    }
    
    println()
    
    // findAll() の例: すべての一致を見つける
    println("All dates found:")
    dateRegex.findAll(text).forEach { matchResult ->
        println("  - ${matchResult.value} at range ${matchResult.range}")
    }
    // 出力:
    // All dates found:
    //   - 2023/10/27 at range 6..15
    //   - 2024/01/15 at range 41..50
    
    // findAll().toList() で List<MatchResult> に変換
    val allMatchesList = dateRegex.findAll(text).toList()
    println("\nFound ${allMatchesList.size} dates.")
    
    // findAll().map { ... } で一致した値のリストを取得
    val allDateStrings = dateRegex.findAll(text).map { it.value }.toList()
    println("Date strings: $allDateStrings") // [2023/10/27, 2024/01/15]
    

    }
    ``find()は最初の一つだけ、findAll()はすべて、という違いがあります。findAll()Sequenceを返すため、必要に応じてtoList()などでコレクションに変換したり、シーケンス操作関数 (map,filter,forEach` など) を使って処理したりします。

置換 (Replacing)

正規表現パターンに一致する部分を別の文字列に置き換えます。

  • replace(input: CharSequence, replacement: String): String
    入力文字列中のすべての一致部分を、指定された置換文字列で置き換えます。

    • 注意: Kotlin 1.5 以降、Regex.replace および String.replace(regex, ...) はすべての一致を置換するようになりました。Kotlin 1.4 以前では Regex.replace は最初のものだけを置換していたという歴史的経緯がありますが、現在はすべてを置換します。混乱を避けるため、すべてを置換したい場合は replace を、最初の一つだけを置換したい場合は replaceFirst を使うのが明確です。
  • replaceFirst(input: CharSequence, replacement: String): String
    入力文字列中の最初の一致部分のみを、指定された置換文字列で置き換えます。

    置換文字列 (replacement) の中では、特殊な記法を使って、マッチした文字列全体やキャプチャグループの内容を参照できます。
    * $0 または ${0}: マッチした文字列全体 (MatchResult.value と同じ)
    * $1, $2, … または ${1}, ${2}, …: キャプチャグループの内容 (1番目、2番目…)
    * ${name}: 名前付きキャプチャグループの内容
    * \$: リテラルのドル記号 ($)

  • replace(input: CharSequence, transform: (MatchResult) -> CharSequence): String
    入力文字列中のすべての一致部分を置き換えますが、置換する文字列を固定文字列ではなく、一致情報 (MatchResult) を受け取る関数 transform の戻り値で動的に決定できます。

    “`kotlin
    fun main() {
    val text = “The price is $100 and tax is $10.”
    val priceRegex = Regex(“””\$(\d+)”””) // $100 の $100 部分にマッチ、$の後ろの数字をキャプチャグループ1にする

    // replace() の例: すべての価格を [SECRET] に置換
    val replacedText = priceRegex.replace(text, "[SECRET]")
    println("Replaced text: $replacedText") // The price is [SECRET] and tax is [SECRET].
    
    println()
    
    val textFirst = "First match: AAA, Second match: BBB, Third match: CCC"
    val pattern = "match: ([A-Z]{3})".toRegex() // match: AAA の AAA 部分をキャプチャ
    
    // replaceFirst() の例: 最初の一致だけを置換
    val replacedFirst = pattern.replaceFirst(textFirst, "HIDDEN")
    println("Replace First: $replacedFirst") // First HIDDEN, Second match: BBB, Third match: CCC
    
    println()
    
    val textWithGroups = "Date: 2023/10/27"
    // YYYY/MM/DD 形式を MM-DD-YYYY 形式に変換
    // (\d{4}) -> グループ1 (年)
    // (\d{2}) -> グループ2 (月)
    // (\d{2}) -> グループ3 (日)
    val dateRegexWithGroups = Regex("""(\d{4})/(\d{2})/(\d{2})""")
    
    // 置換文字列でキャプチャグループを参照: $2-$3-$1
    val transformedDate = dateRegexWithGroups.replace(textWithGroups, "$2-$3-$1")
    println("Transformed date: $transformedDate") // Date: 10-27-2023
    
    println()
    
    val textDynamic = "User: Alice, ID: 12345, Role: Admin. User: Bob, ID: 67890, Role: Guest."
    val userRegex = Regex("""User: (\w+), ID: (\d+)""")
    
    // 関数を使って動的に置換
    // マッチ結果 (MatchResult) を受け取り、置換文字列を返す関数を指定
    val censoredText = userRegex.replace(textDynamic) { match ->
        val username = match.groups[1]?.value ?: "Unknown" // グループ1 (ユーザー名) を取得
        val userId = match.groups[2]?.value ?: "Unknown"     // グループ2 (ID) を取得
        // ユーザー名とIDを使って新しい文字列を生成
        "User: $username (ID: ${userId.replaceRange(0 until userId.length - 2, "*".repeat(userId.length - 2))})" // IDの最後の2桁以外を*に置換
    }
    println("Censored text: $censoredText")
    // 出力:
    // Censored text: User: Alice (ID: ***45), Role: Admin. User: Bob (ID: ***90), Role: Guest.
    

    }
    “`
    動的な置換は、一致した内容に応じて置き換えたい文字列を変えたい場合に非常に強力です。

分割 (Splitting)

正規表現パターンに一致する部分を区切り文字として、文字列を分割します。

  • split(input: CharSequence, limit: Int = 0): List<String>
    正規表現パターンに一致する区切り文字で入力文字列を分割し、その結果を文字列のリストとして返します。limit 引数を指定すると、分割数を制限できます(0以下の場合は無制限)。

    “`kotlin
    fun main() {
    val text = “apple,banana;orange grape”
    // カンマ(,) または セミコロン(;) または スペース(\s) を区切り文字とする
    val delimiterRegex = Regex(“[,;\s]+”) // 1つ以上の区切り文字

    val parts = delimiterRegex.split(text)
    println("Split result: $parts") // [apple, banana, orange, grape]
    
    println()
    
    val textWithEmpty = "a,,b,,"
    val commaRegex = Regex(",")
    
    val partsWithEmpty = commaRegex.split(textWithEmpty)
    println("Split with empty parts: $partsWithEmpty") // [a, , b, , ]
    
    println()
    
    // limit を指定
    val textLimit = "1,2,3,4,5"
    val commaRegexLimit = Regex(",")
    
    val partsLimited = commaRegexLimit.split(textLimit, limit = 3)
    println("Split with limit=3: $partsLimited") // [1, 2, 3,4,5]
    

    }
    ``splitメソッドは、指定された区切りパターンにマッチした部分を除外して文字列を分割します。limitを指定すると、最大でlimit – 1回の分割を行い、リストのサイズは最大でlimit` になります。

Kotlinで正規表現を「深く使う」:応用的な機能

ここからは、正規表現のより応用的な機能と、それをKotlinでどう使うかを見ていきます。

キャプチャグループの詳細と後方参照

前述のグループ化 () は、単に要素をまとめるだけでなく、マッチした部分文字列を「キャプチャ」し、後から参照できるようにする機能も持ちます。これをキャプチャグループと呼びます。

  • 番号付きキャプチャグループ (...)
    パターン中の丸括弧 () は、左から順に1から始まる番号が振られます (グループ0はパターン全体のマッチ)。find()findAll() で得られる MatchResult オブジェクトの groups プロパティから、これらのグループにマッチした部分文字列にアクセスできます。

    “`kotlin
    fun main() {
    val text = “Email: [email protected], Website: www.kotlinlang.org”
    // (\w+): グループ1 (ユーザー名またはサブドメイン)
    // ([.\w]+): グループ2 (ドメイン名)
    // (\w+) : グループ3 (トップレベルドメイン)
    val emailRegex = Regex(“””(\w+)@([.\w]+).(\w+)”””)

    val match = emailRegex.find(text)
    
    if (match != null) {
        println("Full match (Group 0): ${match.groups[0]?.value}") // [email protected]
        println("Group 1 (User): ${match.groups[1]?.value}")     // user
        println("Group 2 (Domain): ${match.groups[2]?.value}")   // example
        println("Group 3 (TLD): ${match.groups[3]?.value}")      // com
    }
    
    println()
    
    val websiteRegex = Regex("""www\.(\w+)\.(\w+)""") // www.(ドメイン).(TLD)
    val matchWebsite = websiteRegex.find(text)
    
    if (matchWebsite != null) {
        // グループ0: www.kotlinlang.org
        // グループ1: kotlinlang
        // グループ2: org
        println("Full match (Group 0): ${matchWebsite.groups[0]?.value}") // www.kotlinlang.org
        println("Group 1 (Domain): ${matchWebsite.groups[1]?.value}")   // kotlinlang
        println("Group 2 (TLD): ${matchWebsite.groups[2]?.value}")      // org
    }
    

    }
    ``match.groupsMatchGroupCollection型で、インデックスを使ってmatch.groups[index]のようにアクセスします。存在しないグループにアクセスするとnull` になります。

  • 後方参照 (Backreference)
    パターン自身の中で、過去にキャプチャしたグループの内容を参照することができます。これは通常 \1, \2, \3, … のように、参照したいグループの番号の前にバックスラッシュを付けて記述します。

    “`kotlin
    fun main() {
    val text = “abab abcabc acacac”
    // (ab)\1 : (ab) というグループにマッチし、その内容 (ab) が直後に繰り返されるパターン
    val regex = Regex(“””(ab)\1″””) // abab にマッチ

    println("'(ab)\\1' matches:")
    regex.findAll(text).forEach { println("  - ${it.value}") }
    // 出力:
    // '(ab)\1' matches:
    //   - abab
    
    println()
    
    val textDoubleWord = "This is a test test string string."
    // (\b\w+\b)\s+\1 : 単語の境界で囲まれた単語(\b\w+\b)をキャプチャグループ1とし、
    // その後ろに1つ以上の空白(\s+)があり、さらにグループ1(\1)と同じ単語が続くパターン
    val regexDoubleWord = Regex("""(\b\w+\b)\s+\1""")
    
    println("'(\\b\\w+\\b)\\s+\\1' matches:")
    regexDoubleWord.findAll(textDoubleWord).forEach { println("  - ${it.value}") }
    // 出力:
    // '(\b\w+\b)\s+\1' matches:
    //   - test test
    //   - string string
    

    }
    ``
    パターン内で後方参照を使うと、繰り返されるパターンや対称的なパターンなどを効率的に記述できます。Kotlinの文字列リテラル内では、バックスラッシュをエスケープして
    \1,\2` のように書く必要があります。

  • 名前付きキャプチャグループ (?<name>...)
    番号でグループを管理するのは、パターンが複雑になったり、グループの追加・削除があったりすると分かりにくくなります。そこで、グループに名前を付けて参照できる名前付きキャプチャグループが便利です。これは (?<name>...) または (?'name'...) の形式で記述します。

    MatchResult.groups からは、インデックスだけでなく名前でもアクセスできます。置換文字列の中で参照する場合も ${name} の形式で参照できます。

    “`kotlin
    fun main() {
    val text = “User: Alice, ID: 12345. User: Bob, ID: 67890.”
    // (?\w+): username という名前のグループ
    // (?\d+): userId という名前のグループ
    val userRegex = Regex(“””User: (?\w+), ID: (?\d+)”””)

    val matches = userRegex.findAll(text).toList()
    
    matches.forEach { match ->
        // 名前でグループにアクセス
        val username = match.groups["username"]?.value
        val userId = match.groups["userId"]?.value
        println("User: $username, ID: $userId")
    }
    // 出力:
    // User: Alice, ID: 12345
    // User: Bob, ID: 67890
    
    println()
    
    val dateText = "Date: 2023/10/27"
    val dateRegexNamed = Regex("""(?<year>\d{4})/(?<month>\d{2})/(?<day>\d{2})""")
    
    // 置換文字列で名前付きグループを参照: ${month}-${day}-${year}
    val transformedDateNamed = dateRegexNamed.replace(dateText, "${month}-${day}-${year}")
    println("Transformed date (named groups): $transformedDateNamed") // Date: 10-27-2023
    

    }
    “`
    名前付きキャプチャグループを使うと、パターンの可読性が大幅に向上し、メンテナンスが容易になります。特にグループが多い場合や、置換文字列でどのグループを参照しているかを分かりやすくしたい場合に有効です。

キャプチャしないグループ:(?:...) (非キャプチャグループ)

グループ化はしたいが、その部分をキャプチャして後から参照する必要がない場合は、(?:...) という形式を使います。これを非キャプチャグループと呼びます。

非キャプチャグループを使うことのメリットは、

  • キャプチャグループの番号がずれるのを防げる。
  • (わずかではあるが) パフォーマンスが向上する可能性がある。

“`kotlin
fun main() {
val text = “apple,banana;orange”
// (?:,|;): カンマまたはセミコロンのグループ (非キャプチャ)
// (\w+): 単語のグループ (キャプチャ)
val regex = Regex(“””(?:,|;)(\w+)”””)

val matches = regex.findAll(text).toList()

matches.forEach { match ->
    // グループ0: ;orange, ,banana
    // グループ1: orange, banana
    println("Full match (Group 0): ${match.groups[0]?.value}")
    // 非キャプチャグループは番号が振られないため、(\w+) がグループ1になる
    println("Captured group (Group 1): ${match.groups[1]?.value}")
}
// 出力:
// Full match (Group 0): ,banana
// Captured group (Group 1): banana
// Full match (Group 0): ;orange
// Captured group (Group 1): orange

}
``
この例では、区切り文字 (
(?:,|;)) はグループ化されていますがキャプチャされないため、続く単語 ((\w+)) がグループ1になります。もし区切り文字をキャプチャグループ(,|;)` にしてしまうと、単語がグループ2になってしまいます。このように、非キャプチャグループは番号の管理をシンプルに保つのに役立ちます。

一致する「位置」だけを指定:先読み・後読み (Lookahead / Lookbehind)

先読み (Lookahead) と後読み (Lookbehind) は、「指定したパターンの直前/直後に別のパターンが存在するか」という条件を満たす「位置」にマッチする機能です。これらはアサーション (Assertion) と呼ばれ、マッチした文字列自体には含まれません。

  • 肯定先読み (?=...): 現在位置の直後... にマッチする場合に、その現在位置にマッチ。
  • 否定先読み (?!...): 現在位置の直後... にマッチしない場合に、その現在位置にマッチ。
  • 肯定後読み (?<=...): 現在位置の直前... にマッチする場合に、その現在位置にマッチ。
  • 否定後読み (?<!...): 現在位置の直前... にマッチしない場合に、その現在位置にマッチ。

後読み ((?<=...), (?<!...)) のパターン ... は、多くの正規表現エンジンでは固定長である必要があります(KotlinのRegexエンジンは固定長のみをサポートしています)。

“`kotlin
fun main() {
val text = “apple price: $10, banana price: $20, orange cost: 30”

// 肯定先読み (?=...) の例: " price:" の直前にある単語にマッチ
// 単語 (\w+) にマッチするが、その直後に " price:" があるかを確認
val regexLookahead = Regex("""\w+(?=\s*price:)""")
println("Lookahead matches:")
regexLookahead.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// Lookahead matches:
//   - apple
//   - banana

println()

// 肯定後読み (?<=...) の例: "$10" のように、"$" の直後にある数字にマッチ
// "$" の直後にある数字 (\d+) にマッチ
val regexLookbehind = Regex("""(?<=\$\s*)\d+""") // \$ をエスケープ、$の後ろに0個以上の空白があってもOK
println("Lookbehind matches:")
regexLookbehind.findAll(text).forEach { println("  - ${it.value}") }
// 出力:
// Lookbehind matches:
//   - 10
//   - 20

println()

val textDoubleQuote = """This is a "quoted" string and this is not."""
// 否定先読み (?!...) と 否定後読み (?<!...) の組み合わせ:
// 単語境界にある単語 (\b\w+\b) にマッチするが、
// 直前がダブルクォートでない (?<!") かつ 直後がダブルクォートでない (?!")
val regexNotQuotedWord = Regex("""(?<!")\b\w+\b(?!")""")
println("Words not in double quotes:")
regexNotQuotedWord.findAll(textDoubleQuote).forEach { println("  - ${it.value}") }
// 出力:
// Words not in double quotes:
//   - This
//   - is
//   - a
//   - string
//   - and
//   - this
//   - is
//   - not

}
“`
先読み・後読みを使うと、「Xの前にあるY」や「Zの後ろにあるW」といった、特定の文脈にある文字列にのみマッチさせたい場合に便利です。

アトミックグループ (?>...) ※発展

アトミックグループは (?>...) の形式で記述され、バックトラックを無効にする機能です。正規表現エンジンの内部的な挙動に関わるため、超入門としては少し難易度が高いですが、パフォーマンス問題 (特に指数的なバックトラックによる ReDoS 攻撃) や意図しないマッチを防ぐために知っておくと良いでしょう。ここでは概念の紹介に留めます。

アトミックグループ内のパターンが一度マッチを確定させると、その後、全体のマッチを成功させるために必要になっても、そのアトミックグループ内のマッチ結果は変更されません。これにより、バックトラックの試行回数を減らすことができます。

“`kotlin
fun main() {
val text = “aaaaab”
// (a+)b: aが1回以上(\a+) + b にマッチ。a+はできるだけ多くのaにマッチしようとする(貪欲マッチ) -> aaaaab にマッチ
val regexNormal = Regex(“””(a+)b”””)
println(“Normal group match: ${regexNormal.matches(text)}”) // true

// (?>a+)b: aが1回以上(?>a+) にマッチ(バックトラックしない) + b
// (?>a+) は可能な限り多くのa (aaaaa) にマッチを確定させる。
// その後、b にマッチしようとするが、'a' の後ろは 'b' ではなく文字列の末尾なのでマッチしない。
// aaaaaa の場合は、(?>a+) が aaaaaa にマッチしてしまい、b にマッチする対象がなくなるため false となる。
// しかし、aaaaab の場合は、(?>a+) が aaaaaa にマッチすると次の b がないため失敗。
// バックトラックがあれば (a+) が aaaa にマッチし、残りの 'ab' で b にマッチできる。
// アトミックグループだとバックトラックしないため失敗。
val regexAtomic = Regex("""(?>a+)b""")
println("Atomic group match: ${regexAtomic.matches(text)}") // false

}
“`
この例は少し分かりにくいかもしれませんが、アトミックグループは「一度マッチしたものは確定、後戻りなし」という動作をします。これにより、特定のパターンにおける非効率なバックトラックを防ぎ、パフォーマンスを向上させたり、意図しないマッチを回避したりできます。ただし、パターンの意味が変わってしまうこともあるため、注意して使用する必要があります。

やってみよう! 実践的な使用例

これまでに学んだ正規表現のメタ文字とKotlinのRegexクラスの機能を組み合わせて、いくつかの実践的なタスクに挑戦してみましょう。

例1:簡単なメールアドレス形式の検証

入力された文字列が簡単なメールアドレスの形式に合っているかを確認します。

“`kotlin
fun main() {
fun isValidSimpleEmail(email: String): Boolean {
// 簡単なメールアドレス形式のパターン:
// ^ – 行頭
// \w+ – 1文字以上の単語構成文字 (ユーザー名)
// @ – @マーク
// \w+ – 1文字以上の単語構成文字 (ドメイン名の一部)
// . – ピリオド (メタ文字なのでエスケープ)
// \w+ – 1文字以上の単語構成文字 (トップレベルドメイン TLD)
// $ – 行末
val emailRegex = Regex(“””^\w+@\w+.\w+$”””)
return emailRegex.matches(email)
}

println("[email protected]: ${isValidSimpleEmail("[email protected]")}") // true
println("[email protected]: ${isValidSimpleEmail("[email protected]")}") // false (現在のパターンでは対応できない)
println("test@example: ${isValidSimpleEmail("test@example")}") // false
println("test@example.: ${isValidSimpleEmail("test@example.")}") // false
println("@example.com: ${isValidSimpleEmail("@example.com")}") // false

}
``matches()` メソッドを使うことで、文字列全体がパターンに合致するかを検証できます。ただし、このパターンは非常に簡易的です。RFCに厳密に準拠したメールアドレス検証パターンは非常に複雑になり、正規表現だけで完璧に行うのは困難であることに注意してください。これはあくまで入門レベルの簡単な例です。

例2:テキストの中からURLを見つける

文字列中に含まれる簡単なURL (httpまたはhttpsで始まるもの) を見つけ出します。

“`kotlin
fun main() {
val text = “Visit our website at https://kotlinlang.org/ or check out the GitHub repo at https://github.com/JetBrains/kotlin.”

// 簡単なHTTP/HTTPS URL パターン:
// https?   - 'http' の後に 's' が0回または1回 (?は直前の文字sに作用)
// ://      - :// という文字列リテラル
// [^\\s]+   - 1文字以上の、空白文字以外の文字 ([^\s]に+が作用)
val urlRegex = Regex("""https?://\S+""")

println("Found URLs:")
urlRegex.findAll(text).forEach { matchResult ->
    println("  - ${matchResult.value}")
}
// 出力:
// Found URLs:
//   - https://kotlinlang.org/
//   - https://github.com/JetBrains/kotlin. <-- 末尾のピリオドも含まれている!これは修正が必要かも

}
“`
この例では、末尾のピリオドまでマッチしてしまっています。これを避けるには、パターンを修正する必要があります。例えば、URLの末尾によく来る記号 (ピリオド、カンマ、括弧など) を除外する後読みや、文字クラスの工夫が必要になりますが、入門としてはここまでとします。

例3:特定の日付形式 (YYYY/MM/DD) を別の形式 (MM-DD-YYYY) に変換

YYYY/MM/DD 形式の日付を MM-DD-YYYY 形式に変換します。キャプチャグループと置換文字列での参照を使います。

“`kotlin
fun main() {
val text = “Today’s date is 2023/10/27 and tomorrow’s is 2023/10/28.”

// 日付パターンとキャプチャグループ:
// (\d{4}) - グループ1: 年 (YYYY)
// /       - / リテラル
// (\d{2}) - グループ2: 月 (MM)
// /       - / リテラル
// (\d{2}) - グループ3: 日 (DD)
val dateRegex = Regex("""(\d{4})/(\d{2})/(\d{2})""")

// 置換文字列: "$2-$3-$1" は、グループ2(月)-グループ3(日)-グループ1(年) を意味する
val transformedText = dateRegex.replace(text, "$2-$3-$1")

println("Original: $text")
println("Transformed: $transformedText")
// 出力:
// Original: Today's date is 2023/10/27 and tomorrow's is 2023/10/28.
// Transformed: Today's date is 10-27-2023 and tomorrow's is 10-28-2023.

}
``replace()メソッドと置換文字列$n` の組み合わせは、特定のフォーマットの文字列を別のフォーマットに変換するタスクに非常に有効です。

例4:文字列中の特定の単語を強調する

文字列中の特定の単語(例:「Kotlin」)を見つけ、太字タグで囲んで強調します。動的な置換 (replace(input, transform)) を使います。

“`kotlin
fun main() {
val text = “Kotlin is a great language. I love Kotlin!”
val wordToHighlight = “Kotlin”

// 強調したい単語を単語境界(\b)で囲んでパターンを作成 (大文字小文字を区別しないオプション付き)
val regex = Regex("""\b${wordToHighlight}\b""", RegexOption.IGNORE_CASE)

// 動的な置換関数を使う
val highlightedText = regex.replace(text) { matchResult ->
    // マッチした単語全体を取得し、太字タグで囲む
    "<b>${matchResult.value}</b>"
}

println("Original: $text")
println("Highlighted: $highlightedText")
// 出力:
// Original: Kotlin is a great language. I love Kotlin!
// Highlighted: <b>Kotlin</b> is a great language. I love <b>Kotlin</b>!

}
“`
動的な置換を使うことで、単に固定文字列に置き換えるだけでなく、マッチした内容に基づいて柔軟な処理を行うことができます。

例5:ログ行から特定情報の抽出

単純なログ形式から、タイムスタンプとメッセージを抽出します。名前付きキャプチャグループと findAll() を使います。

“`kotlin
fun main() {
val log = “””
[2023-10-27 10:00:01] INFO: Application started.
[2023-10-27 10:01:05] WARNING: Disk space low.
[2023-10-27 10:02:30] ERROR: Database connection failed.
[2023-10-27 10:03:15] INFO: User logged in.
“””.trimIndent()

// ログ行のパターンと名前付きキャプチャグループ:
// \[                    - リテラルの [
// (?<timestamp>.*?)     - timestamp という名前のグループ: 任意の文字が0回以上 (非貪欲)
// \] \w+:              - ] と スペース1つ以上(\s+) と 単語構成文字1つ以上(\w+) (ログレベルなどをスキップ)
// \s*                   - 0個以上の空白文字
// (?<message>.*?)      - message という名前のグループ: 任意の文字が0回以上 (非貪欲)
// $                     - 行末
// MULTILINE オプションで各行にマッチ
val logRegex = Regex("""^\[(?<timestamp>.*?)\] \w+:\s*(?<message>.*?)$""", RegexOption.MULTILINE)

println("Extracted Log Entries:")
logRegex.findAll(log).forEach { matchResult ->
    val timestamp = matchResult.groups["timestamp"]?.value
    val message = matchResult.groups["message"]?.value
    println("Timestamp: $timestamp, Message: $message")
}
// 出力:
// Extracted Log Entries:
// Timestamp: 2023-10-27 10:00:01, Message: Application started.
// Timestamp: 2023-10-27 10:01:05, Message: Disk space low.
// Timestamp: 2023-10-27 10:02:30, Message: Database connection failed.
// Timestamp: 2023-10-27 10:03:15, Message: User logged in.

}
``findAll()と名前付きキャプチャグループを組み合わせることで、構造化されていないテキストデータから特定の情報を効率的に抽出できます。.?は非貪欲マッチと呼ばれ、可能な限り短い文字列にマッチします。これに対し.(貪欲マッチ) は可能な限り長い文字列にマッチしようとします。ここでは、タイムスタンプとメッセージの区切り(]:`)を正しく認識させるために非貪欲マッチを使っています。

正規表現を使う上での注意点とヒント

正規表現は強力なツールですが、使う上でいくつかの注意点があります。

  1. 可読性の維持: 複雑な正規表現パターンは、人間には非常に読みにくく、理解しにくいものになりがちです。

    • 三重引用符文字列を使ってエスケープを減らす。
    • COMMENTS オプションを使ってパターン中にコメントを入れる。
    • 名前付きキャプチャグループを使って、グループの意味を分かりやすくする。
    • 可能であれば、複雑なパターンをより小さな部品に分割する。
    • コメントや変数名を工夫して、パターンの意図を明確にする。
  2. パフォーマンスへの配慮: ほとんどの場合、正規表現は非常に高速ですが、特定のパターン(特に複雑な繰り返しやグループが絡み合ったパターン)と特定の入力文字列の組み合わせによっては、処理時間が極端に長くなることがあります(ReDoS – Regular expression Denial of Service)。

    • 信頼できない外部入力を正規表現で処理する場合は、複雑なパターンに注意する。
    • バックトラックが多く発生しそうなパターン(例: (a+)*)は避けるか、代替手段(アトミックグループなど)を検討する。
    • 大規模なテキストを処理する場合は、パフォーマンスを意識する。
  3. デバッグ: 意図した通りにマッチしない場合、原因を見つけるのが難しいことがあります。

    • オンラインの正規表現テスター (regex101.com, regexr.com など) を活用して、パターンがどのようにマッチするかをステップ実行しながら確認する。
    • 短い入力文字列で試しながらパターンを段階的に構築していく。
    • Kotlinコード中で find()findAll() の結果を詳しく調べて、どこがマッチしているか、グループが正しくキャプチャされているかを確認する。
  4. 「万能ではない」ことを知る: 正規表現は文字列のパターンマッチングには非常に強力ですが、構造化されたデータの解析には向かない場合があります。

    • HTML や XML のパース: 正規表現で HTML タグを正確に処理するのは非常に難しく、多くの場合失敗します。専用のパーサーライブラリ (jsoup など) を使うべきです。
    • JSON や CSV のパース: これらも構造を持ったデータ形式なので、正規表現よりも専用のライブラリを使った方が安全で確実です。
  5. エスケープの重要性: メタ文字をリテラルとして扱いたい場合は、必ずエスケープ (\) が必要です。Kotlinの文字列リテラルにおけるエスケープとの二重エスケープ ( \\ など) に注意が必要です。前述の通り、三重引用符文字列 ("""...""") を使うことで、Kotlin側のエスケープを気にせず正規表現のエスケープだけに集中できます。

これらの注意点を意識することで、正規表現をより安全に、効率的に、そして効果的に活用できるようになります。

まとめ:正規表現をあなたのツールキットに

この記事では、Kotlinで正規表現を使うための超入門として、正規表現の基本的なメタ文字、KotlinのRegexクラスの生成方法、そしてmatches, containsMatchIn, find, findAll, replace, split といった主要なメソッドの使い方を、多くのコード例とともに解説しました。さらに、キャプチャグループ、後方参照、名前付きグループ、先読み・後読みといった応用的な機能にも触れ、実践的な例を通じて正規表現の力を紹介しました。

正規表現は最初は少し難しく感じるかもしれませんが、基本的なメタ文字とKotlinのRegexクラスのメソッドをいくつか覚えるだけでも、文字列処理の効率が格段に向上します。最初は簡単なパターンから始め、少しずつ複雑なパターンや応用機能に挑戦していくのが良いでしょう。

Kotlinにおける正規表現は、文字列操作の強力な味方となります。ぜひあなたのツールキットに正規表現を加え、日々のコーディングに役立ててください。

さらに深く学びたい場合は、Kotlinの公式ドキュメントの kotlin.text.Regex のページや、正規表現に関するより専門的な書籍やオンラインリソースを参照することをお勧めします。

Happy Coding!


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール