01 月 27 日(金)

フィルタと正規表現

フィルタとは, 連続的に入力されたデータに対して,規則的な加工を施し(ほどこし), その結果のデータを出力するようなプログラムのことである.

Unix コマンドの多くは,単純な機能しかもたないフィルタであるが, 次のように,複数のフィルタをパイプで連結することによって, 複雑な機能を実現できるようになっている:

$  コマンド1 < 入力ファイル | コマンド2 | コマンド3 | ... | コマンドn > 出力ファイル
もっとも単純なフィルタコマンドが cat だ. cat は,本来はファイルを連結するためのコマンドだが, 入力データをそのまま出力する(つまり,何も加工しない)フィルタでもある.

今回は,テキストデータ中の文字列パターンに対して作用するタイプの フィルタコマンドについて実験する.


パターンの正規表現

まず,文字列パターンを表現するための 正規表現(regular expression)について説明する.

シェルのファイル名置換では, 特殊記号(* など)を使って, 複数のファイルの集合を短い文字列(グロブパターン) で表現することができた:

$  ls
boo.c  foo.c  woo.h

$  echo *.c    #  グロブパターン *.c が boo.c と foo.c を表現
boo.c  foo.c

正規表現では,これとよく似た方法 (同じ方法とは言っていない!! 別の方法)で, 文字列の集合を表現する. 正規表現の例を Table 1 に示しておく.

グロブパターンと正規表現は,一見とても似ているが,実際にはかなり違う. どちらにも共通の特殊記号(*? など)が利用されるが, 意味が異なるので混同しないよう注意せよ.

グロブパターンは,シェル独自の機能であって, 実在するファイル名の文字列しか取り扱えない. 一方,正規表現は,さまざまな Unix コマンドに共通の機能であって, 文字列なら何にでも利用できる. (すべてのコマンドで使えるとは言っていない!! 正規表現が使えないコマンドもある.)

Table 1. 正規表現の例
パターンの例 意味 マッチする文字列の集合
1 文字の表現
a 文字 a そのもの "a"
[abc] 文字 a,b,c のうちのどれか 1 文字 "a","b","c"
[a-z] 文字 a,b,c,...,z のうちのどれか 1 文字 "a","b","c",...,"z"
[^a-zA-Z] アルファベット以外の 1 文字 "3","=","あ"
. 任意の 1 文字 "A","b","3","=","あ"
文字列の表現
a+ 文字 a の 1 個以上の連続 "a","aa","aaa",...
a* 文字 a の 0 個以上の連続 "","a","aa","aaa",...
a? 0 個または 1 個の文字 a "","a"
a{2,4} 文字 a の 2 〜 4 個の連続 "aa","aaa","aaaa"
位置の表現
^a 行の先頭の文字 a 省略
a$ 行の末尾の文字 a 省略
文字列のグループ化・選択
(string) 文字列 string のひとまとまり 省略
\1 1 番目の文字列グループ 省略
alpha|beta 文字列 alpha または beta のどちらか "alpha","beta"
グループ化とは,次のようなものだ:
正規表現 "(pen)pine(apple)\2\1"  →  マッチする文字列 "penpineappleapplepen"

なお,正規表現には, 基本正規表現拡張正規表現の2種類があり, これらの間で,パターンの記述方法が微妙に異なる. Table 1 では,拡張正規表現を使用している. また,Table 1 以外にも,さまざまなパターンがある. より詳しい説明については,次の資料を参照しよう:


パターンの検索・抽出

では実際に,grep を利用して, 正規表現によるパターン検索を試してみよう. grep は,パターンにマッチした行だけを入力データから抽出する 「検索フィルタ」である. 基本的な使用方法は次の通り:

$  grep  -E  '拡張正規表現'  ファイル ...

拡張正規表現で検索する場合,オプション -E を明示すること. grep 日本語マニュアル を参考にしながら実験を進めよう.

