シェルスクリプトでコマンドライン引数を組み立てる。POSIX準拠で！配列を使わず！安全に！

## はじめに

はい、bash で配列使えばいいじゃんという人には必要がない記事です。でも Debian や Ubuntu のシステムシェルの dash や Alpine Linux の BusyBox ash 等、配列が使えない環境・シェルがありますよね？

コマンドライン引数の組み立てとは、例えばコマンド（例 `find` コマンド）を呼び出す時に、そのコマンドのオプションや引数を変数の値によって変えたい（動的に組み立てたい）という話です。

## TL;DR

小難しいテクニックを使うわけでもなく、出来ることに気づくかどうかの話なので解説するよりもコードを出したほうが説明が早いでしょう。

```sh
#!/bin/sh
set -eu

include_symlinks="" # または 1

get_files() {
  set -- -name "$1"
  if [ "$include_symlinks" ]; then
    set -- \( -type f -o -type l \) "$@"
  else
    set -- -type f "$@"
  fi
  find . "$@" # $@ にコマンドライン引数を組み立てた結果が入る
  # 例 find . -type f -name "*"
}
get_files "*"
```

このように配列を使わなくても位置パラメータと `set` を使えば良いので実現可能です。

## 解説

### 配列を使わない理由

配列を使わない理由は、単に POSIX シェルの範囲では配列が存在しないからです。もし bash などの配列が使えるシェルであればこのようになるでしょう。さほど変わらないと思いませんか？

```sh
#!/bin/bash
set -eu

include_symlinks="" # または 1

get_files() {
  opts=(-name "$1")
  if [ "$include_symlinks" ]; then
    opts+=( \( -type f -o -type l \) )
  else
    opts+=( -type f )
  fi
  find . "${opts[@]}"
}
get_files "*"
```

### `set` による位置パラメータの変更

位置パラメーター `$@` を `shift` を使って前から一つずつ取り出す事ができる（シフトできる）というのを知らない人はいないと思います。

```sh
while [ $# -gt 0 ]; do
  echo "$1"
  shift
done
```

また bash などで配列に要素を追加するというのは、一般的なユースケースです。

```bash
ary=(foo bar)
ary=("${ary[@]}" baz) # 後に追加する
ary=(baz "${ary[@]}") # 前に追加する
```

しかしながらシェル関数の引数でもある位置パラメータを配列のように考えて、前後に引数を追加することができる（正確には再設定できる）というのは、どうも見逃しやすい機能のように思います。実は私も長い間それができることに気づいていませんでした。

```sh
set -- foo bar
set -- "$@" baz # 後に追加する
set -- baz "$@" # 前に追加する
```

`--` は、`set` のオプションと勘違いされないようにするためのものです。つまり `set` のより一般的な使い方である `set -e` の形のことです。これはシェルのオプションを設定するという意味であり、もし `set -- -e` という形であれば、この `-e` はオプションではなく位置パラメータへ設定するための値です。値が `-` で始まらない場合はオプションと勘違いされることはないので `--` を省略することができますが、意図を明確にするためにも使用した方が良いでしょう。

このように `set` コマンドはこのようにシェルのオプションを設定する機能と、位置パラメータを設定する機能の二つの異なる機能を持っています。コマンドの設計としてはあまり良いとは思えません。これが `set` が位置パラメータを変更する機能を持っていることに気づきにくい要因なのかもしれません。

### 位置パラメータを使えば安全に組み立てられる

POSIX シェルに配列がなくても変数を使うだけできるように思うかもしれません。例えばこのようなコードです。

```sh
get_files() {
  if [ "$include_symlinks" ]; then
    opts='( -type f -o type )'
  else
    opts='-type f'
  fi
  find . $opts -name "$1"
}
```

この場合に限れば大丈夫です。なぜなら引数に空白やワイルドカードが含まれないからです。しかし以下の場合は問題が発生します。

```sh
get_files() {
  opts="-name $1"
  if [ "$include_symlinks" ]; then
    opts="$opts ( -type f -o type )"
  else
    opts="$opts -type f"
  fi

  find . $opts
  # 本当は find . -type f -name "file no*" と実行したい
  # しかし find . -type f -name file no* が実行されてしまう
}
get_files "file no*" # 引数に空白が含まれている
```

`opts="-name \"$1\""` と書けば動くのでは？と思うかもしれませんが動きません。この場合は以下のように解釈されます。

```sh
find . -type f -name  '"file'  'no*"'  
```

変数に空白やワイルドカードが含まれている場合、その変数をダブルクォートしないで使うと「単語分割」や「パス名展開」が行われてしまうため意図したとおりに動きません。含まれていない場合は正しく動くため、特定の場合にだけ発生する見つけにくいバグになってしまいます。詳しくは「[シェルスクリプトの変数はダブルクォートしなければいけない！という話](https://qiita.com/ko1nksm/items/60b67cb24aa4ae634dd5)」を参照してください。

一応変数を使った場合でも `eval` を正しく使えば実現可能です。しかしその場合はシェルで特殊な意味を持つ文字をエスケープする必要があります。それはもっと面倒な方法ですが、それを怠ってしまうと脆弱性となってしまう可能性があります。可能であれば `eval` を使うのは避けた方が良いでしょう。

### 関数を使うことの意味

このコードでは `get_files` という関数を作って処理しています。何気ない関数ですが重要な意味があります。それは呼び出し元の位置パラメータを保存することです。

POSIX シェルの範囲ではシェルにローカル変数はありません。グローバル変数のみです。しかし位置パラメータは関数ごとに存在するためローカル変数としての効果があります。もしここで関数を使わなければ、呼び出し元の位置パラメータを壊してしまいます。例えば以下のようなコードの場合 関数を作らなければ困ることになるでしょう。

```sh
while [ $# -gt 0 ] do
  echo "$1"
  get_files "$1"
  # もし関数を使わないで get_files の中のコードを
  # ここに直接書いてしまうと $@ が変わってしまう
  shift
done
```

## おまけ 簡易版

コマンドライン引数の組み立ての要件がさほど複雑ではない場合は以下のように書くことも出来ます。もちろん空白やワイルドカードが含まれていたとしても安全に動作します。

```sh
logfile="$1"

foobar ${logfile:+--logfile} ${logfile:+"$logfile"}

# logfile が空文字の場合は以下のように展開される
foobar 

# logfile が空文字でない場合は以下のように展開される
foobar --logfile "$logfile"
```

この書き方の欠点は組み立てる引数が多くなると可読性が低下するところです。

もちろん位置パラメータと組み合わせることも出来ます。以下の書き方をするか `if` や `&&` を使って書くかはお好みで。

```sh
logfile="$1"

do_foobar() {
  set -- ${logfile:+--logfile} ${logfile:+"$logfile"} "$@"
  foobar "$@"
}
do_foobar arg
```

## まとめ

- 配列がなくとも位置パラメータを使ってコマンドライン引数を組み立てることが出来る
- 位置パラメータは `set` コマンドで変更できる
- 関数を使うことで位置パラメータをローカル変数のように使える
- 位置パラメータを使うのでオプション組み立てのための一時変数変数は不要
- 引数にスペースやワイルドカードが含まれていても正しく扱える

私も配列があったらいいなぁとは思うのですが、それでもコマンドライン引数の組み立て程度であれば POSIX シェルの範囲でなんとかなるもんです。