M.Hiroi's Home Page

Linux Programming

お気楽 Perl プログラミング超入門

[ PrevPage | Perl | NextPage ]

正規表現 (前編)

今回は Perl の強力な文字列処理の中心である「正規表現 (regular expression)」を取り上げます。正規表現は Unix 系 OS のコマンド grep, sed, awk などで使われる「文字列のパターンを示した式」のことです。Perl の正規表現は、基本的には awk と同じです。そこにいくつかの拡張機能が追加されています。最初に awk で採用されている基本的な正規表現について説明します。

●正規表現の基礎知識

正規表現は、ワイルドカードの * や ? のように、ある文字に特別の意味を持たせています。これを「メタ文字」といいます。このメタ文字を組み合わせることで、複雑な条件を表すことができます。基本的なメタ文字を下表に示します。

表 : 基本的なメタ文字
記号意味
|この前後にある正規表現のどちらかと一致する
*直前の正規表現の0回以上の繰り返しに一致する
+直前の正規表現の1回以上の繰り返しに一致する
?直前の正規表現に0回もしくは1回一致する
[...] ... に指定した文字のどれかと一致する
[^...] ... に指定した文字でない場合に一致する
.任意の1文字と一致する
^行頭と一致する
$行末と一致する
( )正規表現をグループにまとまる

それでは、具体的に説明していきましょう。まず大前提として、メタ文字以外の文字は、それ自身の正規表現です。つまり、abc という正規表現は、文字列 abc と一致します。

メタ文字 . はどんな文字にも一致します。

a.c   => aac, abc, aAc, aBc
a..c  => aaac, abcc, aABc

これは簡単ですね。ファイルのワイルドカード ? と似ています。

次は文字クラス [ ] です。[ ] 中の文字のどれかと一致します。

a[ABC]c    => aAc, aBc, aCc
a[AB][CD]c => aACc, aADc, aBCc, aBDc

文字クラスは、- を使って文字の範囲を表すことができます。文字コードの大小関係が逆になると、範囲を示さずその文字が文字クラスの指定になります。

[a-zA-Z]    ; アルファベットと一致
[0-9]       ; 数値と一致
[z-a]       ; a, -, z と一致
[a-]        ; a, - と一致

文字クラスの先頭に ^ を付けると、指定した文字以外の文字と一致します。^ は先頭に付けたときに有効で、それ以外の位置では通常の文字として扱われます。

[^a-zA-Z]    ; アルファベット以外の文字と一致
[^0-9]       ; 数値以外の文字と一致
[^z-a]       ; a, -, z 以外の文字と一致
[^a-]        ; a, - 以外の文字と一致

メタ文字 * と + と ? は、繰り返しを指定します。* は直前の正規表現の 0 回以上の繰り返しと一致します。0 回以上とは空文字列にも一致する、というとです。

a*b   => b  (a がない場合にも一致する)
         ab aab aaaab aaaaab など

+ は直前の正規表現の 1 回以上の繰り返しと一致します。* と違って空文字列とは一致しません。

a+b   => ab aab aaaab aaaaab など  (b とは一致しない)

? は空文字列もしくは直前の正規表現と一致します。

a?b   => b ab

繰り返しは、優先順位が高いことに注意して下さい。たとえば、ab* は ab の繰り返しではなく、b の繰り返しです。ab の繰り返しを実現するには ( ) を使って、正規表現をひとつのグループにまとめます。

(ab)+   => ab abab ababab abababab など
(ab)*c  => c abc ababc abababc ababababc など

文字クラスと繰り返しを組み合わせることで、いろいろな文字列を表現することができます。

[a-zA-Z]+   ; 英文字列と一致
a[a-zA-Z]*  ; a で始まる英文字列と一致 (a 1文字とも一致)
a[a-zA-Z]+  ; a で始まる英文字列と一致 (a 1文字には一致しない)
[0-9]+      ; 数字列と一致

$ と ^ は位置を指定するメタコードです。^ は行頭を指定し $ は行末を指定します。^ は文字クラス内とは別の意味になりますので注意が必要です。また、コードの意味と矛盾する位置にある場合は、通常の文字として扱われます。

^abcd       ; 行頭の abcd と一致する
^[a-z]+     ; 行頭にある英文字列と一致する
abcd$       ; 行末にある abcd と一致する
[a-z]+$     ; 行末にある英文字列と一致する
abc^        ; abc^ と一致(^ は先頭にないから)
$abc        ; $abc と一致($ は最後尾にないから)