なお,以下の実行例では, テキストデータとして英単語データファイル /usr/share/dict/words を利用する:

$  less /usr/share/dict/words		# サンプルデータを確認
...

$  grep -E 'regular' /usr/share/dict/words	# "regular" を含む行を検索
contraregular
contraregularity
...
regularization
...
unregular

$  grep -E 'regular$' /usr/share/dict/words	# "regular" で終わる行を検索
contraregular
...
unregular

$  grep -E 'ex.*sion' /usr/share/dict/words	# "exなんとかsion" を検索
antiexpansionist
coexplosion
coextension
...

正規表現は,このように, あいまい検索絞り込み検索の際に威力を発揮する.

なお,grep の検索パターンについては, 特殊記号(*?!,等)が, grep へ渡される前に, シェルによって展開されてしまい, 期待した結果を得られない場合がある. この余計な展開を抑止するためには,いつでも上の例のように シングルクォートしておくのが安全だ. (「"」では NG,必ず「'」 を使うこと.)

正規表現の特殊記号は,コマンドライン内では, シェルのグロブパターンやヒストリ置換などの特殊記号と見分けがつかない.

ユーザが正規表現を指定したつもりであっても, シェルは正規表現をまったく理解できないので, そこにあるファイル名や前に使ったコマンドに置き換えてしまうことになる.

正規表現をクォートせずに使って,おかしな結果が出たとしても, シェルは自分にできることだけをバカ正直にやってくれているだけだ. 適切な指示を出さなかった人間が悪い. (こういう残念な状況は,人間対人間でもよくある... コミュニケーション能力の問題...)

練習問題

  1. 英大文字だけからなる行 にマッチする拡張正規表現を書け.
  2. $  grep -E '拡張正規表現'  /usr/share/dict/words
    A
    AA
    ...
    ZIP
    ...
    
    注意:英大文字の連続を含む,ではない. 要するに,先頭から末尾まで,すべて大文字.
  3. 文字 t を 4 つ以上含む行 にマッチする拡張正規表現を書け.
  4. $  grep -E '拡張正規表現'  /usr/share/dict/words
    aftertreatment		# 4つ含むものや...
    anitinstitutionalism
    ...
    anticonstitutionalist	# 5つ含むものも...
    ...
    
    注意:文字 t の 4 つの連続,ではない. 要するに,t の前後(t と t の間)に他の文字が入ってもよい.
  5. (上級者向け問題)文字 t を 4 つだけ含む行 にマッチする拡張正規表現を書け.

パターンの置換

sed は, 指定された特定の文字列(検索パターン)を 別の文字列(置換パターン)へ変換する 「置換フィルタ」である. sed 日本語マニュアル を参考にしながら実験を進めよう.

$  man sed

sed コマンドの置換機能

sed の基本的な利用方法は次の通り:(拡張正規表現の場合)

$  sed -E 's/検索パターン/置換パターン/' 入力ファイル

このコマンドを使うと, テキストファイル内の多数のスペルミスの修正や名前の変更を一気に実行できる. たとえば,Cのソースコードとして, "printf" ではなく "pritnf" と打ってしまう病気は, 次のようにすると簡単に治療できる:

$  cat bug.c
main()
{
	int a, b;
	int kotae;

	pritnf("a > "); scanf("%d", &a);
	pritnf("b > "); scanf("%d", &b);

	kotae = a + b;

	pritnf("%d + %d = %d\n", a, b, kotae);
}

$  cc bug.c
コンパイルエラー:pritnf って何よ?
...

$  sed 's/pritnf/printf/' bug.c > ok.c

$  cc ok.c

また,変数名を "kotae" から "answer" に変えたければ, 次のようにする:

$  sed 's/kotae/answer/' ok.c > int.c

ただし,調子に乗りすぎないこと. 次のようにはしない方がよい:

$  sed 's/int/double/' int.c > double.c

これだと,"printf" も置換されてしまう. 意味不明な文字列 "prdoublef" に...

次に,特殊記号の正規表現も使ってみよう:

$  grep -E '^ex.*sion$' /usr/share/dict/words
excision
exclusion
excursion
...

$  grep -E '^ex.*sion$' /usr/share/dict/words | sed -E 's/(ex)(.*)(sion)/\2/'
ci
clu
cur
...

この例では,"exなんとかsion" を "なんとか" へ置換している. ("ex" と "sion" の部分を破棄して,"なんとか" の部分だけを抽出している.)

なお,sed には置換以外の機能もある. 興味のある人は,調べてみよう.

注意:行内にマッチする部分が複数個ある場合, デフォルトでは, 最初の部分だけしか置換されない. すべての部分を置換するには 's/.../.../g' とすればよい.

vi エディタの置換機能

エディタ vivim)の操作中にも,実は, sed と同じ置換機能を利用できる. vi のノーマルモードで:

