シェルスクリプトのコマンド置換 $(...) の出力は変数に入れないとエラー処理ができないという話

## はじめに

一般的にコマンド置換（`$(...)`）の出力は変数に入れてから使う必要があります。そうしないとコマンド置換でエラーが発生してもエラー処理をする方法がないからです。コマンド置換でエラーが起きないと確信が持てる場合、エラーを意図的に無視したい場合、エラーが発生しても空文字チェックなど別の回避策を実装している場合、そういった場合で無い限りコマンド置換の出力は変数に一旦入れて（エラー処理をして）から使うのが正しい方法です。

## サンプルコード

コマンド置換の出力を変数に入れずに使うというのは例えばコマンドの引数で直接使うようなコードです。以下のコードで `dirname` コマンドがエラーになったらどうなるでしょうか？

```sh:/tmp/script.sh
#!/bin/sh
file=$1
# 指定したファイルがあるディレクトリに移動（移動できなければエラー終了）
cd "$(dirname "$file")" || exit $?
pwd
echo "END"
```

`dirname` がエラーになることはほとんどないのですが、もしエラーになった場合、それでも `cd` は実行されてしまうことに気づいているでしょうか？ `dirname` がエラーになった場合、その出力は空文字となり `cd ""` が実行されてしまいます。この場合の結果は POSIX では未定義です。ほとんどのシェルではカレントディレクトリを変更せずに正常終了しますが、ksh では `cd: bad directory` を表示してエラー終了します。安全性で言えば ksh の方が良いように思えます。

## コードに潜むバグ

実は上記のコードにはわずかにバグがあり `dirname` がエラーになる場合が存在します。それは指定するファイルを `-` で始まるディレクトリに入れた場合です（例 `-x/file.txt`）。この場合 `dirname` コマンドはディレクトリの `-x` をオプションの `-x` と誤認識してエラーになってしまいます。

```console
$ cd /tmp
$ mkdir ./-x
$ touch ./-x/file.txt

$ ./script.sh -x/file.txt
dirname: illegal option -- x
usage: dirname path
/tmp
END
```

途中でエラーが発生しているにもかからわず、ディレクトリは移動することなく（`pwd` の出力は `/tmp` ディレクトリ）スクリプト最後の `END` まで出力されています。

### 正しいコード

ちなみに `dirname` コマンド（と `cd` コマンド）が `-` で始まるディレクトリ名を扱う正しいコードは次のようになります。`--` を入れることで以降の引数はオプションではなくただの引数として扱われます。

```sh:/tmp/script.sh
#!/bin/sh
file=$1
cd -- "$(dirname -- "$file")" || exit $?
pwd
echo "END"
```

この `--` に対応しているかはコマンド次第ではあるのですが、POSIX で規定されている方法であるため標準的なコマンドは一部（例 `echo` コマンド）を除いて対応しています。[cd](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/cd.html) の説明には書いてないように見えるかもしれませんが、オプションの所に書いてある[XBD Utility Syntax Guidelines](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02)の

> OPTIONS
> 
> The cd utility shall conform to XBD Utility Syntax Guidelines .

ガイドライン 10 で `--` 以降はオプションとしては扱わないと定義されています。

> Guideline 10:
> The first -- argument that is not an option-argument should be accepted as a delimiter indicating the end of options. Any following arguments should be treated as operands, even if they begin with the '-' character.

（これはこれで `dirname` が返すディレクトリ名が `-` そのものだったらどうするの？って問題が残ってたりしますが見なかったことにします。そもそも `-` で始まるディレクトリ・ファイル名を作れなくする設定ってできないんでしょうか？）

## どうやってもエラーで停止できないの？

正しいコードは一旦忘れて、コマンド置換を変数に入れないと本当に対応できないのか？を試してみます。

### 終了ステータスはどうなってるの？
 
終了ステータスを見て手動で終了できないのかやってみます。

```sh:/tmp/script.sh
#!/bin/sh
file=$1
cd "$(dirname "$file")"
ret=$?
echo "ret: $ret"
if [ "$ret" -ne 0 ]; then
  exit "$ret"
fi
pwd
echo "END"
```

```sh
$ ./script.sh -x/file.txt
dirname: illegal option -- x
usage: dirname path
ret: 0
/tmp
END
```

残念ながらこのコードで比較している `$?` は `dirname` の終了ステータスではなく `cd` の終了ステータスです。`cd ""` は正常に実行されているので、この時点での終了ステータスは `0` です。（ただし ksh では `cd ""` がエラーになるため停止することができます。）

### `dirname` の直後で `exit` してみる

`exit` すればスクリプトは終了するはずです。これを使えば終了できないかを試してみます。

```sh:/tmp/script.sh
#!/bin/sh
file=$1
cd "$(dirname "$file" || exit $?)" || exit $?
pwd
echo "END"
```

この時 `exit` は確かに実行されています。しかしシェルスクリプトを終了することはできません。そもそもコマンド置換はサブシェルとして実行されており、サブシェルで　`exit` してもコマンド置換の部分しか終了しないからです。

### `set -e` してみる

`set -e` を使用すればエラー発生時に自動的に停止するはずです。これを使えばうまくいくかを試してみます。

```sh:/tmp/script.sh
#!/bin/sh
set -e
file=$1
cd "$(dirname "$file")"
pwd
echo "END"
```