| は選択を表すメタ文字で、前後どちらかの正規表現と一致します。

ab|cd        => ab cd
(a|b)c       => ac bc
(ab|cd)e     => abe cde

選択は優先順位が低いことに注意して下さい。ab|cd は (ab)|(cd) であり、a(b|c)d ではないのです。

●Perl の正規表現

次は、Perl 独自の正規表現について説明します。Perl の場合、エスケープ記号 \ でメタ文字を打ち消しますが、ある英数字に \ をつけると、Perl はそれをメタ文字として認識します。たとえば、\t と \n がタブと改行を表すのは当然ですが、それ以外に次のようなものがあります。

\w    英数字とアンダースコア _ に一致 ([_a-zA-Z0-9] と同じ)
\W    \w 以外と一致
\s    空白文字と一致 ([ \t\n\r\f] と同じ)
\S    \s 以外と一致
\d    数字と一致 ([0-9] と同じ)
\D    \d 以外と一致
\b    単語境界と一致 (\w と \W の間の空文字列と一致)
\B    \B 以外と一致

このほかに、8 進数や 16 進数で文字を表すこともできます。

\nnn  8 進数で表現 (n : 0 - 7)
\xnn  16 進数で表現 (n : 0 - 9, a - f)

この表現方法は文字列中にも使うことができます。これにより、ASCII コードで表示できない文字を、正規表現や文字列中に含めることができます。

次は繰り返しです。Perl では {n,m} を使って繰り返しの回数を指定することができます。

{n,m}     直前の正規表現の n 回以上 m 回以下の繰り返し
{n,}      直前の正規表現の n 回以上の繰り返し
{n}       直前の正規表現のちょうど n 回の繰り返し
{0,}      * と同じ
{1,}      + と同じ
{0,1}     ? と同じ

さて、ここまで説明した機能は確かに便利ですが、なくてもどうにかなるかな、といった程度です。実は Perl には「後方参照」という必殺技があるのですが、説明ばかりでは退屈ですね。それでは、実際に正規表現を使ってみましょう。

●文字列の検索

Perl では、いろいろなところで正規表現を使うことができますが、いちばんよく使われるのが m 演算子です。

 /pattern/
m/pattern/

/ で区切られた pattern に正規表現を指定します。区切り文字 (デリミタ) として / を使う場合は、先頭の m を省略することができます。m を指定すると、その直後の文字がデリミタとなります。たとえば、# を使いたい場合は、次のようになります。

m#pattern#

/ を含むパターン (Unix 系 OS でのパスなど) を検索する場合、この機能が役に立ちます。

m 演算子は、スカラーコンテキストでは真 (1) か偽 ("") を返します。文字列の指定がない場合、$_ が検索対象となります。文字列を指定する場合は =~ か !~ を使います。

/abcd/         # $_ と abcd のマッチング
$s =~ /abcd/   # $s と abcd のマッチング

!~ を使うと、結果が反転されます。つまり、正規表現と一致しなかった場合に真を返すことになります。

たとえば、ファイルの中から 3 桁以上の数字を探す場合は、次のようにプログラムできます。

リスト : 3 桁以上の数字を探す

use strict;
use warnings;

while (<>) {
    if( /\d{3,}/ ){
	print "$.: $_";
    }
}

行入力演算子 <> は while の条件部で使用されると、特殊変数 $_ にデータをセットすることを思い出してください。まず $_ と /\d{3,}/ を照合します。一致すれば真を返すので、print で $_を表示すればいいわけです。とても簡単ですね。

pattern に変数を書くと、" で囲まれた文字列と同じく変数展開が行われます。したがって、/$foo/ とすると、$foo にセットされている文字列を正規表現として認識します。

$foo = '\w+';
/$foo/;         # /\w+/ と同じこと

この機能を使うと、指定した文字列をファイルから検索する grep のようなプログラムは、簡単に作ることができます。

リスト : grep.pl

use strict;
use warnings;

my $pattern = shift;
my $filename = shift;
open my $in, $filename or die "Can't open file: $filename\n";
while (<$in>) {
    if (/$pattern/o) {
	print "$.: $_";
    }
}
close $in;

このプログラムは次のように起動します。

$ perl grep.pl pattern file

まず、特殊変数 @ARGV から正規表現 pattern とファイル名 file を取り出して、変数 $pattern と $filename にセットします。shift は引数が省略されると、@ARGV が操作対象になることを思い出してください。あとは、ファイルをオープンして正規表現 $pattern と照合すればいいわけです。

