3 ポイント 投稿者 GN⁺ 2023-11-24 | 1件のコメント | WhatsAppで共有
  • Unix 系では、名前が 1 文字の記号である /bin/[ という実行ファイルが存在することがあり、シェルの条件式のように見える構文も、実際にはコマンド実行と終了コードの上に成り立っている
  • test は式を評価し、真なら 0、偽なら 1 を返し、[ として呼び出された場合は最後の引数が ] かどうかまで確認する
  • 多くのシェルは test[組み込みコマンド としても提供しているため、外部の /bin/test とシェル組み込み実装では、エラーメッセージや動作が異なることがある
  • Bash 拡張の [[ は外部コマンドではなく組み込み構文であり、例のように long* をグロブ展開せずリテラル文字列として比較するなど、[ とは異なる規則を適用する
  • 移植性のあるスクリプトでは [ を使うのが適切で、Bash 専用なら [[ を一貫して使うほうがよいが、両者の展開規則の違いを理解したうえで選ぶ必要がある

/bin/[/bin/test の正体

  • Unix システムには、名前が 1 文字の記号である実行ファイル /bin/[ が存在することがある
    • 例のコマンド ls /bin/?/bin/[ を表示する
  • /bin/[/bin/test は同じバイナリを指している場合がある
    • 例では、2 つのパスは同じ inode とサイズを持つファイルとして表示される
    • ただし、すべてのシステムでハードリンクである必要はない
  • test はシェルで 式を評価 するプログラムである
    • 文字列比較
    • 数値比較
    • ファイル条件の確認
  • 評価結果が真なら終了コード 0、偽なら 1 を返す

なぜ [ がコマンドのように動作するのか

  • test a = b は条件式のようには見えにくいが、同じロジックを [ a = b ] のように書くと、より見慣れた形になる
  • [ は別個の構文に見えるが、実際にはコマンド呼び出しである
    • if [ a = b ]; then ... fi[ コマンドを実行し、その終了コードを確認する
    • [ として呼び出された場合、プログラムは最後の引数が閉じ角括弧 ] かどうかを検査する
  • if 文は条件式を直接解釈するのではなく、渡された コマンドの終了コード に基づいて分岐する
    • test a = a; echo $?0
    • test a = b; echo $?1
    • [ a = a ]; echo $?0
    • [ a = b ]; echo $?1
  • 同じ文脈で truefalse も、終了コードを返す補助バイナリと見なせる

外部バイナリとシェル組み込みコマンドの違い

  • test[ はシェルスクリプトで頻繁に使われるため、ほとんどのシェルはこれらを 組み込みコマンド としても実装している
  • 同じ入力でも、外部バイナリとシェル組み込みコマンドでは出力が異なることがある
    • /bin/test a btest: a: unexpected operator
    • test a bdash: 2: test: a: unexpected operator
  • このような違いは test[ だけでなく、echo のような単純に見えるコマンドでも起こりうる
  • シェルごとに組み込み実装が異なるため、スクリプトの動作が実行するシェルによって変わる余地がある

Bash 拡張 [[ が適用する別ルール

  • [[ は Bash 拡張であり、[ の代わりに使える
  • 最大の違いは、[[常に組み込み構文 であるという点だ
    • 外部バイナリとして実行されうる [ とは異なり、[[ では Bash が式の内部に対する言語規則を変更できる
  • グロブの例では、[[[ は異なる動作をする
    • touch long-name の後、[ long* = long-name ] && echo matchmatch を出力する
    • [ コマンドの引数には通常のシェル展開規則が適用されるため、long* はディレクトリ内の long-name に展開される
    • [[ long* = long-name ]] && echo match は何も出力しない
    • [[long* をリテラル文字列として扱い、long-name とそのまま比較するため失敗する
  • Bash 専用スクリプトであれば、[[ で正規表現マッチ =~ のような機能も利用できる

スクリプトでは何を選ぶべきか

  • 移植性のあるシェルスクリプトでは [ を使うのが適切である
  • test も使えるが、一般的な選択ではない
  • スクリプトが Bash 専用なら、[[ を一貫して使うほうがよい
  • シェル自体にも !&&|| のような式演算子がある
    • これらの演算子はコマンドの終了状態を基準に動作する
    • grep ^hello$ ... && grep ^bye$ ... は、2 つのコマンドがどちらも成功すれば全体の終了コードが 0 になる
    • 最初の grep が失敗すると、&& の後ろのコマンドまで成功しないため、全体の終了コードは 1 になる
  • したがって、test/[ の式とシェルの論理演算子は、同じ条件文の中で組み合わせることができる
    • 例: [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • /bin/[/bin/test は、POSIX ではハードリンクであることを要求されていない
    • NetBSD ではハードリンクだった
    • macOS Catalina では、同じバイナリの別個のコピーが提供されている
    • Debian testing では、互いに異なるバイナリが提供されている
    • POSIX 仕様 は、2 つのファイルがリンクされていることを要求していない

1件のコメント

 
GN⁺ 2023-11-24
Hacker News のコメント
  • 原文の著者です。共有してくれてありがとう。フロントページまで上がってうれしいです。タイトルにはおそらく (2020) を付けるのが正しく、「test」は実際にコマンドを指す語なので、大文字にしないほうがよいと思います
    2021年に書いた関連する記事もあり、bash の [[ 演算子まで扱っているので、この文脈では面白く読めると思います: https://jmmv.dev/2021/08/useless-use-of-gnu.html

    • [[ は厳密に言えば組み込みコマンドではなく、根本的には 構文要素に近いものです。おそらく内部的には、ほとんどアクセスできない組み込みコマンドを使っているのでしょうが、面白いのは ]] も、予約語が意味を持てる位置に来られないにもかかわらず予約語だという点です
      一部の非 bash シェルでは、特定の種類の関数を宣言するには function キーワードが必要です。make$(shell) は、多くのターゲットをビルドする際に性能差が測定できる場合があります。それでも何もしない場合には損なので、通常は再生成を促すには include を使うほうが正しいです。GNU が POSIX を無視するのは完全に妥当で、POSIX は現実の問題の大半を解くうえであまり役に立ちません
    • https://jmmv.dev/2021/08/useless-use-of-gnu.htmlで扱われている GNU 拡張の多くは、対話的な利用では非常に便利です。現在のディレクトリを明示的な . なしで検索するのも便利ですし、直前に入力したコマンドへオプションを後ろから付け足すのも本当に楽です。そういう機能をサポートしていないコマンドを見ると、いつもいら立ちます
      スクリプトでは POSIX sh に合わせるのが、おおむね合理的です。少なくとも Bash 専用構文を使っているかどうかは把握しておくべきです
    • その記事は、--ignore-caseset -o pipefail のような GNU 拡張を使うとスクリプトの移植性が下がると不満を述べています。それ自体は正しいです
      しかし Linux ユーザーがなぜ移植性をそこまで気にすべきなのかは説明していません。OpenBSD と FreeBSD は健在ですが、ユーザー数が少なすぎるので、特に心配すべき対象ではないように思えます。公平性の観点から、そうした OS も考慮すべきだと言うことはできますが、その基準はどこで止めるべきなのでしょうか? vxWorks のようなマイナーなものまで考慮すべきなのでしょうか? BusyBox や Alpine 方面はもっと興味深いですが、変更点が非常に大きいので、いずれにせよほぼ常に別途移植が必要です。GNU 以外のエコシステムを気にすべき、ほかの説得力ある理由はあるのでしょうか?
    • if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi のようなものは、単にごく普通の日常的なシェルの使い方ではないですか?
    • 余談ですが、ブログソフトウェアがタイトルを壊しているようです。たとえば make $(shell …) expansion のように表示されていますが、本来は make $(shell ...) expansion であるべきです
      本文では正しく書かれているように、3つのピリオドであって単一の省略記号ではないので、mldr 自体も正しくありません。おそらく互いに無関係な2つのバグが同時に影響したのでしょう
  • 最後の要点をさらに一歩進めると、if ブロック自体もなくせます。if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi[ a = b ] && echo "Oops!" || echo "Expected; phew!" になります
    どれくらい頻繁にこうすべきかは分かりませんが、[ "$debug" ] && echo "what's going on" >&2 のように、条件付きでデバッグ出力を標準エラーに出すときには時々便利です。if ブロックが通常のコマンドを検査するという事実のおかげで、if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi のようなことも可能です。まだ調べていないのは、[ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ] と書くべきなのか、それとも test の組み込み論理積である [ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ] を使うべきなのかです。性能が問題でなければ、どちらも似たような理由で妥当に見えます

    • [ a = b ] && echo "Oops!" || echo "Expected; phew!" を一般的な規則として受け取ってはいけません。おそらく bash はこの行を ([ a = b ] && echo "Oops!") || echo "Expected; phew!" のように解釈します
      そのため、&& の後のコマンド列が失敗すると、|| の後のコードはいずれにせよ実行されます。たとえば >/dev/full echo "strings match" が書き込みエラーで失敗すると、文字列は一致していたのに "strings don't match" が出力されます。これは if ブロックの意味とは異なります
    • そのような省略は避けたほうがよいです。使うべき set -e を使っているなら、if [ a = b ]; then echo "Oops!"; fi は期待どおりに動作しますが、[ a = b ] && echo "Oops!" は式 ab と等しくないときにエラーで終了します
    • POSIX によれば、-a-o の二項基本式と () 演算子は廃止予定とされています。詳しくは https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t... の "Application Usage" を参照してください
    • -a を使うにせよ、2つのテストと && を使うにせよ、bash の算術評価を使えるなら expr を実行しに行く必要はありません: [ $((1+1)) -eq 2 ]
  • 数年前から [ を使わなくなりました。test は、これが構文ではなく他のコマンドと同じコマンドにすぎない、という点を強調してくれます。それに man bash を漁るより man test のほうがずっと快適です

    • 話がかみ合っていません。GNU Coreutils には man test だけでなく man [ のマニュアルページもあります
      Bash には手早いチートシートとして help test があります。[ コマンドは非常に古く、すでに1979年の Version 7 Unix に入っていました
  • 同意。[ と bash 専用の [[ は、実際に何が起きているのかについて多くの混乱を生み、確信を持ちにくかった
    ただし [[ が組み込みコマンドであることが保証される点には、シェルスクリプトの性能に意味があった時代には明確な目的があったし、それもそれほど昔の話ではない

    • この記事を読んだ後なら、単に test を使うと思う。bash スクリプトを頻繁には書かないので、if 文、特に空白のルールにいつも引っかかる。理由を見てみるとあまりに当然で、test を使えば単に引数を渡しているだけだという点がよりよく分かる
  • [test で最大の落とし穴は、引数が1つだけの場合の挙動。たとえば変数が空でないことを確認しようとして [ -n $FOO ] と書ける
    ところが FOO が設定されていないと、空文字列ではなく何もないものに展開され、[ -n ] と同じになる。POSIX は [ の引数1つの形式では、その引数、ここでは "-n" が空でなければ成功しなければならないと要求している。そのため $FOO が空でないと誤って報告する。変数は必ず引用符で囲むこと

    • 最後の文をいちばん前に持ってくるべき。変数は引用符で囲めtest 組み込みコマンドの仕様自体に落とし穴があるのではなく、落とし穴はシェルそのものにある
      その挙動は筋が通っている。[ "$FOO" ] は内容が何であれ、たとえ "-n" であっても、常に空でないかを検査する形式だから
    • スクリプトには ShellCheck を走らせればよい
    • $FOO に空白が入っていると複数の引数に展開される。とにかく常に変数を引用符で囲め
    • [ x"$FOO" != x"" ]
    • この場合なら [ -n "${FOO?}" ] を使って、$FOO が null か未設定のときにスクリプトが即座に止まるようにすると思う
  • chubot が test/[/[[ のさらに微妙な部分を掘り下げた興味深い文書を書いていた。そのブログの他の記事も、シェルのおかしな点をかなり興味深く説明している
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html

  • [ がプログラムだとはまったく知らなかったし、最後の引数が閉じ角括弧かどうかを確認するという事実が少し笑える
    それでも、なぜ角括弧の両側に空白が必要なのかは説明がつく

  • [[ は bash 専用。bash だけを使うと分かっているなら使えばよい。詳しくは記事がうまく扱っている

    • zsh にもある :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • 常に [[ を使えばよい
      zsh と ksh にもあり、実際には 1988 年かそれ以前の ksh で始まったものだとほぼ確信している
    • つまり test[ は POSIX に明記されており、たいてい実際のバイナリとして存在する。ただしシェル組み込みコマンドがそれを隠すこともある
      一方で [[ は POSIX に明記されておらず、通常はシェル組み込みとしてしか存在しない
    • Fish のようなまったく別のシェルを明示的に使う場合でないなら、なぜ Bash を使わないのか分からない
      最小公分母のシェルだけを対象にするのは、完全に古代的な心配のように感じる
  • 最後の if 文がなぜ紛らわしいのか、よく理解できなかった。シェルスクリプトを初めて学ぶとき、[ は単なる別プログラムではなく bash スクリプト言語の一部だと普通は仮定するから、ということなら理解できる。そうでないなら、なぜ驚くべきことなのか説明してほしい

    • [ がバイナリだと知らなくても、なぜ紛らわしいのかよく分からない。ごく普通の bash に見える
  • シェルについて強い好みがあるが、世の中の大多数とはあまり合わない
    [ は絶対に使うべきではなく、test だけを使うべきだと思っている。[ はその仕組みが言語文法の一部であるかのように錯覚させるが、実際にはもう1つの「プログラム」にすぎない。ここでの「プログラム」には組み込みコマンドや関数も含む。if/||/&& は終了ステータスを見ており、プログラムは展開後にはただの文字列である魔法の変数 $? を見る以外に、ほかのものの終了ステータスを見ることはできない。case は文字列を見るが、終了ステータスに基づいて動作するわけではなく、case ... esac の動作の一部として終了ステータスを設定するわけでもない。「プログラム」が終了ステータスを設定する。また [/test はファイルシステム構造を評価するときだけ使うべきで、test -f /dev/null のような場合がそれに当たる。文字列評価には case を使うべきだと思う。当然、ほとんどのスクリプトを見るとかゆくなるし、自分の書いたスクリプトは他人には奇妙に見える

    • 冗談がこちらに返ってきたな。それこそが記事の要点だった
      シェルを書くときは、if の次にプログラムを別行に置き、then に進む形を好む。if の後にあるものは「then の前の最後のコマンドの終了ステータスを見る」のだと強調するためだ。たとえば if; ls /tmp/goober; test -d /tmp/goober; echo "I'll always execute the then clause because echo will always return a 0 return code"; then ... fi のような構造では、if は結局 echo の終了ステータスだけを見るため、常に then 節が実行される
    • 同じ道を歩む同志に出会った。test が入った 8 年分のスクリプトが、私が同意している証拠だ。孤独な道だ……Google シェルスタイルガイドのせいにしている
      shbash の両方で動く必要があるスクリプトを書きながら test を好む習慣がついたが、文字 [ をコマンドのように扱うより意味的に納得できるので使い続けている。] が別バイナリではなく [ の引数だという点も奇妙だ。技術的な理由は理解しているが、ハックのように感じる
    • ここのコメントを読むと、どうやら何があっても test だけを使う人が数十人はいるようだ。数十人も!
  • 正規表現マッチをしたいときだけ [[ を使う、というふうに学んだ。例: if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    それ以外では単に "test""[" を使う。bash を 85,000 行目まで書いているところだ。bash が素晴らしいと言うつもりはないが、まだ多くの用途で自分のニーズを満たしてくれる

    • 比較的単純なパターンマッチを POSIX 互換の方法で行いたいなら、expr基本正規表現をマッチでき、キャプチャグループも返せる

1: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/e...

  • 正規表現をそのまま書いて引用符で囲まないという事実のせいで、しばらく苦労した。何でも宗教的に引用符で囲む人間なので、ばかみたいに単純な正規表現がなぜマッチしないのか突き止めるのに時間がかかった
  • その例はあまり意味がない。[ "$foo" = bar ] && echo Yesでよい
    部分文字列のマッチングには、[*グロブでたいてい十分。[ "$bar" = extra* ] && echo '$bar began with extra'のような形だ。Bashの正規表現方言は原始的なので、わざわざ使う価値はほとんどない。複雑なことはgrepawkperlのような別のツールを使うのが正しい。より高い再利用性、モジュール性、組み込み型が必要な複雑な作業まで、すべてbashでやろうとこだわると、得られるものは急激に少なくなる