mruby-regexp-pcre の String#gsub について

IIJ さんが開発している mruby 向けの正規表現ライブラリ iij/mruby-regexp-pcre を使ってたら、本家 CRuby と違う挙動の部分を見つけました。

# 文字列の先頭に空白が含まれている場合、それを取り除く。
"foo".gsub /^\s*/, ""

上の "foo" の場合では、先頭に空白は含まれていないので、そのままの文字列 "foo" が返ってくるはずです。 iij/mruby-regexp-pcre を使うと、これが空の文字列 "" として返ってきてました。 CRuby1.9.3 でチェックしてみたところ、"foo" が返ってきています。

最初は mruby の String#gsub の問題かなと思って mruby/mruby のコードを追っていましたが該当箇所が見つかりません。よく考えてみたら、正規表現ライブラリは mruby の基本実装には組み込まれていないはずなので mruby/mruby に問題箇所があるわけがありませんね。2013年の下記の issue を見てみても、「正規表現ライブラリはちゃんと切り分けて、String#gsub なんかの挙動は拡張側で適宜上書きしようぜ!」という流れが見てとれました。(英語苦手だけど)

Library independent RegExp ・ Issue #841 ・ mruby/mruby

また、一瞬だけ本家 CRuby で使用している正規表現ライブラリは鬼車であって、mruby-regexp-pcre は PCRE である違いも考えましたが、

"foo".gsub /^\s*/, ""

で空文字が返ってくるのは、そもそも使用しているライブラリ以前の問題のような気がしました。

ということで、iij/mruby-regexp-pcre の該当コードを読んでいると String#gsub上書きしているコードがありました。 対象部分をデバッガで追ってみると、位置指定子(^)があるにも関わらず、1文字ずつ正規表現マッチ→マッチしたインデックスだけを用いて(空文字で)置換→元文字列がなくなるまでくりかえし、という挙動をしていたため、恐る恐る issue を投げてみました。 (颯爽と手直ししてプルリクできるくらいの能力がどこかに落ちてないでしょうか!)

2015/12/7 追記

レポートしておいた部分をしっかり修正していただけました!感謝多謝です。

tr1の正規表現ライブラリを使う

Microsoft Visual Studio 2008 の SP1 からは C++ Technical Report 1 (TR1)をサポートしているので、Boost やPCRE などのライブラリを用いずに正規表現を扱うことができます。

[cpp]

include <iostream>

include <string>

include <regex>

// usage: a.exe pattern replace-to

using namespace std;

int main(int c, char** v)
{
if (c != 3) return 1;

tr1::regex ex(v[1]); // 正規表現オブジェクト
char buf[256]; // 読み取り用のバッファ

while (cin.getline(buf, sizeof(buf)))
cout << tr1::regex_replace(string(buf), ex, string(v[2])) << endl;

return 0;
}
[/cpp]

Boost のコードと比べれば、名前空間が boost から tr1 に変わったこと、regex ヘッダが標準パスに入ったこと、regex_replace() の第3引数が char* から std::string に変わったことくらいでしょうか。Boost からの移植ですので、互換性があって当然ですね。

C++11 では、名前空間がさらに tr1 から std に移されているようです。TR1 は C++ の言語自体の規格ではないものの、拡張ライブラリの標準規格ですので、名前空間などの移り変わりに注意しておけば、今後も安心して使っていけると思われます。

ロブ・パイクの正規表現マッチャを拡張する

ブライアン・カーニハンとロブ・パイクによる The practice of programming (邦題:プログラミング作法) に掲載されている正規表現マッチャを拡張したいと思います。

※ O’Reilly から出版された Beautiful Code: Leading Programmers Explain How They Think (邦題: ビューティフル コード)にも掲載されており、該当部分の全文は公式サイトで読むことができます。

前提

元の正規表現マッチャは、以下の表現をサポートしています。

  • 任意文字指定子: .
  • くりかえし指定子: *
  • 位置指定子: ^$

まず、正規表現マッチャを評価する為に main() 関数を追加しました。

#include <stdio.h>

/* match: テキスト中の任意位置にある正規表現を探索 */
int match(char *regexp, char *text)
{
  if (regexp[0] == '^')
    return matchhere(regexp+1, text);
  do { /* 文字列が空の場合でも調べる必要あり */
    if (matchhere(regexp, text))
      return 1;
  } while (text++ != '\0');
  return 0;
}
/* matchhere: テキストの先頭位置にある正規表現のマッチ */
int matchhere(char *regexp, char *text)
{
  if (regexp[0] == '\0')
    return 1;
  if (regexp[1] == '')
    return matchstar(regexp[0], regexp+2, text);
  if (regexp[0] == '$' && regexp[1] == '\0')
    return text == '\0';
  if (text!='\0' && (regexp[0]=='.' || regexp[0]==text))
    return matchhere(regexp+1, text+1);
  return 0;
}
/* matchstar: 「c」型の正規表現をテキストの先頭位置からマッチ */
int matchstar(int c, char *regexp, char *text)
{
  do { /* 「」は「0回以上の繰り返し」であることに注意 */
    if (matchhere(regexp, text))
      return 1;
  } while (text != '\0' && (*text++ == c || c == '.'));
  return 0;
}

int main(int argc, char** argv)
{
  int r;
  if ( argc != 3 ) {
    printf("Too few or many arguments for regex.\n");
    return 2;
  }
  printf("%s\n", (r = match(argv[1], argv[2])) ? "Match" : "No match" );
  return !r;
}

これで、

gcc -o myregex myregex.c

すると、実行バイナリが作成され

./myregex regex-pattern source-string

とタイプする事により、挙動を確認することができます。引数は、シェルによる展開を避ける為、クォートで囲むことをオススメします。

拡張1: 「c+(直前のパターンが1回以上くりかえす)」表現を追加する。

上の37行目にあるコメント『「*」は「0回以上の繰り返し」であることに注意』が最大のヒントになります。「+」は1回以上のくりかえしですので、0回でもマッチする dowhile 文による検索ではなく、while 文で実現すれば良いはずです。

ということで、次の関数を追加しました。

/* matchcorss: 「c+」型の正規表現をテキストの先頭位置からマッチ */
int matchcross(int c, char *regexp, char *text)
{
  while (text != '\0' && (*text++ == c || c == '.')) {
    if (matchhere(regexp, text))
      return 1;
  }
  return 0;
}

1回もマッチすることがなければ、return 0をして検索を終了します。また、呼び出し元となる matchhere() 関数を次のように変更します。