ここで、/$pattern/o の o に注目してください。Perl は正規表現をあるデータ構造 [*1] に変換 (コンパイル) してから文字列と照合します。m 演算子に変数が使われている場合、その値が変化しているかもしれないので、Perl は文字列と照合する前に正規表現を再コンパイルします。今回のプログラムでは、$pattern の値は変化しませんね。この場合、m 演算子に o を指定 [*2] することで、正規表現のコンパイルを 1 回だけに抑制することができます。

照合に成功すると、次に示す特殊変数に結果がセットされます。

$&   正規表現と一致した文字列
$`   一致した文字列の前の文字列
$'   一致した文字列の後の文字列

たとえば、/\d+/ と "abcd1234ABCD" を照合させると、各変数の値は次のようになります。

$&  =>  1234
$`  =>  abcd
$'  =>  ABCD

照合に失敗した場合、これらの変数には値がセットされません。失敗したからといって、$& は空文字列にはならないのです。直前に成功したときの値がそのまま残っているので、注意してください。

-- note --------
[*1] Perl は正規表現を「非決定性オートマトン (NFA)」というデータ構造に変換し、それを使って照合を行います。
[*2] 英大小文字を区別しないで検索する場合は i を指定します。

●クロスリファレンスの作成

それでは簡単な例題として、クロスリファレンスを作成するプログラムを作ってみましょう。クロスリファレンスとは、プログラムで使用された変数名と、それが現れる行番号をすべて書き出した一覧表のことです。今回作成するプログラムは変数名ではなく、正規表現と一致する文字列をキーワードとし、それが現れる行番号を出力することにします。

処理内容を流れ図で表すと、次のようになります。

キーワードは文字コード順に整列して出力した方が見やすいので、今まで出てきたキーワードと行番号を覚えておいて、ファイルを読み終わってから結果をまとめで出力することにします。したがって、キーワードを取り出して行番号を追加する場合、記憶してあるキーワードがあれば、そこに行番号を追加し、新しいキーワードであれば、それを記憶する必要があります。

このプログラムは、キーワードの検索処理によって実行時間が大きく左右されます。コンピュータの世界では、昔からデータを高速に検索するアルゴリズムが研究されています。基本的なところでは「二分探索」や「ハッシュ法」があります。これらのアルゴリズムは、拙作のページ Algorithms with Python, 二分木とヒープ, ハッシュ法 で詳しく説明していますので、興味のある方は読んでみてください。Perl の場合、連想配列を使うことで、キーワードを高速に検索することができます。

ところで、連想配列はスカラーしか格納できないので [*3]、複数の行番号を格納することができません。そこで、行番号は文字列として格納し、新しい行番号は後ろに連結していくことにします。 キーワードと行番号は、連想配列 %word_table に格納することにします。結果を出力するときは、そこからデータを取り出す処理が必要になります。

-- Note --------
[*3] まだ説明していませんが、スカラーだけではなく「リファレンス (reference)」も格納することができます。クロスリファレンスのプログラムは、リファレンスを説明するときに改良する予定です。

●連想配列の操作関数

Perl には、連想配列からデータを順番に取り出す関数が用意されています。

keys   %table  : %table に登録されているキーをリストにして返す。
values %table  : %table に登録されている値をリストにして返す。
each   %table  : %table に登録されているキーと値を取り出して、
                 2 要素のリスト値にして返す。

簡単な使用例を示しましょう。Perl には、環境変数を保持する連想配列 %ENV が定義されています。たとえば、環境変数 path の値を表示する場合は、次のようにプログラムすればいいでしょう。

print $ENV{'path'};

keys を使えば、設定されている環境変数を表示することができます。

foreach my $key (keys(%ENV)) {
    print "$key\n";
}

値も表示したい場合は、each を使いましょう。

while (my ($key, $value) = each(%ENV)) {
    print "$key=$value\n";
}

これで、環境変数とその値をすべて表示することができます。keys や each は、連想配列に登録された順番でデータを取り出すわけではありません。取り出される順番は、連想配列のアルゴリズム (ハッシュ法) によって左右されます。したがって、データを文字コード順に表示したい場合は、データを「ソート (sort)」しないといけません。

ソートとは、ある規則に従ってデータを順番に並べることです。たとえば、データが数値であれば、小さい順かもしくは大きい順に並べることになります。Perl では文字列をソートする場合、関数 sort を使います。

sort LIST

