7.3 正規表現

(1/1)
シマフクロウのイラスト
Python で、セキュリティ対策一覧表にある「悪意のあるスクリプト」「想定外の値」の対策をするために、文字列のバリデーションを行う。UNIX/Linuxに昔から備わっており、現在は多くのプログラミング言語で利用できる正規表現という仕組みを使うことで、さまざまなバリデーションを組むことができる。
今回は、正規表現を使った文字列バリデーションについて学ぶ。

目次

サンプル・プログラム

自然数かどうかを判定する

4.5 例外処理」で作った validateNumber関数を使えば、文字列が自然数かどうかを判定できる。
ここでは、復習の意味を兼ねて、intへの変換はせずに、純粋に判定だけをプログラムにすることを考える。
def isNaturalNumber1(numStr):
	"""自然数かどうかを判定する:int関数,if文を利用

	Args:
		numStr(str): 自然数(文字列)
	
	Returns:
		bool: 自然数か否か
	"""
	# 整数変換を試みる.
	try:
		num = int(numStr)

	# 失敗したらFalseを返す.
	except ValueError as e:
		return False

	# 成功したら...
	else:
		# 0以下の場合はFalseを返す.
		if (num <= 0):
			return False
		# それ以外の場合はTrueを返す.
		else:
			return True
まず、int関数を使って整数変換を試みる。ValueError例外が発生したら引数には整数以外の文字が混ざっていると考え、Falseを返す。
変換に成功したら、intの値が0以下ならFalseを、そうでないならTrueを返す。

この関数 isNaturalNumber1 は正しく動作するが‥‥。
def isNaturalNumber2(numStr):
	"""自然数かどうかを判定する:正規表現を使う

	Args:
		numStr(str): 自然数(文字列)
	
	Returns:
		bool: 自然数か否か
	"""
	# 正規表現パターン
	pat = re.compile(r"^[1-9][0-9]*$")
	return not pat.search(numStr) is None
ユーザー定義関数 isNaturalNumber2isNaturalNumber1 とまったく動きをするのだが、こちらはたった2行で実現している。どんな手を使ったか――。

