昨日の @karupanerura さんの Perlワンライナー小技集 に続いて、私もワンライナーの話題です。ワンライナーといえば……ということで xargs
に迫っていこうと思います。
シェルでのコマンドライン作業やシェルスクリプトをある程度経験していると、 xargs コマンド に出会います。
xargs コマンドの簡単な紹介
xargs を本当に簡単に説明すると、 標準入力のデータをコマンドライン引数にしてくれる と言えますが、実際の xargs の具体例は Qiita で xargs タグを検索してみると先人の記事が色々出てきます。中には並列処理といった興味深いトピックを扱っているものもあるでしょう。
find コマンドとの相性の良さ
この xargs は、ファイル検索を行う find コマンド と特に相性が良いことが知られていて、検索すると find コマンドと xargs コマンドをパイプでつなぐ記事が多く見つかるでしょう。
例えば「現在のディレクトリ以下にある拡張子 .pl ファイルに実行権限をつける」といったことを行いたい場合、 find . -name \*.pl
で現在のディレクトリ以下にある拡張子 .pl ファイルを探して画面(標準出力)に表示することができますが、 chmod
コマンドは対象ファイルをコマンドライン引数で指定する必要があります。このとき、find と xargs をパイプでつなぐことで、xargs が標準入力で得た内容をコマンドライン引数に置き換えてくれます。
find . -name \*.pl | xargs chmod +x
find コマンドで出力されるファイルの数が少なければ、コマンド置換 (Command Substitution) $()
を使って
chmod +x $(find . -name \*.pl)
と書けばいいです。ただ、シェルはコマンドラインで指定できる最大文字数が決まっていて、あまりに長過ぎる場合はエラーで実行してくれないことがあります。そのようなことも想定して、xargs は適切な回数に分けてコマンドを実行してくれるというすぐれもの。
$ 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 で指定したコマンドは、通常はコマンドライン引数の区切りを空白文字とするので、意図した実行ができません。
$ 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
オプションを使います。
$ 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 プログラムを出力することができました。
$ 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
を使用していることがわかります。
しかしながら、ワンライナーで全て終わらせる……という趣旨からはそれてしまいます。さらに find2perl コマンド、普通によく使われる find コマンドのオプションを結構知らなかったりと、何でも変換してくれるかと言えば、その期待からは程遠いとも感じます。
$ find2perl . -maxdepth 2 -name \*.pl
Unrecognized switch: -maxdepth
Perl 5.22 以降に同梱されなくなった こともありますし、find2perl コマンドは「そういうものもコアで付属していた時代がある」といった知識かなと思います(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 を利用することが良い場合もあります。
先ほどの
find . -name \*.pl | xargs chmod +x
の場合、標準入力を受け取るには <STDIN>
と書けばいいこと、Perl ワンライナーで while (<STDIN>) { ... }
のブロックを外側に書いたものとみなしてくれる -n
オプションに、ワンライナーの Perl コードを書く -e
オプションを組み合わせた -ne
を利用すると
find . -name \*.pl | perl -ne 'chomp; system "chmod", "+x", $_;'
といった感じになるでしょう。とはいえ、改行文字除去の chomp
が面倒ですよね。自動で chomp
する -l
オプションもあるので、上記は
find . -name \*.pl | perl -lne 'system "chmod", "+x", $_;'
まで簡略できます。
とはいえ、1行つまり1ファイルごとに chmod
外部コマンドを呼ぶのはコストが高い、かといって -n
オプションの力を借りず
find . -name \*.pl | perl -e 'system "chmod", "+x", map { chomp; $_ } <STDIN>'
とすることは、膨大な数の引数を chmod
コマンドに指定することになり、何かの制限に引っかからないか不安にもなります1。
今回は chmod
をしたいということでしたが、実は Perl の組み込み関数にも chmod
があり、今回に限っては組み込み関数を使うことで外部コマンド呼び出しコストを抑えることができるでしょう。
find . -name \*.pl | perl -lne 'chmod 0755, $_;'
Perl 組み込み関数版 chmod
では、 +x
といった権限の追加付与をするには工夫が必要です。詳しくは perldoc -f chmod
を参照下さい。
拡張子 .pl ファイルのパスに空白文字が含まれていることはほぼ無いと思いますが、空白文字が含まれる場合も、上述の find . -name \*.pl | perl -lne 'system "chmod", "+x", $_;'
はうまくいきます。詳しく説明すると、2引数以上の system
はシェルを経由しないため、Perl プログラム上の system
の第2引数以降と、実際に実行される外部コマンドの第1引数以降が正しく対応するためです。
実は perl
コマンド自体にも xargs
と同じ -0
オプションがあり、これを使うと find
の -print0
と組み合わせて、改行の代わりにヌルバイトを標準入出力の区切りにすることができます。
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
というコマンドがあったとします。
カレントディレクトリ直下のディレクトリを掃除してもらおうと
mysweep */
とすることで、シェルのグロブ展開 */
を使うことでカレントディレクトリにある全てのディレクトリをコマンドライン引数に展開することができます。なお、この場合はグロブ展開されるディレクトリに空白文字を含むものがあっても、 mysweep
コマンドには適切に引数が渡ります。
とはいえ、カレントディレクトリ以下の特定の名前のディレクトリだけ除外したいという場合、グロブ展開だと難しさがあります。例えばカレントディレクトリが
$ 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
コマンドで掃除対象ディレクトリだけ表示するにはどうすればよいでしょうか。頑張って書いてみます。
find . -mindepth 1 -maxdepth 1 -type d \( -not -name Library -and -not -name Applications \)
とはいえ、どんな状況でもこの難易度の find
検索構文が書けるかと言われると「大変そうだな」という感想もあるでしょう。筆者は正規表現が好きなこともあり、
$ \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
を使う方法を思い出して
$ \ls -1d */ | grep -v -E '^(Applications|Library)/$' | perl -lne 'system "mysweep", $_'
または
$ \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
が担うとシンプルになりそうです。
$ \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 さんです。お楽しみに!
-
シェルを経由しない場合、シェルの制限以外の制限があるかは不勉強でわからないのですが、たぶん何らかの制限はあるはずです。 ↩