(210) FileRenamer で学ぶ Ruby プログラム開発

Ippei Kishida

Last-modified:2018/12/10 15:53:44.

初心者に毛を生やせ。 中級者養成文書。

1 はじめに

1.1 最初のお題

たくさんのファイルをリネームすることを考えよう。 たとえば fig1.jpg, fig2.jpg の 2個のファイルがあるとして、 fig を削って 1.jpg, 2.jpg にする必要があるとしよう。 なに、2個だと多くないだと? 全くもってその通りだ。 だがここでは 100 とか 1万とか 100万のファイルを相手にすることを想像しながら読んで欲しい。 ここで 2個にしているのは、作業の全体を完全に見せるためだ。 100万行のスクリプトなんか書いても誰も嬉しくないし、 中途半端な省略よりも完全なスクリプトの方が見易い筈だ。 2は最小の複数だ。 2個での作業が理解できたら、100万個の作業を想像することも容易だろう。

科学技術計算をしていると 1万個を越えるファイルを扱うことはざらにある。 一般人でも、デジカメの写真を PC に取り込んだときなどに 1000個オーダーのファイルを扱うことも多いだろう。 そのような、日常的に行うリネーム作業を想定する。

1.2 愚直な方法

愚直な方法として、以下のように1つずつリネームしていくことが考えられる。

% mv fig1.jpg 1.jpg
% mv fig2.jpg 2.jpg

2個程度なら手でやってもすぐだが、数が増えると大変なことはすぐ分かる。 1つの変換に10秒かかるとしたら、1万個の場合には 24時間以上かかることになる。 100万個の場合には飲まず食わずで100日間。 これは知性ある人間のする仕事ではない。

1.3 本文書の目的

本稿では最終的に、ファイルの作成日時をファイル名にするのに、 ワンコマンドで済ませられるようにする。 何百万ファイルあろうが、コマンドを打つのに10秒で、あとは手間がかからない形にする。 そのような仕組みを自分で作れるようにすること、 それを作ることを通して UNIX の仕組みを理解することを目的とする。

読者として、Ruby の文法を一通りさらった初心者で、 中級者に成ろうとしている人を想定する。 個々の項ではプログラムのサンプルコードの明示を積極的には行わない。 それくらいのことはできる人を対象にしているわけだが、 検索しながら、あるいは教科書的な本を参照すれば難しいことではないだろう。 難しいと感じる人は課題として各項で実現すべきコードを書きながら読み進めて欲しい。 プログラミングを学ぶつもりでなくリネームスクリプトが欲しい人は、 rubyGems で filerenamer をインストールして使い方を調べれば、本稿を読む必要はない。

2 シェルスクリプト

諸君らが最初に覚えるべきは、シェルスクリプトだ。 以下のような内容のファイルを作る。 ファイル名は tmp.sh としておこう。 tmp は temporary(一時的な) の略として UNIX でよく使われる文字列だ。

mv fig1.jpg 1.jpg
mv fig2.jpg 2.jpg

要は、コマンドラインに打ち込むべき文字列をそのままファイルにしたものだ。 これをシェルに食わせてやると、その通りに実行する。

% /bin/sh tmp.sh

なに? ファイルを作るために 結局文字列を打ち込むんだからタイプ量が一緒だって? いやいやこんなのは 楽して作ればいいんだよ。

command ls > tmp.sh

上記のようにリダイレクトで tmp.sh の中身に ls の結果を流し込んで、 vim で 矩形編集してやれば良い。 UNIX 今日の技/vim の『範囲指定とそれに伴う編集』の、 特に『C-v で矩形選択』 を参考にすると良いだろう。 ちょっと面倒なように見えるが、 矩形選択を使った方法は ファイル数が何万になろうが ほぼ一定の作業コストで完了する。

その作業が1回だけと分かっている場合は、 私は大抵 Ruby も使わずに このように使い捨てのシンプルなシェルスクリプトを作る。

2.1 標準入力とファイル入力

% /bin/sh

のように、 シェルが引数なしで起動されたとき、 キーボードから入力を受け付けてそれに反応する。

% /bin/sh tmp.sh

のように、シェルがファイル名を引数として与えて起動されたとき、 そのファイルの中身を入力として受け入れて反応する。 perl や ruby などのインタプリタ型プログラムも、同様の挙動をするように作られている。

2.2 スクリプトファイル名

同じような作業を何度もしなければならないとしたらどうだろうか。 たとえばデジカメから写真を取り込むのは、 半定期的に行うことになるだろう。

まずは以前使った tmp.sh を何処かに保存しておいて、 それをコピーして適切に修正し、利用するということが考えられる。 たとえば十分に大きな数までのリネームを行うスクリプトを作っておいて、 最大番号のファイルに合わせてそれ以降を削除して使うとか。

tmp.sh という名前だと「一時的」ということしか分からない。 むしろ繰り返し使うのなら、体を表さない名ということになる。 ファイルの名前をもうちょっと良いものにしておこう。 ファイル名は短いものにすべきだが、ファイルの内容を正確に示そうとすると長くなりがちだ。 いつどのような場面でもピリッと気の利いた名前を付けるのは難しい。 ここではとりあえず、rename_fig.sh とでも付けようか。

2.3 拡張子