:%s/検索パターン/置換パターン/

これはとても便利なので,vi ユーザは是非活用しよう.

ただし,vi で使える正規表現は「基本正規表現」みたいです. 細かな部分で拡張表現とは異なるので注意が必要.

練習問題

sed コマンドで, printf に影響を与えずに int 型だけを置換する方法を考えよ.

ヒント:int を単語の一部ではなく, ひとつの単語として取り扱うということ.

大ヒント:単語としての int の場合, 次のような構成になっている.

英文字以外(括弧or空白orタブの連続)or先頭 + int + 英文字以外(空白orタブの連続)

これを正規表現で書けばよい. (括弧は \(,タブは \t,先頭は ^, 文字の or は [...], 文字列の or は ...|... である.)

ただし,“or(または)” の正規表現については,ちょっと難しいかもしれない. グローバル変数がない場合には, この条件を無視しても構わないだろう. もちろん,インデントが整っていることが前提だが.

本日の課題

sed を利用して, 一部の内容だけが異なる複数のテキストファイルを自動生成する bash スクリプト mkcards.bash を作成せよ.

実行例:

$  ls *.txt
card.txt	# この入力テキストファイル(テンプレートファイル)を用意しておく..

$  cat card.txt		# ...テンプレの中身はこんな感じに
Dear, #NAME#.
A happy new year.

$  ./mkcards.bash  card.txt  Amy  Becky  Chucky  ...
			# 宛名は何人分でも OK に...
$  ls *.txt
Amy.txt  Becky.txt  Chucky.txt  ...  card.txt

$  cat Amy.txt
Dear, Amy.		# "#NAME#" の部分が "Amy" に置換されている
A happy new year.

やりたいことは,要するに,差し込み印刷だ.

この課題では sed のリダイレクト先を複数のデータファイルとしている. 実用上の問題点として,このままでは, メール送信したりハガキ印刷したりする手間が別途必要になってしまう.

しかし,リダイレクト先をプリンタ出力フィルタや メール送信フィルタに変えるだけで簡単に, 実用的なダイレクトメール印刷 or スパムメール送信のシステムへ改造できる. (ただし,気軽にやってはいけません.)

ヒント:

レポート提出
  • 提出方法: 電子メール
    • 宛先:yanagawa@kushiro-ct.ac.jp
    • 件名:ex-0127
  • 提出期限:02月02日(木)17:00
  • 提出内容(本文):
    • 学年学科,出席番号,氏名
    • スクリプト
    • 入力テキスト
    • 実行結果
    • (疑問)
注意事項
  • コードを書く前に:問題をよく読もう.
  • メールを編集する前に:動作テストを繰り返そう.
  • 送信ボタンを押す前に:内容を再確認しよう.

パターンに対する高度な処理

プログラミング言語 AWK を使うと, フィルタコマンドを簡単に自作できる. 次回の予習として AWK について調べておこう.

AWKの第一歩


(c) 2017, yanagawa@kushiro-ct.ac.jp