はじめに

正規表現は様々なプログラミング言語で利用されている、テキスト処理のためのパターン言語です。 正規表現はテキストエディタでの検索や置換、入力文字列のバリデーションなどプログラミングの様々な分野で実用されています。 ある程度の規模のプログラムにおいて、正規表現を全く利用しない (利用していない) ということはほとんど無く、正規表現は今日のプログラミングにおいて非常に重要なパーツだと言えます。

JavaScriptやRubyといったプログラミング言語では正規表現はファーストクラスのリテラルとして実装されているため、とても簡単に利用できます。 例えば次のRubyプログラミングでは変数fooに入った文字列の部分にfizzbuzzが含まれるかどうかを、正規表現/fizz|buzz/を使ってチェックしています。

foo =~ /fizz|buzz/

さらに、計算機科学 (コンピューターサイエンス) の分野においても、正規表現は重要な概念の一つです。 形式言語理論やオートマトン理論といった抽象的な理論の分野に限らず、コンパイラやデータマイニングなどの具体的な応用にも、正規表現は活用されています。

こういった重要性のある正規表現ですが、実際に利用しているプログラマの中でも「挙動が複雑でよく分からない」「予想外の動きをするのでバグの原因になる」といった意見があります。 他にも、正規表現のパターンと文字列によってはマッチング時間が爆発し、正常な時間で処理が行えなくなるReDoSと呼ばれる脆弱性の原因となることもあります。 これらの問題が生じる理由は、正規表現のバックトラックなどを用いたパターンに対する網羅的なマッチングが、通常のプログラミングで扱う逐次的な処理とはやや異なり、直感に反する動作をすることがあるためだと考えられます。

そこでこの本では、kantan-regexという小さな正規表現エンジンのRubyによる実装を通じて、正規表現マッチングの動作を説明します。 実際に作ってみることで、言葉で説明されるよりもよく理解できるのではないかと思います。

正規表現はある種、小さなプログラミング言語と言えます。 実際、この本では正規表現のマッチングを「正規表現をパース (構文解析)」「マッチング用のVMの命令列へと変換」「VMの実行」という流れで行います。 これは多くのプログラミング言語が動作すると似た流れになっていて、プログラミング言語の実装をしてみたい人が入門として実装するのにも適しています。

また、実装してみると分かりますが、正規表現エンジンは最適化などを除いたコアの部分だけであれば数百行程度で実装でき、かなり単純です。 そのため、一度実装すると、機能追加や最適化など様々なところに手を加えることができます。

それでは、正規表現エンジンの実装をはじめましょう。

本書のターゲット

この本は次のような方をターゲットとしています。

  • 正規表現を普段から使っていて、より深く知りたい方。
  • プログラミング言語を作ってみたいけれど、小さなものから始めたい方。

正規表現やプログラミング言語の基本についてはそこまで詳細に説明しないので、適宜調べてください。 とくに、Rubyの比較的新しい機能であるData型やcase ... inによるパターンマッチなどを利用するので、注意してください。

kantan-regexの仕様

ここでは、これから実装する正規表現エンジン (kantan-regex) の仕様を確認していきます。 いわゆる正規表現と大差あるわけではありませんが、詳細な仕様を把握しておくことは、実装する上では重要となります。

API: KantanRegex

KantanRegexを唯一の公開されているクラスとします。

KantanRegex.newにパターンを表す文字列を渡すことで、正規表現オブジェクトを生成できます。 さらに、KantanRegex#matchにマッチ対象の文字列を渡すことで、最初にマッチした文字列の範囲 (Rangeオブジェクト) を返します。

re = KantanRegex.new('fizz|buzz')

re.match('foo fizz bar') #=> 4...8

サポートする構文

実装を単純にするために、サポートする正規表現のパターンの構文はかなり絞ったものになっています。 具体的には、kantan-regexでは次の構文をサポートしています。

  • 文字リテラル、一部のエスケープ文字
  • 括弧によるグループ化 ((a))
  • 基本的な繰り返し (a*a+a?)
  • 選択 (a|b) と連接 (ab)

文字クラス ([abc]) や行の先頭・末尾 (^$)、後方参照などの機能は、今回は (一旦) 実装しない方針とします。

文字リテラル、一部のエスケープ文字

文字リテラルは (いわゆる) 通常の文字です。 一部のパターン中で意味を持つ文字を除いて、そのまま書いた文字が文字リテラルとして扱われます。 そして、文字リテラルはその文字自身にマッチするパターンとなります。

