xargs や find と合わせて使う・代わりに使う Perl

昨日の @karupanerura さんの [Perlワンライナー小技集](https://qiita.com/karupanerura/items/366f9f082da4567e93a8) に続いて、私もワンライナーの話題です。ワンライナーといえば……ということで `xargs` に迫っていこうと思います。

------

シェルでのコマンドライン作業やシェルスクリプトをある程度経験していると、 **xargs コマンド** に出会います。

# xargs コマンドの簡単な紹介

xargs を本当に簡単に説明すると、 **標準入力のデータをコマンドライン引数にしてくれる** と言えますが、実際の xargs の具体例は Qiita で xargs タグを検索してみると先人の記事が色々出てきます。中には並列処理といった興味深いトピックを扱っているものもあるでしょう。

- [「tag:xargs」の検索結果 - Qiita](https://qiita.com/search?q=tag%3Axargs)

## find コマンドとの相性の良さ

この xargs は、ファイル検索を行う **find コマンド** と特に相性が良いことが知られていて、検索すると find コマンドと xargs コマンドをパイプでつなぐ記事が多く見つかるでしょう。

例えば「現在のディレクトリ以下にある拡張子 .pl ファイルに実行権限をつける」といったことを行いたい場合、 `find . -name \*.pl` で現在のディレクトリ以下にある拡張子 .pl ファイルを探して画面（標準出力）に表示することができますが、 `chmod` コマンドは対象ファイルをコマンドライン引数で指定する必要があります。このとき、find と xargs をパイプでつなぐことで、xargs が標準入力で得た内容をコマンドライン引数に置き換えてくれます。

```bash
find . -name \*.pl | xargs chmod +x
```

find コマンドで出力されるファイルの数が少なければ、コマンド置換 (Command Substitution) `$()` を使って

```bash
chmod +x $(find . -name \*.pl)
```

と書けばいいです。ただ、シェルはコマンドラインで指定できる最大文字数が決まっていて、あまりに長過ぎる場合はエラーで実行してくれないことがあります。そのようなことも想定して、xargs は適切な回数に分けてコマンドを実行してくれるというすぐれもの。

```bash
$ seq 1 100000 | xargs perl -e 'printf "execute: from %7d to %7d\n", $ARGV[0], $ARGV[-1]'
execute: from       1 to    5000
execute: from    5001 to   10000
execute: from   10001 to   15000
execute: from   15001 to   20000
execute: from   20001 to   25000
execute: from   25001 to   30000
execute: from   30001 to   35000
execute: from   35001 to   40000
execute: from   40001 to   45000
execute: from   45001 to   50000
execute: from   50001 to   55000
execute: from   55001 to   60000
execute: from   60001 to   65000
execute: from   65001 to   70000
execute: from   70001 to   75000
execute: from   75001 to   80000
execute: from   80001 to   85000
execute: from   85001 to   90000
execute: from   90001 to   95000
execute: from   95001 to  100000
```

筆者の環境（macOS Catalina）では上記のように5000個区切りでしたが、環境によって結果は変わるかもしれません。

## find と xargs で空白文字を含むパスに対応する

find と xargs の組み合わせで落とし穴となるのが、find で出力されたパス中に空白文字が混じっているケース。xargs で指定したコマンドは、通常はコマンドライン引数の区切りを空白文字とするので、意図した実行ができません。

```bash
$ find . -name \*.txt
./iPhone/iPhone 11.txt
./iPhone/iPhone SE.txt
./iPhone/iPhone 12.txt
./iPhone/Other.txt
./iPhone/iPhone X.txt
$ find . -name \*.txt | xargs chmod -v 600
chmod: ./iPhone/iPhone: No such file or directory
chmod: 11.txt: No such file or directory
chmod: ./iPhone/iPhone: No such file or directory
chmod: SE.txt: No such file or directory
chmod: ./iPhone/iPhone: No such file or directory
chmod: 12.txt: No such file or directory
chmod: ./iPhone/iPhone: No such file or directory
chmod: X.txt: No such file or directory
```

このときに用いられるのが、区切り文字を空白文字ではなくヌルバイトに変更する方法。それぞれ `find` コマンドの `-print0` と `xargs` コマンドの `-0` オプションを使います。

```bash
$ find . -name \*.txt
./iPhone/iPhone 11.txt
./iPhone/iPhone SE.txt
./iPhone/iPhone 12.txt
./iPhone/Other.txt
./iPhone/iPhone X.txt
$ find . -name \*.txt -print0 | xargs -0 chmod -v 600
./iPhone/iPhone 11.txt
./iPhone/iPhone SE.txt
./iPhone/iPhone 12.txt
./iPhone/Other.txt
./iPhone/iPhone X.txt
```

なお、空白文字が含まれている場合に問題が発生するのは、前節で紹介したコマンド置換を用いた例でも同様です。

ごくごく簡単ではありましたが、xargs コマンドの簡単な紹介と、xargs コマンドと相性のよい find コマンドとの組み合わせを見てきました。

# find と xargs の組み合わせに Perl を活用する

find と xargs の相性が良いことは分かったとはいえ、ちょっとした悩みもあるかと思います。

- find コマンドの検索構文がそもそも難しい
- xargs コマンドがコマンド実行を分けることがたまに煩わしいことがある

難しい・煩わしいとはいえ、find と xargs を全部捨てて Perl プログラムをゼロから書き始めようというつもりはありません（それが最善の場合もありますが、今回の趣旨からは逸れているので割愛します）。find や xargs で難しかったり煩わしかったり悩ましかったりする部分があれば、そこだけを Perl で置き換えてみたら簡単になったり見通しが良くなったりすれば儲けもの。

どんな Unix 系サーバにも大体 perl インタープリタはインストールされていることを活用しましょう！

## find コマンドの代わりに Perl を利用する

find コマンドの代わりに Perl を利用することはできるのでしょうか。

実は Perl 5.20 まで find2perl というプログラムが同梱されており、`find` コマンドの find 部分を find2perl に置き換えることで、それと等価な Perl プログラムを出力することができました。

```bash
$ perl -v

This is perl 5, version 20, subversion 3 (v5.20.3) built for darwin-2level
(with 1 registered patch, see perl -V for more detail)

Copyright 1987-2015, Larry Wall

Perl may be copied only under the terms of either the Artistic License or the
GNU General Public License, which may be found in the Perl 5 source kit.

Complete documentation for Perl, including FAQ lists, should be found on
this system using "man perl" or "perldoc perl".  If you have access to the
Internet, point your browser at http://www.perl.org/, the Perl Home Page.

$ find2perl . -name \*.pl
#! /Users/tetsuji.ogata/.plenv/versions/5.20.3/bin/perl5.20.3 -w
    eval 'exec /Users/tetsuji.ogata/.plenv/versions/5.20.3/bin/perl5.20.3 -S $0 ${1+"$@"}'
        if 0; #$running_under_some_shell

use strict;
use File::Find ();

# Set the variable $File::Find::dont_use_nlink if you're using AFS,
# since AFS cheats.

# for the convenience of &wanted calls, including -eval statements:
use vars qw/*name *dir *prune/;
*name   = *File::Find::name;
*dir    = *File::Find::dir;
*prune  = *File::Find::prune;

sub wanted;



# Traverse desired filesystems
File::Find::find({wanted => \&wanted}, '.');
exit;


sub wanted {
    /^.*\.pl\z/s
    && print("$name\n");
}
```

コアモジュール [`File::Find`](https://perldoc.perl.org/File::Find) を使用していることがわかります。

しかしながら、ワンライナーで全て終わらせる……という趣旨からはそれてしまいます。さらに find2perl コマンド、普通によく使われる find コマンドのオプションを結構知らなかったりと、何でも変換してくれるかと言えば、その期待からは程遠いとも感じます。

```bash
$ find2perl . -maxdepth 2 -name \*.pl
Unrecognized switch: -maxdepth
```

[Perl 5.22 以降に同梱されなくなった](https://metacpan.org/pod/release/RJBS/perl-5.22.0/pod/perldelta.pod#find2perl,-s2p-and-a2p-removal) こともありますし、find2perl コマンドは「そういうものもコアで付属していた時代がある」といった知識かなと思います（[`App::find2perl`](https://metacpan.org/pod/App::find2perl)モジュールをインストールすれば、たぶん最新の Perl5.32 系でも find2perl は使えると思います）。

コアモジュールではありますが `File::Find` のインターフェースは若干わかりづらさもあります。長い歴史を持つモジュールゆえ、モダンな視点から見たときにわかりづらいと感じるのは致し方ないでしょう。とはいえ、込み入ったディレクトリ再帰処理を想定した本格的な Perl プログラムで `File::Find` モジュールは大きな力を発揮してくれることも事実です。

「find コマンドの構文が難しい」解決策として、Perl で `find2perl` コマンドや `File::Find` モジュールを使って頑張る……というのは、ワンライナーとして見る場合においては難しさは変わらない、むしろ `find` コマンドのほうが簡単にも思えます。むしろ `find` コマンドをよく知った人にとっては、 `find2perl` コマンドは所望の検索構文を `File::Find` コマンドでどのように書けばよいのかという学習教材として活用出来る側面が大きいでしょう。今回はワンライナーを主に想定しているので割愛しますが、本格的な Perl プログラムで `File::Find` を利用したい場合に `find2perl` コマンドを思い出してみましょう。

## xargs コマンドの代わりに Perl を利用する

find コマンドの代わりに……での Perl 代用は少々歯切れの悪いものとなりましたが、xargs コマンドの代わりに Perl を利用することが良い場合もあります。

先ほどの

```bash
find . -name \*.pl | xargs chmod +x
```

の場合、標準入力を受け取るには `<STDIN>` と書けばいいこと、Perl ワンライナーで `while (<STDIN>) { ... }` のブロックを外側に書いたものとみなしてくれる `-n` オプションに、ワンライナーの Perl コードを書く `-e` オプションを組み合わせた `-ne` を利用すると

```bash
find . -name \*.pl | perl -ne 'chomp; system "chmod", "+x", $_;'
```

といった感じになるでしょう。とはいえ、改行文字除去の `chomp` が面倒ですよね。自動で `chomp` する `-l` オプションもあるので、上記は

```bash
find . -name \*.pl | perl -lne 'system "chmod", "+x", $_;'
```

まで簡略できます。

とはいえ、1行つまり1ファイルごとに `chmod` 外部コマンドを呼ぶのはコストが高い、かといって `-n` オプションの力を借りず

```bash
find . -name \*.pl | perl -e 'system "chmod", "+x", map { chomp; $_ } <STDIN>'
```

とすることは、膨大な数の引数を `chmod` コマンドに指定することになり、何かの制限に引っかからないか不安にもなります[^1]。

[^1]: シェルを経由しない場合、シェルの制限以外の制限があるかは不勉強でわからないのですが、たぶん何らかの制限はあるはずです。

今回は `chmod` をしたいということでしたが、実は Perl の組み込み関数にも `chmod` があり、今回に限っては組み込み関数を使うことで外部コマンド呼び出しコストを抑えることができるでしょう。

```bash
find . -name \*.pl | perl -lne 'chmod 0755, $_;'
```

Perl 組み込み関数版 `chmod` では、 `+x` といった権限の追加付与をするには工夫が必要です。詳しくは `perldoc -f chmod` を参照下さい。

- [Perlの組み込み関数 chmod の翻訳 - perldoc.jp](http://perldoc.jp/func/chmod)

拡張子 .pl ファイルのパスに空白文字が含まれていることはほぼ無いと思いますが、空白文字が含まれる場合も、上述の `find . -name \*.pl | perl -lne 'system "chmod", "+x", $_;'` はうまくいきます。詳しく説明すると、2引数以上の `system` はシェルを経由しないため、Perl プログラム上の `system` の第2引数以降と、実際に実行される外部コマンドの第1引数以降が正しく対応するためです。

実は `perl` コマンド自体にも `xargs` と同じ `-0` オプションがあり、これを使うと `find` の `-print0` と組み合わせて、改行の代わりにヌルバイトを標準入出力の区切りにすることができます。

```bash
find . -name \*.pl -print0 | perl -ln0e 'chmod 0755, $_;'
```

とはいえ、先述の通り、引数に空白文字が含まれていてもシェルを経由しなければ空白文字が引数の区切りにならないこともあり、（1行1データが遵守されている形態である）行指向シェルコマンド `find` と今回の `xargs` の代替としての `perl` においてはヌルバイト区切りを出さなくても良いでしょう。

複数行1データという出力をするコマンドがもしあれば、そのコマンドのデータの区切りをヌルバイトにすることで `| perl -ln0e` で受けることに意義が出てきますが、シェルコマンドやシェル上で扱うログ等は行指向であることが多いので、`find` と （`xargs` の代わりの） `perl` との組み合わせでヌルバイトを敢えて活用する機会はそこまで多くない気がしています（筆者の知らない活用法は色々あると思います）。

むしろ、Perl の面目躍如である、データベース接続や HTTP リクエストといった外部リソースを拠り所にする処理をワンライナーで行った場合、`xargs` が出てくると所定回数（筆者の環境では5000行ごと）に `xargs` で指定したコマンドの再実行が発生することにより、100万行単位での処理の場合に再利用できるデータベース接続やHTTPレスポンス結果を捨てて再度それを得ようとするという挙動に無駄を感じることがあります。その際、`xargs` を置き換えるように `perl` を使うことによって、自分でそのタイミング等をコントロール可能というメリットは、少し込み入ったワンライナーを書くときに効果を発揮する場合があるでしょう。

## find 以外のコマンドでの出力と Perl を組み合わせる

今回紹介したかったのは、 `find` 以外のコマンドでの出力を `xargs` にわたすときに空白文字が問題となるパターンです。

何度か触れましたが、 `find` コマンドの構文の難しさで悩むことがあります。

例えば、引数としてディレクトリのパスを渡すと、そのディレクトリを適切に掃除してくれる `mysweep` というコマンドがあったとします。

カレントディレクトリ直下のディレクトリを掃除してもらおうと

```bash
mysweep */
```

とすることで、シェルのグロブ展開 `*/` を使うことでカレントディレクトリにある全てのディレクトリをコマンドライン引数に展開することができます。なお、この場合はグロブ展開されるディレクトリに空白文字を含むものがあっても、 `mysweep` コマンドには適切に引数が渡ります。

とはいえ、カレントディレクトリ以下の特定の名前のディレクトリだけ除外したいという場合、グロブ展開だと難しさがあります。例えばカレントディレクトリが

```shell-session
$ ls -l
total 0
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Applications/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Books/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Desktop/
drwxr-xr-x  3 tetsuji.ogata  staff  96 12  8 10:10 Documents/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Downloads/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Library/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Movies/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Music/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 My Documents/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Pictures/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Program Files/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Public/
drwxr-xr-x  2 tetsuji.ogata  staff  64 12  8 10:10 Videos/
```

として、「Applications と Library 以下はシステムが使うもので不安だから `mysweep` で掃除させない」という場合、グロブ展開だと悩みがあります。今回は A と L で始まるディレクトリを除けばいいので `mysweep [^AL]*/` が回答となりますが、ユーザが新たに作ったユーザ独自ディレクトリが A や L で始まっていた場合、cron などに仕掛けていると意図せず掃除されていないディレクトリが出来ることとなります。

`find` コマンドで掃除対象ディレクトリだけ表示するにはどうすればよいでしょうか。頑張って書いてみます。

```bash
find . -mindepth 1 -maxdepth 1 -type d \( -not -name Library -and -not -name Applications \)
```

とはいえ、どんな状況でもこの難易度の `find` 検索構文が書けるかと言われると「大変そうだな」という感想もあるでしょう。筆者は正規表現が好きなこともあり、

```shell-session
$ \ls -1d */ | grep -v -E '^(Applications|Library)/$'
```

といった書き方の方がスッと出てきます。ファイル一覧といえば `ls` ですし、1行1エントリ出力を `ls -1` オプションで指示しつつ、 `grep -v` で指定正規表現にマッチしない行を出力という流れは、行指向のシェルの考え方に馴染みやすいと思います。正規表現の好き嫌いはあると思いますが、 `find` よりコマンドライン指定が短いのもポイント。

- `ls` の前のバックスラッシュ前置は `ls` が alias されていたときも元のパスが通ったところにある `ls` を素で使うという意味です。ls は alias されやすいコマンドでもあり、alias を避ける `\ls` 表記はしばしば行われます。ls をフルパスで書く方法と同じですが、移植性の問題があるでしょう。 `env ls` と書く解決法もあると思います
- `grep` を使う場合、基本正規表現(BRE) ではなく拡張正規表現(ERE) の方が Perl や JavaScript などのモダンなプログラム言語で使えるPerl互換正規表現(PCRE)に近いため指定しています。これがないと、この正規表現でも選択の `|` は基本正規表現の構文として `\|` と書かなくてはならなかったり、PCRE目線で見ると若干の違和感がある書き方となります

ただ、この `ls` `grep` 組み合わせ作戦だと、空白文字を含んだディレクトリを渡す際に問題となります。

一つの方法は `xargs` の代わりに `perl` を使う方法を思い出して

```bash
$ \ls -1d */ | grep -v -E '^(Applications|Library)/$' | perl -lne 'system "mysweep", $_'
```

または

```bash
$ \ls -1d */ | grep -v -E '^(Applications|Library)/$' | perl -e 'system "mysweep", map { chomp; $_ } <STDIN>'
```

とする方法。今回はカレントディレクトリ直下に膨大な数のディレクトリはないだろうと推測できるので（膨大な数のディレクトリがあったら `\ls -1d */` できっとコケます）、前者の1エントリ `system` 1コマンド呼び出しや、後者の全エントリ一括コマンドライン指定呼び出しをしています。

`perl` の中でやると、シェルには無かった `system` が出てくるのがちょっと……とも感じます。

であれば `perl` は黒子に徹して、 `find` の代わりにわかりやすい（？） `ls` と `grep` の組み合わせがあるけれど、それを受けるのは `xargs` 、だけど空白文字の問題が発生しないよう、 `ls` `grep` の行指向出力を改行文字区切りでは無くヌルバイト文字区切りに変換する役割を `perl` が担うとシンプルになりそうです。

```bash
$ \ls -1d */ | grep -v -E '^(Applications|Library)$' | perl -pe 's/\n/\x00/' | xargs -0 mysweep
```

`perl` のオプション `-p` は、`-n` と似た動作となりますが `while (<STDIN>) { ... }` ブロックの終わりで `print $_` を行うという追加動作をしてくれるオプションです。 `perl` がやることは読み込んだ1行の（末尾についている）改行文字をヌルバイト文字に置換して標準出力に書いているだけです。ここまで単機能に徹すると、Perl だけで全て書くようなパフォーマンスやバラエティ豊かな処理といったメリットは出ませんが、シェルスクリプトならなんとか分かるけれど……という後任の方が見てもわかりやすいのではないでしょうか。なにより、 `perl` はどんな Unix系 OS にもほぼ入っていると思っていいところは心強いです。

この例だと「 `ls` や `grep` の仕事も `perl` に肩代わりさせたらどう？」という発想もあると思います。とはいえ、GNU coreutils が提供している一般的なシェルコマンドをメインに使用しつつ、それらだけでは不得手な部分のみを Perl で補うという書き方は、広く普及している共通言語ともいえるシェルスクリプトに馴染んでいる多くのエンジニアにとって優しいでしょう。

`perl -pe 's/\n/\x00/'` と同じことは、もしかしたら `sed` `awk` `tr` といったシェルスクリプトでよく用いられる置換系コマンドでも可能かもしれません。そちらに寄せていくという作戦も有効ですが、Linux (GNU) 系と BSD 系といった系譜の違いで `sed` 等のコマンドの仕様に差異があるため、移植性の問題に当たることもしばしば。その点 Perl は Perl 5.10 以降の基本的な文法は安定しているため、今回解説した範囲でのちょっとしたワンライナー程度では移植性にまつわる問題をほぼ心配しなくて良いこともメリットです。

------

明日は @papix さんです。お楽しみに！