rename_fig.sh の末尾に .sh と付けた。 Windows ではファイル末尾の . を含む文字列は「拡張子」と呼ばれ、 ファイルタイプを判断する重要な要素だ。 しかし 実は、 UNIX のシステムは拡張子を使って何もしない。 rename_fig.sh でも、 rename_fig でも、 /bin/sh に与えるときに正しいファイル名を与えれば同じように動作する。 UNIX でも「拡張子」を付けるのは、人間にとって便利なことが多いからだ。 .sh で終わっているファイルは、それが何かの処理を実行するためのものであると 中身を見なくともファイル名を見るだけで推測できる。

2.4 shebang

ここまではコマンドの文字列をファイルに落とし混み、 コマンドラインで シェルを起動して引数でファイルを与え、これを食わせた。 しかしこのスクリプトは /bin/sh に食わせるものであり、 perl や ruby に食わせるわけではない。 ならばファイル内に「/bin/sh に食わせる」 と宣言してしまえば、 外部でシェルを指定する必要がなくなってちょっぴり便利だ。 実際にやってみよう。 rename_fig.sh の中身を以下のようにする。

#! /bin/sh
mv fig1.jpg 1.jpg
mv fig2.jpg 2.jpg

最初の #! が「おまじない」で、 そのあとに続く /bin/sh に食わせるべきだということを OS に教える役割を持つ。 この「おまじない」は shebang (シバン、シェバン)という名前が付けられているが、 別に覚える必要はない。 このように「おまじない」を書いておくと、より簡潔にスクリプトを実行できる。

% chmod 755 rename_fig.sh

まず上記のように実行権限を与えておく。 一度与えたら二度目以降の chmod は不要だ。 そして以下のように実行する。

% ./rename_fig.sh

「『/bin/sh 』 が『./』に変わっただけで 6 文字減っただけじゃん。』 それも事実だ。 しかし、コマンドラインの第一に実行ファイルが来たことで、 cd や ls のようなコマンドっぽい匂いがすることに気付いた人は鋭い。

2.5 パスによる指定

前節では「 ./rename_fig.sh」のように実行したが、 頭の「./」はカレントディレクトリを表す相対パスだ。 コマンドラインの第1要素のコマンドは、 実はパスであれば何でも良い。 先の例は「./」 でカレントディレクトリのファイルであることを示したが、 別に絶対パスで以下のように指定してもそのまま実行できる。 この絶対パスでの実行は、コマンド開発においてよく使うテクニックだ。 詳しくは Sec. 7.4 で扱う。

% /home/ippei/image/rename_fig.sh

絶対パスか相対パスでの実行ファイル指定が原則だが、 カレントディレクトリ直下のファイルだけが例外だ。 コマンド名と区別がつかなくなるから、 「./」を付けないとカレントディレクトリと見做さない、 というようになっているのだろう。

たとえば、 cd というファイルがあるディレクトリで コマンドラインに cd と打ち込んだとする。 このとき、 cd コマンドではなく cd というファイルを実行しようとされたら困る。 手に馴染んだコマンドを実行するとき、人はその挙動をするものと思い込んでいる。 これがカレントディレクトリのファイルの存在に依存するとなると、 いついかなる時でも cd を実行するときにはファイルの存在をチェックしなくてはならなくなる。 これはかなりイラッと来る挙動なので、通常はこうならないようになっている。

2.6 コマンドの実体

実は、ほとんどのコマンドにファイルの実態がある。 Ubuntu の場合、/bin/ls が ls コマンドの実体だ。 確認するには where コマンドを用いる。

% where ls
ls: aliased to ls -F --color=auto --show-control-char
/bin/ls

レスポンスの 1行目の「 ls: aliased to ls -F –color=auto –show-control-char」は、シェルに設定してあるエイリアス。 2行目がファイルの実体である。 試しに /bin/ls や /bin/ls -F –color=auto –show-control-char を実行してみると良い。 普段見ている ls 出力と同じものが得られるだろう。

2.6.1 シェル組込みコマンド

本節は余談。 ファイルの実体がないコマンドもある。 cd が代表例で、これはシステムに何かを行うのではなく、 ただシェルが扱うカレントディレクトリという情報を変更するだけだ。 これに where をかけると、以下のように シェル設定のほかに「shell built-in command(シェル組込みコマンド)」 と表示される。

% where cd
cd () {
  builtin cd $@ && ls -F --color=auto --show-control-char
}
cd: shell built-in command

これは諸君らの ~/.zshrc の内容に依存する。 岸田作の .zshrc を使っている人は上記のようになるはずだ。

なお、「~」はチルダ(tilde)と読み、UNIX のシェルではホームディレクトリを表す。 私の場合、ホームディレクトリは /home/ippei であり、これが ~/ となる。 /home/ippei/src は ~/src である。 他人のホームディレクトリは ~/ では表現できない。 特別な記法として、 「~kishida」 のように 「~」 の直後に 「/」がなければ、 ~ を 「/home/」 に置換して 「/home/kishida」となる。

チルダは「波」の意味で、英語の「tide」 と同じ語源を持つ単語。 海の召喚獣の必殺技を「タイダルウェイブ(Tidal wave)」と呼んだりするが、あれと関係のある単語と言える。