パターン中で意味を持つ文字には次のようなものがあります。

  • グループ化に使う()
  • 繰り返しに使う*+?
  • 選択に使う|

これらの文字にマッチするパターンを書きたいときのために、バックスラッシュ\を使ってエスケープすることができます。 加えて、バックスラッシュ自身も\\のようにしてエスケープできることにします。

括弧によるグループ化 ((a))

括弧を使ってパターンをグループ化できます。 これは次の繰り返しや分岐の適用される範囲を示すために利用できます。

基本的な繰り返し (a*a+a?)

基本的な繰り返しの記号 (演算子) として次のものをサポートします。

  • *: 0回以上の繰り返し
  • +: 1回以上の繰り返し
  • ?: 0回か1回の繰り返し

?はあまり繰り返しの記号としては考えないかもしれませんが、0回以上1回以下の繰り返しと考えることで一般化できます。

また、繰り返しは貪欲にマッチします。 つまり、可能な限り多く繰り返すようにマッチします。

例えばパターンa*に対して文字列"aaaa"をマッチする場合、先頭からaaaaaというように中途半端なところまででマッチが完了することはなく、aaaaまでのすべてのaがマッチします。

選択 (a|b) と連接 (ab)

選択の記号|を使うことで、複数のパターンのいずれかにマッチするパターンを書けます。 例えば、a|b|caまたはbまたはcにマッチするパターンです。

選択は左側から優先的にマッチをしていきます。 例えばパターンa|aaに対して文字列"aa"をマッチする場合、一番左側のパターンaが優先されて先頭から1文字の"a"のみにマッチします。

また、複数のパターンを並べることで、連続してマッチするパターンを書けます。 例えば、abは文字abの並びにマッチするパターンです。

これらのグループ化、繰り返し、選択、連接を組み合わせてパターンを記述できます。 例えば(a|b)*aba(a|b)*というパターンは、abaという並びがどこかに存在する、abからなる文字列の部分にマッチするパターンです。

また、選択と繰り返しでは繰り返しの方が優先順位が高いです。 そのため、a|b*a|(b*)の意味になります。 連接と繰り返しも同様で、a*bc*(a*)b(c*)の意味になります。

正規表現のパーサー

正規表現のパターンは前の章で説明した構文を持った文字列です。 そのため、マッチングを行うためにはまず、構文解析 (パース) をして、どのようなパターンなのかコンピュータが理解できるようにしなければなりません。 この構文解析を行う部分をパーサーと呼びます。 この章ではパターンのパーサーの実装をします。

NOTE

この章は「正規表現の動作を理解する」という目標からは少し離れた内容になっています。 そのため、場合によっては次の「抽象構文木 (AST)」の部分に目を通したのち、パーサーの実装をコピーして、次の章に進んでも構いません。

抽象構文木 (AST)

パターンを構文解析した結果を表すデータ構造を、抽象構文木 (AST) と呼びます。 そして、文字列からこの抽象構文木へと変換することが構文解析 (パース) になります。

抽象構文木はDataを使って表すことにします。

class KantanRegex
  # 文字リテラル・エスケープ文字に対応する抽象構文木のデータ型。
  Literal = Data.define(:value)

  # 繰り返しに対応する抽象構文木のデータ型。
  Repetition = Data.define(:child, :quantifier)

  # 選択に対応する抽象構文木のデータ型。
  Choice = Data.define(:children)

  # 連接に対応する抽象構文木のデータ型。
  Concat = Data.define(:children)
end

それぞれどのような型か説明します。

  • Literalは文字リテラルやエスケープ文字を表す型です。 valueにはその文字を表す長さ1の文字列が入ります。 例えば1文字のパターンaLiteral['a']として表します。
  • Repetitionは繰り返しを表す型です。 childは繰り返し対象の抽象構文木です。 quantifierがどの繰り返しの種類かを表すパラメータで:star:plus:questionのいずれかになります。 例えばパターンa*Repetition[Literal['a'], :star]として表します。
  • Choiceは選択を表す型で、childrenは抽象構文木の配列です。 例えばパターンa|bChoice[[Literal['a'], Literal['b']]]として表します。
  • Concatは連接を表す型で、childrenは抽象構文木の配列です。 例えばパターンabConcat[[Literal['a'], Literal['b']]]として表します。

