次期POSIXシェル仕様の「宣言ユーティリティ」とシェルスクリプトの互換性問題

## はじめに

次期 POSIX の仕様である Issue 8（2022年 後期予定）ではシェルの機能として「宣言ユーティリティ (Declaration Utility)」というコマンドの種類が追加されます。この記事では宣言ユーティリティの解説とこの種類が追加された理由。これにまつわるシェルスクリプトの互換性問題について解説します。

## 宣言ユーティリティとは

宣言ユーティリティはその名の通り変数を宣言するためのユーティリティ（コマンド）のことで `export`、`readonly`（`command export` 等も含む）が該当します。また POSIX 規定外の `local` や `typeset` なども含まれます。これらのコマンドに共通するのは変数代入 `foo="123"` と似たような形式で `export FOO="123"` のような書き方で変数代入ができるという点です。

「変数代入」と「宣言ユーティリティ」は書き方が似ていますが文法の解釈は異なっており、前者（`foo="123"`）は変数代入として解釈されますが、後者（`export FOO="123"`）は `export` というコマンドの実行で `FOO="123"` はコマンドの引数として解釈されます。つまり `export FOO="123"` は `echo FOO="123"` や `echo "FOO=123"` と同じように解釈されるのです。（注意 `export` がコマンドなのは POSIX シェルの定義の場合です。zsh では予約語だったりとシェルによって実装が異なる場合があります。）

変数代入とコマンドの引数では異なる動きをします。その一つが単語分割（POSIX では [Field Splitting](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_05)）です。

```sh
foo() {
  echo "1:[$1] 2:[$2] 3:[$3]"
}

args="a b c"

# ダブルクォートで括らない場合は、スペースで複数の引数に分割される
foo $args # => 1:[a] 2:[b] 3:[b]

# ダブルクォートで括ると単語分割されない
foo "$args" # => 1:[a b c] 2:[] 3:[]
```

上記のようにコマンドの引数をダブルクォートで括らない場合はスペース（正確には `IFS` 変数に設定されてる文字）で複数の引数に分割されます。これが単語分割です。（zsh はデフォルトでは単語分割は無効になっているので注意してください。）

しかしこの単語分割が行われない場合がいくつかあります。その一つが変数代入です。実は単なる変数代入の場合は（全ての POSIX シェルで）ダブルクォートは必須ではありません。

```sh
args="A value2=B"
value=$args # ダブルクォートがなくても単語分割は行われない
echo "[$value]" # => [A value2=B]

value=$(echo "A value2=B") # コマンド置換でも必須ではない
echo "[$value]" # => [A value2=B]
```

ではこの頭に `export`、`readonly` をつけたらどうなるでしょうか？

```sh
args="A value2=B"
export value=$args
echo "[$value] [$value2]" # => ？
```

実はこの場合の動きはシェルによって異なります（具体的なシェルは次項参照）。一部のシェルでは単語分割が行われ `export value=A value2=B` として実行されます。そして一部というか bash などの大部分のシェルでは単語分割が行われず `export value="A value2=B"` として実行されたかのように扱われます。もしこれが `echo` であればどのシェルでも単語分割が行われて `echo value=A value2=B` のように扱われるわけで `export` や `readonly` はその他のコマンドと異なる動きをしていることになります。