2.7 コマンドサーチパス

コマンドラインで ls と打ったとき、 シェルは ls を「/bin/ls のことだ」と判断して、 /bin/ls を実行しているのだ。 ではどうして ls で /bin/ls なのだろうか。 こんなとき、逆に考えるんだ。 毎回正確に /bin/ls と打たねばならないなら……、 なんて不便なんだろう! ls のように頻繁に使うコマンドを /bin/ls なんて毎回打っていられない。 そこで「決まったディレクトリに入っているファイルはコマンドとして扱う」 という機能がシェルに備えられている。 /bin/ ディレクトリはその「決まったディレクトリ」の一つであり、 その中に ls というファイルが存在している。

ls /bin してみよう。 以下のような錚々たるコマンドの実体が存在している。

bash, cat, chmod, chown, cp, date, dd, df, dmesg, echo, grep, gunzip, kill, less, ln, ls, mkdir, more, mv, ping, ps, pwd, rm, rmdir, sh, sleep, su, tar, touch, which, zsh

この「決まったディレクトリ」は「コマンドサーチパス」と呼ばれる。 コマンドサーチパスは /bin だけ登録されているわけではなく、 またユーザが任意に設定できる。 zsh で これを設定するのが 環境変数 PATH だ。 echo コマンドで確認できる。 私の環境では、

% echo $PATH
/home/ippei/.gem/ruby/2.3.0/bin:/home/ippei/.cabal/bin:/home/ippei/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

コロン区切りで1行にまとめられている。 見難いので箇条書きにしてみよう。

コマンドラインに ls が指定されたとき、 シェルはこれらのパスの中から ls というファイルを探し、それを実行する。 私の環境では /bin/ls で見つかったので、これが実行されたわけだ。

2.8 rehash

apt-get install や gem install でインストールした筈のコマンドが、 直後に実行できなかった、といった経験はないだろうか。 実は、コマンドサーチパス内のファイルはリアルタイムに追跡されていない。 apt-get などでインストールされたコマンドの実体は コマンドサーチパスのディレクトリ内に作られるが、 パス内の情報をシェルが捕捉していないため、 そのようなことが起こる。 この場合、 rehash コマンドを実行してやると 登録コマンドを更新する。

いやちょっと待って。 なんで rehash なんてあるんだ? コマンド実行する度にサーチしてやればいいだろう? これは端的に、実行コストの問題だ。 サーチするのにはそれなりに時間がかかる。 まあ今時の PC なら体感的にゼロ秒だが、 低性能機でサーチパスやコマンドの多いシステムといった条件では 体感できる程度に時間がかかることがある。 コマンドが追加されるのは比較的レアなことなので、 日常的に打つ全てのコマンドでこのようなコストをかけるよりも シェルの起動時や 明示的に rehash した時にだけサーチするのがリーズナブルなのだ。

2.9 自作プログラムをコマンドとして使う

どんなファイルでも、コマンドサーチパスの通っているディレクトリに配置すれば、 コマンドとして実行できる。 そう、君が作ったプログラムもだ。 コマンドサーチパスの通っているディレクトリは多数あるが、 そのうちどれが良いか? まずは ~/bin に入れるものと思っておけば良い。 ホームディレクトリ以外のディレクトリは一般ユーザにファイルを置く権限がないので 置けない。 ホームディレクトリ以下でも ~/bin 以外のディレクトリは RubyGems などの仕組みからインストールされるもので、 やはりエンドユーザが直接触る必要がないものと思っておこう。 ~/bin が存在しなければ、作っておこう。

なお、bin は元々「バイナリ」の意味で、 二進数で表現されたファイルを示す言葉だったが、 ソースファイルからコンパイルされた実行ファイルのことを 指すようになり、 これらを納めるディレクトリに頭3文字を取って bin となった。 スクリプトファイルは二進数ファイルとは言えないが、 実行ファイルなので慣習的にこのディレクトリに入れられる。

さて、rename_fig.sh を ~/bin/ に置いて、rehash した。 これで君はどのディレクトリにいても rename_fig.sh を実行できるようになった筈だ。

2.10 このシェルスクリプトからの卒業

だがちょっと待って欲しい。 rename_fig.sh はほとんど使い捨てスクリプトの体裁であって、 汎用性に乏しい。 任意のディレクトリで実行するには、そのディレクトリ内のファイル構成によって スクリプト自体を書き換える必要がある。 これは不便だし、 エラーを生じうるプログラムは危険なニオイがする。 汎用性を強化すべく、プログラムを改良したい。

sed コマンドやらを駆使すれば、 シェルスクリプトのまま改良を行うことは不可能ではない。 しかし ちょっと複雑な処理をしようとすると、シェルスクリプトでは死ぬほど苦労することになる。 Ruby などの高級言語を使えるようになった方が、圧倒的に楽になる。 たとえて言うならば、 シェルスクリプトは小学校の算数で Ruby は中学校の数学だ。 算数でも鶴亀算を解くことができるが、中学校の数学で方程式を学んでしまえば 「なんであんな苦労して算数の枠組みで解かなあかんかったんだ」と感じるだろう。 これと同じで、シェルスクリプトで解決すべきなのは単純な処理、 ちょっと複雑なものは然るべき枠組みを用意してくれている高級言語を使うべき、 ということになる。 さあ、シェルスクリプトを卒業すべき時が来た。

3 Ruby スクリプト

3.1 system() を使った Ruby への単純な書き換え

では rename_fig.sh を Ruby に書き換える。 rename_fig.rb と拡張子を変更しておこう。 最初の内容は以下のようにしよう。

#! /usr/bin/ruby
system("mv fig1.jpg 1.jpg")
system("mv fig2.jpg 2.jpg")

shebang の「おまじない」のパスが変わった他は、 system メソッドで文字列をシェルに投げているだけだ。 「うーんでもこれ便利なのか? 却って文字数増えているじゃないか」。 ごもっとも。 でもここでは「シェルに投げるコマンドを systemメソッドで投げられること」を示したかったのだ。 シェルに投げている仕事は、何でも Ruby から実行できるのだ。

3.2 ファイルシステムからファイル情報を取得して実行

まずプログラムをファイル数に対する制約から解き放とう。 以下のようにすることで、 カレントディレクトリにファイルが幾つあっても 修正なく対応できる。

#! /usr/bin/ruby
Dir.glob("fig*.jpg") do |old_file|
  new_file = old_file.sub("fig", "")
  system("mv #{old_file} #{new_file}")
end

3.3 .rb を削る

今の状態で、どのディレクトリでも rename_fig.rb が そのまま実行できる筈だ。 任意の個数の fig*.jpg が存在するディレクトリで動作するだろう。

さて、rename_fig.rb というコマンド名をユーザとして見たらどうだろうか。 まず .rb という情報が不要だ。 ユーザからすれば、fig という文字列が削除されたリネームがなされるという挙動が 重要なのであって、 それが Ruby で書かれようが C で書かれようがどうでもいい。 君自身、たとえば ls コマンドを知ってから今まで、 それが C で書かれたか Ruby で書かれたか、 興味がなかったのではあるまいか。 なので、コマンド名としては rename_fig.rb よりも rename_fig の方がふさわしい。 _ を使ったコマンド名はあまり見慣れないが、 少数ではあるものの存在するのでまあ許容範囲というところか。

4 次のお題: JPG から jpg

君は別のデジカメからも画像を吸い出すようになった。 そのデジカメは画像を 01.JPG のように、大文字の拡張子 JPG で書き出していた。 君はこれを小文字に統一したいと考えた。

インスタントに思い付くのは、 先の rename_fig をコピーして一部修正して別コマンドを作ることだろう。 しかしこの時、「コピー」を考えたときに少し嫌なニオイを感じ取って欲しい。 この方法でも、実際に動くモノを作ることができる。 しかしプログラムの発展はそこでほとんど止まってしまう。 コピーしてしまうと、 バグフィクスや新機能実装は、コピーした全てのファイルに対して行う必要がある。 本稿ではこの後、 リネーム先が既に存在する時の安全機能や リネームのかわりにコピーするといった機能追加を行うが、 複数のスクリプトファイルに追加していくとなると大変だ。 10のスクリプトに10の機能を追加すると、100回の修正になる。 これはやっていられない。

4.1 コマンドライン引数を使う

ここで機能を再考しよう。 「fig を削る」、「JPG から jpg」というのは、いずれも ファイル名の文字列置換をしたいということだ。 置換元と置換先の情報があって、その通りに置換するということが求められる。 たとえば rename というコマンドを考えて以下のようなコマンドラインでどうだろうか。

% rename fig ""
% rename JPG jpg

これでコマンドの汎用性が格段に広がる。 狭いトンネルから抜けて青空が広がったような、自由に何でも置換できるような そんな広がりが感じられる。 コマンドラインのタイプ量はわずかに増えるが、 スクリプトの内容を変更せずにどんな文字列置換もこのコマンドで対応できる。 こういうものこそ ~/bin に置いてコマンドとして活用すべきものだろう。 rename_fig よりもコマンド名がすっきりするし、 複数の機能を1つのスクリプトにまとめられたというのが大きい。 今後どんな機能追加をするにしてもこの rename スクリプト1つをいじるだけでいい。

Ruby プログラム内でコマンドライン引数を扱うには ARGV 変数を使えば良い。 ARGV[0] を置換元、 ARGV[1] を置換先の文字列としてファイル名の文字列操作で 新しいファイル名を作り、リネームしてやればいい。 このスクリプトを ~/bin/rename としよう。

このプログラムを作ることで、 下のコマンドの違いがはっきりすることだろう。 1つめは第2引数として 空文字を渡し、 2つめは第2引数がそもそもいない。 2つめは、何も対処しなければプログラムは処理できずエラーになるだろう。

% rename fig ""
% rename fig

これ以降、具体的なコードはあまり出さない。 コーディングは自分でやってみよう。

4.1.1 rename コマンド名のバッティング

使っているシステム(Linux のディストリビューション)によっては rename というコマンドがプリインストールされていることがある。 名前のバッティングについては Sec. 6.2.4.1 で述べるので、取り敢えずそのまま我慢し、痛みに耐えて進めて欲しい。 「痛くなければ覚えませぬ」、と。

4.2 リネーム先が存在する場合の対処