UNIX/Linuxには、昔から正規表現と呼ばれる文字列マッチングの仕組みが備わっている。Windowsでファイル検索するときにワイルドカード *, ? を使うが、これより細かい検索指定ができる表現方法である。
Python でも文字列に対して正規表現を使うことができ、まず、標準モジュール re をimportする。
ここで使う正規表現は r"^[1-9][0-9[*$" である。これは、冒頭1文字は1から9の間の数字で(^[1-9])、2文字目以降末尾までは0から9の間の数字 [0-9[*$ にマッチするという意味になる。要するに、「1以上の整数」と同じパターンになる。

Python で、正規表現文字列の頭に r を付与し、これを使えるようにするために re.compile する。戻り値の正規表現オブジェクトは、このあとの文字列のマッチングに使うので、変数 pat に代入する。
正規表現オブジェクトの searchメソッド は、引数で指定する文字列の中でマッチする部分文字列を返す。マッチしなければ None を返す。ここでは、マッチしなければ Falseを、そうでなければ Trueを返したいので、not演算子を使っている。

カタカナかどうかを判定する

正規表現が威力を発揮するのは、標準関数やモジュールに用意されていないような文字列の種類判定をしたいときだ。
たとえば、入力文字をカタカナかどうかを判定するプログラム "isKatakana.py" を実行してみてほしい。
def isKatakana1(kana):
	"""カタカナかどうかを判定する:list関数,for文を利用

	Args:
		kana(str): 文字列
	
	Returns:
		bool: カタカナか否か
	"""
	# カタカナ一覧
	katakana = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワンガギグゲゴザジズゼゾダヂヅデドバビブベボパピプペポァィゥェォャュョヮヰヱヲヵヶヴヷヸヹヹヺー"

	# 1文字ずつ比較する
	for ch in list(kana):
		if (not ch in katakana):	return False
	return True
ユーザー定義関数 isKatakana1 は正規表現を使わず、for文を使って1文字ずつ比較するものだ。
まず、カタカナ一覧を変数 katakana に代入する。
引数で渡された文字列 kanalist関数に渡すと、1文字ずつを要素に分解したリストを返す(このあたりは Python の便利なところだ)。これを利用し、1文字ずつ、変数 katakana に存在するかどうかを in演算子を使って調べる。
def isKatakana2(kana):
	"""カタカナかどうかを判定する:正規表現を利用

	Args:
		kana(str): 文字列
	
	Returns:
		bool: カタカナか否か
	"""
	# 正規表現パターン
	pat = re.compile(r"^[ァ-ヺー]+$")
	return not pat.search(kana) is None
ユーザー定義関数 isKatakana2 は正規表現を使うもので、カタカナ一覧もfor文による繰り返し制御もなく、とてもシンプルな関数になっている。
ここで使った正規表現は r"^[ァ-ヺー]$" である。文字列の冒頭から末尾の間にある文字は「ァ」から「ヺ」の間にある文字、および長音記号「ー」であればマッチするという意味だ。

ここで、Wikipediaの「片仮名 (Unicodeのブロック)」をご覧いただきたい。
Python は内部的にUTF-8で文字列を処理していると書いたが、これは正規表現でも同じで、「ァ」から「ヺ」の間にある文字というのは、文字コード U+30A1 から U+30FA の間にある文字を指す。また、長音記号「ー」は文字コード U+30FC である。

文字列のバリデーション

ファイル入出力の話の途中で正規表現の話題を出したのは、「7.1 セキュリティ対策」の一覧表No.2「悪意のあるスクリプト」、No.4「想定外の値」の対策をするためだ。
def validateString(text, reList, exclusion=True, min=None, max=None, label=""):
	"""文字列のバリデーションを行う.

	Args:
		text(str): 文字列
		reList(list): 正規表現リスト
		exclusion(bool, optional): True 正規表現リストにマッチしたら受容,
									False マッチしたら否認(省略時はTrue)
		min(int, optional): 文字列の最小長(省略時はNone)
		max(int, optional): 文字列の最大長(省略時はNone)
		label(str, optional): データ名称(省略時は"")
	
	Returns:
		str: 成功した場合は引数と同じ文字列

	Raises:
		ValueError: 否認またはmin/maxの範囲外

	"""
	# 正規表現によるマッチング
	for reStr in reList:
		pat = re.compile(reStr)
		if exclusion:
			if (pat.search(text) is None):
				err = text if not label else label
				raise ValueError(f"{err} は否認されました")
	
	# 文字列長のチェック
	if ((min != None) and (max != None) and (min > max)):
		raise ValueError(f"最小値 {min} と最大値 {max} が逆です")
	if ((min != None) and (len(text) < min)):
		raise ValueError(f"{label} は最小長 {min} より短い")
	if ((max != None) and (len(text) > max)):
		raise ValueError(f"{label} は最大長 {max} より長い")

	return text
4.5 例外処理」でつくったバリデーションチェック用のモジュール "pahooValidate.py" に、文字列のバリデーションを行う関数 validateString を追加した。
第1引数にバリデーション対象となる文字列を、第2引数に正規表現パターンを収めたリストを指定することで、正規表現は1つ以上指定できるようにした。第3引数 exclusion は、Trueを指定すると正規表現リストのすべてにマッチしたときに成功を、Falseを指定すると正規表現リストのいずれかにマッチすると否認を返す。省略時はTrueだ。第4引数に文字列の最小長、第5引数に最大長を入力する。最小長や最大長は省略可能で、省略時には文字列長のチェックは行わない。最後の引数はデータのラベルで、省略可能。
関数の動きは、前述のユーザー定義関数 isKatakana1 とほぼ同じで、リストに対して適用することから for文を使っている。
文字列長は、組み込みの len関数を使う。
# メイン・プログラム =======================================================
# 初期値
STR_LENGTH_MIN = 3		# 文字列長の最小値
STR_LENGTH_MAX = 10		# 文字列長の最大値

# 日本語文字の正規表現
RE_JAPANESE = [r"^[\u3005\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF01-\uFF5E]+$"]

# キーボードから入力する.
text = input("文字列=")

# 入力バリデーションを行う.
try:
	pv.validateString(text, RE_JAPANESE, True, STR_LENGTH_MIN, STR_LENGTH_MAX, "入力文字列")

# 否認したらエラーメッセージを表示してプログラムを終了する.
except ValueError as e:
	pv.dispErrorMessageAndExit(str(e))

# 成功したら文字列を表示する.
print(text)
プログラム "isJapanese.py" を実行してみてほしい。入力文字が日本語文字でなければ否認のメッセージを表示する。
日本語文字の正規表現は変数 RE_JAPANESE に代入した。文字化けがあるといけないので、r"^[ァ-ヺ]$" のように直接文字を指定する形式ではなく、Unicodeのコード番号で指定した。
\u3040-\u309F は全角ひらがな、\u30A0-\u30FF は全角カタカナ、\u4E00-\u9FFF は漢字、\uFF01-\uFF5E は全角英数字・記号にマッチする。

文字列バリデーション関数 validateString を使えば、たとえば氏名の読み仮名入力を全角カタカナに制限したり、また、"" や "SELECT *~" のようなJavaScriptを動かしたり、データベース操作する可能性があるインジェクション文字列を排除することができる。

CSVファイルを読み込む時にバリデーション実施

7.2 ファイル入力」で、CSVファイルの成績表を読み込んで集計・表示するプログラム "testScore7.py" を作ったが、もしCSVファイルが壊れていたり、不正に改ざんされていると、想定外の表示を恐れがある。
このプログラムは集計・表示するだけなので、システムに悪影響を与えるような結果にはならないが、「7.1 セキュリティ対策」で学んだように、信頼境界を行き来するファイルにはバリデーションを実施するに越したことはない。
そこで、CSVファイルから読み込んだ1項目ずつバリデーションを実施するプログラムが  "testScore7.py" である。読み込むCSVファイルは "testScore.csv" を同じフォルダに配置してほしい。カレントディレクトリをこのフォルダにしたら、"testScore6.py" を実行してみてほしい。
import os
import csv
import pahooValidate as pv

def validNaturalNumber(numStr):
	"""数字(文字列)を自然数に変換する.バリデーション付き.

	Args:
		numStr(str): 数字(文字列)
	
	Returns:
		int: 変換した自然数
	
	Raises:
		ValueError: 自然数ではない
	"""
	return "" if not numStr else pv.validateNumber(numStr, "int", 1)

def validInteger(numStr):
	"""数字(文字列)を整数に変換する.バリデーション付き.

	Args:
		numStr(str): 数字(文字列)
	
	Returns:
		int: 変換した整数
	
	Raises:
		ValueError: 整数ではない
	"""
	return "" if not numStr else pv.validateNumber(numStr, "int")

def validJapanese(text):
	"""日本語バリデーション.OKならそのまま返す.

	Args:
		text(str): 文字列
	
	Returns:
		str: 文字列
	
	Raises:
		ValueError: 日本語ではない
	"""
	# 日本語文字の正規表現
	RE_JAPANESE = [r"^[\u3005\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF01-\uFF5E]+$"]
	return pv.validateString(text, RE_JAPANESE, True)

# メイン・プログラム =======================================================
# 初期値
# 読み込み可能なファイルの最大サイズ
MAX_FILESIZE = 10 * 1024 * 1024

# CSVファイルのエンコード = シフトJIS
csvEncoding = "shift_jis"

# 成績表:CSVファイル名
csvFileName = "./testScore.csv"

# 入力データのバリデーション・リスト:1つの要素が1つのカラムに対応
validaterList = [validNaturalNumber, validJapanese, validJapanese, validInteger, validInteger, validInteger, validInteger]

try:
	# ファイル最大サイズをチェックする
	if (os.path.getsize(csvFileName) > MAX_FILESIZE):
		raise OSError(f"ファイルサイズが{MAX_FILESIZE}バイトを超えています.")
	# ファイルを読み込む
	else:

正規表現の応用

正規表現は、バリデーションだけでなく、ファイル検索やHTMLコンテンツのスクレイピング、テキストの置換や校正など、さまざまなシーンに応用が効く。その分、パターンの表記方法も多様であるので、今後、正規表現を使うシーン毎に解説していくことにする。
興味をお持ちの方は、当サイトの記事「PHPで正規表現」をご覧いただきたい。プログラミング言語は異なるが、正規表現のパターンは Python とほぼ同じである。正規表現は一度覚えてしまえば、他のプログラミング言語やシェルにも適用できるというメリットがある。

練習問題

次回予告

次回はPython を使って、出力バリデーション例外処理に留意しつつ、テキストをファイルに出力するプログラムや一時ファイルの作り方を学ぶ。応用として、小数点以下1万桁の円周率を計算し、その結果をファイルに出力するプログラムを作る。

コラム:正規表現の歴史

スティーヴン・コール・クリーネ
スティーヴン・コール・クリーネ
正規表現の歴史は古く、アメリカの数学者スティーヴン・コール・クリーネがが1950年代に発明した。クリーネは帰納的関数論の創始者で、計算機科学にも大きな影響を与えた。帰納的関数論とは、プログラミングで言えば再帰呼び出しや関数型プログラミングに対応する。こうした背景から、正規表現は、if文やfor文といった制御文を使わずに、あらゆる記号(文字)パターンを表現できるようになっている。

オリジナルUNIX開発者メンバーのケン・トンプソンが、1070年代初頭にUNIXのエディタ ed のテキスト検索に正規表現を利用できるようにしたことから、UNIX系ツールに正規表現が普及した。
1980年代後半、カナダのプログラマヘンリー・スペンサーが、UNIXの正規表現ライブラリ regx の代替ライブラリを作り、フリーソフトとして提供した。Perlがこのライブラリを利用するようになり、Windowsプログラマも正規表現を利用するようになる。

その後、Perl 5 互換の正規表現をC言語で実装したライブラリ PCREPerl Compatible Regular Expressions)がオープンソースとして流通し、ApacheやPHPなど新しいアプリは、これを利用するようになる。Pythonも PCRE を利用している。

本文でも紹介したように、プログラムでテキスト検索するとき、組み込みの文字検索関数を使うとf文やfor文といった制御文を併用せざるを得ないが、正規表現を活用するとその必要がなくなる。
正規表現を使わなくてもプログラムを書くことはできるが、関数型プログラミングを目指すのであれば、正規表現の習得は欠かせない
(この項おわり)
header