LoginSignup
8
1

More than 1 year has passed since last update.

ぜんぜんわからない。俺達は雰囲気で `perl -p -i.bak` をやっている

Last updated at Posted at 2022-11-28

perl -p -i.bak で何が起こるかを正確に理解する

はじめに

多数のファイルの特定の文字パターンを一括して書き換えたい場合に、我々はこのようなワンライナーを使うだろう。

perl -p -i.bak -e "s/foo/bar/g;" *.txt

お手軽に目的が果たせてとても楽ちんだが、裏でなにが起こっているか正確に理解しているだろうか。

理解しなくても目的が果たせれば大抵は問題ない。
だが、*.txt の一部に UTF-16LE が混ざっていたのでそれも処理できるようにしたい。
などと特殊ケースに対応させようとすると、とたんに行き詰ってしまうのだ。

「ぜんぜんわからない。俺達は雰囲気で perl -p -i.bak をやっている」

というネットミームを呟くことになる。

-iオプションの解説文が何処にあるのか、ぜんぜんわからない

-i.bak オプションの正確な動作を知ろうとして site:perldoc.jp "-i" で検索をかけてみたが

  1. HTML::Entities::ImodePictogram - i-mode用絵文字
  2. Perlの組み込み変数 $^I の翻訳
  3. perl 5.10.0 と5.18.1 の差分 - perldoc.jp

こんなものがgoogle検索トップ3に出てきて、-iオプションの説明に辿り着けない。
かなりの時間をかけて下記URLに辿り着き、ようやく解説文を得た。

-i[extension]
<> の構文で処理されたファイルを置き換えるための拡張子を 指定します。
これは、入力ファイルをリネームし、元の名前で出力ファイルを open し、
print() 文のデフォルトとしてその出力ファイルを select することで行ないます。
extension が指定されると、昔の内容のバックアップを行なう ファイル名の拡張子として、元のファイル名に付け加えられます。
extension が指定されないと、バックアップを作らず、 現在のファイルが上書きされます。
extension に * が含まれていない場合、現在のファイル名の末尾に 接尾子として付け加えられます。
extension に一つ以上の * の文字がある場合、 それぞれの * は現在のファイル名で置き換えられます。

この後もバックアップファイル名の生成方法について具体例が示されているが、そこは省略して動作説明箇所を引用する。

シェルから以下のように起動すると:

    $ perl -p -i.orig -e "s/foo/bar/; ... "
プログラムで以下のようにするのと同じで:

    #!/usr/bin/perl -pi.orig
    s/foo/bar/;
以下とほぼ等価です:

    #!/usr/bin/perl
    $extension = '.orig';
    LINE: while (<>) {
        if ($ARGV ne $oldargv) {
            if ($extension !~ /\*/) {
                $backup = $ARGV . $extension;
            }
            else {
                ($backup = $extension) =~ s/\*/$ARGV/g;
            }
            rename($ARGV, $backup);
            open(ARGVOUT, ">$ARGV");
            select(ARGVOUT);
            $oldargv = $ARGV;
        }
        s/foo/bar/;
    }
    continue {
        print;  # this prints to original filename
    }
    select(STDOUT);
違うのは、-i の形式が、いつファイル名が変わったかを知るために、
$ARGV と $oldargv を比較する必要がないことです。 
しかしながら、選択するファイルハンドルとして ARGVOUT は使用します。
ループのあとは、STDOUT がデフォルトのファイルハンドルとして再設定されます。

ふう、ようやく知りたかった動作の詳細に辿り着いた。

<> の解説文が何処にあるのか、ぜんぜんわからない

上記の -i オプションの動作解説には、$ARGV が説明なしで登場する。perlユーザなら常識であるが定義を確認してみる。

$ARGV
<> から読込みを行なっているとき、その時点のファイル名を示します。

とんでもなくあっさりした解説である。わかる人にはわかる系のダメな説明なのである。

何がダメかというと <> についての説明がなく、<> の解説文へのリンクが無いことである。
site:perldoc.jp "<>" で検索をかけてみると何もヒットしないので、わからない人や、雰囲気でわかったつもりになっている人にとってはそこで手がかりが切れてしまうのだ。

正確に理解したいので、再びかなりの時間をかけて下記URLに辿り着き、ようやく解説文を得た。

ヌルファイルハンドル <> は特別で、sed や awk の動作を エミュレートするために使われます。
<> からの入力は、標準入力からか、コマンドライン上に並べられた個々の ファイルから行なわれます。
動作の概要は、以下のようになります。
最初に <> が評価されると、配列 @ARGV が調べられ、空であれば、 $ARGV[0] に "-"を設定します。
これは、open されるとき標準入力となります。
その後、配列 @ARGV がファイル名のリストとして処理されます。

    while (<>) {
        ...                     # code for each line
    }
