R

ReflexKR

Reflex公式ドキュメント
目で見て覚える正規表現
12限目

時間 9:10–10:00

バックトラッカーの正体:総まとめとトークナイザー

CASE-012

グレップ・アンド・コー データ探偵事務所

依頼書

バックトラッカーの正体

依頼人

事務所の全員

証拠品

朝9時にメールボックスに届いた暗号文が1通。バックスラッシュと数字と英字だけでできている。

解決条件

暗号文をトークンに分けて解読し、バックトラッカーが誰なのかを明らかにする。

朝9時、事務所は人でいっぱいです。カン班長は戸口に立ち、チョン社長は手帳を開き、オ記者はノートパソコンを膝に載せています。所長がモニターを全員に見えるように向けます。「今日はみんなで読む。1文字ずつ」

メールボックスに届いた暗号文です。

\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$

オ記者が首を横に振ります。「これ、文字じゃなくてただの記号じゃないですか」。所長が答えます。「記号にもルールがある。ルールを1つずつ書き出せば、機械が代わりに読んでくれる」

長いテキストを意味のあるかけら(トークン)に分けるプログラムを、トークナイザーと呼びます。今日はこれまで学んだことを総動員してトークナイザーを作り、この暗号文を解きます。

12-1. トークナイザー:名前付きグループを | でつなぐ (12分)

まずは練習として、3.5*(2 + x) のような計算式を分けてみます。Python 公式ドキュメントの「Writing a Tokenizer」の例と同じ構造です。

import re

TOKEN_SPEC = [
    ("NUMBER",   r"\d+(?:\.\d*)?"),   # 整数または小数
    ("ID",       r"[A-Za-z_]\w*"),    # 名前
    ("OP",       r"[+\-*/]"),         # 演算子
    ("LPAR",     r"\("),
    ("RPAR",     r"\)"),
    ("SKIP",     r"[ \t]+"),          # 空白は捨てる
    ("MISMATCH", r"."),               # それ以外の文字:知らない文字
]
TOKEN = re.compile("|".join(f"(?P<{name}>{rx})" for name, rx in TOKEN_SPEC))

def tokenize(code):
    for m in TOKEN.finditer(code):
        kind, value = m.lastgroup, m.group()
        if kind == "SKIP":
            continue
        if kind == "MISMATCH":
            raise ValueError(f"모르는 글자 {value!r} (위치 {m.start()})")
        yield kind, value

print(list(tokenize("3.5*(2 + x)")))
# [('NUMBER', '3.5'), ('OP', '*'), ('LPAR', '('), ('NUMBER', '2'), ('OP', '+'), ('ID', 'x'), ('RPAR', ')')]

事務所のコードなので、エラーメッセージは韓国語のままです(모르는 글자 は「知らない文字」、위치 は「位置」)。

構造は3つだけです。

  • ルールごとに名前付きグループ (?P<名前>ルール) を作り、| でつなぎます。ルールをリストに書いておくので、読むのも直すのも簡単です。
  • finditer で前から1つずつ捕まえ、m.lastgroup(6限目)でどのルールが捕まえたかの名前を得ます。
  • 最後の MISMATCH は . 1つです。どのルールにも合わない文字を、ここで捕まえます。
list(tokenize("3.5 $ 2"))
# ValueError: 모르는 글자 '$' (위치 4)

MISMATCH がないとどうなるでしょうか。finditer はマッチしない文字を黙って読み飛ばします。暗号文に妙な文字が紛れ込んでいても、気づかずに通り過ぎてしまうのです。

NO_GUARD = re.compile(r"(?P<NUMBER>\d+)|(?P<OP>[+*])")
[m.group() for m in NO_GUARD.finditer("3 $ 2")]   # ['3', '2']

| は左から試すので、順番がそのまま優先順位になります。広いルールを前に置くと、狭いルールには出番が回ってきません。

BAD = re.compile(r"(?P<ID>\w+)|(?P<NUMBER>\d+)")
[m.lastgroup for m in BAD.finditer("x 3")]   # ['ID', 'ID']

証拠分析室

re.search(r"", text)

戻り値

グループごとに色が違います。今の NUMBER のルールは \d+ なので、3.5 が 3 と . と 5 に分かれてしまいます。\d+(?:\.\d*)? に直してみましょう。テキストに $ を入れると MISMATCH の色になります。

12-2. パターンの中の文字エスケープ (8分)

暗号文の \x46 や \uc11c のようなものを文字エスケープと呼びます。Python の文字列で使っていたエスケープのほとんどを、re はパターンの中でも直接理解します。だから raw 文字列 r"\t" で書いても、タブを探してくれます。