/* matchhere: テキストの先頭位置にある正規表現のマッチ */
int matchhere(char *regexp, char *text)
{
  if (regexp[0] == '\0')
    return 1;
  if (regexp[1] == '')
    return matchstar(regexp[0], regexp+2, text);
  /* ここから */
  if (regexp[1] == '+')
    return matchcross(regexp[0], regexp+2, text);
  /* ここまで追加 */
  /* … */

matchstar() を呼び出しているところと同じように、regexp[1]+ だった場合、matchcross() を呼び出しています。

これを実行してみると

$ ./myregex '^co+l' 'cool'
Match

$ ./myregex '^c+l' 'cool'
No Match

ちゃんと動いているようです。

「c?(直前のパターンが0回もしくは1回)」表現を追加する。

直前のパターンが0回、もしくは1回マッチすればいいので、次のような関数を追加しました。

int matchquestion(int c, char regexp, char *text)
{
  if (text == c)
    text++;
  return matchhere(regexp, text, 0);
}

仮引数は、matchstar()matchcross() と同じです。ポインタ text が指している場所にある文字が c と同じなら、text をインクリメントします。 同じでない場合は、「0回マッチした」ということですので、ポインタ text はそのままで検索をすすめていきます。

呼び出し元となる matchhere() はこんな感じになりました。

/* matchhere: テキストの先頭位置にある正規表現のマッチ */
int matchhere(char *regexp, char *text)
{
  if (regexp[0] == '\0')
    return 1;
  if (regexp[1] == '')
    return matchstar(regexp[0], regexp+2, text);
  if (regexp[1] == '+')
    return matchcross(regexp[0], regexp+2, text);
  /* ここから */
  if (regexp[1] == '?')
    return matchquestion(regexp[0], regexp+2, text);
  /* ここまで追加 */
  /* … */

くりかえし指定子ですので、おんなじですね。

$ ./myregex 'colour' 'color'
No Match

$ ./myregex 'colou?r' 'color'
Match

$ ./myregex 'colou?r' 'colour'
Match

ちゃんと動いてます。

指定子のエスケープに対応する

正規表現では、文字 *. そのものを指定したい場合、それぞれ *. といったバックスラッシュを用いたエスケープを行います。

それに対応します。

考え方としては、エスケープ文字が指定子の前にあれば、その指定子を単なる文字として解釈するわけですが、まずは matchhere() を修正してみましょう。

int matchhere(char *regexp, char *text)
{
  if (regexp[0] == '\0')
    return 1;

  /* 直前がバックスラッシュじゃない場合のみ、指定子として処理する */
  if (regexp[0] != '\\') {
    if (regexp[1] == '*')
      return matchstar(regexp[0], regexp+2, text);
    if (regexp[1] == '+')
      return matchcross(regexp[0], regexp+2, text);
    if (regexp[1] == '?')
      return matchquestion(regexp[0], regexp+2, text);
  }
  else {
    /* バックスラッシュだった場合、regexp をインクリメントして検索を続ける。 */
    return matchhere(regexp+1, text);
  }
  /* ... */

これで、くりかえし指定子のエスケープができるようになりました。さて、問題は次の位置指定子と任意文字のマッチングの部分です。

if (regexp[0] == '$' && regexp[1] == '\0')
  return *text == '\0';

if (text!='\0' && (regexp[0]=='.' || regexp[0]==text))
  return matchhere(regexp+1, text+1);

くりかえし指定子の処理突入部の場合、「regexp[0]が直前のパターン、regexp[1]が指定子」というペアでしたが、これらの文では、regexp[0]が指定子であり、直前のパターンを判断する手立てがありません。

直前のパターンがエスケープ文字かどうか、つまり、matchehere() が呼び出された時点で、エスケープされているのかどうかを知ることができればいいわけです。

matchhere() を次のように変更しました。

/* int matchhere(char *regexp, char *text) */
int matchhere(char *regexp, char *text, int esc)
{
/* … */

第3引数の int esc は、直前でエスケープして呼び出された場合には 0 以外が入るようにします。既存の match()matchstar()matchcross()matchquestion() 内で呼んでいる matchhere() は、全て第3引数を 0 にして呼び出すように修正をします。

さらに、matchhere()

int matchhere(char *regexp, char *text, int esc)
{
  if (regexp[0] == '\0')
    return 1;

  if (regexp[0] != '\\') {
    if (regexp[1] == '*')
      return matchstar(regexp[0], regexp+2, text);
    if (regexp[1] == '+')
      return matchcross(regexp[0], regexp+2, text);
    if (regexp[1] == '?')
      return matchquestion(regexp[0], regexp+2, text);
  }
  else {
    return matchhere(regexp+1, text + esc, 1); /* エスケープされている事を教える */
  }

  if (!esc && regexp[0] == '$' && regexp[1] == '\n')
    return *text == '\n';

  if (*text!='\n' && ((!esc && regexp[0]=='.') || regexp[0]==*text))
    return matchhere(regexp+1, text+1, 0);

  return 0;
}

のように変更します。条件式のところどころに、esc を差し込んでいます。順に追っていってみます。

15行目の

return matchhere(regexp+1, text + esc, 1);

は、regexp[0]がエスケープ文字だった場合に、第3引数を 1 に設定して matchhere() を呼び出します。また、第2引数の text + esc は、「エスケープされていたら1文字進め、そうでなければ現在のポインタの指す位置から」という意味になります。エスケープされており、かつこの return 文のスコープに入ってきたという事は、パターン「\」が指定されたことになります。エスケープされておらず、この return 文が実行される場合は、「\」以外のパターン「」や「\w」などの場合であり、それぞれ、文字「」「w」として解釈されるようになります。

次に、18行目の

if (!esc && regexp[0] == '$' && regexp[1] == '\0')

ですが、ここは「エスケープされていない$があり、かつパターンの文末」を意味しています。エスケープされていたら、マッチしません。

最後の、21行目

if (text!='\0' && ((!esc && regexp[0]=='.') || regexp[0]==text))

では、パターン「.」を弾いています。

これを試してみると

$ ./myregex '.++.+=.+' '12+34=46′
Match

$ ./myregex '^\abc' '\abc'
No Match

$ ./myregex '^\abc' '\abc'
Match

しっかり動いているようです。

ここまでの機能を盛り込んだソースコード

2つのくりかえし指定子とエスケープを実装した後のコードです。

#include <stdio.h>

/* match: テキスト中の任意位置にある正規表現を探索 */
int match(char *regexp, char *text)
{
  if (regexp[0] == '^')
    return matchhere(regexp+1, text, 0);
  do { / 文字列が空の場合でも調べる必要あり /
    if (matchhere(regexp, text, 0))
      return 1;
  } while (text++ != '\0');
  return 0;
}
/* matchhere: テキストの先頭位置にある正規表現のマッチ */
int matchhere(char *regexp, char *text, int esc)
{
  if (regexp[0] == '\0')
    return 1;

  /* くりかえし */
  if (regexp[0] != '\\') {
    if (regexp[1] == '*')
      return matchstar(regexp[0], regexp+2, text);
    if (regexp[1] == '+')
      return matchcross(regexp[0], regexp+2, text);
    if (regexp[1] == '?')
      return matchquestion(regexp[0], regexp+2, text);
  }
  else {
    return matchhere(regexp+1, text + esc, 1);
  }

  /* 文末 */
  if (!esc && regexp[0] == '$' && regexp[1] == '\n')
    return *text == '\n';

  /* その他、任意文字 */
  if (*text!='\n' && ((!esc && regexp[0]=='.') || regexp[0]==*text))
    return matchhere(regexp+1, text+1, 0);

  return 0;

}
/* matchstar: 「c」型の正規表現をテキストの先頭位置からマッチ */
int matchstar(int c, char *regexp, char *text)
{
  do { /* 「」は「0回以上の繰り返し」であることに注意 */
    if (matchhere(regexp, text, 0))
      return 1;
  } while (text != '\0' && (text++ == c || c == '.'));
  return 0;
}
/* matchcorss: 「c+」型の正規表現をテキストの先頭位置からマッチ */
int matchcross(int c, char *regexp, char *text)
{
  while (text != '\0' && (text++ == c || c == '.')) {
    if (matchhere(regexp, text, 0))
      return 1;
  }
  return 0;
}
/* matchquestion: c? */
int matchquestion(int c, char *regexp, char *text)
{
  if (text == c)
    text++;
  return matchhere(regexp, text, 0);
}

int main(int argc, char** argv)
{
  int r;
  if ( argc != 3 ){
    printf("Too few or many arguments for regex.\n");
    return 2;
  }
  printf("%s\n", (r = match(argv[1], argv[2])) ? "Match" : "No match" );
  return !r;
}

次回は、範囲パターン([a-z])の実装と、パターンのコンパイルフェイズ・チェックフェイズの分離をやってみたいと思います。

Bash, Perl, Ruby, Pythonで正規表現置換

前回のC++/boost.NETに加え、各種インタプリタ言語でも同じ動作をするスクリプトを書いてみました。

シェルスクリプト(bash)

シェルスクリプトはそもそもグルー言語ですので、他のコマンドを呼び出して処理をすることが一般的です。下のサンプルではsedコマンドで置換処理をしています。bashだと正規表現マッチングは可能なので、ガリガリとスクリプトを書けばsedを使わずに実現できるかもしれません。

[bash]

!/usr/bin/env bash

[ $# -ne 2 ] && exit 1
cat | sed -e "s/$1/$2/g"
exit 0
[/bash]

リプレイスメント置換文字は、sedの書式になります。グループ指示子はダラー($)ではなくバックスラッシュ(\)を用いています。

Perl

Perlの場合、正規表現による文字列操作は、関数でもクラスでもなく構文として組み込まれています。その為、コマンドライン引数から渡された「$1」のようなリプレイスメントの展開方法に、若干の工夫が必要です。

[perl]

!/usr/bin/env perl

exit 1 if ($#ARGV != 1);
for (<stdin>) {
eval "s/$ARGV[0]/$ARGV[1]/g" && print;

s/$ARGV[0]/$ARGV[1]/gee && print; # これではダメ

}
exit 0;
[/perl]

リプレイスメントが格納されている$ARGV[1]は展開されると、例えば「$1$2」という文字列になります。この展開後の文字列をリプレイスメントとして正規表現置換の処理に投げたいのですが、正規表現置換処理を行なった後に変数展開されるようでリプレイスメントのグループ指示子としての$nが正規表現置換処理に伝わりません。そこで、正規表現置換処理を行う前に$ARGV[1]を展開させるべく、eval関数に投げています。これにより、正規表現置換処理が評価される時点でリプレイスメントは「$ARGV[1]」ではなく「$1$2」という文字列として解釈され、指示子が正しく伝わるようです。
検索パターンに加え、リプレイスメント文字列も変数で持つという処理は多々あるはずなので、もっとスマートな方法が準備されているのかもしれませんが、性質さえ知っていれば公式を知らずとも期待する処理が可能である、まさに”TMTOWTDI / There’s More Than One Way To Do It(やり方はひとつじゃない)”という設計思想を持つPerlらしい実装です。理に適った挙動は見ていて気持ちがいいです。

Python

Pythonでは、シェルスクリプトやPerlと違って正規表現機能はクラス(re)として提供されています。下のサンプルではreの静的関数を叩いていますが、プリコンパイルしたオブジェクトとしても利用できたと思います。

[python]

!/usr/bin/env python

import sys
import re
v = sys.argv
if len(v) != 3: quit(1)
for line in sys.stdin:
print re.sub(v[1], v[2], line),
quit(0)
[/python]

リプレイスメントのグループ指示子は、sedと同じくバックスラッシュです。Cやシェルライクなエスケープ文字という捉え方をするなら、バックスラッシュがしっくり来ますね。(といっても、ダラーの場合でも同じく「シェルライク」ですけど :D )

ちなみに、len() がオブジェクトのメソッドではなく独立した関数になっているのは、評価対象(上の例の場合は v )が null オブジェクトでも、null チェックなしで利用できるようにする為だとどこかで読みました。Python の場合は空文字列(len=0)は null オブジェクトになるんですね。

Ruby

Rubyの場合は、シェルスクリプトを除いた他のどの言語よりもマニアックな感じがしますが、よく見てみると一番「ナチュラル」に理解し易い構文です。これだけの構文で比較するのはアレかもしれませんが、これだけの構文だけでここまで革新的な要素をたくさん見てとれるのは、rubyだからこそと言った感じでしょうか。ファイルディスクリプタまでオブジェクトであり、イテレータをせおっているあたり、カワイイです。

[ruby]

!/usr/bin/env ruby

if ARGV.size != 2 then exit 1 end
STDIN.each_line do |line|
puts line.gsub(/#{ARGV[0]}/, ARGV[1])
end
exit 0
[/ruby]

Rubyの何十倍もPythonのコードは書いていますが、Pythonは「VB.NETよりも使いものになる、綺麗なVB.NET的な何か」というイメージが拭えません。いいところ、悪いところの両方を知った上で使いこなせていけたらいいなあ、と思いました。

See also

.NETで正規表現ライブラリを使う

前回のboostの正規表現ライブラリのサンプルに引き続き、C#とC++/CLI、VB.NETでも同じサンプルを書いてみました。全て.NET FrameworkのSystem.Text.RegularExpressions.Regexを用いています。

なんてことはありませんね。

C#版

こちらはMono 2.6.7環境のmcs/gmcsでビルドしてチェックしました。

[csharp]
using System;
using System.Text.RegularExpressions;

// usage: a.exe pattern replace-to

public class Program
{
static int Main(string[] v)
{
if (v.Length != 2) return 1;

Regex r = new Regex(v[0]); // 正規表現オブジェクト
string buf;                // 読み取り用のバッファ

while ((buf = Console.ReadLine()) != null)
  Console.WriteLine(r.Replace(buf, v[1]));

return 0;

}
}
[/csharp]

C++/CLI版

C++/CLIはオープンソースなコンパイラが存在しないため、Microsoft Visual Studio 2008 SP1を用いてビルド、チェックしました。

[cpp]
using namespace System;
using namespace System::Text::RegularExpressions;

// usage: a.exe pattern replace-to

int main(array<System::String ^> ^v)
{
if (v->Length != 2) return 1;

Regex^ r = gcnew Regex(v[0]); // 正規表現オブジェクト
String^ buf;                  // 読み取り用のバッファ

while ((buf = Console::ReadLine()) != nullptr)
    Console::WriteLine(r-&gt;Replace(buf, v[1]));

return 0;

}
[/cpp]

VB.NET版

ついでにVB.NET版も。これもMonoのvbncでテストしました。

[vb]
Imports System
Imports System.Text.RegularExpressions

Module Program

Public Sub Main(ByVal v() As String)

If v.Length &lt;&gt; 2 Then Exit Sub

Dim r As New Regex(v(0)) ' 正規表現オブジェクト
Dim buf As String        ' 読み取り用のバッファ
buf = Console.ReadLine() ' 代入結合判定できないので
                         ' 予め内容を詰めておく。

While buf &lt;&gt; Nothing
  Console.WriteLine(r.Replace(buf, v(1)))
  buf = Console.ReadLine()
End While

End Sub

End Module
[/vb]

boostの正規表現ライブラリを使う

boostの正規表現ライブラリ boost::regex を使ったサンプルです。
boost::regex は以下のコマンドでインストールすることができます。

[bash]
$ sudo aptitude install libboost-regex-dev
[/bash]

サンプル regextest.cpp を準備します。これは正規表現リプレイサです。

[cpp]

include <iostream>

include <string>

include <boost/regex.hpp>

// usage: a.out pattern replace-to

using namespace std;

int main(int c, char** v)
{
if (c != 3) return 1;

boost::regex ex(v[1]); // 正規表現オブジェクト
char buf[256];         // 読み取り用のバッファ

while (cin.getline(buf, sizeof(buf)))
cout << boost::regex_replace(string(buf), ex, v[2]) << endl;

return 0;
}
[/cpp]

ビルドします。

[bash]
$ g++ -lboost_regex regextest.cpp
[/bash]

実行します。

[bash]

先頭の二文字を入れ替える

$ ls / | ./a.out ‘^(.)(.)’ ‘$2$1’
ibn
obot
edv
tec
ohme
niitrd.img
ilb
olst+found
nmt
pot
rpoc
orot
bsin
eslinux
rsv
yss
mtp
sur
avr
mvlinuz
[/bash]

簡単ですね。

正規表現を使ったファイル名一括変換 rename コマンド

ファイルリネーマを書いた。引数に置換パターン、置換後文字列、ファイル名を渡すと、ファイル名を置換してくれる。正規表現は、インストールしている sed がサポートするものとなる。

続きを読む 正規表現を使ったファイル名一括変換 rename コマンド