はじめに
正規表現は様々なプログラミング言語で利用されている、テキスト処理のためのパターン言語です。 正規表現はテキストエディタでの検索や置換、入力文字列のバリデーションなどプログラミングの様々な分野で実用されています。 ある程度の規模のプログラムにおいて、正規表現を全く利用しない (利用していない) ということはほとんど無く、正規表現は今日のプログラミングにおいて非常に重要なパーツだと言えます。
JavaScriptやRubyといったプログラミング言語では正規表現はファーストクラスのリテラルとして実装されているため、とても簡単に利用できます。
例えば次のRubyプログラミングでは変数foo
に入った文字列の部分にfizz
かbuzz
が含まれるかどうかを、正規表現/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"
をマッチする場合、先頭からaa
やaaa
というように中途半端なところまででマッチが完了することはなく、aaaa
までのすべてのa
がマッチします。
選択 (a|b
) と連接 (ab
)
選択の記号|
を使うことで、複数のパターンのいずれかにマッチするパターンを書けます。
例えば、a|b|c
がa
またはb
またはc
にマッチするパターンです。
選択は左側から優先的にマッチをしていきます。
例えばパターンa|aa
に対して文字列"aa"
をマッチする場合、一番左側のパターンa
が優先されて先頭から1文字の"a"
のみにマッチします。
また、複数のパターンを並べることで、連続してマッチするパターンを書けます。
例えば、ab
は文字a
とb
の並びにマッチするパターンです。
これらのグループ化、繰り返し、選択、連接を組み合わせてパターンを記述できます。
例えば(a|b)*aba(a|b)*
というパターンは、aba
という並びがどこかに存在する、a
とb
からなる文字列の部分にマッチするパターンです。
また、選択と繰り返しでは繰り返しの方が優先順位が高いです。
そのため、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文字のパターンa
はLiteral['a']
として表します。Repetition
は繰り返しを表す型です。child
は繰り返し対象の抽象構文木です。quantifier
がどの繰り返しの種類かを表すパラメータで:star
か:plus
か:question
のいずれかになります。 例えばパターンa*
はRepetition[Literal['a'], :star]
として表します。Choice
は選択を表す型で、children
は抽象構文木の配列です。 例えばパターンa|b
はChoice[[Literal['a'], Literal['b']]]
として表します。Concat
は連接を表す型で、children
は抽象構文木の配列です。 例えばパターンab
はConcat[[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
この方針でもLiteral
とConcat
は上手く実装できます。
しかし、Repetition
やChoice
の実装はどうでしょうか?
選択は左側から優先的にマッチしていきます。
そのため、次のように順番に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
を考えます。
これはac
かabc
にマッチするパターンのはずです。
しかし、これに対応するを抽象構文木を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_pc
とpos
の組をstack
にプッシュして、pc
をインクリメントする。[:jump, next_pc]
:pc
をnext_pc
に変更する。[:char, c]
:input[pos] == c
ならpc
とpos
をインクリメントして、そうでないならstack
をポップしてその値をpc
とpos
に変更する (バックトラックする)。stack
が空の場合はマッチ失敗となる。[:match]
: マッチを成功としてpos
を返す。
なんと、これら4種類の命令だけで正規表現の基本的な機能は実装できてしまいます。 どのように実現するのかは次の章で説明します。
バックトラック型VMの実装
それでは、仕様に沿ってバックトラック型VMを実装していきましょう。
バックトラック型VMはKantanRegex::BacktrackVM
クラスに実装します。
このクラスはマッチの度に作り直されることを想定していて、program
とinput
を受け取ります。
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 ... in
がprogram
の命令を処理している部分になります。
さらに、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行程度と、かなり少ない行数で実装できています。
今回の実装を通じて、正規表現マッチの動作は複雑なわけではなく、選択や繰り返しがよくあるプログラムの分岐やループとは少し異なり、全ての可能なマッチを網羅する形で動作することが特徴的だということが伝わっていたら嬉しいです。 そして、正規表現を今後利用する際にこのことを思い出して、意図するパターンを上手く書けるようになったり、これまでに書いた正規表現のバグを発見することに役立つことを期待しています。
それでは、最後まで読んでいただきありがとうございました。