ysapでbash入門⑤

·
#CLI#Terminal#ysap#bash

bash学習記録:基礎編(Chapter 07, Section 02まで)

今回はChapter 06 Section 00の後半からChapter 07 Section 02まで進めた。寄り道も多かったが、その分学びも多かった。

環境について

自分の環境はMacのzshにoh-my-zshを使っている。講師のDaveは純粋なbash環境で進めているため、ところどころ挙動の違いが出てくる。最初は「bashに切り替えるべきか?」と悩んだが、結論としてはこのままzshで続けるのがベストという判断に至った。

理由はシンプルで、スクリプトを書く際は#!/usr/bin/env bashのシェバンを指定してbashとして実行するので、インタラクティブシェルが何かはあまり本質ではない。むしろzshとbashの挙動の違いに気づける分、学習上プラスになっている。と思いたい…

また、docker start -i bashlabでbashコンテナに入れる環境を事前に用意していたので、壊れる恐れのあるコマンドや実験はそこで行うことにした。


Chapter 06-01: Pipe Status

パイプを使ったコマンドで$?がエラーを握りつぶす問題について学んだ。

cat /this-doesnt-exist | tr , :
# cat: /this-doesnt-exist: No such file or directory

echo $?  # → 0(catが失敗しているのに!)

catは存在しないファイルを開こうとして明らかに失敗しているのに、$?0を返す。これはbashのデフォルトの挙動で、$?パイプラインの最後のコマンドの終了ステータスしか返さないからだ。

パイプラインの各コマンドの終了ステータスを分解すると:

  • cat /this-doesnt-exist → exit code 1(失敗)
  • tr , : → 入力が空でも正常終了、exit code 0

$?は最後のtr0だけを見るため、catのエラーが完全に握りつぶされてしまう。スクリプト内でエラーハンドリングをしたい場合に致命的な問題になる。

この問題を解決するのが$PIPESTATUS(bash)または$pipestatus(zsh)だ。パイプラインの各コマンドの終了ステータスを配列として保持してくれる。

# bash:大文字、0始まりインデックス
cat /this-doesnt-exist | tr , :
echo ${PIPESTATUS[0]}  # → 1(catの終了ステータス)
echo ${PIPESTATUS[1]}  # → 0(trの終了ステータス)
echo ${PIPESTATUS[@]}  # → 1 0(全部まとめて確認)
# zsh:小文字、1始まりインデックス(zshの配列は全般的に1始まり)
cat /this-doesnt-exist | tr , :
echo ${pipestatus[1]}  # → 1(catの終了ステータス)
echo ${pipestatus[2]}  # → 0(trの終了ステータス)
echo ${pipestatus[@]}  # → 1 0

ここでの重要ポイントはパイプの直後に即座に参照すること$PIPESTATUS$?と同様に、次のコマンドを実行した瞬間に上書きされてしまう。

cat /this-doesnt-exist | tr , :
ls                         # ← これだけで上書きされる
echo ${PIPESTATUS[@]}      # もうlsのステータスになってしまう

実務では変数に退避させるのが定石だ。

cat /this-doesnt-exist | tr , :
pipe_status=("${PIPESTATUS[@]}")  # 配列ごと即退避

# あとから何コマンド挟んでも安全に参照できる
echo "${pipe_status[0]}"  # → 1
echo "${pipe_status[1]}"  # → 0

("${PIPESTATUS[@]}")@は「配列の全要素」を意味し、それを()で囲むことで新しい配列として受け取っている。$?を変数に退避させるのと同じ感覚だが、配列なので少し記法が異なる。


Chapter 06-02: Timing Commands

timeコマンドでコマンドの実行時間を計測する。処理の速度を確認したいときやボトルネックを探したいときに使う。

bashでは素直に動く:

time echo hi
# hi
# real  0m0.001s  ← 実際に経過した時間(壁時計時間)
# user  0m0.000s  ← ユーザー空間でCPUが使った時間
# sys   0m0.000s  ← カーネル空間でCPUが使った時間

realusersysの3つの意味を簡単に整理すると、realは人間が感じる経過時間、usersysはCPUが実際に動いた時間だ。並列処理ではuser + sys > realになることもある。

zshではwhich timeを確認するとshell reserved wordと表示されるのに、time echo hiだと何も出力されないという不思議な挙動があった。サブシェルで囲むと動いた:

time (echo hi)
# hi
# ( echo hi; )  0.00s user 0.00s system 40% cpu 0.002 total

出力フォーマットもbashとは異なる。TIMEFMT変数で変更できるので、bashに近づけたい場合は.zshrcに追加しておくといい:

TIMEFMT=$'\nreal\t%E\nuser\t%U\nsys\t%S'
time (echo hi)
# real  0.002s
# user  0.000s
# sys   0.000s

Chapter 07-00: Sourcing Code

source(または.)でスクリプトを読み込む方法について学んだ。.はPOSIX由来なのでbash/zsh/shどれでも使える。

source ./lib/greetings   # フルコマンド
. ./lib/greetings        # 短縮形(同じ意味)

sourceと普通の実行(./script.sh)の違いは現在のシェルで実行されるかどうかだ。sourceは現在のシェルの中で直接実行されるため、スクリプト内で定義した関数や変数がそのまま現在のシェルに引き継がれる。普通の実行はサブシェルで行われるため、何も引き継がれない。

ライブラリ的に関数をまとめたファイルをsourceすることで、複数のスクリプトから共通の関数を使い回せるようになる。