現行の POSIX Issue 7 では `echo` などのコマンドと `export` などのコマンドを区別する種類はありません。そのため同じように扱うのが POSIX シェルの仕様に厳密に従った正しい解釈といえるでしょう。つまり大部分のシェルは POSIX に違反していることになります。この問題に対して POSIX では大部分のシェルが POSIX に準拠してないのではなく POSIX の仕様が間違っているとされ修正されることになりました（[参照](https://www.austingroupbugs.net/view.php?id=351#c943) って決定したの 10 年も前なんですが・・・）。そもそも POSIX シェルの元となった ksh88 でさえ単語分割は行われないので POSIX シェルの仕様策定もれと言っていいと思います。そして Issue 8 で登場するのが「宣言ユーティリティ」という種類です。宣言ユーティリティとして指定されたコマンドでは、変数代入形式（`変数名=値`）となっている引数は単語分割やパス名展開を行わないと新たに定義が追加されました。

POSIX を修正することによって大部分のシェルで POSIX 違反だったものが POSIX 準拠に変わります。一方困るのが POSIX に厳密に従って実装してきた一部のシェルです。実際の所、`export` や `readonly` で意図的に単語分割を使っている人がいるとも思えないのでシェルの動作が変わったとしても影響は小さいと思います。むしろ現在、一部のシェルで単語分割されるということを知らないでダブルクォートでくくらないことによる潜在的なバグを入れてしまってる可能性のほうが高い気がします。とはいえわずかに非互換性が発生するわけで注意が必要です。（POSIX の仕様が変更になったからと言って、実際のシェルが必ずしもそれに従うとは限りません。互換性の方が重要なので変更する場合でも POSIX モードだけに限定するとかシェルによって対応が変わる可能性があります。）

## 各シェルの挙動

### 単語分割を行うシェル （POSIX Issue 7 準拠）

- dash 0.5.10（[0.5.11 で修正済み](https://git.kernel.org/pub/scm/utils/dash/dash.git/commit/?id=cbb71a836874d176809a34e22f6b6e4e3ba8c85b)）
- bosh/pbosh 2021/07/23
- posh 0.14.1
- yash 2.51
- NetBSD sh （NetBSD 9.0)

バージョンは確認した中で最も新しいバージョン

### 単語分割を行わないシェル （POSIX Issue 8 準拠）

- dash 0.5.11 以降
- bash 2.03 以降
- ksh88, ksh93 以降
- mksh R28 以降
- zsh 3.1.9 以降
- BusyBox ash 1.17.1 以降
- FreeBSD sh （FreeBSD 10.4 以降）
- OpenBSD ksh （OpenBSD 6.6 以降）

バージョンは確認した中で最も古いバージョン（おそらくこのバージョン以前も同じです）

## シェルスクリプト開発者側での対策

どのシェルでも動くシェルスクリプトを書く場合、どのシェルでも同じように動く書き方をする必要があります。書き方は二通りあります。以下の書き方を使用すれば現行のどのシェルでも将来のシェルでも同じように動くことが保証されます。

### 値をダブルクォートで括る

値にコマンド置換が含まれない場合のおすすめです。

```sh
args="a b c"
export value="$args"
echo "[$value]" # => [a b c]
```

新たに宣言ユーティリティという種類ができ、特定のコマンドでは単語分割やパス名展開が行われないと決まったからといって、その細かい仕様を正しく把握するのは大変です。特定のコマンドだけ挙動が違うので混乱することでしょう。そうならないようにコマンドの引数に変数が含まれる場合には（単語分割を意図的に利用するのでない限り）必ずダブルクォートで括るようにするのは良いコーディングスタイルです。

### ２ つの行に分ける

この方法は値にコマンド置換が含まれる場合におすすめです。

```sh
value=$(echo "a b c") # ダブルクォートで括る必要はない
export value
echo "[$value]" # => [a b c]
```

宣言ユーティリティに限らずコマンド全般に当てはまりますが、引数で直接コマンド置換を使用した場合、そのコマンド置換がエラーになっても終了ステータスは `0` になってしまいます。なぜならコマンド置換でエラーになっても中断せずにコマンド（以下の場合は `echo`）が実行されてしまうからです。

```sh
echo "$(no-such-a-command)" # エラーになっても echo は実行される
echo "$?" # => 0 ・・・ 上記の echo の実行結果
```

コマンド置換を使用する場合、一旦変数に入れることでこの問題を回避することができます。

```sh
output=$(no-such-a-command)
echo "$?" # => 127
echo "$output"
```

コマンド置換を使用する場合、変数代入とコマンドの実行の 2 つに分けることで、互換性問題を解決しつつ正しくエラー処理を行うことができます。

## さいごに

`export` や `local` の挙動がシェルによって違うことはなんとなく把握してはいたのですが、その理由を POSIX シェルの仕様からは見つけられなくて、変だなと思っていたのですがようやく理由を見つけることができました。POSIX シェルの仕様がもれてるならいくら 現行の Issue 7 のPOSIX シェルの仕様を読んでも理解できるわけが無いです。Issue 8 の仕様を完全に読んだわけではないですが、互換性を破壊しない機能追加とは違い影響が小さいと想定できるとはいえ非互換性をもたらす変更なので次回の改定の一番大きな仕様変更なのではないかと思います。