シェルスクリプトのset -eを正しく使ってエラー処理を楽にしよう！

## はじめに

この記事は正しく理解してないとハマりやすい `set -e` を正しく使う方法を解説しています。実はこの記事は前に書いた「[シェルスクリプトのset -eを罠を避けて使う方法](https://qiita.com/ko1nksm/items/39be7f01f31f9cdaad7d)」の簡略版で前回は実際の動作を詳しく書いたのですが、内容がうまくまとまらなくて満足しておらず、私が `set -e` を使う時に気をつけてることを考えるともっとシンプルだよなということで新たに書き直すことにしました。普通に `set -e` を使うだけならこの記事の範囲の理解で十分だと思います。詳しい挙動については前回の記事を参照して下さい。

**2021-12-16 「4. set -e の効果がコマンド置換に継承しないシェル対策」を追加**

## set -e とはなにか？

この記事を読むような人は知っているかと思いますが `set -e` とはコマンドの実行結果がエラー（= 終了ステータスが 0 以外）になった時にシェルスクリプトを自動的に中断させる機能です。自動でチェックが行われるためミスが減りコードもシンプルになるとても便利な機能ですが正しく理解する必要がある機能でもあります。一見他の言語でいう例外のような機能に見えると思いますが、シェルスクリプト独自の仕様を持っている機能であるため同じような動きだと思って使ってはいけません。

## 気をつけるポイント

`set -e` はコマンド実行後の終了ステータスによって中断処理を行います。そのため終了ステータスがいつどのように変わるか？も意識することが重要です。以下の 1. と 2. の話は実際には `set -e` の話ではなく終了ステータスの話です。しかし関連がありハマりやすい点なので紹介します。

### 1. コマンド置換の結果は変数に代入してから使う

```sh
# 間違い
echo "Hello $(get_name)."

# 正しい
name="$(get_name)"
echo "Hello ${name}."
```

`set -e` はコマンド実行時の終了ステータスを見て停止させるかどうかを判断しますが「間違い」の書き方をすると `get_name` がエラーになったとしても `echo` が実行され終了ステータスは `echo` の結果（つまり正常）になってしまうため停止しません。「正しい」書き方では変数への代入を行っているだけです。この場合はコマンド置換（`get_name`）の結果が行全体の終了ステータスとなります。また `msg="$(get_greeting) $(get_name)"` のような変数への代入を行っていても複数のコマンド置換を同時に実行するのも避けて下さい。最後に実行したコマンドの終了ステータスのみが意味を持ちます。

**備考** コマンド置換で実行するコマンドがエラーになることはない、またはなっても良い場合は変数代入は必須ではありません。

**余談** 実は `name="$(get_name)"` のダブルクォートは必須ではありません。これはコマンド実行の引数ではないので単語分割は行われません。

### 2. コマンド置換が含まれる変数の代入は単独の行で行う

```sh
# 間違い
local name="$(get_name)"
#      ^-^ SC2155: Declare and assign separately to avoid masking return values.

# 正しい
local name
name="$(get_name)"
```

補足 `SC<nnnn>`（n は数字）は [ShellCheck](https://github.com/koalaman/shellcheck) を実行した時に警告される内容です。このような問題があるコードを指摘してくれるので、ぜひ導入しましょう。

実はこれは 1. と全く同じ話です。なぜなら `local` は `echo` と同じく**コマンド**だからです。`echo name="$(get_name)"` や `echo "name=$(get_name)"`を実行しているのと全く変わりません。同様の話は `export` コマンドや `readonly` コマンドにも当てはまります。`get_name` でエラーになったとしても `local` コマンドが実行されるためその終了ステータスで上書きされてしまいます。

**備考** `export VAR=123` のようにエラーにならないと断定できる場合は別々の行にする必要はありません。

他の言語を知っていると `local` コマンドや `export` コマンドは予約語だと勘違いしやすいですが、実際には変数に属性をつけるという処理を行う**コマンド**として実行されます。属性付与と同時に値の代入機能があるだけです。（ちなみに値の代入機能は POSIX シェル以前の古い Bourne シェルにはなく `export` コマンドや `readonly` コマンドは本当に属性をつけるだけのコマンドでした。）シェルスクリプトの変数には型がありませんが代わりに属性をつけることが出来ます。POSIX シェルの範囲ではエクスポート属性とリードオンリー属性しかありませんが bash や ksh 等では配列属性、連想配列属性、整数属性などの型に近い属性もあり、これらの属性は `typeset` (`declare`) コマンドによって付与することが出来ます。（書き方によっては勝手に属性が付きます。）

余談ですがシェルスクリプトではほとんどのものがコマンドであり、コマンドではない単語（のうち最初の単語になるもの）は `for`, `while`, `until`, `if`, `case` の 5 つだけです（一部のシェルの拡張機能を除く）。これらは制御構造を実現するための予約語でそれら以外は全てコマンドです。例えば `return`, `continue`, `break`, `shift` などもシェルスクリプトではコマンド（ただし「特殊シェルビルトインコマンド - [Special Built-In Utilities](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_14)」というカテゴリ）として扱われています。`cmd` [`args`...] のような形式で `cmd` が実行されるのであれば、その `cmd` はコマンドなのです。

### 3. 条件文（`&&` や `||` を含む）と共に使うシェル関数は `set -e` の効果が無効になる

**これが一番のハマりどころでしょう。**まず以下の例を見て下さい。

```sh
set -e

if mycmd; then
  echo "ok"
else
  echo "error" >&2
fi
```

`set -e` をしていたとしても条件文（`if` / `while` / `until`）や条件演算子（`&&` / `||`）とともに使った場合は、`mycmd` がエラーになったとしてもシェルスクリプトは中断されずに条件判定を行うことが出来ます。ここまではさほど違和感がない動作だと思います。

しかし `mycmd` がシェル関数の場合、そのシェル関数の内部すべて（間接的に呼ばれてるシェル関数も含む）で `set -e` が無効になります。また `set -e` を実行したとしても再度有効にすることは出来ません。

```sh
set -e

foo() {
  set -e # 有効化出来ない
  [ "a" = "b" ]
  echo "foo" # 実行される
  return 1
}

myfunc() {
  # set -e されてないのと同じ動きになる
  expr 'foobarbaz' + 2 # エラーは出力されるが停止しない
  foo # 実行される
  echo "myfunc" # 実行される
  return 0
}

# ここならエラーで停止する
# expr 'foobarbaz' + 2 

if myfunc; then
  echo "ok" # こちらが実行される
else
  echo "error" >&2
fi
```

#### この問題にハマらないようにするには？

#### a. 条件文と共に使うシェル関数は自分で判定して明示的に return する

条件文と共に使うシェル関数は `set -e` に頼らずに終了ステータスを自分で判定して明示的に `return` します。

```sh
set -e

foo() { ...; }

myfunc() {
  if ! foo; then    # 明示的に判定して return する
    return 1        # これだと $? を保持できないので注意
  fi
  foo || return $?  # || を使って書く場合（$? を保持できる）
  return 0
}

if myfunc; then
  echo "ok"
else
  echo "error" >&2
fi
```

「`set -e` は動きがよくわからないから使わない」という人もいますが、実は上記の書き方は `set -e` を使わない場合と同じです。つまり `set -e` を使うことで特定の場合のコードをシンプルにすることが可能になるが、動きがわからないなら今まで通り `set -e` を使わない場合の書き方をすれば良いので、とりあえず `set -e` を使っておいても問題ないということです。（`set -e` を使ってなおかつ動きはわからないが `set -e` は機能してるだろうという考えのコードはダメですが。）

#### b. シェル関数ではなく外部コマンドにする

`set -e` が無効になるのはシェル関数の内部の話なので、シェル関数にするのではなく（シェルスクリプトで実装した）外部コマンドにすることでこの問題から逃れられます。外部コマンドはまったく別のプロセスなので `set -e` が有効にできないようなことはありません。ただし外部コマンドはシェル関数に比べて遅いので注意が必要です。ループの中で多数の外部コマンドが呼ばれるような場合は致命的な速度低下を引き起こす場合があります。

#### c. シェル関数を条件文と共に使わない

シェル関数を条件文や条件演算子と共に使うから `set -e` が無効になるわけで、そもそも条件分岐と共に使わなければ `set -e` は無効になりません。そしてコードをシンプルにすることが出来ます。

```sh
set -e

foo() {
  [ "a" = "b" ]
  # もちろん必要ならば内部で明示的に比較して return しても構いません
  # [ "a" = "b" ] && return 0
  # echo "error" >&2
  # return 1
}

myfunc() {
  expr 'foobarbaz' + 2
  foo
}

myfunc
echo "ok"
# 注意 エラーメッセージの出力はエラーが起きた場所で出力されるものとして考える
```

a. では明示的に判定して `return` すると書きましたが c. では逆に何もしません。この違いは「条件文と共に使うシェル関数なのか？」という点です。条件文と共に使う関数は、終了ステータスをエラーではなく戻り値として扱う関数であることを意味します。私はシェル関数の種類を大きく２つに分けて考えており「A. 終了ステータスをエラーとして使う関数」と「B. 終了ステータスを戻り値として使う関数」です。だいたいは A. に当てはまるのですが、ときどき B. に当てはまるものがあり、例えば引数が数字かどうかを判定する `is_number` 関数のようなものです。こういった関数はどんな引数を渡したとしてもエラーにならないように設計しています。

さてコードがどれだけ複雑であるかを計測する指標に[循環的複雑度](https://ja.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E7%9A%84%E8%A4%87%E9%9B%91%E5%BA%A6)というものがあります。この理論の難しい話は置いといて計算するのは簡単で（関数毎に）条件分岐（`if` 等）とループ（`for` 等）の数を数えるだけです。c. では条件文を使わないので循環的複雑度の指標に照らし合わせるとコードの複雑度が下がったことを意味します。つまり `set -e` を有効活用にするには条件分岐を減らしてシンプルにすればいい、逆に言うと条件分岐を減らしてシンプルにすると `set -e` が有効活用できるというわけです。シンプル・イズ・ベスト、実によく出来ていると思いませんか？

### 4. `set -e` の効果がコマンド置換に継承しないシェル対策

以下のコードは POSIX に準拠しているシェルであれば途中で終了するはずですが、そうならないシェルが存在します。

```sh
set -e
ret=$(false; echo "error")
echo "[$ret]"
```

POSIXに準拠してないシェル

- bash (`set -o posix` あり) <= 2.04（相当古いので考慮する必要なし）
- **bash (`set -o posix` なし) <= 5.1.x（現在最新）**
- dash 0.5.4 あたりまで（相当古いので考慮する必要なし）
- **busybox ash <= 1.34.1（現在最新）**
- **NetBSD sh <= 9.x（現在最新）**

注意すべきはbash（`set -o posix` なし）、busybox ash、NetBSD sh です。特に重要な bash は `set -o posix` を実行しておいたほうが良いでしょう。（勘違いしている人が多い気がしますが）bash の POSIX モードは歴史的理由で POSIX に準拠してない動作を POSIX に準拠させるだけで bash の拡張機能（配列など）を無効にしたりしないので、常に `set -o posix` を有効にしても良いはずです。

この問題の本質は `set -e` (errexit) がコマンド置換（サブシェルは問題なし）に継承されないということで、busybox ash や NetBSD sh でも動作するようにしたい場合は、以下のようにコマンド置換の中で再度有効にすることで回避することが可能です。

```sh
set -e
ret=$(set -e; false; echo "error")
echo "[$ret]"
```

ただ個人的にはコマンド置換の中で `set -e` の効果に頼るのをやめた方が良いと考えます。一つはコマンド置換の中であまり複雑なコード（複数のコマンド）を書かないことです。そしてどうしても必要であれば、複数のコマンドを実行するのではなく `&&` などでつなげることをおすすめします。

```sh
set -e
ret=$(false && echo "error")
echo "[$ret]"
```

## まとめ

細かすぎる話をすればいろいろあるのですが、私が普段気をつけてるのはこれぐらいのもんです。意外と簡単と思ったのではないでしょうか？ `set -e` を正しく使えるようになるとシェルスクリプトのコードはシンプルで安全になりますよ。