コンテンツにスキップ

Rustの正規表現(regex)

正規表現(regular expression、略して regex)は、テキストのための小さなパターン言語です。「cat という単語を探して」と言う代わりに、「数字4つ、ダッシュ、数字2つ、ダッシュ、数字2つを探して」と言えば、2024-05-17 のような日付を一度にすべて見つけられます。

Rust の標準ライブラリには、contains、split、find のような便利な文字列メソッドがあります(文字列を見てください)。決まったテキストでは足りず、テキストの形で探したいときは、regex(英語)クレートを使いましょう。Rust プロジェクトがメンテナンスしていて、とても速いクレートです。

regex を追加して、最初のマッチ

Section titled “regex を追加して、最初のマッチ”

プロジェクトのフォルダで、次を実行します。

Terminal window
cargo add regex

すると、Cargo.toml の [dependencies] に regex = "1" が追加されます。(クレートが初めてなら、Cargoとクレートを見てください。)「Try it」ボタンは Rust Playground を開きます。Playground には最初から regex が入っています。

use regex::Regex;
fn main() {
let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("2024-05-17 にリリースされました。"));
println!("{}", re.is_match("去年の春にリリースされました。"));
}

実行結果:

true
false
▶ 実際に試してみよう

\d は「任意の数字1つ」、{4} は「直前のものをちょうど4つ」という意味です。is_match が答えるのは1つの質問だけです。テキストのどこかにマッチする部分はあるか?

パターンの前にある r に注目してください:r"\d{4}"。これは生文字列(raw string)です。ふつうの Rust の文字列では、\ は \n(改行)のようなエスケープの始まりになります。正規表現のパターンはバックスラッシュだらけなので、生文字列を使えば "\\d{4}" と書かずに済みます。パターンには必ず生文字列を使いましょう。

パターンは小さなプログラムなので、閉じ忘れたかっこのような間違いが入っていることがあります。Regex::new はパターンをチェックして Result を返します。正しければ Ok(Regex)、間違っていればわかりやすいメッセージ付きの Err です。

use regex::Regex;
fn main() {
match Regex::new(r"(\d+") {
Ok(_) => println!("正しいパターン"),
Err(e) => println!("不正なパターン:\n{e}"),
}
}

実行結果:

不正なパターン:
regex parse error:
(\d+
^
error: unclosed group
▶ 実際に試してみよう

error: unclosed group は「グループ(かっこ)が閉じられていない」という意味です。パターンをコードの中に書いていて、正しいとわかっているなら、.unwrap() で問題ありません。間違っていれば、最初にプログラムを実行したときに気づけます。パターンがユーザーから来る場合(たとえば検索ボックス)は、クラッシュさせずに Err を処理しましょう。

find は最初のマッチを Option で返します。find_iter はすべてのマッチをたどるイテレータを返します。

use regex::Regex;
fn main() {
let re = Regex::new(r"\d+").unwrap();
let text = "3 apples, 12 pears and 7 plums";
if let Some(m) = re.find(text) {
println!("最初の数: {} (バイト位置 {}..{})", m.as_str(), m.start(), m.end());
}
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("すべての数: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum();
println!("果物の合計: {total}");
}

実行結果:

最初の数: 3 (バイト位置 0..1)
すべての数: ["3", "12", "7"]
果物の合計: 22
▶ 実際に試してみよう

\d+ は「1つ以上の数字」という意味なので、12 は2つではなく1つのマッチとして見つかります。それぞれのマッチは、自分のテキスト(as_str())と位置(start() と end()、単位はバイト)を知っています。調べる文字列 text が英語のままなのは、位置がバイト単位で表示されるからです。日本語の文字は UTF-8 で1文字3バイトなので、数字が変わってしまいます。

キャプチャグループと名前付きグループ

Section titled “キャプチャグループと名前付きグループ”

マッチ全体ではなく、その一部分が欲しいこともよくあります。パターンの一部をかっこで囲むと、その部分をキャプチャできます。(?<name>...) で名前を付けると、コードが読みやすくなります。

use regex::Regex;
fn main() {
let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
let text = "バックアップは 2024-05-17 と 2024-06-02 に実行されました。";
if let Some(caps) = re.captures(text) {
println!("マッチ全体: {}", &caps[0]);
println!("年: {}", &caps["year"]);
println!("月: {}", &caps[2]);
println!("日: {}", caps.name("day").unwrap().as_str());
}
for caps in re.captures_iter(text) {
println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]);
}
}

実行結果:

マッチ全体: 2024-05-17
年: 2024
月: 05
日: 17
17/05/2024
02/06/2024
▶ 実際に試してみよう

グループを読む方法は3つあります。

  • caps[0] は、いつもマッチ全体です。グループには左から順に1から番号が付くので、caps[2] は月です。
  • caps["year"] は、グループを名前で読みます。[...] でのアクセスは、そのグループがマッチに参加していなかった場合にパニックします。
  • caps.name("day") は Option を返します。グループが省略可能なときは、こちらが安全です。

captures は最初のマッチを見つけます。captures_iter は、find_iter と同じように、すべてのマッチを順にたどります。

replace は最初のマッチを、replace_all はすべてのマッチを置き換えます。置換後のテキストの中では、$name(または $1)で、グループがキャプチャした内容を差し込めます。

use regex::Regex;
fn main() {
let spaces = Regex::new(r"\s+").unwrap();
println!("{}", spaces.replace_all("too many spaces", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap();
let text = "2024-05-17 から 2024-06-02 まで";
println!("{}", date.replace(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$m_$y"));
println!("{}", date.replace_all(text, "${m}_${y}"));
}

実行結果:

too many spaces
17/05/2024 から 2024-06-02 まで
17/05/2024 から 02/06/2024 まで
2024 から 2024 まで
05_2024 から 06_2024 まで
▶ 実際に試してみよう

4行目を見てください。05_2024 ではなく 2024 から 2024 まで と表示されています。なぜでしょうか? グループ名には _ を含められるので、$m_ は m_ という名前のグループだと解釈されたのです。そんなグループはないので、空のテキストになりました。名前を ${m}_ のように波かっこで囲めば、どこで終わるかをはっきり伝えられます。迷ったら波かっこを使いましょう。

\s+ は「1つ以上の空白文字」という意味です。これを1つのスペースに置き換えるのは、ごちゃごちゃした入力を整える定番の方法です。

split は、パターンにマッチした場所で文字列を切り分けます。str::split と似ていますが、区切りにパターンを使えます。

use regex::Regex;
fn main() {
let sep = Regex::new(r"\s*[,;]\s*").unwrap();
let parts: Vec<&str> = sep.split("apple, banana;cherry , date").collect();
println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect();
println!("{:?}", two);
}

実行結果:

["apple", "banana", "cherry", "date"]
["a", "b, c, d"]
▶ 実際に試してみよう

[,;] は「カンマかセミコロン」という意味で、両側の \s* が前後のスペースを飲み込みます。splitn(text, 2) は、2つに分けたところで止まります。

コンパイルは一度だけ、ループの中ではしない

Section titled “コンパイルは一度だけ、ループの中ではしない”

時間がかかるのは Regex を作る部分です。クレートがパターンを効率のよいマッチャーに変換するからです。使うほうは速いです。なので、ループの中や、何度も実行される関数の中で Regex::new を呼んではいけません。一度だけ作って、使い回しましょう。

プログラムのあちこちで使うパターンは、std::sync::LazyLock を使って static に入れましょう。最初に使ったときに作られ、その後は共有されます。

use regex::Regex;
use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()
});
fn is_email(s: &str) -> bool {
EMAIL.is_match(s)
}
fn main() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

実行結果:

not-an-email -> false
x@y -> false
▶ 実際に試してみよう

このパターンに出てくる新しい部品です。

  • (?i) は大文字と小文字を区別しないマッチングをオンにします。なので [a-z] は B や M にもマッチします。
  • ^ と $ は「テキストの先頭」と「テキストの末尾」という意味です。これがないと、"hello [email protected]!!" にも is_match は「はい」と答えます。メールアドレスがその中のどこかに含まれているからです。
  • \. は本物のドットです。ただの . は「任意の1文字」という意味になります。

これは「メールアドレスっぽいか」のチェックで、入力ミスを見つけるのに向いています。正規のメールアドレスをすべて正しく判定する正規表現を書くのは難しいことで有名で、本当に確かめるにはそのアドレスにメールを送ってみるしかありません。

例:ログの行からデータを取り出す

Section titled “例:ログの行からデータを取り出す”

ここまでのことを組み合わせてみましょう。ログを読み、形式に合わない行は飛ばして、ERROR の行ごとに日付、時刻、メッセージ中のメールアドレスを表示します。

use regex::Regex;
use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()
});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()
});
fn main() {
let log = "\
2024-05-17 09:12:03 INFO user [email protected] logged in
2024-05-17 09:15:44 ERROR payment failed for [email protected]
this line is garbage
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email [email protected] or [email protected]";
let mut errors = 0;
for line in log.lines() {
let Some(caps) = LOG_LINE.captures(line) else {
println!("スキップ: {line:?}");
continue;
};
if &caps["level"] == "ERROR" {
errors += 1;
let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect();
println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails);
}
}
println!("エラーは {errors} 件ありました");
}

実行結果:

2024-05-17 09:15:44 -> ["[email protected]"]
スキップ: "this line is garbage"
2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]
エラーは 2 件ありました
▶ 実際に試してみよう

いくつか補足です。

  • log の最初にある "\ は Rust の小技です。文字列の中で行末にバックスラッシュを置くと改行が飛ばされるので、ログの1行目がすぐに始まります。
  • let Some(caps) = ... else { ... } は let-else です。行がマッチしなければ、その行を表示して continue で次の行に進みます。
  • (?<level>[A-Z]+) + はレベルと、その後の1つ以上のスペースにマッチします。INFO と WARN は ERROR と桁をそろえるために、スペースが1つ余分に入っているからです。
  • 実際のプログラムでは、ログをファイルから1行ずつ読むことになるでしょう。ファイル入出力を見てください。