グループ化はパターンのマッチ結果に影響しない構文のため、型としては定義していません。 例えばa*(a)*はマッチする文字列は同じで、どちらもRepetition[Literal['a'], :star]で表します。

パーサーの実装

パーサーの実装方法は、パーサージェネレーターを使う方法や、手書きでパーサーを実装する方法など、いくつか存在します。 今回はその中でも手書きで実装する再帰下降パーサーという方法を使うことにします。 再帰下降パーサーは文字列を複数の再帰関数 (メソッド) を用いて走査することで構文解析を行う方法です。

実装はKantanRegex::Parserクラスで行います。 このクラスは構文解析の度に生成されて、再帰下降パーサーの状態を管理します。

class KantanRegex::Parser
  def initialize(pattern)
    @pattern = pattern
    @offset = 0
  end
  
  def current_char =@pattern[@offset]
  def end? = @pattern.size <= @offset

  def next_char
    @offset += 1
  end
end
  • インスタンス変数@patternは構文解析対象の文字列で、@offsetは現在の構文解析中の文字列上の位置です。
  • current_charは現在の構文解析中の文字を返すメソッドで、end?は末尾に到達しているかを判定するメソッドです。
  • next_charは構文解析中の文字を次に進めるメソッドです。

これらのメソッドを使って構文解析を行います。 基本的には各構文の優先順位に従ってメソッドを呼び出していきます。

class KantanRegex::Parser
  # 構文解析の開始地点。
  def parse
    tree = parse_choice

    # 最後までちゃんと構文解析したかをチェックする。
    raise 'End-of-string is expected' unless end?
    tree
  end

  # 選択 (`a|b`) を構文解析するメソッド。
  def parse_choice
    children = []
    children << parse_concat

    while current_char == '|'
      next_char
      children << parse_concat
    end

    # `children`が1つしか無い場合は`Choice`を生成しない。
    return children.first if children.size == 1
    KantanRegex::Choice[children]
  end

  # 連接 (`ab`) を構文解析するメソッド。
  def parse_concat
    children = []

    until concat_stop?
      children << parse_repetition
    end

    # `children`が1つしか無い場合は`Concat`を生成しない。
    return children.first if children.size == 1
    KantanRegex::Concat[children]
  end

  # 現在の文字が連接を続けるべきか判定するメソッド。
  def concat_stop? =
    end? || current_char == ')' || current_char == '|'

  # 繰り返し (`a*`, `a+`, `a?`) を構文解析するメソッド。
  def parse_repetition
    child = parse_group

    quantifier =
      case current_char
      when '*' then :star
      when '+' then :plus
      when '?' then :question
      else nil
      end
    return child unless quantifier
    next_char

    KantanRegex::Repetition[child, quantifier]
  end

  # グループ化 (`(a)`) を構文解析するメソッド。
  def parse_group
    return parse_literal if current_char != '('
    next_char

    child = parse_choice

    raise '")" is expected' if current_char != ')'
    next_char

    child
  end

  # 文字リテラルやエスケープ文字を構文解析するメソッド。
  def parse_literal
    if current_char == '\\'
      next_char
      raise 'Missing escaped character' if end?

      value = current_char
      case value
      when '(', ')', '*', '+', '?', '|', '\\'
        next_char
      else
        raise 'Unsupported escaped character'
      end

      return KantanRegex::Literal[value]
    end

    value = current_char
    case value
    when '(', ')', '*', '+', '?', '|', '\\'
      raise 'Invalid literal character'
    else
      next_char
    end

    KantanRegex::Literal[value]
  end
end

KantanRegex::Parserクラスの作成とparseメソッドの呼び出しを一度に行うKantanRegex.parseメソッドを追加しておきます。

class KantanRegex::Parser
  def self.parse(pattern) =
    KantanRegex::Parser.new(pattern).parse
end

最後にirbからこれを使ってみます。

irb(main):001> load './kantan-regex/parser.rb'
=> true
irb(main):002> KantanRegex::Parser.parse('a*')
=>
#<data KantanRegex::Repetition
 child=#<data KantanRegex::Literal value="a">,
 quantifier=:star>