print(re.findall(r"\t|\n", "F-02\t03:02\n"))   # ['\t', '\n']
print(re.fullmatch(r"\\", "\\"))              # <re.Match object; span=(0, 1), match='\\'>
  • 制御文字:\t(タブ)\n(改行)\r \f \v \a(ベル)、そしてバックスラッシュそのものは \\。
  • 16進数:\x41(2桁)、\u00e9(4桁)、\U0001F600(8桁)。
  • 名前:\N{EM DASH} のように Unicode の文字名で。
print(re.findall(r"\x46-\x30\x32", "badge=F-02"))   # ['F-02']
print(re.fullmatch(r"caf\u00e9", "café"))           # <re.Match object; span=(0, 4), match='café'>
print(re.fullmatch(r"\N{EM DASH}", "—"))            # <re.Match object; span=(0, 1), match='—'>
print(len("\U0001F600"), "\U0001F600" == "\N{GRINNING FACE}")   # 1 True

8進数もあります。\0 で始まるか、3桁のときに8進数の文字になります(10限目で見たとおり、\10 はグループ番号です)。そして \b は、文字クラスの中でだけバックスペース文字になります。

print(re.findall(r"\101|\0", "A\0B"))    # ['A', '\x00']
print(re.findall(r"[\b]", "F\b-02"))     # ['\x08']

逆に、決められていない英字のエスケープはエラーです。将来新しい意味を割り当てるために空けてある場所だからです。

re.compile(r"\q")
# re.PatternError: bad escape \q at position 0

ドットのひとこと

ぼくは \b を見るたびに、しっぽが3回揺れちゃいます。正規表現の外(ただの "\b" という文字列)ではバックスペース、パターンでは単語の境界、なのにパターンの [ ] の中ではまたバックスペースなんですって。だからパターンはいつも r"..." で書いて、[\b] は本当にバックスペースが必要なときだけ使うんです。

証拠分析室

re.search(r"", text)

戻り値

\x46-\x30\x32 は F-02 と同じパターンです。- を \N{HYPHEN-MINUS} に、\x46 を F に変えてみて、\N{EM DASH} で長いダッシュも探してみましょう。

12-3. パターンのエラーを扱う:re.error (8分)

分析室に赤いエラー欄が出るときのメッセージは、re.error という例外から来ています。3.13 からは re.PatternError という名前もありますが、この2つは同じ例外です。例外オブジェクトには、何が間違っていて、どこで間違えたのかが入っています。

print(re.PatternError is re.error)   # True

try:
    re.compile(r"[a-Z]")
except re.error as e:
    print(e.msg)       # bad character range a-Z
    print(e.pattern)   # [a-Z]
    print(e.pos)       # 1

パターンが複数行なら、lineno(何行目)と colno(何文字目)が役に立ちます。位置は1から数えます。

user_pattern = r'''(?x)
    (?P<badge> F-\d+ )
    (?P<time \d\d:\d\d )
'''
try:
    re.compile(user_pattern)
except re.PatternError as e:
    print(e.msg)                  # missing >, unterminated name
    print(e.pos, e.lineno, e.colno)   # 36 3 9

人が入力したパターンは、いつ間違っていてもおかしくありません。検索窓のようにパターンを受け取るプログラムでは、必ず try/except で囲みます。

def safe_compile(text):
    try:
        return re.compile(text)
    except re.error as e:
        print(f"패턴 오류: {e.msg} (위치 {e.pos})")
        return None

safe_compile(r"(?P<SIGN>(a+)+$")   # 패턴 오류: missing ), unterminated subpattern (위치 0)

(패턴 오류 は「パターンエラー」です。)

ただし、try/except が防いでくれるのは文法エラーだけです。(a+)+$ のように文法は正しいのに遅いパターンは、例外も出さずに止まってしまいます(8限目)。だから証拠分析室は、パターンを切り離された作業員に実行させ、時間を超えたらその作業員を取り替えているのです。

証拠分析室

re.search(r"", text)

戻り値

誰かが10限目の日付パターンを写すときにミスをしました。エラーメッセージと ^ の印を見て、直してみましょう。

12-4. bytes パターン (7分)

ファイルやネットワークから読んだデータは、str ではなく bytes のことがあります。bytes には rb"..." のように bytes パターンを使います。str と bytes は混ぜられません。

data = "badge=F-02 서진".encode()
print(re.findall(rb"F-\d+", data))   # [b'F-02']

re.findall(r"F-\d+", data)
# TypeError: cannot use a string pattern on a bytes-like object
re.findall(rb"F-\d+", "badge=F-02")
# TypeError: cannot use a bytes pattern on a string-like object

bytes パターンの \w \d \s は ASCII だけを見ます。ハングルは複数のバイトに分かれているので、文字として見えません。ハングルを扱うなら、まず decode() で str にします。

print(re.findall(rb"\w+", data))            # [b'badge', b'F', b'02']
print(re.findall(r"\w+", data.decode()))    # ['badge', 'F', '02', '서진']

フラグも逆になります。re.L は bytes パターンでしか使えず(11限目)、re.U は bytes パターンに付けるとエラーです。

print(re.compile(rb"\w", re.L).flags)   # 4
re.compile(rb"\w", re.U)
# ValueError: cannot use UNICODE flag with a bytes pattern

12-5. 警告と隠れた道具 (5分)

2限目で、文字クラスの中の [[、--、&&、~~、|| は避けるように言いました。将来「集合演算」に使われる予定の場所なので、今は FutureWarning が出ます。文字そのものとして使いたいならエスケープします。

re.compile(r"[[a-z]")
# FutureWarning: Possible nested set at position 1
re.compile(r"[a-z--]")
# FutureWarning: Possible set difference at position 4
re.compile(r"[\[a-z]")   # 警告なし

re モジュールには、公式ドキュメントに載っていない内部の道具もあります。re.Scanner(トークナイザーを代わりに作ってくれるクラス)、Pattern.scanner()、Match.regs(すべてのグループの span)などです。存在だけ知っておいてください。ドキュメントにないものはいつ変わってもおかしくないので、実際のコードには今日作った finditer + lastgroup の方式を使います。

m = re.search(r"(F)-(\d+)", "badge=F-02")
print(m.regs)   # ((6, 10), (6, 7), (8, 10))

事件解決 (10分)

いよいよ暗号文のルールを書き出します。文字エスケープ4種類、署名、空白、そしてすき間をふさぐ MISMATCH です。11限目で学んだとおり、re.X で書き下してコメントを付けます。

import unicodedata

CIPHER = re.compile(r'''
    (?P<HEX>   \\x [0-9a-fA-F]{2} )    # \x46    16進数2桁
  | (?P<UNI>   \\u [0-9a-fA-F]{4} )    # \uc11c  16進数4桁
  | (?P<NAME>  \\N \{ [A-Z -]+ \} )    # \N{...} 文字名
  | (?P<OCT>   \\ [0-7]{3} )           # \060    8進数3桁
  | (?P<SIGN>  \(a\+\)\+\$ )           # 署名。re.escape("(a+)+$") と同じ
  | (?P<SPACE> [ \n]+ )                # 空白と改行はそのまま
  | (?P<MISMATCH> . )                  # それ以外の文字はすべて怪しい
''', re.X)

def decode(message):
    out = []
    for m in CIPHER.finditer(message):
        kind, value = m.lastgroup, m.group()
        if kind in ("HEX", "UNI"):
            out.append(chr(int(value[2:], 16)))
        elif kind == "OCT":
            out.append(chr(int(value[1:], 8)))
        elif kind == "NAME":
            out.append(unicodedata.lookup(value[3:-1]))
        elif kind == "SPACE":
            out.append(value)
        elif kind == "MISMATCH":
            raise ValueError(f"모르는 토큰 {value!r} (위치 {m.start()})")
    return "".join(out).strip()

print(decode(message))
# F-02 = 서진
# 4F 03:02

re.X の中でも、文字クラス [A-Z -] と [ \n] の空白は残ることを思い出してください。署名 SIGN は何も出力せずに捨てます(모르는 토큰 は「知らないトークン」)。

証拠分析室

re.search(r"", text)

戻り値

暗号文トークナイザーの1行版です。今は \N{...} と8進数の \060 を知らないので、MISMATCH の色になります。(?P<NAME>\\N\{[A-Z -]+\}) と (?P<OCT>\\[0-7]{3}) を MISMATCH の前に入れてみましょう。

解読した文字が合っているかは、re に直接聞いてみることもできます。暗号文の1行目は、それ自体が正しい正規表現です。re も同じエスケープを理解するからです。

first = message.splitlines()[0]
print(re.fullmatch(first, "F-02 = 서진"))   # <re.Match object; span=(0, 9), match='F-02 = 서진'>

F-02 = 서진。서진 はハングルで「ソ・ジン」と読みます。4階、午前3時02分。11限目の入退室記録とぴったり同じ時刻です。

カン班長が尋ねます。「ソ・ジンってのは誰です?」。所長は引き出しから、伏せてあった写真立てを取り出し、机の上に立てます。若い2人が、この事務所の看板の下に立っている写真です。

「グレップ・アンド・コーを私と一緒に立ち上げた人。F-02 はあの人の入館証だよ」。所長は写真の中の男性を指先でなぞります。「天才だった。何でも1行の正規表現で書いてしまう。誰もそのコードを読めなかったし、誰も直せなかった。だから私たちが1つずつ書き下していったんだけど……あの人は、それが自分を消すことだと思ったんだろうね。ある日、入館証だけを置いて出ていった」

「入館証はどうして無効にしなかったんです?」。所長は答える代わりに、画面の最後の行 (a+)+$ を見つめます。「読めない正規表現こそ最高だと言っていた人が、自分の名前を読めるように送ってきた。最初から、誰かに読んでほしかったんだよ」

事件解決報告書 — バックトラッカーの正体は、グレップ・アンド・コーの創立メンバー、ソ・ジン。廃棄されていなかった入館証 F-02 で事務所に出入りし、挑戦状と手紙を残していた。彼が残した正規表現はすべて書き下してコメントを付け、事件手帳に貼った。入館証 F-02 は停止した。

12の事件を読み返す

その日の午後、所長と新人探偵は12の事件ファイルを最初からめくり直します。「今ならどう書くか」

  • CASE-001 決済記録:手でバックスラッシュを付ける代わりに re.escape("$4.99")。
  • CASE-002・003 ナンバープレートと電話番号:\d{2,3}[가-힣]\d{4} のように量指定子で短くし、検証は fullmatch で。
  • CASE-004 偽の会員登録:search ではなく re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id)。
  • CASE-005・010 日付:(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)。名前があるので、番号を数えなくていい。
  • CASE-006 アクセスログ:名前付きグループと groupdict() で1行を dict に。
  • CASE-007 顧客名簿:関数による置換、そして count= のようなキーワード引数。
  • CASE-008 バックトラッカーの挑戦状:^(a|aa)+$ の代わりに ^a+$。重なる繰り返しを作らない。
  • CASE-009 金庫のルール:先読み1つに条件1つ、そして条件ごとにコメント1行。
  • CASE-011 1行の正規表現:re.X で書き下し、re.compile で名前を付ける。
  • CASE-012 暗号文:ルールをリストに書いたトークナイザー、最後は MISMATCH。

夜遅く、メールボックスにもう1通届きます。今度は暗号ではありません。

ハン・ビョル。最後まで読んでくれてありがとう。あのころ、誰もおれのコードを読めなかったんじゃない。おれが読めるように書かなかったんだ。次はコメントを付けるよ。

(?x) a+ $ # もう引き返さない

署名が変わっていました。新人探偵は癖のように、その署名を分析室に入れてみます。外側の繰り返しが消えたパターンは、失敗するときもためらいません。

sign = r"(?x) a+ $   # もう引き返さない"
print(re.fullmatch(sign, "a" * 30 + "b"))   # None

所長が古い手帳を新人探偵の机に置きます。最初のページから、事件ごとに所長の字でびっしり埋まっています。「これからは、きみのものだ。短い正規表現が勝つんじゃない。読める正規表現が勝つ。それだけは忘れないで」

練習問題

  1. 12-1 のトークナイザーに = を ASSIGN として、改行を NEWLINE として追加し、x = 3.5 の次の行に y = x * 2 がある2行のコードをトークンに分けてみましょう。
  2. 暗号文の解読器に、\U の後ろに16進数8桁が続くトークンを追加し、\U0000C11C\U0000C9C4 を解読してみましょう。
  3. ユーザーからパターンを受け取り、エラーが出たらパターンの下の e.pos の位置に ^ を付けて見せる関数を作ってみましょう。証拠分析室がしていることと同じです。

所長の手帳

  • トークナイザー:(?P<名前>ルール) を | でつなぎ、finditer + m.lastgroup。順番がそのまま優先順位、最後は MISMATCH。

  • パターンの中のエスケープ:\t \n \r \f \v \a \\、\x41 \u00e9 \U0001F600 \N{EM DASH}、8進数 \0 \101。[\b] はバックスペース。

  • 決められていない英字のエスケープ(\q)はエラー。ユーザーのパターンは try/except re.error、e.msg e.pattern e.pos e.lineno e.colno。

  • bytes には rb パターン。str と混ぜると TypeError。bytes の \w は ASCII、re.L は bytes 専用、re.U は bytes ではエラー。

  • 文字クラスの中の [[ -- && ~~ || は FutureWarning。re.Scanner、Pattern.scanner、Match.regs はドキュメントにない内部の道具。

  • 短い正規表現より、読める正規表現が勝つ。

← 前へ

11限目 · 読めない正規表現:フラグとコンパイル

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex