🐿️

ifから考えたシェルスクリプト言語の曖昧さと奥深さ

2024/06/12に公開
3

はじめに

いわゆる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

参考文献

Discussion

mixingmixing

if ... ; then; についての解説を探していてここにたどり着きました。
他ではあまり見かけない切り口でおもしろかったです。

Script ファイルの拡張子について

所詮は名前でしかないため絶対的な規則はないわけですが、次のように考えるのがしっくりくる気がします。

  • 拡張子は「処理系」ではなく「記述言語」を表す。
    • たとえば gcc でコンパイルすることが前提の C 言語ソースファイルであっても、拡張子は.gcc ではなく.c

なので、#!/bin/bash で始まるファイルの拡張子に .sh を付与された方の気持ちは次ではないかと。

  • 記述言語: shell (sh) およびその派生
  • 処理系: bash
  • 心の声: このコードは (bash だけでなく) 任意の sh で動くはず。たぶん。動くといいな。

また、.bash を付与された方の気持ちは次ではないかと。

  • 記述言語: bash
  • 処理系: bash
  • 心の声: このコードは bash にしか対応していないよ。他の shell で動くことは保証しないからね。
mixingmixing

次の方が良いと思います。

  • ls -l /bin | grep sh
  • ls -l /bin/ | grep sh

/bin が symbolic link file であった場合でも動くように。

akkuakku

shにだけ絞ってlsするなら
ls -l /bin/sh とするほうがタイプ数が少なくて更に良いと思います