sort は、LIST に格納されている文字列を文字コード順に並べ、その結果をリストにまとめて返します。この場合、英大小文字は区別されます。英大小文字を区別せずにソートするとか、数値データをソートすることもできますが、データを比較する関数を sort に渡さないといけません。これは、関数を取り上げるときに詳しく説明します。

●行番号の表示

今度は行番号を表示する処理を考えてみます。行番号は文字列として格納されています。これをそのまま表示するよりも、桁をそろえて表示した方が見やすいですね。今回は 1 行につき 8 個ずつ行番号を書き出すことにします。

文字列から行番号を切り出す処理ですが、Perl には split という便利な関数が用意されています。

split /pattern/, 文字列

split は正規表現 pattern と一致する文字をデリミタとして、文字列を分割してリストにまとめて返します。スカラーコンテキストで split を評価すると分割数を返します。文字列の指定を省略すると、特殊変数 $_ に格納されたデータが対象となります。引数を省略すると、空白文字 (\s) をデリミタとして、$_ に格納されている文字列を分割します。次の例を見てください。

@a = split /,/, "abcd,efgh,ijkl,mnop"; => ("abcd", "efgh", "ijkl", "mnop")

$_ = "123 456 789";
@b = split; => ("123", "456", "789")

最初の例では、カンマ ( , ) をデリミタとして文字列を分割して、配列 @a に代入しています。次の例では、$_ に文字列をセットして、split を引数なしで呼び出しています。配列 @b には、空白文字をデリミタとして分割された文字列がセットされます。

split とは逆の働きをする関数が join です。

join セパレータ, LIST

join は LIST に格納されている文字列を連結します。このとき、セパレータで指定した文字を間に挿入します。join を使えば split で分割した文字列を結合することができます。

@a = ("abcd", "efgh", "ijkl", "mnop");
join ',', @a; => "abcd,efgh,ijkl,mnop"
join '', @a;  => "abcdefghijklmnop"

最初の例は、カンマ , で文字列をつないでいます。空文字列をセパレータとして join を実行すると、文字列を連結したことになります。join を使わずに連結しようとすると、次のようにプログラムしなければいけません。

リスト : @a に格納されている文字列を連結

my $result = '';
foreach my $str (@a) {
    $result .= $str;
}

join だけで文字列を連結できるのですから、とても便利ですね。

次は、行番号の桁をそろえる処理ですが、今回は関数 printf を使うことにします。これはC言語の関数 printf とほぼ同じ機能を持っています。

printf 書式文字列, データ, ...

printf は、基本的には書式文字列を STDOUT へ出力するのですが、その中にデータの出力形式を指定することができます。整数値を出力する書式は %d です。% から書式指定が始まり、d が整数値を出力することを表します。そして、% とd の間に出力する桁数 (フィールド幅) などを指定することができます。

簡単な使用例を示しましょう。[ ] の中に数値を出力する場合を考えてみます。

(1) printf "[%d]\n", 10;      # [10]
(2) printf "[%4d]\n", 10;     # [  10]
(3) printf "[%4d]\n", 10000;  # [10000]

(1) がフィールド幅を指定しない例で、(2) は 4 に指定した例です。10 ではフィールド幅に満たないので、左詰めに出力されていますね。もし、フィールド幅に収まらない場合は、(3) のように指定を無視して数値を出力します。

(4) printf "[%04d]\n", 10;    # [0010]
(5) printf "[%-4d]\n", 10;    # [10  ]

フィールド幅の前に 0 を付けると、左側の空いたフィールドに 0 を詰め込みます。(4) のように、10 の場合は [0010] と出力されます。- を指定すると右詰めに出力されます。(5) のように、10 の場合は [10  ] となります。

このほかにも、printf は文字列や浮動小数点数の指定も可能です。高機能なのですが、それだけ指定方法が複雑です。実をいうと、Perl には BASIC の PRINT USING 文と同様の書式が使える format という機能があります。こちらの方が直感的でわかりやすいのですが、ページ単位でレイアウトを決めるものなので、今回は printf を使いました。たいていの場合は format で処理できるので、複雑な printf の書式を無理に覚えることはありません。format は別の機会で詳しく説明することにします。

●プログラムの作成

それでは、プログラムを作りましょう。次のリストを見てください。

リスト : クロスリファレンスの作成 (cref.pl)

use strict;
use warnings;

my $pattern = shift;
my $filename = shift;
my %word_table = ();
open my $in, $filename or die "Can't open file: $filename\n";

