Documents of TEBA

TEBA演習: 第2回 文字列の置換

1 Perl による文字列の置換

TEBAは、ソースプログラムの書換えを字句を単位として行なっていますが、 内部では、字句の書換えルールを文字列の置換に置き換えて、 Perl の文字列置換の機能を使って実現しています。 TEBAを知るためには、まず、Perl の文字列置換を理解していることが必須です。

2 join-token.pl を読む

文字列の書換えを実際にやっている簡単な例として、join-token.pl の簡易版を示します。 配布しているパッケージに含まれる join-token.pl は、 字句の属性の情報を出力するデバッグ用の機能があり、やや複雑ですが、 基本的な処理は以下のプログラムと同じです。

#!/usr/bin/env perl

while (<>) {
   chomp;
   if (/^(\w+)(?:\s+(#\w+))?\s+<(.*)>$/) {
     my ($t, $i, $s) = (, , );
      $s = &ev($s);
     print  "".$s;
   }
}

sub ev() {
   my $s = shift;
   my @r;
   while ($s ne "") {
      if ($s =~ s/^[^\\]+//) { push(@r, ); }
      if ($s =~ s/^\\n//) { push(@r, "\n"); next; }
      if ($s =~ s/^\\t//) { push(@r, "\t"); next; }
      if ($s =~ s/^\\(.)//) { push(@r, ); }
   }
   return join('', @r);
}

2.1 Perlのプログラムの実行準備

perl で記述したスクリプトは、次のように、 perl コマンドにスクリプトファイルを指定すれば実行できます。

perl join-token.pl

この方法では、常に perl コマンドを指定する必要がありますが、 これを省略する方法があります。スクリプトの先頭に次のように書くと、 このスクリプトが perl コマンドで解釈実行するように指定できます。

#!/usr/bin/env perl

ただし、そのままでは実行できず、 スクリプトファイルに次のように実行許可を与える必要があります。

chmod a+x join-token.pl

実行許可が与えられているかどうかは、ls -l を実行して、 属性に x が付いていることを確認してください。 これで、次のようにスクリプトファイルの名前だけで実行できるようになります。

./join-token.pl

実行許可を与えたスクリプトファイルを環境変数 PATH で指定している ディレクトリに置くと、パスを指定することなく、ファイル名のみで実行できます。

補足

スクリプトファイルの先頭の2文字 #! は「マジックナンバー」と呼ばれ、 スクリプトファイルであることを意味します。この2文字があると、実行時に、 そのあとにはスクリプトを実行するプログラム名が書かれていると解釈し、 #! のあとの記述にスクリプトファイルの名前を付けた命令を実行します。 したがって、join-token.pl では、先頭に #!/usr/bin/env perl と書いているので、スクリプトファイルを実行しようとすると、 実際には /usr/bin/env perl join-token.pl という命令が実行されます。

ここで /usr/bin/env は、指定した環境変数の状態で、 指定されたコマンドを起動するためのコマンドです。 ただし、ここでは単に perl のインタプリタを呼び出すためだけに使っています。 perl は環境によってインストールされているディレクトリが異なります。 #!の後ろは絶対パスを指定する必要があるので、perl のインタプリタを指定すると、 同じディレクトリにインストールされている環境でしか動かなくなります。 env コマンドは、通常、/usr/bin に標準インストールされているので、 環境の違いを吸収できます。

スクリプトファイルは、#! の後ろの文字列にファイル名を付加して実行するという単純な仕組みで動いています。 例えば #!/bin/cat と書くと、そのスクリプトの中身を出力するコマンドになります。 また、#!/bin/ls -lと書くと、そのスクリプトの情報を ls コマンドが出力します。 このようなスクリプトには実用性はありませんが、スクリプトファイルが起動される仕組みを理解するときの助けになると思います。

自習課題

  • コマンド chmod について調べなさい。

2.2 フィルタ型のPerlのプログラム

ソースプログラムを字句系列に変換したり、逆に元に戻すような処理は、 一つの入力から一つの結果を出力します。このような処理は、 フィルタ型のプログラムとして作成すると、他のツールとの連携が容易になります。

Perl でフィルタ型のプログラムを書くときの基本形は以下のループです。

while (<>) {
    ... 変換処理 ...
}

while 文の条件式の <> は特殊なファイルハンドルで、次のことを意味します。

例えば、次のように書くと、cat コマンドと同じ動作をします。

while (<>) {
  print ;     # 行末には改行が含まれているので、そのまま出力
}

このとき、読み込んだ 1 行の最後には改行も含まれます。 処理の内容によっては改行が不要なこともあります。 各行の文字数を出力するプログラムは、次のように chomp を使って改行を削ります。

while (<>) {
  chomp ;
  print length(), "\n";
}

while文はその内部処理を最後まで実行してから、条件判定を行いますが、 途中で条件判定に戻りたい場合は next 命令を、 強制的に while 文から抜けたい場合には last 命令を使います。 それぞれ、C言語では breakcontinue に相当します。

自習課題

  • 「標準入力」とは何か調べなさい。
  • C言語で、実行時のコマンドの引数はどのように参照するか調べなさい。
  • C言語で、break と continue はどのような意味があるのか調べなさい。
  • perldoc -f length と実行したときに表示される文のうち、 最初の段落に書かれている内容を理解しなさい。

2.3 暗黙の変数 $_

自然言語では、言葉を省略することがあるように、$_ は暗黙的な変数で、 省略することができる場合があります。 例えば、各行の文字数を出力するプログラムは以下のように書くことができます。

while (<>) {
   chomp;                # 省略しなければ chomp $_
  print length, "\n";   # 省略しなければ length($_)
}

Perl では、変数名は $, @, % のいずれかで始まり、 それぞれスカラー変数(単純に、1つの値を持つ変数と考えてください)、 リスト、ハッシュを意味します。 暗黙の変数 $_$ で始まるのでスカラー変数です。 リストやハッシュは、@arr%hsのように表現しますが、 各要素を参照するときはスカラー変数として参照します。 例えば、リスト @arr の要素は $arr[0], $arr[1], … と参照します。

2.4 chomp

標準入力やファイルから読み込む文字列は行単位であり、行末には改行が含まれます。 ただし、ファイルの最後の行は、改行が存在しない場合もあります。 多くの場合、改行は入力を切り分ける区切り文字であり、 処理をするうえで邪魔になることがあります。 そこで、あらかじめ、入力を受け取った直後に改行文字を削除しておくと、 以降の処理が見通し良くなります。

改行を削るときは、 chomp を使うと便利です。chomp は、 次の例のように引数に指定された変数の中の文字列の最後尾に改行文字があればそれを削除し、 改行文字がなければ何もしません。

chomp $str; # chomp の実引数は変数のみ

このとき、変数 $str の中の文字列が書換えられている点に気をつけてください。 また、引数に変数を指定しない場合には、暗黙の変数 $_ が指定されたものとして扱われます。

自習課題

  • Perl には、chomp コマンドと良く似た chop コマンドがあります。その違いを調べなさい。

2.5 パターンマッチング

文字列の中に特定の文字列が含まれるかどうかを調べるときは、 演算子 m// を使って判定します。 たとえば、変数 $str が保持する変数に "hoge" という文字が含まれているかどうかは次のように書きます。

if ($str =~ m/hoge/) {
  print "Yes, I found a hoge.\n";
}

探したい文字列は m// の中に書きますが、単純な文字列だけでなく、 任意の文字の並びなどパターンを書くことができます。パターンを書くときには、 以下のようなメタ文字を用いた正規表現を使うことで、様々な条件を表現できます。

メタ文字 意味
. (ピリオド) 任意の1文字
* 直前の文字の 0 回以上の繰り返し
+ 直前の文字の 1 回以上の繰り返し
? 直前の文字が存在しないか1つだけ存在
\w a-z, A-Z, _, 0-9
\d 0-9
\s 空白, タブ
^ 文字列の先頭
$ 最後の先頭
(A) 文字列 A をひとまとめに扱う(グループ化; 適合した文字列は変数 $数字 に格納)
(?:A) 文字列 A をひとまとめに扱う(グループ化; 適合した文字列は変数に格納されない)
A|B 文字列 A または B
[X-Y] 文字 X から Y の間の任意の 1 文字
[^X-Y] 文字 X から Y の間の文字以外の文字
\X 文字 X をエスケープ

なお、メタ文字を、単純に文字として扱いたいときには、直前にバックスラッシュを書きます。 Perlの正規表現については、man perlre または perldoc perlre を実行するとマニュアルが表示されますので、それを読んでください。

たとえば、$str が、hoge という文字列そのものであることを調べるには m/^hoge$/ と記述します。 また、hoge で始まり、tako で終わる文字列であるかどうかを調べるときは m/^hoge\w*tako$/ と書きます。

join-token.pl では、TEBAの字句に対して、次のようなパターンマッチングを行います。

if (/^(\w+)(?:\s+(#\w+))?\s+<(.*)>$/) {

このパターンに適合する TEBA の字句の例は次の通りです。

ID_TP      <int>
B_S #B0001 <>
LIS        <"<hoge>">

また、グループ化を用いることで、字句の各部が変数 $数字 に格納されます。 上記の字句の場合の例を以下に示します。

対象文字列 変数$1 変数$2 変数$3
ID_TP <int>; ID_TP int
B_ST #B0001 <>; B_ST #B0001
LIS <"<hoge>">; LIS "<hoge>"

$ のあとの数字は、左括弧の出現順を表しています。 ただし、(?: ) の左括弧は出現順を数えるときに含めません。

2.6 パターンマッチングを用いた書換え

パターンに適合した文字列を他の文字列に置き換えるには演算子 s///を、 以下のように用います。

$str =~ s/パターン/置換後の文字列/;

この場合、$str に含まれる文字列において、 パターンに適合する箇所のうち1箇所だけが「置換後の文字列」に置き換わります。 例えば、次のように実行すると、出力は Xaa になります。

$str = "aaa";
$str =~ s/a/X/;
print $str;

適合箇所をすべて置換したい場合は、次のように置換演算子の最後に gオプションを付けます。

$str =~ s/パターン/置換後の文字列/g;

例えば、次のように実行すると、出力は XXX になります。

$str = "aaa";
$str =~ s/a/X/g;
print $str;

置換後の文字列の中で、グループ化した箇所を参照する変数 $数字 を用いることができます。 cparse.pl の出力である字句系列を種別だけのリストに変換するためには、以下のように書きます。

while (<>) {
   s/^(\w+)\s+(\#\w+\s+)?<.*>$//;  # これは $_ =~ s/^(\w+)\s+(\#\w+\s+)?<.*>$/$1/; の省略形
   print;                            # これは print $_; の省略形
}

また、特殊な変数として $& を用いると、パターンに適合した部分を参照できます。 例えば、種別を括弧で囲むようにする変換するには以下のように書きます。

while (<>) {
   s/^\w+/()/;
   print;
}

なお、パターンは最長の長さになるように適合するので、 パターン ^\w+ は先頭からアルファベットや数字が続く限り適合範囲に入り、 その途中で止まることはありません。

2.7 局所変数

局所変数を宣言するときは、変数名の前に予約語 my を付けた宣言文を記述します。 Perl の場合、型は静的ではありませんので、型は記述しません。 例えば変数 $hoge を宣言するには以下のように書きます。

my $hoge;

複数の変数を宣言する場合は、括弧で囲ったリストとして記述をします。

my ($one, $two, $three);

宣言と同時に代入もできます。複数の変数に一度に代入するには、右辺にリストを 記述します。

my $hoge = "hoge";
my ($one, $two, $three) = (1, "two", 3);

2.8 サブルーチン

サブルーチンを定義するときは以下のように記述します。

sub 名前() {
  ... ここに定義を書く ...
}

サブルーチンを呼び出すときは、名前の前に & を付けます。

&名前(実引数, ...)

サブルーチンを定義するときに仮引数は記述しません。 代わりに、実引数はリスト @_ に入っています。 実引数を局所変数に代入するときは、次のように書くと簡単です。

my ($arg1, $arg2, $arg3) = @_;

これは以下のように書いた場合と同じです。

my $arg1 = [0];  # 注意: @_[0] ではなく、$_[0] と書く
my $arg2 = [1];
my $arg3 = [2];

実引数の先頭の値だけを取り出したいときは、次のように shift 命令を使って書くこともできます。

my $arg = shift @_;

この場合、@_ は省略して以下のようにも書けます。

my $arg = shift;

shift 命令リストの先頭の要素を削ってから値を返します。 したがって、この場合は @_ から先頭の要素が消えます。 複数の引数がある場合には、次のように書くこともできます。

my $arg1 = shift;
my $arg2 = shift;
my $arg3 = shift;

サブルーチンは return 文で値やリストを返すことができます。 リストを返す場合は、リスト変数か、個々の変数を持つリストで受けます。

my $max = &max(1, 2);   # 変数名の $max とサブルーチンの &max は区別される
my @ret = &func1();
my ($key, $val) = &func2();

sub max() {
  my ($a, $b) = @_;
  my $ret = ($a > $b ? $a : $b);  # 3項演算子は C言語と同じ
  return $ret; 
}

sub func1() {
  return (1, 2, 3, 4);   # 整数のリスト
}

sub func2() {
  return ("key", "value");  # 文字列のリスト
}

2.9 文字列

文字列はダブルクォートかシングルクォートで囲います。 ダブルクォートで囲った場合は、文字列中の変数名はその値に置換され、 \n などエスケープ文字も該当する文字に置換されます。 シングルクォートで囲った場合は、一切の置換が行われず、そのまま文字列になります。

補足

Perlでは、クォートの書き方は 1 通りではありません。 TEBA のモジュールでは、ダブルクォートを qq() で、シングルクォートを q() で表現しています。 これを用いると、 ダブルクォートを含む文字列を書くのが楽になります。 ただし、括弧の対応が合っていることが必要になります。

$x1 = "LIS <\"IKA\">";  # ダブルクォートはエスケープする必要がある
$x2 = qq(LIS <IKA>);   # エスケープする必要がない。
$y1 = qq(P_R <\)>);    # ただし、括弧はエスケープが必要
$y2 = qq/P_R <)>/;     # qq の後の記号は変更可能

詳しくは man perlop を実行し、“Quote and Quote-like Operators” を読んでください。

文字列は連結はピリオド演算子で行います。例えば、以下のように記述することで、 複数の文字列を組み合わせて標準出力に出力できます。

$who = "every"."one";
print "Hello, $who.\n".'Hi, $who.'."\n";

この場合、$who"everyone" が代入されるので、

Hello, everyone. Hi, $who.

と出力されます。なお、良く似た書き方で以下のように書くこともできます。

print "Hello, $who.\n", 'Hi, $who.'."\n";

出力結果は同じですが、この場合、カンマは print 命令の引数の区切りを表し、 文字列の連結ではありません。

文字列の等価性の判定には、演算子 eqne を使用します。 前者が等しい(equal)、後者が等しくない(not equal)を判定します。 数値の等価性を判定する ==!= は実体の同一性の比較になり、 意図通りには判定できません。演算子 eq と ne の例を以下に示します。

$x = "hoge";
if ($x eq "hoge") {
  print "Yes, you have hoge!\n";
}
if ($x ne "tako") {
  print "Oh, no! you don't like tako?\n";
}

自習課題

  • C言語で、文字列が等しいかどうかをどう判定するか調べなさい。

2.10 リスト

Perl では、リストを標準的なデータ構造として扱えます。 リストを保持する変数の名前はアットマーク @ で始めます。 ただし、その値を参照するときはドルマーク $ で始めます。 リストへの代入とその値の参照の例は次の通りです。

@list = (1, 2, 3, 4, 5);
print $list[0], "\n";
print $list[3], "\n";

リストの各要素の添字番号は 0 から始まります。 要素の添字番号の最大値は $#名前 で表現できます。 よって、すべての要素を列挙する例は、次のように書くことができます。

for (my $i = 0; $i <= $#list; $i++) {
  print '$list[', $i, '] = ', $list[i], "\n";
}

また、リスト変数を通常の数値演算の文脈(スカラコンテキスト)で参照すると、 要素数として解釈されます。上記の記述は、以下のように書くこともできます。

for (my $i = 0; $i < @list; $i++) {
  print '$list[', $i, '] = ', $list[i], "\n";
}

なお、すべての要素の列挙は次のように簡潔に書く方法もあります。

for my $x (@list) {
print "$x\n";
}

この例の $x は次のように省略でき、その場合、 $_ に各要素の値が入ります。

リストを列挙するときに、 すべての要素を結合した文字列を作成してから出力する方が楽な場合があります。 特に、カンマ区切りのように、各要素間の間に特定の文字列を挟みたい場合には join() を使用します。

print join(', ', @list), "\n";

リストの操作には、その他に、push(), pop() や、 shift(), unshift() があります。 push(), pop() はリストをスタックとみなして操作します。 shift()unshift() はキューとみなして操作をします。

unshift は、その名前の通り、shift の逆の操作をします。 以下のように記述すると、リストの先頭に要素を追加します。

@a = (1, 2, 3);
unshift(@a, 4);    # 先頭に 4 を挿入するので @a は (4, 1, 2, 3) になる。
print $a[0], "\n"; # 出力は 4 になる。

push と pop はリストをスタックとみなした操作で、push(@a, "x")$y = pop(@a) のように書きます。

Perl の命令は man perlfunc (または perldoc perlfunc) で確認することができます。

自習課題

  1. join を使った記述例を join を使わないで記述しなさい。
  2. データ構造の「スタック」と「キュー」とは何か調べなさい。 「キュー」の基本操作と shift, unshift の関係を理解しなさい。

3 演習課題

3.1 課題2-1

空白字句とコメント字句を、それぞれ1つの空白だけを持つ空白字句 (SP_B < >)に置換するフィルタ型プログラムを作成しなさい。 プログラムの出力は字句系列のままにし、ソースプログラムに戻すときは、 次のように join-token.pl を用いること。

cparse.pl hoge.c | shrink-space.pl | join-token.pl

ヒント

スペースやコメントを表現する字句は何か調べ、 その字句だけを置き換えるプログラムを書く。

3.2 課題2-2

join-token.pl を改造して、 ソースプログラムを HTML 化するフィルタを作成しなさい。 ここでの HTML 化とは、ウェブブラウザで表示させたときに、 元の記述が正しく表示される状態ことを言う。 課題2-1と同様に、出力は字句系列にすること。

ヒント

  • HTML の文字実体参照を使用する(「HTML エスケープ 文字」で検索すると見つけやすかも)。
  • 文字(列)定数やコメントなどの中にも含まれる可能性があることにも注意しよう。

3.3 課題2-3

ソースプログラムから空白類(空白や改行、コメント)を、 プログラムの意味を変えない範囲で削るフィルタを作成しなさい。

ヒント

  • まず、すべての空白類の字句を削るフィルタを作り、実験してみよう。
  • そのあと、削ってはいけない条件を考えて、例外を付け加えていくと作りやすい。
  • 以下のようなプログラムの例を考えて、試すこと。
extern int x;
static char func(int x, int y);
struct S { int s1; char s2; } ss;
#define HOGE x
#ifdef HOGE
int y;
#endif
void g() {
  do printf("hoge\n"); while (x > 0);
}

3.4 課題2-4

ソースプログラムの意味(振舞い)を保存しつつ、プログラムを難読化するフィルタを作成しなさい。 難読化の方法は各自で自由に考えること。

ヒント

  • 課題2-3の応用です。読みやすいプログラムとはどのようなものか、 ソフトウェア工学の教科書などを参考に調べ、その逆の処理を加えましょう。
  • 元に戻す(または、可読性が高い状態にする)フィルタが作れるかどうかを考えましょう。 フィルタができないということは、元のプログラムに含まれていた情報が復元できないほど失われており、 より難読になっている可能性が高いです。例えば、コメントをすべて削ると、 そのコメントを復元することはできないので、より難読になったと言えます。 一方、適当な文字列で構成された不要なコメントを大量に入れる方法は、 一見すると難読になったように思えますが、コメントを削るフィルタを通すことで、 可読性を高められるので、あまり効果がないということになります。
Copyrighted by Atsushi Yoshida. atsu@nanzan-u.ac.jp
Contact: Yoshida Atsushi, atsu@nanzan-u.ac.jp
[Documents of TEBA]