irb(main):003> KantanRegex::Parser.parse('(a|b)*c')
=>
#<data KantanRegex::Concat
 children=
  [#<data KantanRegex::Repetition
    child=
     #<data KantanRegex::Choice
      children=
       [#<data KantanRegex::Literal value="a">,
        #<data KantanRegex::Literal value="b">]>,
    quantifier=:star>,
   #<data KantanRegex::Literal value="c">]>

正しく動作してそうです。

バックトラック型VM

今回は、正規表現マッチングをバックトラック型VMで実行します。 この章では、なぜバックトラック型VMが必要なのかを説明したのち、バックトラック型VMの仕様を解説し、実装します。

なぜバックトラック型VMが必要なのか

バックトラック型VMの説明に入る前に「なぜバックトラック型VMが必要なのか」について説明します。

kantan-regexでは正規表現マッチングを、正規表現をバックトラック型VMの命令列にコンパイルして実行することで実現します。 どうして、こんなに回りくどいことをしなければいけないのでしょうか?

理由は簡単に言うと「分岐や繰り返しの挙動を正しく実装するため」となります。

もう少し詳細に説明します。 例えば、抽象構文木をマッチ対象の文字列と同時に再帰的に辿っていく方針で正規表現マッチングを実装するとします。

すると、次のような抽象構文木tree、マッチ対象の文字列input、マッチ中の位置posの引数を受け取って、マッチした場合は位置の数値を、マッチに失敗した場合はnilを返すメソッドで実装することになります。

def naive_match(tree, input, pos)
  case tree
  in KantanRegex::Literal[value]
    if input[pos] == value
      pos + 1
    else
      nil
    end
  in KantanRegex::Concat[children]
    children.each do |child|
      pos = naive_match(child, input, pos)
      return nil unless pos
    end
    pos
  in KantanRegex::Repetition[child, quantifier] # TODO
  in KantanRegex::Choice[children] # TODO
  end
end

この方針でもLiteralConcatは上手く実装できます。 しかし、RepetitionChoiceの実装はどうでしょうか?

選択は左側から優先的にマッチしていきます。 そのため、次のように順番にmatchを呼び出して、最初にマッチしたものを返すようにすれば実装できそうに思えます。

def naive_match(tree, input, pos)
  case tree
  in KantanRegex::Literal[value] # 省略
  in KantanRegex::Concat[children] # 省略
  in KantanRegex::Repetition[child, quantifier] # TODO
  in KantanRegex::Choice[children]
    children.each do |child|
      choice_pos = naive_match(child, input, pos)
      return choice_pos if choice_pos
    end
    nil
  end
end

ですが、実はこれは上手く動作しません。

パターン(a|ab)cを考えます。 これはacabcにマッチするパターンのはずです。 しかし、これに対応するを抽象構文木をtreeとして、naive_match(tree, "abc", 0)を呼び出しても3が返らずnilが返る (つまりマッチしていない) ことになります。 どうしてこうなるのかと言うと、naive_matchではChoiceの1つマッチする部分を見つけたら他がマッチするかどうかを考慮せず、結果を考慮してしまいます。 そのため、2つ目以降のマッチする部分を選んだ場合に正しく全体がマッチする場合に、上手く動作しなくなってしまいます。

繰り返しの場合も素朴に実装すると、可能な限りマッチするのではなく、繰り返し回数を減らさなければいけないようなパターンのときに、上手く動作しなくなります。 例えばパターンがa*abの場合に、abにマッチしなくなってしまいます。

選択や繰り返しの動作を正しく実装するためには、複数のマッチの可能性を上手く扱えなければいけません。 matchメソッドが複数のマッチ結果を返すように実装することでも良いのですが、そうするとパフォーマンス上の問題があります。

そこで、バックトラック型のVMを使うことで、この複数のマッチの可能性を自然に扱えるようになります。 こちらはパフォーマンス上の問題もありません。 そのため、実際の正規表現エンジンの実装でも、バックトラック型のVMが利用されることが多いです。

バックトラック型VMの仕様

ここからはバックトラック型VMの仕様を実装しつつ説明していきます。

バックトラック型VMは、次のパラメータを受け取って動作します。

  • program: バックトラック型VMの命令列 (配列)
  • input: マッチ対象の入力文字列
  • start_pos: マッチ開始位置

そして、次の内部の状態を持っています。

  • pc: マッチ中のprogramのインデックス
  • pos: マッチ中のインデックス
  • stack: バックトラック先を記憶するスタック

これらの値を変化させるのが、programに格納された命令です。 programは次の命令 (1要素目がシンボルの配列) の配列になります。

  • [:push, backtrack_pc]: backtrack_pcposの組をstackにプッシュして、pcをインクリメントする。
  • [:jump, next_pc]: pcnext_pcに変更する。
  • [:char, c]: input[pos] == cならpcposをインクリメントして、そうでないならstackをポップしてその値をpcposに変更する (バックトラックする)。stackが空の場合はマッチ失敗となる。
  • [:match]: マッチを成功としてposを返す。

なんと、これら4種類の命令だけで正規表現の基本的な機能は実装できてしまいます。 どのように実現するのかは次の章で説明します。

バックトラック型VMの実装

それでは、仕様に沿ってバックトラック型VMを実装していきましょう。

バックトラック型VMはKantanRegex::BacktrackVMクラスに実装します。 このクラスはマッチの度に作り直されることを想定していて、programinputを受け取ります。

class KantanRegex::BacktrackVM
  StackBacktrack = Data.define(:pc, :pos)

  def initialize(program, input)
    @program = program
    @input = input
  end

  def exec(start_pos)
    pc = 0
    pos = start_pos
    stack = []

    loop do
      case @program[pc]
      in [:push, backtrack_pc]
        stack << StackBacktrack[backtrack_pc, pos]
        pc += 1
      in [:jump, next_pc]
        pc = next_pc
      in [:char, c]
        if @input[pos] == c
          pc += 1
          pos += 1
        else
          return nil if stack.empty?
          stack.pop => StackBacktrack[pc, pos]
        end
      in [:match]
        return pos
      end
    end
  end

  def self.exec(program, input)
    vm = KantanRegex::BacktrackVM.new(program, input)
    (0...input.size).each do |start_pos|
      end_pos = vm.exec(start_pos)
      return start_pos...end_pos if end_pos
    end
    nil
  end
end

実際にVMの実行をするのはexecメソッドで、こちらでさらにstart_posを受け取ります。 loopの中にあるcase ... inprogramの命令を処理している部分になります。

さらに、KantanRegex::BacktrackVM.execメソッドが定義されていて、これは最初にマッチが

これをirbで読み込んで試しています。

irb(main):001> load './kantan-regex/backtrack_vm.rb'
=> true
irb(main):002* program =
irb(main):003*   [[:push, 3],
irb(main):004*    [:char, 'a'],
irb(main):005*    [:jump, 0],
irb(main):006>    [:match]]
=> [[:push, 3], [:char, "a"], [:jump, 0], [:match]]
irb(main):007> KantanRegex::BacktrackVM.exec(program, 'aaa')
=> 0...3

ここで用いたprogramはパターンa*に相当するものなので、正しい結果になっていることが分かります。

正規表現からのコンパイル

ここでは、正規表現からVMの命令列への変換 (コンパイル) を実装します。 そして、公開するAPIを整理して、正規表現エンジンとしての実装を完成させます。

正規表現のコンパイル

正規表現からVMの命令列への変換は、正規表現の抽象構文木を再帰的に辿っていくことで行います。

それぞれの抽象構文木の型について、次のようにしてコンパイルしていきます。

Literal[value]

char命令に対応しているので、[:char, value]にコンパイルします。

Concat[children]

childrenを順番にコンパイルしていきます。

Choice[children]

children = [child1, child2]のように2要素の場合で考えます。 このとき、次のようにコンパイルします。

+--------o [:push, (*1)]
|          ... child1のコンパイル結果 ...
| +------o [:jump, (*2)]
+-|-> (*1) ... child2のコンパイル結果 ...
  +-> (*2) ... 続くコンパイル結果 ...

childrenが3要素以上ある場合も同様に、最後の要素以外にはpush命令とjump命令を配置するようにします。

Repetition[child, :star]

次のようにコンパイルします。

+-o +-> (*2) [:push, (*1)]
|   |        ... childのコンパイル結果 ...
|   +------o [:jump, (*2)]
+-----> (*1) ... 続くコンパイル結果 ...

Repetition[child, :plus]

次のようにコンパイルします。 :starと比べてpush命令の位置が変わっているだけです。

+---> (*2) ... childのコンパイル結果 ...
| +------o [:push, (*1)]
+-|------o [:jump, (*2)]
  +-> (*1) ... 続くコンパイル結果 ...

Repetition[child, :question]

次のようにコンパイルします。 :starのコンパイル結果からjump命令が無くなったもので、単純になっています。

+------o [:push, (*1)]
|        ... childのコンパイル結果 ...
+-> (*1) ... 続くコンパイル結果 ...

コンパイルの実装

それでは、これらのコンパイルを実装します。

実装はKantanRegex::Compilerクラスで行います。 このクラスはコンパイルの度に生成されて、コンパイル中のprogramを管理します。

class KantanRegex::Compiler
  def initialize
    @program = []
  end

  attr_reader :program

  # 命令を`program`に追加するメソッド。
  # `insn`はinstructionの略。
  # 返り値は追加した命令の`program`上のインデックス。
  def insn(*insn)
    index = @program.size
    @program << insn
    index
  end

  def compile_root(tree)
    compile(tree)
    insn :match
  end

  private def compile(tree)
    case tree
    in KantanRegex::Literal[value]
      insn :char, value
    in KantanRegex::Repetition[child, :star]
      push_index = insn(:push, nil) # まだジャンプ先が分からないのでひとまず`nil`を入れている
      compile child
      insn :jump, push_index
      @program[push_index][1] = @program.size
    in KantanRegex::Repetition[child, :plus]
      start_index = @program.size
      compile child
      insn :push, @program.size + 2
      insn :jump, start_index
    in KantanRegex::Repetition[child, :question]
      push_index = insn(:push, nil) # まだジャンプ先が分からないのでひとまず`nil`を入れている
      compile child
      @program[push_index][1] = @program.size
    in KantanRegex::Choice[children]
      jump_indices = []
      children.each_with_index do |child, i|
        is_last = i == children.size - 1
        unless is_last
          push_index = insn(:push, nil) # まだジャンプ先が分からないのでひとまず`nil`を入れている
        end
        compile child
        unless is_last
          jump_index = insn(:jump, nil) # まだジャンプ先が分からないのでひとまず`nil`を入れている
          jump_indices << jump_index
          @program[push_index][1] = @program.size
        end
      end
      jump_indices.each do |jump_index|
        @program[jump_index][1] = @program.size
      end
    in KantanRegex::Concat[children]
      children.each { compile _1 }
    end
  end

  def self.compile(tree)
    compiler = KantanRegex::Compiler.new
    compiler.compile_root(tree)
    compiler.program
  end
end

insnという命令を追加するメソッドや、コンパイルの起点となるcompile_rootメソッドを持っています。

実際に抽象構文木を辿るのはcompileメソッドで、このメソッドのcase ... inで先ほど説明したコンパイルの方法を実装しています。 Choiceの場合がやや複雑ですが、注意深く処理を追えば何をやっているのか理解できると思います。

公開APIの実装

最後に、公開APIであるKantanRegexクラスとKantanRegex#matchメソッドを実装します。 これは、ここまで実装してきたものを使えば簡単に実装できます。

require_relative './ast.rb'
require_relative './parser.rb'
require_relative './backtrack_vm.rb'
require_relative './compiler.rb'

class KantanRegex
  def initialize(pattern)
    @program = Compiler.compile(Parser.parse(pattern))
  end

  def match(input)
    BacktrackVM.exec(@program, input)
  end
end

これをirbから読み込んで使ってみましょう。

irb(main):001> load './kantan-regex/kantan-regex.rb'
=> true
irb(main):002> KantanRegex.new('(a|ab)c').match('abc')
=> 0...3
irb(main):003> KantanRegex.new('a*ab').match('aaab')
=> 0...4
irb(main):004> KantanRegex.new('a*ab').match('bc')
=> nil

前の章で説明した選択や繰り返しが正しく実装されていないといけない例も、上手く動作しているように見えます。

これにてkantan-regexの実装はひとまず完了となります。 おつかれさまでした。

あとがき

Rubyを用いて小さな正規表現エンジンであるkantan-regexを実装してきました。 ここまで実装してみて、想像よりも少ないコード行数で実装できることに驚いた方も多いのではないかと思います。 こちらの手元でのコードは空行やコメントを含めて300行程度と、かなり少ない行数で実装できています。

今回の実装を通じて、正規表現マッチの動作は複雑なわけではなく、選択や繰り返しがよくあるプログラムの分岐やループとは少し異なり、全ての可能なマッチを網羅する形で動作することが特徴的だということが伝わっていたら嬉しいです。 そして、正規表現を今後利用する際にこのことを思い出して、意図するパターンを上手く書けるようになったり、これまでに書いた正規表現のバグを発見することに役立つことを期待しています。

それでは、最後まで読んでいただきありがとうございました。