regex が(わざと)できないこと

Section titled “regex が(わざと)できないこと”

JavaScript、Python、Perl で正規表現を使ったことがあれば、先読み・後読み(look-around:(?=...)、(?<=...))や後方参照(backreference:\1、「グループ1と同じテキスト」)を使いたくなるかもしれません。regex クレートはこれらに対応しておらず、そのことをエラーで教えてくれます。

regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported

「先読み・後読みを含む look-around には対応していない」という意味です。これは意図的なトレードオフです。こうした機能を使うと、パターンによっては特定の入力に対して指数関数的な時間がかかることがあります。短いパターンと十分に長い文字列だけで、プログラムが固まってしまうのです(これは破滅的バックトラッキングと呼ばれ、実際に Web サイトを落としたこともあります)。こうした機能を持たないことで、regex クレートは、マッチングにかかる時間がテキストの長さに比例して(パターンの大きさをかけた分だけ)しか増えないことを保証できます。テキストが2倍になれば、仕事もおよそ2倍。爆発的に増えることはありません。だから、パターンやテキストがユーザーから来る場合でも安全です。

たいていは、別の方法で同じ結果が得られます。キャプチャグループを使って欲しい部分だけを読むか、ふつうの Rust のコードでもう1つかんたんなチェックをしましょう。どうしてもこれらの機能が必要なら、fancy-regex(英語)クレートが追加してくれます。ただし、時間の保証はなくなります。

パターン マッチするもの
. 改行以外の任意の1文字
\d \w \s 数字、「単語」の文字(英字、数字、_)、空白
\D \W \S その逆:数字以外、単語の文字以外、空白以外
[abc] [a-z] [^0-9] a、b、c のどれか1つ;任意の英小文字;数字以外の任意の文字
* + ? 直前の要素の0回以上、1回以上、0回か1回
{3} {2,5} {2,} ちょうど3回、2〜5回、2回以上
^ $ テキストの先頭と末尾
\b 単語の境界(単語の文字と、単語以外の文字の間)
(...) (?<name>...) 番号付きグループ、名前付きグループ
(?i) ここから先は大文字と小文字を区別しない

もう1つ。縦棒は「または」という意味です。たとえば cat|dog はどちらの単語にもマッチし、gr(a|e)y は gray と grey にマッチします。特別な意味を持つ文字をそのままマッチさせたいときは、前にバックスラッシュを付けます:\.、\(、\$。

生文字列にし忘れる。 ふつうの文字列では \d は正しいエスケープではないので、regex クレートがパターンを見る前に、Rust がコンパイルを止めてしまいます。

use regex::Regex;
fn main() {
let re = Regex::new("\d+").unwrap();
println!("{}", re.is_match("id=22"));
}
error: unknown character escape: `d`
--> src/main.rs:4:27
|
4 | let re = Regex::new("\d+").unwrap();
| ^ unknown character escape
|
help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal
|
4 | let re = Regex::new(r"\d+").unwrap();
| +

「d は不明なエスケープ文字」というエラーです。コンパイラが直し方まで提案してくれています。前に r を付けましょう。

ループの中で正規表現を作る。 これはコンパイルできて動きもしますが、遅くなります。行ごとにパターンがコンパイルし直されるからです。

for line in lines {
let re = Regex::new(r"\d+").unwrap(); // 何度も何度もコンパイルされる
if re.is_match(line) { /* ... */ }
}

Regex::new をループの上に移すか、前に見たように LazyLock の static に入れましょう。

入力のチェックで ^ と $ を忘れる。 is_match は、パターンをどこかから探します。入力全体がある形をしているかを確かめるには、パターンの両端を固定しましょう。

  • cargo add regex でクレートを追加し、パターンは生文字列で書きます:r"\d+"。
  • パターンが不正なこともあるので、Regex::new は Result を返します。
  • is_match は「はい / いいえ」を答え、find / find_iter はマッチしたテキストを、captures / captures_iter は番号や (?<name>...) でグループを返します。
  • replace と replace_all は $name でグループを差し込めます。英字や _ が後に続くときは ${name} を使います。
  • パターンは一度だけコンパイルして使い回しましょう。たとえば static の LazyLock に入れます。
  • 先読み・後読みや後方参照は、設計上ありません。その代わり、マッチングはいつも線形時間で終わります。

理解度チェック

1. Rust の正規表現のパターンが、r"\d+" のようにたいてい r で始まるのはなぜですか?

2. パターン (?<y>\d{4})-(?<m>\d{2}) で、テキスト 2024-05 に対する caps["m"] は何になりますか?

3. regex クレートが先読み・後読みや後方参照に対応していないのはなぜですか?

4. 大きなファイルのすべての行で使うパターンの場合、Regex::new を呼ぶのにいちばんよい場所はどこですか?

スコア: 0 / 4