# 探索処理
while (<$in>) {
    while (/$pattern/o) {
        if($word_table{$&}){
            $word_table{$&} .= " $.";   # 文字列として連結する
        } else {
            $word_table{$&} = "$.";
        }
        $_ = $';                        # 残りの文字列をセットする
    }
}
close $in;

# 表示処理
foreach my $word (sort(keys(%word_table))) {
    print "$word\n";
    my $count = 0;
    foreach my $num (split(/ /, $word_table{$word})) {
        printf("%8d", $num);
        if (++$count >= 8) {
            print "\n";
            $count = 0;
        }
    }
    print "\n";
}

少し長いですが、前半部分が探索処理で、後半部分が表示処理です。まず、探索処理から見ていきましょう。まず、連想配列 %word_table に空リスト ( ) を代入しています。これで、連想配列の中身を空にすることができます。実は、%word_table は Perl によって空に初期化されるので、この処理はなくても動作します。

2 番目の while 文で、正規表現 $pattern との照合を行います。一致する部分文字列を見つけたら、次の if 文で連想配列に行番号が登録されているかチェックします。$& にはキーワードがセットされているので、$word_table{$&} で文字列が出現した行番号を取り出すことができます。

Perl の場合、まだデータがセットされていない変数にアクセスすると、空文字列または 0 をセットしてから使用されます。したがって $word_table{$&} が真であれば、既に行番号がセットされていることがわかります。この場合、文字列連結演算子 . を使って、後ろに行番号をつなげます。このとき、先頭に空白を付けることをお忘れなく。

$word_table{$&} が偽の場合、そこに行番号をセットします。行番号は特殊変数 $. で求めることができました。$. を文字列に変換してからセットしていますが、そのままセットしても正常に動作します。探索処理の最後で、残りの文字列 $' を $_ にセットして、正規表現との照合を繰り返します。見つからない場合、m 演算子は偽を返すので while ループが終了し、先頭の while 文に戻ってファイルから新しいデータが読み込まれます。

次は、表示処理を見ていきます。最初の foreach は少々複雑ですが、やっていることは簡単です。まず keys で連想配列 %work_table に格納されたキーワードを求め、sort で文字コード順に並べ、それを foreach でひとつずつ取り出します。変数 $word には、取り出されたキーワードがセットされます。

次に $word を表示してから、$word_table{$word} でデータを取り出し、split で行番号に分割します。それを foreach でひとつずつ取り出して書き出します。変数 $count は、書き出した行番号を数えるカウンタとして使います。

行番号を 8 個出力したら改行します。ここで、インクリメント演算子 ++ の位置に注目してください。

        if (++$count >= 8) {

インクリメント演算子 ++ が $count の前に付いていますね。これを後ろに付けると 1 行に 9 個の行番号を出力してしまいます。インクリメント演算子は前に付けると、$count を +1 してから変数の値が取り出されます。ところが後ろに付けると、$count の値を取り出してから +1 するのです。これはデクリメント演算子でも同じです。次の例を見てください。

$a = 10;
$b = ++$a;   # $b => 11, $a => 11
$c = $a++;   # $c => 10, $a => 11

++$a は 10 + 1 を実行してから、その値が $b に代入されるので 11 になります。$a++ では、$a の値 10 が $c に代入されてから $a の値を +1 するので、$c は 10 のままですが、$a は 11 になるのです。前後の位置関係で結果は大きく変わるので注意してください。

改行を出力したら変数 $count を 0 に戻すことを忘れないでください。これでプログラムは完成です。

●簡単な実行例

それでは実際に動かしてみましょう。下図に示すファイル test.txt で、\w+ をキーワードにしたクロスリファレンスを作成します。

  abc def ghi jkl
  def ghi jkl mno
  ghi jkl mno pqr
  jkl mno pqr stu
  mno pqr stu vwx

図 : test.txt の内容

実行結果は次のようになりました。

$ perl cref.pl '\w+' test.txt
abc
       1
def
       1       2
ghi
       1       2       3
jkl
       1       2       3       4
mno
       2       3       4       5
pqr
       3       4       5
stu
       4       5
vwx
       5

正規表現で表せるパターンであれば、そのクロスリファレンスを cref.pl で作成することができます。このように、正規表現を使うと文字列を処理するプログラムを簡単に作ることができます。


初版 2015 年 4 月 12 日
改訂 2023 年 3 月 19 日

Copyright (C) 2015-2023 Makoto Hiroi
All rights reserved.

[ PrevPage | Perl | NextPage ]