ifから考えたシェルスクリプト言語の曖昧さと奥深さ
はじめに
いわゆるbash, zshなどのPOSIX系シェルスクリプトのif
文は、一般に次のような形で使います。
if [ -d ~/foo/bar ]; then
echo hoge
fi
これを説明する表現として、ネット上で以下のようなものを結構見るように見えます。
if [ 条件式 ]; then
....
fi
実際、上記の理解の仕方は基本的に使えます。プログラミングはとりあえず使えることが大事だと思うので、その認識で事足りることは多いでしょう。
大抵のプログラミング言語は海外記事の説明は概ね厳密な解説を書いていることが多い印象が私にはあるのですが、興味深いことに、シェルスクリプトは海外でもどこか似た傾向がある気がしました。ここまで色々な人が色々な説明を記載しているプログラミング言語はあまり見たことがありません。
このとても一見基本的な部分に思えるif
文にシェルスクリプトやUnixの歴史なるものが色々隠れている気がしてならならないように思えました。今回文字にしてみて、改めてそう思えました。
以下に書くことはそれなりの教科書的な本では、細部を読めば大抵今から書くことを説明しています。しかし、それぞれの著者が注意を啓発するくらいには、「落とし穴」的な要素が蔓延しているのだと思っています。
それだけシェル言語は初学者を落とし穴に追い込みやすい要素が沢山あるのだと考えています。私自身、詳しいとは言えませんが、それでもシェルやターミナルについて色々な知識が増えていって現在の解釈に行き着くまで、上記の認識をしていました。
シェルスクリプトは普通に苦手で知識もあまりありません。だから、自分がちゃんと理解を咀嚼したい意味も込めて、現在の認識をまとめようと、この記事を書きました。
[
というコマンドと引数
正体はでは、上記の事例の[]
とは何なのでしょうか。その正体は、[
というコマンドの実行を指示し、引数として]
が最後に与えられている、と解釈しています。
上記の「条件式」と書かれているものの実体は、[
の引数です。bashでhelp
を叩くと、次のように表示されます。
[ arg... ]
そう、「コマンド」と「引数」なのです…。少なくとも、if
コマンドの引数が〜なんてことでも決してない。[
の引数です。
概念的には同じだが、異なる存在
[]
はtest
コマンドと同義語と見做されています。これはbashのmanページにも示されていることから、等価であると認識しています。だから、これは「正解」だと思います。しかし、見方によっては[]
はtest
ではない、とも思うのです。
- 1つ目として、コマンドとしての文脈では、
[
がtest
と同等です。 - 2つ目として、
]
は上記の説明をそのまま汲み取れば「コマンド」ではありません。先述したように「引数」です。
test
と[
は別々のプログラムとして用意されていたであろうことは、Linuxで/usr/bin
を見て認識しました。例えば、Ubuntuで見た場合、[
が実在します。そしてそれはtest
と別に存在し、少なくとも外面上はリンクが貼られていて、または内部参照している実行ファイルのようには見えません。
筆者がUbuntu 22.04で確認した所、test
は43.5kbであり、[
は51.6kbでした。これから見るに、[
はそれぞれ、共通の仕様の別の独立した存在であったと解釈しています。
これが仕様上同じならリンクすれば良いんじゃないか、ってことで、ハードリンクしたりするOSなども出てきたのだろうと認識しています。
/usr/bin/[
は実行されない
しかし、/usr/bin/[
は実行されるのでしょうか。[
を使うと、/usr/bin/[
が実行される、と考えてしまいそうです。
これは恐らく、昔の世代の方としては、実際にそうだったのでしょう。
しかし私は、現代では、現実的に使う局面はほぼ実行されることはないのであろうと考えました。これはtype [
とすれば分かります。BashやDashではこう表示されるからです。
$ type [
$ [ is a shell builtin
それは最終的には、シェルのビルトインコマンドという形で各シェルに実装されたということなのでしょう。これは現代のほとんどのシェルで該当します。結果的にシェルの機能として、シェルの[
が実行されます。
従って、OS設計者でない側の私達にとっては、/usr/bin/[
はシステムの保険であり、過去の遺物のようなものと捉えれば良いのだと考えます。
-e
はオプションであるか
次の疑問は、test
コマンドの-e
や-z
みたいなものがオプションと説明される文献は多いと思うと言うことでした。これは厳密な意味では「オプションではない」と解釈しています。上記のBashマニュアルのリンクの引用によれば、次のように説明されます。
test does not accept any options
確かに、各パラメータはオプションに見えるような値として設計されているであろうから、そう考えて腑に落とせます。現実的にはゆるくオプションという認識でもお互いの理解が通用すれば、全く差し支えないであろうと思います。
しかし、あえて厳密に言うなら、与えているものは全て「引数」と言わなければならないのだろうと私は思いました。だから、]
も「引数」であり、最後のパラメータとして[
が認識するために使用します。
元来のif構文と機能
では、本来の「if構文」とは何でしょうか。それは、以下の通りとなります。
if .. then
elif .. then
else
fi
この構文でif
が何を意図しているかと言うと、終了コードの判別です。
一見多くの挙動でそれは実際に一般的なプログラミング言語のif
と同じ振る舞いをしますが、元来のプログラミング言語のような「式の真偽判定」とは意味が異なると考える必要があります。実際にはもうちょっと原始的であると認識しなければなりません。Linuxが当初組み込んでいたBoune ShellのWikipediaの説明を見ると、Boune Shellにはそもそも式を評価する機能が仕様として存在しなかったことは重要なポイントであると思えます。
PowerShellと比較して
この振る舞いを考えるには、POSIX系シェル以外のシェルの振る舞いを見た方が良いのかもしれないと考えました。&&
というパイプ演算子を考えてみます。&&
演算子は、任意のコマンドを実行させ、正常終了したなら後続のコマンドを実行します。
例えば、以下のコマンドは~/.local/bin/eza
があるなら、eza exists!
と表示します。Bash実行させた結果、これは期待通りとなりました。
[ -f ~/.local/bin/eza ] && echo "eza exists!"
一方でWindowsでよく使われるシェルのPowerShellでは、7系以上では&&演算子があります。これは同じように、正常終了したかでものごとを判定するように機能します。
同様のコマンドを考えた場合、こうなるでしょう。
Test-Path ~/.local/bin/eza && echo "eza exists!"
しかし、存在した場合、以下のように表示されるのです。
True
eza exists!
この結果の理由は、Test-Path
コマンドレットの戻り値型がBoolean
だからです。PowerShellには「型」の概念があり、Test-Path
は存在していようがなかろうが「正常終了」と見做すので、このコマンドレットを使うなら、焦点は「型」にあります。それで「True」という文字が出力されています。「そういう仕様」になっているのです。
上記のコマンドを、if
を使うことで期待結果通りにすることができます。「True」であることを評価しており、文字列は出力されません。
if (Test-Path ~/.local/bin/eza) { echo "eza exists!" }
実はこの方がむしろ、Pythonなどの多くのプログラミング言語を触る方が思い描く「if文のイメージ」の振る舞いを正確に表しています。
だから、どっちが良いと言うより、お互い「そういう仕様」になってるので、「条件分岐とは何か」と言う捉え方を私達が変えなくてはいけないのだと思います。
なぜbashのようなシェルでif文を書く時は、最後に;
を使わなければならないか(あるいは改行せねばらならないか)、もこれによって説明できると考えます。コマンドの境界線の意味を持つことから、then
が引数であると見做されないようにする為だと解釈しています。
式でもある矛盾
しかし、矛盾するようですが、現在、シェルプログラミングに式は存在していると理解しています。これは2つの観点が根拠です。
- 1つ目は、
test
コマンドが実装されており、引数のことをみな実際に「式」と呼称しているからです。例えば、bashではBash Conditional Expressionsと言います。だから、-f ~/.local/bin/fuga
みたいなものは、概念上test
コマンドに与える「式」と言って良いのだと思います。 - 2つ目は、Boune Shellから派生した現代のデファクトシェル、つまりbash, zshなどでは
[[ expression ]]
というものが、構文として存在するからです。すなわち、bashなどでは[[
などは予約語として機能しており、実際に言語機能として昇格しました。
2つ目の観点からは、シェルプログラミングでは[[ expression ]]
という方を使いましょう、という考え方があります。実際GoogleはShell Style Guideで[[]]
を推奨としています。少なくとも「bashやzshの世界で通用する環境」においては、式は[[ ... ]]
を使うことで、より明瞭に使えるから良いのだ、という考え方も説得力があります。
/bin/sh
という色々なもの
やや話が逸れますが、/bin/sh
は現代Linuxにおいて、多くはBourne Shell
ではなく、デフォルトシステムシェルを示すシンボルのようなものとなっており、POSIXという仕様が定められて、それに準拠または互換性を持った様々な後継シェルに置き換えられています。それは確かにbash
であることもありますが、Alpineのようにash
であったりDebianのようにdash
であったりします。
UbuntuはDebian系なので、dash
というシェルが使われています。これはls -l /bin | grep sh
などとしてみれば分かります。
lrwxrwxrwx 1 root root x xxxx xxxx sh -> dash
.sh
という特異性
POSIX系シェルスクリプトで本質的に重要なのはスクリプト1行目に付与するシバンです。拡張子は人やプログラムが認識しやすくするためのおまけです。だから、拡張子がないこともよく見ます。
ところが、いざ拡張子をつける際には、.sh
という拡張子が使われることが多い点は特異だな、と思えてなりません。Windowsバッチなら.bat
だし、PowerShellなら.ps1
みたいにします。Fishも.fish
という拡張子が使われます。C Shellも.csh
です。
POSIX系シェルスクリプトは、一貫して.sh
を使うことはごく普通のことです。なぜ#!/usr/bin/bash
は.bash
だったり、#!/usr/bin/zsh
は.zsh
としないのでしょうか。いや、そうしている人も普通に沢山見ますが、そうしない人もまた、ごく普通にいます。そこに良い悪いはなく、それぞれの理由があってのことで実に自然なことです。私も周囲の雰囲気やルールに合わせることで、あるいは気分で、そうしたりそうしなかったりするでしょう。
よくよく振り返ってみると、こういった文化はある種の「特異性」があると私には思えます。分かる人・実際運用する立場にとってはほぼ全ての局面で問題ないし、むしろそっちの方が便利なことも多い、で全部片付けられますが、やはりこれも初学者にとっては誤解の入口にはなりやすいのだろうと思うのです。
これは私としては、だから.bash
って拡張子にしなよ、ということではなく、色々なツールを使っていく立場として「そういう世界観なんだ」と、まず念頭に置かないといけないんだろうな、ということが真っ先に出てきたのでした。
おわりに
これまで記載したように、シェルスクリプトのif文、と言っても、色々な曖昧さがあり、とても奥深いものがあるのだと分かりました。そして、だからこそif文だけとっても色々と深堀らないことが多いことが分かりました。それは悪いものと言うよりも、長年の積み重ってきた積木のようなものなのでしょう。映画「君たちはどう生きるか」で出てくるみたいな…笑
本来はこんなに考えなくても大体良いのかもしれません。しかし、私はこういったことを探って考えることで、シェルスクリプトを考えるには、世の中の色々なことが「拡張された結果」であり、その内容は多様性がある前提を持つことが大事ではないか、と考えました。
bash, zshだけではない世界がある
世の中はbash, zshが圧倒的なマジョリティなので、私達はそれだけの世界で「シェルってこういうものです、だからこうしなさい」と学んでしまいます。しかし、現実は多種多様ですし、仕様も多種多様なのだと分かりました。
この話は、「データベースを学びましょう」となったら、RDBMSはまずPostgresとMySQLばかり語られてしまいますが、実際には沢山のデータベースソフトがあることと似ています。だから、まず「当たり前」なことが別の世界では「当たり前」でないことを認識する必要があります。
原始的な姿から考えること
では、何が重要なのだろうと思ったところ、私は「原始的な仕様から思いを巡らすこと」なのだと考えました。このことは、むしろ現在では逆説的に重要になってきているのではないか、と私は考えました。なぜなら、現代ではシェルを使う = コンテナみたいなユースケースが増えているように思えるからです。
現代では開発環境からクラウドなど、コンテナを利用した開発は大いに普及しています。そこでは、軽量さを目指した結果、デフォルトの機能がbash未満の原始的なシェルが採用されている環境に回帰してしまっているものを使うべき状況も沢山あるように思えます。ここではsh
と呼ばれる何か(それはそれぞれ)を使いますが、[[ ... ]]
が使えないことも多いです。それは「bashやzshだからできる仕様」だからです。ですから、「これを使うべきです」ということが通用してとても便利な状況もあるけれど、不便な状況もあるのだろう、と言うことは、改めて認識した方が良いのだろうと思いました。
Ubuntuのbashからdash移行のケース
古い話ですが、2006年以前はUbuntuもデフォルトのシステムシェルがbashだったそうです。しかし、現在利用されているdashはbashよりもより原始的な仕様です。実際にはシンプルにシェルを変えようとしても上手く行かず、dashの移行時には様々な問題が発生してしまっていたそうです。例えば、このリンク先では[[ ... ]]
についてit is still quite reasonable to use [ instead, and portable scripts must do so.
([
を代わりに使うのは依然としてかなり合理的で、移植可能なスクリプトはそうしなればならない)と記載されています。先述のGoogle Shell Style Guideと逆のことを述べているではありませんか!
このような歴史を教訓にして、ではないのですが、シェルって色々考えるだけでも奥深さがあります。
だからシェルスクリプトって好きじゃないなあ、と思うこともありますし、でもそれが面白い点でもあるのだとも感じました。その為、理解不足なところも沢山ありますが、こういったことを念頭に入れながら、今後も学んでシェルという道具を使っていきたいと感じました。
最後に、先述したBourne ShellのWikipediaに書いてあった語録が面白かったので、引用します。
誰も Bourne shell の文法がどうなっているかを本当には知らない。ソースコードを調べてみてもほとんど役に立たない。 — Tom Duff
参考文献
- 「スーパーユーザーなら知っておくべきLinuxシステムの仕組み」 Brian Ward (著), 柴田 芳樹 (翻訳) インプレス出版 11章「シェルスクリプトの概要」
- Bash Manual - GNU
- DashAsBinSh - Ubuntu.com
- Bourne_Shell - Wikipedia
Discussion
if ... ; then
の;
についての解説を探していてここにたどり着きました。他ではあまり見かけない切り口でおもしろかったです。
Script ファイルの拡張子について
所詮は名前でしかないため絶対的な規則はないわけですが、次のように考えるのがしっくりくる気がします。
.gcc
ではなく.c
。なので、
#!/bin/bash
で始まるファイルの拡張子に.sh
を付与された方の気持ちは次ではないかと。また、
.bash
を付与された方の気持ちは次ではないかと。次の方が良いと思います。
ls -l /bin | grep sh
ls -l /bin/ | grep sh
※
/bin
が symbolic link file であった場合でも動くように。shにだけ絞ってlsするなら
ls -l /bin/sh
とするほうがタイプ数が少なくて更に良いと思います