残念ながらこれも意味がありません。そもそも `set -e` はコマンド実行後に終了ステータスを見て自動的にシェルスクリプトを中断するものであり、終了ステータスを見て終了する方法がない以上 `set -e` で終了することも不可能です（逆に言えば `set -e` で終了できないのは終了ステータスが見れないということを意味します）。なお　`cd` コマンドの後の `exit` は省略することができます。

### kill してみる

極めて乱暴な方法ですが `kill` を使えばコマンド置換（サブシェル）の中からスクリプトを強制終了できるのではないか？を試してみます。

```sh:/tmp/script.sh
#!/bin/sh
pid=$$ # 変数に入れなくてもサブシェルから見た $$ は親プロセスなのですが明確にするため
file=$1
cd "$(dirname "$file" || kill "$pid")" || exit $?
pwd
echo "END"
```

```
$ ./script.sh -x/file.txt
dirname: illegal option -- x
usage: dirname path
[1]    54184 terminated  ./script.sh -x/file.txt
```

これはうまくいきました。しかし余計なエラーメッセージがでていますね。このエラーメッセージは自分で TERM シグナルをハンドリングすれば消すことができます。

```sh:/tmp/script.sh
#!/bin/sh
pid=$$
trap 'exit 1' TERM

file=$1
cd "$(dirname "$file" || kill "$pid")" || exit $?
pwd
echo "END"
```

ということで、ここまですればできないことはないです。さらに bash と zsh であれば ERR 疑似トラップをハンドリングすることで少しコードをシンプルにすることができます。

```sh:/tmp/script.sh
#!/bin/sh
pid=$$
trap 'kill "$pid"' ERR
trap 'exit 1' TERM

file=$1
cd "$(dirname "$file")" || exit $?
pwd
echo "END"
```

`kill` を使えばどうにかできないことはありません。しかしこの程度でプロセスを強制終了させるのには抵抗がある人が多いのでしょうか？私もこういう場合に `kill` は使いません。また `kill` を使った方法はやってみただけなので私は十分な検証はしていませんしする予定もありません。なにか別の問題が潜んでいる可能性が高いです。

## コマンド置換の結果を変数に入れる

いろいろあがいてみましたが、正しいやり方はコマンド置換の結果を変数に入れることです。こうすることで終了ステータスを正しく取得することができます。おまけでいくつかの書き方のパターンを紹介します。

```sh
#!/bin/sh
file=$1

dirname=$(dirname "$file") 
# シンプルな変数代入ではダブルクォートは必須ではなく以下のように書く必要はない
# dirname="$(dirname "$file")"

# これら変数代入ではない。export や local コマンドの実行なのでだめ
# export dirname="$(dirname "$file")"
# local dirname="$(dirname "$file")"

ret=$? # 終了ステータスが消えないように変数に入れる
if [ "$ret" -ne 0 ]; then
  exit "$ret"
fi
cd "$dirname" || exit $?
pwd
echo "END"
```

このコードは次のように簡略化することができます。

```sh
#!/bin/sh
file=$1
dirname=$(dirname "$file") || exit $?
cd "$dirname" || exit $?
pwd
echo "END"
```

`set -e` を使うともっと簡略化することができます。

```sh
#!/bin/sh
set -e
file=$1
dirname=$(dirname "$file")
cd "$dirname"
pwd
echo "END"
```

終了ステータスを比較せずとも `if` で直接処理することもできます。（注意 `set -e` を使用していても `if` や `||` と言った条件と組み合わせると一時的に無効になります。わざわざ `set +e` で戻す必要はありません。`set -e` に関してはこちらの記事を参照してください「[シェルスクリプトのset -eを正しく使ってエラー処理を楽にしよう！](https://qiita.com/ko1nksm/items/e73e343f609c071e6a8c)」）

```sh
#!/bin/sh
set -e
file=$1
if dirname=$(dirname "$file"); then
  # POSIX 準拠ではここにコードが必須（一部のシェルでは省略可能）
  :　 # 何もしないコマンドを書いて文法エラーを回避
else
  # ! で条件を反転させると else が不要になる代わりに $? が 0 になってしまう
  # 終了ステータスが必要な場合は || を使った方がシンプルに書ける
  exit $? 
fi
cd "$dirname"
pwd
echo "END"
```

終了ステータスによって処理を変えたい場合は、このように書くこともできます。

```sh
#!/bin/sh
set -e
abort() {
  echo "$2" >&2
  exit "$1"
}

file=$1
dirname=$(dirname "$file") && : # set -e の効果を意図的に無効にするため
case $? in
  0) ;;
  1) abort $? "..." ;;
  2) abort $? "..." ;;
  *) abort $? "Unknown Error" ;;
esac
cd "$dirname"
pwd
echo "END"
```

または（エラー時のみ処理をしたい場合）

```sh
#!/bin/sh
set -e
abort() {
  echo "$2" >&2
  exit "$1"
}

file=$1
dirname=$(dirname "$file") || {
  case $? in
    1) abort $? "..." ;;
    2) abort $? "..." ;;
    *) abort $? "Unknown Error" ;;
  esac
}
cd "$dirname"
pwd
echo "END"
```

## さいごに

コマンド置換は気軽に使われてるのをよく見かけるのですが、それってちゃんとエラーが発生したときのことをちゃんと考えてるの？って思うことが結構あります。エラーが発生する可能性が限りなく低いとか、エラーを無視していい場合なら別に変数に入れなくてもいいとは思いますが、単にエラー処理のことを全く考えてないとしたら問題です。予期せぬ所で問題を起こさないように正しい書き方を学びましょう！