は以下ののような Perl の擬似コードと等価です:

    unshift(@ARGV, '-') unless @ARGV;
    while ($ARGV = shift) {
        open(ARGV, $ARGV);
        while (<ARGV>) {
            ...         # code for each line
        }
    }
但し、わずらわしく書かなくても、動作します。
実際に @ARGV を shift しますし、その時点のファイル名を変数 $ARGV に 入れています。
また、内部的にファイルハンドル ARGV を使っていて、<> はマジカルな <ARGV> の同義語となっています。 
(上記の擬似コードは、<ARGV> を通常のものとして扱っているので、 うまく動作しません。)

最終的に、@ARGV に扱いたいと思っているファイル名が含まれるのであれば、 
最初に <> を評価する前に @ARGV を変更することも可能です。
行番号 ($.) は、入力ファイルがあたかも 1 つの大きなファイルで あるかのように、続けてカウントされます。
個々のファイルごとにリセットする方法は、"eof" in perlfunc の例を 参照してください。

シンボル <> がファイルの最後で undef を返すのは一度きりです。
そのあとでもう一度呼び出すと、新たに別の @ARGV を処理するものとみなされ
その時に @ARGV を設定しなおしていないと、STDIN からの入力を 読み込むことになります。

perlユーザなら while (<>) は定番イデオムであり、どういう動作をするかは雰囲気でわかったつもりになっている。
だが $. のリセットや、<> が undef を返すタイミングについてもわかっているだろうか。いい加減に理解して、あるいは理解せずに使って嵌ってしまい、投げ出したことはないだろうか。

perl言語の悪いところ

perlはコード記述省略を是とする言語であり、また、記号を多用する言語でもある。
その結果、今回のように動作の詳細を調べようとすると検索に非常に苦労することになる。

perl -p -i.bak で何が行われるか

ようやく情報が揃ったので、本題に入ろう。

perl -p -i.bak -e "s/foo/bar/g;" *.txt

上記のコマンドは、以下の疑似スクリプトの実行に等しい。

#!/usr/bin/perl
@ARGV = <*.txt>; # ワイルドカードを展開してファイル名リストを得る.
while ($ARGV = shift @ARGV) { # ファイル名リストの先頭を抜き出し、$ARGV に代入する.
  open(ARGV, $ARGV);          # ファイル名 $ARGV を ファイルハンドル ARGV で入力オープンする.
  rename($ARGV, "$ARGV.bak"); # ファイル名 $ARGV のファイルを 拡張子".bak" を付けて改名する.
  open(ARGVOUT, ">$ARGV");    # ファイル名 $ARGV を ファイルハンドル ARGVOUT で出力オープンする.
  select(ARGVOUT);            # 標準出力を ARGVOUT に設定する.
  while ($_ = <ARGV>) {       # ファイルハンドル ARGV から1行を読み込み $_ に代入する. $. が更新される.
    s/foo/bar/g;              # ユーザが指定したperlスクリプト.
  } continue {
    print;                    # $_ の内容を、標準出力に設定されたファイルハンドル ARGVOUT へ出力する.
##  close(ARGV) if eof;       # この行を有効にすると $. は通算行番号ではなく、$ARGV毎の行番号になる.
  }
}

ユーザが指定したperlスクリプト s/foo/bar/g; が実行される時点では、特殊変数は以下のようになる。

特殊変数 意味と内容
@ARGV <>演算にて開かれた入力ファイル名までが除去されたコマンドライン引数配列
$ARGV <>演算にて開かれた入力ファイル名
ARGV <>演算にて開かれた入力ファイルハンドル
ARGVOUT -iオプション指定によりARGVと対になって開かれた出力ファイルハンドル
$_ <>演算にてARGVから入力された最新の行データ
$. <>演算にて入力された全ファイル通算の行番号(1始まり)

表中の各特殊変数に、perlloc.jp の該当解説文へのリンクを貼ったので、リンク先も見てほしい。

*.txt の一部に UTF-16LE が混ざっていたのでそれも処理できるようにするには?

冒頭のお題である UTF-16LE ファイルの対応について考えよう。
UTF-16LE で入出力するには、binmode で入力・出力ファイルハンドルのエンコーディングを指定すれば良い。
つまり、UTF-16LE ファイルの先頭行を読み込んだときに以下の処理を実行する。

binmode ARGV, ":encoding(UTF-16LE)";    # 入力ファイルハンドルのエンコーディングを UTF-16LE に変更する.
binmode ARGVOUT, ":encoding(UTF-16LE)"; # 出力ファイルハンドルのエンコーディングを UTF-16LE に変更する.
seek ARGV, 0, 0; $_ = <>;               # 先頭行を UTF-16LE で読み直す.

これにて一件落着に見えるが、「UTF-16LE ファイルの先頭行」を検出するのがとても難しく、それをワンライナーで記述するのは大変である。それを解決したとしても出力改行コードに対してエンコーディング指定がうまく働かない。
さんざんコードを弄り回した挙句、断念した。

perlの闇は深い。

参考資料リンク

END

8
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1