はじめに
突然ですが質問です。以下の正規表現の結果はどうなるでしょうか。
import re
re.match(r'\d{4}-\d{2}', '٢٠٢٢-٠٦')
Pythonで書いてはいますが、内容を補足します。「'٢٠٢٢-٠٦'」という文字列が、数字4文字+ハイフン+数字2文字にマッチするかどうか、という正規表現を書いています。
一見するとどう見ても数字4桁ではないので、正規表現にマッチしないように見えますよね????
実は正規表現にマッチします!!!というのが今回の記事の内容になります。
そもそも正規表現における数字の扱いとは
今回はPythonの正規表現のライブラリを利用しているので、公式のリファレンスを見てみましょう。
該当の部分には以下のような記述があります。
\d
Unicode (str) パターンでは:
任意の Unicode 10 進数字 (Unicode 文字カテゴリ [Nd]) にマッチします。これは [0-9] とその他多数の数字を含みます。 ASCII フラグが使われているなら [0-9] のみにマッチします。
https://docs.python.org/ja/3/library/re.html
重要なのは Unicode 10 進数字 (Unicode 文字カテゴリ [Nd]) にマッチの部分です。Unicode 文字カテゴリ [Nd]って何?と思ったので調べてみました。
するととても分かりやすい記事を見つけました。
Unicodeにある数字の一覧
つまり、私たちが慣れ親しんでいるアラビア数字(123456789)以外にも様々な数字があり、その数字も正規表現では数字として扱う、ということのようです。
今回の例で使用している「'٢٠٢٢-٠٦'」はインド数字と呼ばれるものであり、意味的には「2022-06」という意味になります。
分かりやすく対応を示すと以下の画像のようになります。
右横書き文字のため、対応が少し分かりにくいですよね。
ではどうすればよかったのか
正規表現を4桁の数字、とするのではなく0-9のいずれか4桁、という風に修正すればよいです。
具体的に言うと以下のような形になります。
import re
re.match(r'[0-9]{4}-[0-9]{2}', '٢٠٢٢-٠٦')
これで、想定通りの結果を得ることができます。
今回のように「\d」を使った正規表現を利用する場合、想定外の結果が起きる可能性があるので注意が必要です。悪意のあるユーザが意図的に日付入力部分にインド数字を入れて送信し、システムへ不具合を起こさせる、といったことも考えられます。
皆さんも、正規表現を利用する際は気を付けてくださいね。
現場からは以上です。
追記:その他のパターンについて
なお、Pythonの場合は以下のようにASCIIフラグを設定することでも想定する結果を得ることができます。
import re
pattern = re.compile(r'\d{4}-\d{2}', re.ASCII)
print(bool(pattern.match('٢٠٢٢-٠٦'))) # False
print(bool(pattern.match('2022-06'))) # True
コメントで頂きましたが、以下のように全角数字でも同様の結果となります。
(@KAZAMAI_NaruTo さんありがとうございます。)
import re
pattern = re.compile(r'\d{4}-\d{2}')
print(bool(pattern.match('2022-06'))) # True
print(bool(pattern.match('2022-06'))) # True
追記:正規表現に関して
今回の正規表現は例のためできるだけシンプルな構成にしていました。
ただし、実際に正規表現を利用する場合はもう少し注意する点がありますので、補足します。
正規表現の原則としてできるだけ詳細な正規表現を書くことを心掛けるというルールがあります。
例えば、今回の例ですと「\d{4}-\d{2}」は「数字4桁+ハイフン+数字2桁」という正規表現になりますが、実際は 「数字4桁で始まり次にハイフン、最後に数字2桁で終わる」 という正規表現が実際に自分が求めている内容であると想定されます。
その場合は下位のような正規表現が正しいです。
import re
pattern = re.compile(r'^\d{4}-\d{2}$', re.ASCII)
print(bool(pattern.match('٢٠٢٢-٠٦'))) # False
print(bool(pattern.match('2022-06'))) # True
正規表現は部分一致で判定するため、もともとの正規表現の場合は、以下のようなパターンもマッチします。
※matchは前方一致のため、今回はsearchを利用
import re
pattern = re.compile(r'\d{4}-\d{2}', re.ASCII)
print(bool(pattern.search('dummy-2022-06-dummy'))) # True
また、*や+は正規表現のパフォーマンスに影響する上に、想定外の挙動を引き起こす可能性が上がるので、利用は最低限に抑えるのが無難です。
以下の記事がとても分かりやすいのでぜひ参照ください。
はじめての正規表現とベストプラクティス#9: .*
や.+
がバックトラックで不利な理由
追記:右横書き文字と左横書き文字の配列の扱いについて
こちらも少し気になったので試してみました。
以下のコードをご覧ください。
※Pythonのスライスという記法ですが、配列の0番目から4番目までの値を取得する、という意味になります。
test_string1 = '٢٠٢٢-٠٦'
test_string2 = '2022-06'
year1 = test_string1[0:4] # ٢٠٢٢
year2 = test_string2[0:4] # 2022
日本人的な感覚からすると、文字列を左から4個取得できそうに見えます。ただし、対象の文字列が右横書き文字の場合は、右横書き文字に対応した形で制御されていますね。日本人的な感覚だと文字列は左から順番に配列に格納、というのが常識のように思いがちですが、右横書き文字という新しい概念に触れることで新しい発見ができました。