sourceの検出イディオム

スクリプトが直接実行されているのか、sourceされているのかを判別するイディオムが登場した。

if ! (return 2>/dev/null); then
    # 直接実行されている場合のみ実行
    greet dave
    goodbye john
fi

仕組みはこうだ。returnは本来、関数の中かsourceされたスクリプト内でしか使えない。それ以外の場所で使うとエラーになる。

  • sourceされた場合returnが成功(exit code 0)、!で反転してfalse → if文の中は実行されない
  • 直接実行された場合returnがエラー(exit code 1)、!で反転してtrue → if文の中が実行される

2>/dev/nullは「直接実行された場合にreturnが吐くエラーメッセージを捨てる」ためだ。エラーを意図的に利用しつつ、そのエラーメッセージは邪魔なので捨てるという処理になっている。

Pythonのif __name__ == "__main__":と全く同じ発想だが、Pythonは言語レベルで__name__という専用の仕組みを持っているのに対し、bashはreturnの挙動という副作用を逆手に取っている。知らないと絶対に読めないし書けないコードで、bashの熟練度の差が出るイディオムだと感じた。

ファイルディスクリプタの話

2>/dev/null2はstderrのファイルディスクリプタ番号。ファイルディスクリプタは以下の3つが基本:

番号名前役割
0stdin標準入力
1stdout標準出力
2stderr標準エラー出力

>/dev/null1>/dev/nullの省略形で、stdoutを/dev/null(ブラックホール)に捨てる。2>/dev/nullはstderrを捨てる。

ここで混乱しやすいポイントがある。終了ステータス0が成功・1以上がエラーという仕様なので、数字が被って紛らわしい。ただし全く別の概念なので意識して区別しておく必要がある。


Chapter 07-01: Curlies vs. Parens

{}()の違いについて学んだ。見た目は似ているが、実行される環境が全く異なる。

# {} → 現在のシェルで実行
x=hello
{ x=world; echo "inside: $x"; }
echo "outside: $x"
# inside: world
# outside: world  ← 変数の変更が外に影響している
# () → サブシェルで実行
x=hello
( x=world; echo "inside: $x"; )
echo "outside: $x"
# inside: world
# outside: hello  ← 変数の変更が外に漏れない!

()はサブシェル(現在のシェルのコピー)を生成してその中で実行するため、変数の変更、カレントディレクトリの変更、環境変数の変更など、すべてサブシェルの中だけで完結する。グローバル環境を汚染しないという点で非常に有用だ。

Rustのスコープや所有権の考え方と通じるものがある。「この変数はここだけで使う」という意識は言語問わず大事な考え方で、bashでもそれを()で表現できる。


Chapter 07-02: Return vs. Output

関数の「戻り値」と「出力」の違いについて学んだ。bashの関数では、この2つが明確に区別されている。

my-func() {
    echo 'this goes to stdout' >&1  # 出力(stdout)
    echo 'this goes to stderr' >&2  # 出力(stderr)
    return 0                         # 戻り値(終了ステータス)
}

var=$(my-func)   # stdoutの出力を変数にキャプチャ
code=$?          # 終了ステータスを変数に退避(即座に!)
echo "output=$var, code=$code"
# output=this goes to stdout, code=0

$()(コマンド置換)はstdoutだけをキャプチャする。stderrはキャプチャされずそのまま端末に表示される。returnの値は$?で受け取る。

>&1はstdoutへの明示的な指定で、実は省略しても同じ動作になる。Daveが教育目的で両方書いてくれているおかげで、1と2の対比がわかりやすい。

stderrだけを変数に取る応用技

stdoutを捨ててstderrだけを変数にキャプチャしたい場合:

var=$(my-func 2>&1 >/dev/null)

一見わかりにくいが、左から順番に処理されるのがポイントだ:

  1. 2>&1 → この時点でstdoutはまだ端末を向いている。stderrをそこ(端末)にリダイレクト → stderrがコマンド置換$()にキャプチャされる
  2. >/dev/null → stdoutをブラックホールに捨てる

結果として「stdoutを捨てて、stderrだけを変数に取る」という処理になる。順番を逆にすると全く違う動作になるので注意が必要だ。

cronジョブやサーバーで自動実行されるスクリプトでは、エラーを適切にハンドリングしないと何が起きたか全くわからなくなるため、stderrの扱いは実務で必須の知識になってくる。


今日のvim Tips

学習の合間にvim周りも整備した。

クリップボード連携

+clipboardビルドのvimでクリップボードをシステムと連携させるには~/.vimrcに追加:

set clipboard=unnamed

これでyypが自動的にシステムクリップボードと連携する。

インサートモードでの削除

Ctrl+w   # カーソル前の1ワードを削除
Ctrl+u   # カーソル前の行全体を削除

manページの色付け

batが入っている環境なら~/.zshrcに追加するだけでmanページが綺麗になる:

export MANPAGER="sh -c 'col -bx | bat -l man -p'"
export MANWIDTH=120

まとめ

今回はbashの「知らないと読めないイディオム」に多く触れた回だった。特にif ! (return 2>/dev/null)は、bashの挙動を深く理解していないと生まれないコードで感動すら覚えた。ファイルディスクリプタ、$PIPESTATUS、サブシェルと現シェルの違いなど、シェルの基礎となる概念が繋がってきた感覚がある。

次回はChapter 07 Section 03のRecapから進めていく。


学習リソース: