Diary

Diary

日々学んだことをアウトプットする場として初めてみました

bash スクリプト初心者が知っておきたいことまとめ

いまだに自分も初心者ですが、 初心者から見て初めに知っておきたかったこと思いがけず詰まったことをメモしておきたいと思います。
誰か(来年のぼく)の参考になればと思います。

[目次]

環境

- Machine: Raspberry Pi 4 Model B Rev 1.4
- OS: Linux ubuntu 5.4.0-1045-raspi
- Bash version: GNU bash 5.0.17(1) aarch64

結論

* ShellCheck を導入しよう
    * VSCode の拡張もあるよ
* まとめられない

スタイル編

この辺は ShellCheck で圧倒的にカバー可能。

変数

変数定義では前後に余計なスペースは絶対入れてはなりません

a="hoge"
b=pien
# c: command not found というエラーになる。
c = huga

echo "$a" "$b" "$c"

[ はコマンドです!

[ は例えば次のように使います。

$ cat test.sh
#!/bin/bash

a=2022
if [ $a -gt 2000 ]; then
    echo "2000年代です。"
fi

# これはアウト
# if [$a -gt 2000]; then

この時、[ の前後に変なスペースを入れてはいけません
これは [ がコマンドの一部であるためであるためです。

コマンドの後は引数やオプションが来ると思うのですが、その時にはスペースを開けると思います、その感覚です。
コマンドであること・使い方は、実際に man で確認できます。

$ man [
NAME
       test - check file types and compare values

SYNOPSIS
       test EXPRESSION
       test
       [ EXPRESSION ]
       [ ]
       [ OPTION
...

エラーが起きてもスクリプトは終わらない

$ cat test.sh
#!/bin/bash

a="hoge"
# Error!!
c = huga

echo "$a" "$b" "$c"

上記スクリプトc = huga の部分でエラーになるので、そこで通常はプログラムの実行が止まるのですが(少なくともぼくにとっては)、実はエラーが起きても止まりません!
最後まで強制的に実行を続けます(実はこれは bash の起動時のオプションに依存)

# 上記ファイル実行時の出力
$ bash test.sh
test.sh: line 5: c: command not found
hoge

エラー時にスクリプトを止めるオプション

errexit のオプションを on にすることで、エラー時に終了させることが可能です。

# デフォルトでは off
$ set -o | grep errexit
errexit         off

修正したスクリプト

$ cat test.sh
#!/bin/bash
set -e

a="hoge"
# Error!!
c = huga

echo "$a" "$b" "$c"

実行

# c の定義以降の echo が呼ばれてないことが分かる。
$ bash test.sh
test.sh: line 6: c: command not found

未定義変数の取り扱いに注意する

通常では、未定義変数を参照した時もスクリプトは止まりません!

このことは、時に恐ろしい事態を招いてしまいます。

#!/bin/bash

data_dir="/home/ubuntu/Documents/work/pien/data"
# path_to_file が定義されてない時(実装ミスがあった時)
# フォルダ全部が削除されてしまう。
rm -r "${data_dir}/${path_to_file}"

未定義変数使用時にスクリプトを終了するオプション

エラー時同様、bash 起動時のオプションをつけてあげることで回避可能です。

# デフォルトでは off
$ set -o | grep nounset
nounset         off

修正したスクリプト

$ cat test.sh
#!/bin/bash
set -u

data_dir="/home/ubuntu/Documents/work/pien/data"
rm -r "${data_dir}/${path_to_file}"
echo "ここまで来るかな?"

実行

# 未定義変数参照時にスクリプト終了となる。
# 『ここまで来るかな』は来ない!
$ bash test.sh
test.sh: line 5: result: unbound variable

パイプは最後の終了ステータスしか見ない

bash においては true, false もコマンド(see: man true(false))なので、それを使って確かめてみます。

$ cat test.sh
#!/bin/bash

exit 128 | exit 64 | exit 0
echo $?

# パイプのうち、最後のコマンドの終了ステータスが反映される。
$ bash test.sh
0

これは困ります。
パイプ全体の終了ステータスは、PIPESTATUS で取得可能です。

$ cat test.sh
#!/bin/bash

exit 128 | exit 64 | exit 0
echo "${PIPESTATUS[@]}"

$ bash test.sh
128 64 0

パイプ失敗時にエラーを吐かせる

こちらも、pipefail のオプションで指定可能です。

$ cat test.sh
#!/bin/bash
set -o pipefail

# 3 が全体の終了コード、右から組み立てられる感じ?
exit 128 | exit 64 | exit 3 | exit 0 | exit 0
echo $?
echo hoge

$ bash test.sh
3
hoge

パイプがこけた時点でスクリプトを終了するには、-e と組み合わせて set -eo pipefail と指定しておけば問題ないです。

パイプはそれでも実行されます

set -o pipefail を指定することで、パイプの一部でもこけたらエラーを吐かせるようにすることができました。

ただ、パイプは『繋げて前の出力を待つ』という性質上、組み立てた時点で実行されます!
(パイプ元がパイプ先に接続する時、パイプ先が待機可能になっている必要があるイメージ)

$ cat test.sh
#!/bin/bash
set -eo pipefail

true | false | echo hoge

$ bash test.sh
hoge

引数と標準入力を区別する

bash に慣れてきてパイプを繋げまくっていた頃、xargs が何のためにあるのか一瞬迷子になりました。
それはぼくが引数と標準入力を意識してなかったことに起因してます。

パイプは標準出力と標準入力を繋げて遊ぶゲームです。
そのため基本的にパイプの駒の候補となるのは、標準出力を受け付けるコマンドです。
(引数と標準入力をどちらも取るコマンドも多く存在します。)

そこで引数を受け付けたい場合に、xargs を使う、という戦法になります。

$ man xargs
NAME
       xargs - build and execute command lines from standard input
...

一番簡単な例を示します。

# echo は引数しか受け付けないため、このワンライナーは何も出力されない。
$ echo hoge | echo

# xargs を用いてコマンドを使う。
$ echo hoge | xargs echo
hoge

xargs は外部コマンドのみを対象とする!

ビルドインコマンドと外部コマンドについても意識する必要があります。
挙動が異なるものも多く、ここでは馴染み深い echo を題材に取り上げます。

とりあえず echo について確認します。

# echo の挙動
$ type echo
echo is a shell builtin
$ type -a echo
echo is a shell builtin
echo is /usr/bin/echo
echo is /bin/echo

## ビルドインコマンド(通常)
$ echo -e '\uFF10'## 外部コマンド
$ /usr/bin/echo -e '\uFF10'
\uFF10

xargs では『外部コマンド』が呼ばれていることを確認します。

$ echo -e 'FF10' | xargs -I@ echo -e '\u@'
\uFF10
$ echo -e 'FF10' | xargs -I@ which echo
/usr/bin/echo

ではビルドインコマンドを使いたい場合はどうしたらいいのか。
→ シェルを明示的に呼んであげるとよさそうです。

$ echo 'FF10' | xargs -I@ bash -c 'echo -e "\u@"'

シェルスクリプト実行中に適時シェルスクリプトを読み込む

京大のスパコンのデータが吹っ飛んだ原因です

シェルスクリプトは呼び出されるまで呼び出されません!
(うい)

以下の2ファイルを使って簡単に確認してみます。

test.sh

#!/bin/bash

echo "script start"

sleep 15

echo "test"  # ①
# サブファイル(echo_script.sh)を実行する
bash ./echo_script.sh

echo_script.sh

#!/bin/bash

echo "Echo from another script file"  # ②

sleep 15 の間に ①, ② をそれぞれ変えてどうなるかを確認してみます。

  • ①: 元の文言が出力される
  • ②: 新しい文言が出力される!

どうやらサブファイルの読み込みなどにおいては、実行時に初めて読み込まれるようです。

これは python 等他のスクリプト言語とは異なるため、時間のかかるスクリプトで複数ファイルに分割している際は注意が必要でしょう。

リダイレクトはファイルを初期化する

$ cat hoge
some text

# こういうのは良くない。
# > の時点で対象のファイルが初期化されてしまう。
$ cat hoge > hoge
# hoge は空っぽになってしまっている。
$ cat hoge

コマンドによっては終了ステータスが 0 じゃない

終了ステータスが 0 より大きいものは異常(エラー?)と習うかと思います。

しかし、コマンドによっては(ぼくにとって)予想外のタイミングで 1 を返すことがあります。
errexit が有効になっている bash 環境などでは、こちらが変に効いてきてしまうので注意が必要です。

find 系が多いのかなーと思うのですが、した 2 つは実際に困ったことのあるコマンドです。

diff

diff は差分がなかった時が 0、差分があった時が 1、ファイルがなかった時などのエラーが 2 で終了します。

$ diff <(echo hoge) <(echo pien)
...
$ echo $?
1

grep

grep も diff と同様です。
(差分がなかった時が 0、差分があった時が 1、ファイルがなかった時などのエラーが 2)

# no grep result
$ grep bine test.sh
$ echo $?
1

# no such file
$ grep bine test.she
grep: test.she: No such file or directory
$ echo $?
2

終わりに

随時更新していきます。