ある時君は、リネームを実行したディレクトリに新たに fig*.jpg を移動してきてしまい、 そこで rename fig "" を実行してしまった。 1.jpg と fig1.jpg の存在するディレクトリで rename_fig を実行するとどうなるか。 そう、ファイルが上書きされて失われてしまうのである。 あなたはこのような悲劇を繰り返さないことを心に誓った。

で、どうするか。 心掛ける、というのは安全管理上 下策である。 プログラム側に安全回路を組付けるべきだ。 ひとまずは、移動先のファイルが既に存在すれば実行しない、ということでいいだろう。

4.3 対象ファイルの指定

というファイルがあるディレクトリで、JPG ファイルだけ fig を消したいとする。 今の rename では、rename fig "" とすると png ファイルも fig が削られてしまう。 以下のように一時ディレクトリに JPG ファイルを隔離してそこで作業することもできるが、いかにも手間だ。

% mkdir tmp
% mv *.JPG tmp
% cd tmp
% rename fig ""
% mv *.jpg ../
% cd ..
% rmdir tmp

どうすると便利になるだろうか。 ここでは、第3引数以降にファイルを指定する機能を追加することを提案する。 以下のようなコマンドで fig*.JPG のみを対象にしたい。

% rename fig "" fig*.JPG

これができるようになると、同時にカレントディレクトリ以外のファイルも対象にできるようになる。 たとえば以下のように、 全サブディレクトリの全ファイルをカレントディレクトリに移動させるコマンドも可能になる。

% rename / _ */*

これを実装するには、コマンドの第3引数以降を扱えれば良い。 shift を 2 回使って 置換元、置換先の文字列を抜き出した残りを対象ファイルとすれば良い。

4.4 引数の仕様の検討

4.4.1 順序

rename の引数を、「置換元, 置換先, 対象ファイル(複数)」 としていた。 この順序が最適だろうか? ちょっと考えてみよう。 置換元・置換先 だけに注目すると、両者はこの順序で隣接しているべきだろう。

「置換先, 対象ファイル(複数), 置換元」 みたいな順序を仕様としたら気持ち悪くて仕方ない。 なので実質、以下の二択になる。

後者もなかなか いいような気がする。 「〜〜(ファイル群)の 〜〜(文字列)を〜〜(文字列)に変更」 というように日本語の語順の発想と一致する。 こちらの方がいいかもしれないなあ、と考え、 どちらが良いかを検討していこう。

4.4.2 引数の省略についての検討

それぞれの引数を省略するケースがあるかを考えよう。 不可欠な要素を先に持ってくる方が扱い易い。 引数の位置付けがはっきりしてユーザとしても分かり易くなる。 ここでそれぞれの引数の省略について 省略して動作させることがありうるか、 省略したときの値はどのようになるべきか、 考えてみよう。

置換元については、省略したいケースはありえない。 たとえば空文字をデフォルト値としたとしても、 空文字からの置換は無意味だろう。 なのでこれは省略されることがない、不可欠な要素と見做すべきだ。

置換先についてはどうだろうか。 作成プログラムが rename_fig だった頃の目的が「fig を削除したい」だったように、 置換先を空文字列にすることが自分にとって多く、 これをデフォルト値にすると便利かもしれない。 しかしデフォルト値の設定は「よく使う、便利」という観点よりも、 「それが正当である」という観点を優先すべきだ。 たとえば ls コマンドを考えてみよう。 何も余計なことをしない状態が正当であり、 これがデフォルト値であるべきである。 仮に多くの人が ls -F のようにファイルタイプ識別子をつけて使用していて、 シェルにエイリアスとして登録していたとしても、 -F オプションをコマンドのデフォルト値として設定すべきではない。 -F オプションをデフォルトにして便利かどうかは、属人的だ。 「間違いなくこちらがデフォルトである」と、 誰もが認めるものがデフォルトであるべきだ。

対象ファイルを省略することはどうだろうか。 厳格な適用をした場合、対象ファイルを指定しない場合は空配列となり、 何一つ変更しないという挙動になる筈だ。 でもこれを利用することはありえない。 何もリネームしないのならば、そもそもコマンドを発行しなければ良いのだ。 さて、 rename_fig だった時代を考えてみよう。 この時はプログラム内でカレントディレクトリの全ファイルを取得して それを対象ファイルの指定としていた。 対象ファイルを指定しない場合はこのようにカレントディレクトリの全ファイルとしてはどうだろう? これは ls コマンドの挙動と似ており、馴染み易い。 ls コマンドの場合、複数の引数を与えればそれらを対象とする。 引数を省略したらカレントディレクトリの(ドットファイル以外の)全てのファイルを対象とする。 これで問題ないだろう。

4.4.3 引数の個数変更についての検討

置換元、置換先 の文字列はそれぞれ1個ずつ指定される。 0個とか10個とか指定されることはなく、不変である。 それに対して対象ファイルの個数は可変である。

コマンドを作成するときには、可変個数の要素を最後にまとめた方が作り易い。 個数固定のものを先に置くと、これら自身の引数の番号は不変である。 可変のものを先に置くと、それ以降の引数の番号が変動する。 引数を最後から取り出すということも難しくはないが、より簡単な方法の方が好ましい。

既存コマンドの例として grep を取り上げよう。 grep は 「 grep 検索文字列 ファイル群 」 のように指定して使用する。 これも固定個数の検索文字列を先に指定し、 個数が変動するファイル群を後にしている。 なお、grep はファイル群の指定を省略して空にすると、 カレントディレクトリの全ファイルではなく標準入力を対象とする。 「どうしてこのような仕様になっているのか?」と 考察してみることはコマンド作成者としてのスキルアップに役立つので 是非やってみるべきだ。 ヒントは「フィルタ プログラム パイプ」だ。 よく分からない人はぐぐってみよう。

5 次のお題: mv ではなく cp

君は気になるあの人に写真を送りたいと思った。 その場合、渡すべき媒体へのリネームではなく、コピーにすべきだ。 今迄 mv を使っていたところを cp にすればいいだけだ。 仕組みとしては簡単だが、どのように実装すべきか。 たとえば rename の中身を都度書き換えて対応する方法はなくもないが、 まあ普通にやりたくない。 動いているコードを修正することは、動かなくなる可能性を内包し、 ちょっと気が重い。 rename をコピーして別ファイルを作り、 それを新たなコマンドにする方法はどうだろうか。 Sec. 4 で以下のように述べた。

インスタントに思い付くのは、 先の rename_fig をコピーして一部修正して別コマンドを作ることだろう。 しかしこの時、「コピー」を考えたときに少し嫌なニオイを感じ取って欲しい。

これと同じだ。 先に述べた通り、コピーしたらプログラムの成長性を閉ざすことになる。 コピーせずに1つのプログラムに複数の機能を担わせたらいい。 状況に応じてプログラムの動作を変化させる方法を考えよう。

5.1 オプションで挙動を変更

「動作の一部分が変わる」系の挙動には、オプションで取り扱うのが定石だ。 ls コマンドはファイル名を表示するが、これに -l オプションを付けると詳細な情報を併せて出力する。 我々の rename スクリプトは、デフォルトでは mv するようにしておき、 -c もしくは –copy オプションがあるときは cp する、とすれば良いだろう。

オプションを受け入れるということは、 コマンドライン引数に入ったオプションを正しく扱う必要がある。 引数で “-” から始まる要素はオプションとして解釈するという処理をすれば良い。 自分で実装することもできるが、 optparse あたりのライブラリを使った方が今後ラクになるだろう。

5.2 ディレクトリのコピー

さて、mv を cp に変えたら動くと思ったかもしれないが、 これでは上手く動作しないケースがある。 対象がディレクトリの場合だ。 この場合、cp -r のように cp コマンドに 再帰的(recursive) オプションを付ける必要がある。 -r オプションは通常ファイルに対して作用させても問題がないため、 常に cp -r とすれば良い。

このような「上手く動かないケース」は事前に気付けば良いが、 人間のすることなのでどうしても漏れは生じる。 理想的には最初の問題が生じる前に「こんなこともあろうかと」と先回りして対処しておくのが格好良いが、 そんな真田さんみたいなことは普通はできない。 そして、これらを事前に予見できなければプログラミングできないわけではない。 さあ恐れるな。 迷わず行けよ。 行けば分かるさ。 何かあったときに取り返しがつくようにバックアップを取っておくことも、 思い切り良くプログラミングを進めるために有効だ。

6 次のお題: 更新日時リネーム

君は写真を撮影日時で整理したいと思った。 そのためにファイルの作成日時を使ったファイル名にすると便利だ。 仕組みとしては単純だが、 コピーモードのように、rename への機能追加とするのは難しい。 コマンドライン引数の仕様が異なるからだ。 rename と異なり、 置換元や置換先の情報を渡す必要がなく、 対象ファイルの情報だけから新しいファイル名を作ることになる。

6.1 コマンド名の再考

新機能を追加するこのタイミングで、旧機能との差違を考えてみよう。 またそれぞれの機能をどのような名前で表すべきか、再検討しよう。 新機能に関するキーワードは リネームに加えて、時間・日時・更新時刻 あたりだろうか。 ここでは「時間」(time) という単語を選んだとしよう。 旧機能は「リネーム」としか認識していなかったが、 振り返ってみると実は「文字列置換」(substitute)が重要な要素だったことが分かる。

6.2 実装の仕方の検討

では、time 機能と substitute 機能をどのように実現するか。 コマンドラインはどのようになるべきか。 やり方は以下のように幾つかある。

一つずつ検討していこう。 どのような形が人間にとって把握し、また理解し易くあるか、という事がポイントだ。

6.2.1 オプションで挙動を変更

rename に –copy オプションによるコピーモードを追加したときのように、 オプション –time を渡されたときに挙動を変化させることを考えてみよう。 このとき、オプションの有無で第1、第2引数の扱いが変化する。 これはかなり大きな変化だ。 別のコマンドになるべきものを無理矢理一つにまとめている感じがする。

また、substitute と time のどちらがデフォルトでどちらがオプショナルになるのか という問題もある。 考えてみれば、substitute が time よりも「正当」と言えるほどの根拠はない。

6.2.2 コマンドライン引数の数で自動的に判別

コマンドライン引数の数で挙動を判別する方法がある。 君は date コマンドを知っているだろうか。 単純に date と実行すれば、 以下のように日付を表示する。

% date
2018年  3月 26日 月曜日 22:46:29 JST

しかし以下のように引数 1 を付けて実行すれば、 指定の時刻にシステム時刻を設定する。

% date 03272248
2018年  3月 27日 火曜日 22:48:00 JST

これと同じように、rename も引数があれば文字列置換、 なければ日付変更にするというのはどうだろうか。 しかしこれは、おそらく原理的に不可能だ。 引数が2個のとき、 置換元と置換先を省略したのか、 対象ファイルを省略したのか、判別が付かない。

また、現段階では置換と日時だけの置換コマンドを作っているが、 他の置換コマンドを作りたくなるかもしれない。 そのとき引数の個数で判断しているとすぐに限界が来る。

6.2.3 サブコマンドで挙動を変更

君は git コマンドを知っているだろうか。 コマンドラインの最初は git だが、 それに続けて pull, commit のようにサブコマンドを指定することで 様々な挙動を切り替える。 これを見習い、以下のようにサブコマンド形式にすることも 悪くはない。

% rename substitute
% rename time

ただ、現時点の rename にはちょっと大袈裟だ。 サブコマンド形式の実装は少々手間がかかり、 たった2つの機能をまとめるためにサブコマンド形式を用いるのは コストに見合わない。 理屈の上では単純なんだが、 オプション解析やヘルプや利用方法表示を キチンと動かすには結構 骨が折れる。

6.2.4 別コマンドを作る

substitute 機能と time 機能で別のコマンドを作ることはどうだろうか? 別コマンドにするデメリットの一つはプログラムコードが重複するということ。 これまで Sec. 4 などでコードのコピーを戒めてきた。 しかし、別ファイルにしてもコードのコピーを最小限に抑える方法がある。 コマンドから使われるライブラリを作ることだ。 ライブラリ化については次節で述べる。

コマンドの名前管理の対象が増えるということもデメリットの一つとして挙げられる。 1個のコマンドが増える程度なら大したことないが、 10個、20個と増えていくと 「どういう機能を持ったコマンドをどういう名前で作ったか」忘れたりする。 オプションのように一つの名前のオプションとして扱うことは、 この観点でもメリットがある。

6.2.4.1 コマンド名の指針

気持ちの良いコマンド名は幾つか考え方があるが、基本的には瑣末な問題だ。 その上で岸田が個人的に気をつけていることを述べる。 コマンド名にはタイプ感が良いコマンド名が好ましい。 思い付いたコマンド名をタイプしてみてひっかからなければ良い。 たとえば 「de」のような文字の続きがあると、 同じ指を別の位置に動かしてタイプする必要がある。 「_」 (アンダーバー, アンダースコア) などの Shift キーを押す必要がない方がいい。 タブ補完が気持ち良くできる方がいい。 ここでは substitute, time 機能をそれぞれ rensub, rentime というコマンドにすることにしよう。 このとき、rensub, rentime という名前がコマンドサーチパスにないことを確認すべきだ。 また、できればぐぐって別の用語にヒットしにくいことをチェックして、 googoleability の良い 言葉であるとなお良いだろう。

Sec. 4.1 でコマンド名をとりあえず「rename」にしたが、 上述の観点からこの「rename」という名前を評価してみよう。 タイプ感こそ良いものの、 同名の rename プログラムがシステムに存在することが多い。 諸君らもここまでで自作の rename を実行しようとしたのに プリインストールの rename コマンドが実行されてしまって 辟易したのではあるまいか。 また、google 検索では確実に別の単語が先にヒットする。 これらの観点で、 「rename」 という名前は良くなかったことがよく分かるだろう。

6.3 ライブラリ化

substitute 機能と time 機能を 別コマンドにした場合を考えよう。 ユーザがコマンドを実行した場合、 実行されるファイルが別個に存在することになる。 素直な実装だと、それぞれほとんど重複した部分のある 2つの実行ファイルが存在することになる。 この状態は何度も述べているように冗長であり、変更に弱い作りになっている。 ここで我々はライブラリ化を学ぼう。 共通する部分を括り出す作業だ。

rensub と rentime はいずれもファイルの名前を変更するという機能を持つ。 たとえば、この部分を共通する機能として括り出すことができるだろう。 Ruby では 別ファイルにこの部分のコードを書き出し、require して やる感じになる。 ライブラリを作ることにも作業コストがかかる。 管理すべきファイルが増えるのだ。 すなわち、rensub と rentime の 2 個だったファイルから、 さらに requier されるべきライブラリファイル が追加されて、 管理すべきファイル数が1つ増えて 3 個になってしまう。 プログラマは管理すべきファイルが 増えるコストと、 それによって得られるメリットを秤にかけてコーディングを進めるべきだ。 達人プログラマはファイルが増えるデメリットを甘受しても、 処理の一元化によるメリットが勝ると考えることが多い。 これが実感できない人は当面 このライブラリ化の節を飛ばして次の機能追加に進んでみたらいい。 その方が、先々で同じ修正を複数箇所で行うことのダルさを理解し、 さらに良いプログラマになる経験を得られるかもしれない。

6.3.1 ライブラリパス

共通する処理を別ファイルにまとめたものをライブラリと呼ぶ。 ライブラリはコマンドの実体であるプログラムファイルから require で呼ばれる。

require "rename.rb"

ライブラリのファイル名は rensub と rentime の共通部分を括り出す筈なので、 おそらく rename.rb あたりが良いだろう。 ライブラリはユーザが直接実行するコマンドではないので、 .rb と拡張子をつけておくと良い。

rename.rb を何処に置くべきか。 ~/bin に rensub, rentime, rename.rb を置いて rensub を実行してみても、 エラーになって動かないだろう。 これは rensub の require で指定されている rename.rb がどこにあるのか Ruby の処理系が見つけられなかったからだ。

コマンドサーチパスを思い出そう。 ユーザがシェルのコマンドラインを入力して実行する際、 シェルは環境変数 PATH で設定したディレクトリ群の ファイルからコマンドを検索する。 ファイルシステムにある全ファイルから検索するのでは 無駄が多く、 膨大な数のファイルを検索するのに時間がかかりすぎるからだ。 同じことがライブラリにも当て嵌まり、ライブラリにもサーチパスというものがある。 Ruby の場合は環境変数 RUBYLIB がそれだ。 私の場合、以下のようになっている。

% echo $RUBYLIB
/home/ippei/lib:/home/ippei/.gem/ruby/2.3.0/gems:/usr/local/lib/x86_64-linux-gnu/site_ruby

コードの中で require されたライブラリ名のファイルを、 環境変数 RUBYLIB に「:」(コロン) 区切りで列挙されたディレクトリの中から 検索して、それを読み込む。

~/bin を RUBYLIB に登録すれば当面動くはずだが、ちょっと待って欲しい。 ここで rename.rb が ~/bin にあるべきか検討しよう。 ~/bin/rename.rb が存在すると、rename.rb 自体がコマンドサーチにひっかかる。 しかしこれは直接実行するために作られていないので、そこにあっても邪魔なだけだ。 ~/bin/ を直接実行するコマンドのために作ったように、 直接実行しないライブラリを置くディレクトリを決めることにしよう。 このディレクトリは ~/lib/ が良いだろう。 このディレクトリがなければ作成し、シェルの環境変数 RUBYLIB に ~/lib を追加しよう。 なお、 lib はライブラリを表すためによく使われる名前だ。

ここまでをまとめると、 以下のファイル構成でコードの重複を最小限に抑えて構成できることになる。

7 パッケージ化

君はこうやって開発した rensub, rentime を 別の計算機でも使用したいと考えた。 3 のファイルを然るべき場所にコピーすればちゃんと動く筈だ。 今の君はそれが難しくないと思うことだろう。 しかしそれは精々3個しかファイルがなく、たった今まで考え続けていたからだ。 もっとファイルが増えて、最後に修正してから暫く時間が経過した後だったら、 それを漏れなく把握するのは面倒になる。 そこでこれらのファイルを一つのディレクトリ内にまとめてパッケージ化しておくと、 scp したり開発したりする際に コードの取り回しがラクになる。

7.1 パッケージ名

まずパッケージ名を考えよう。 とりあえずは rename としておこうか。 ただし、将来的に RubyGems で公開することを考えるならば、 RubyGems 内で既に使われている名前は使えない。 また英語辞書に載っているようなヒットし易すぎる単語だと、 ユーザがぐぐって情報に辿り着きにくい (googoleability が低い)。 アナグラムなどを使って普通には使わないパッケージ名を作った方が良い。 (この観点で、拙作の FileRenamer の名前はイマイチで、 firena とかの方が良かったと思う。)

7.2 src ディレクトリの構成

パッケージ名を rename としたならば、 以下のようにディレクトリを作り、ファイルを配置しよう。

src は source の略で、 プログラムのソースコードを格納する場所として 歴史的によく使われている名前だ。 これで以下のようにコピーするということが分かり易く表現できる。

7.3 インストール

前節までで 2 つのコピーコマンドでプログラムを実行できる状態にできることを説明した。 この処理は「インストール」と呼ばれる。 パッケージを作ったら、インストールの方法を用意しておくとなお便利だ。

install.sh のようなシェルスクリプトを用意する方法と、 make や rake を利用する方法がある。 まずは前者を作ってみよう。 ~/src/rename/install.sh を作り、 実行権限を与えておく。 以下は install.sh の例。

#! /bin/sh
mkdir -p ~/bin/ #ディレクトリがなければ作る
mkdir -p ~/lib/ #ディレクトリがなければ作る
cp -r bin/* ~/bin/
cp -r lib/* ~/lib/

後者について、 Ruby を使うのならば make よりも高性能な rake を使おう。 また Ruby プログラムのインストール処理は rake install で行うのが一般的だから、 Rakefile でインストール処理を記述した方がベターである。 ただし、Rakefile の書式はシェルスクリプトより幾分複雑で、 それを説明するのは本稿の目的から外れる。 本稿では後に、プログラムを gem にすることまで述べる予定で、 gem のテンプレートには Rakefile が付属しているため自分で Rakefile を作る必要がない。 以上のことを踏まえて、本稿では Rakefile を自作しない。

7.4 開発の継続

■■■■ TODO ■■■■ To be written.


  1. 書式は man date を参照のこと。また通常、スーパーユーザでないとエラーになる。