LoginSignup
7
2

More than 1 year has passed since last update.

シェルスクリプトの謎の ${1+"$@"} はPOSIX に準拠できてないレガシーシェルのための回避策・・・を使うのはもうやめよう!

Last updated at Posted at 2021-08-31

はじめに

シェルスクリプトでたまに ${1+"$@"} というのコードを見かけたことがないでしょうか?シェルスクリプトを昔から書いている人なら見たことがあるかもしれません。念の為にと今も使っている人がいるかもしれません。見たことがない人は気にしなくて良いです。これが何かは検索すればすぐに出てくきますが POSIX に準拠できてないシェルのための回避策です。それよりも重要なのはこんなコードを書かないといけないの?という話です。

結論を先に言うと「そんなバグがあるシェルに対応させるのはもうやめよう」です。バグがあるシェルを使ってる人、使わなければならない人がいるとは思えないのですが、一応ほとんどメンテナンスされてないと思われるマイナーなシェルの最新版でこの回避策が必要になるものがあります。しかし IE サポートを打ち切る話と同じです。そんなレガシーシェルに対応する必要はありません。むしろ明確に非対応と表明して修正を促す方が正しい対応でしょう。

この記事は What does ${1+"$@"} mean を参照、一部引用しています。

そもそも ${1+"$@"} とはなに?

foo "$@"

の代わりに

foo ${1+"$@"}

と書くことです。

${1+"$@"} を使う目的はなに?

${1+"$@"} は以下の 2 種類の仕様または不具合への回避策です。

  • 1. Bourne シェル(SVR2 - 1984)への対応
  • 2. set -u の不具合の回避策

1. Bourne シェル(SVR2 - 1984)への対応

もともとは 初期の Bourne シェルの仕様(?)に対応するためです。Bourne シェル自体にはバージョンはありませんが Unix と共にリリースされてきており Unix のバージョンで呼ばれていることが多いのでこの記事でもそれを使います。下記は最初の Bourne シェル が登場したときからの一覧と ${1+"$@"} が必要だったシェル(太字のシェル)です。

  • Version 7 (1979)
  • System III (1981)
  • SVR1 (1983)
  • SVR2 (1984)
  • SVR3 (1986)
  • SVR4.0 (1989) Solaris 10
  • SVR4.2 (1992)

補足 すべての Bourne シェルは POSIX に準拠していません。

初期の Bourne シェル(SVR3 よりも前のバージョン)の仕様では以下のコードで、位置パラメータが空の時に "$@" が空の引数に展開されるのではなく 1 つの引数として展開されていました。

# 位置パラメータはないものとする
len() { echo $#; }
len "$@" # => SVR2 以前: 1、SVR3 以降: 0

この問題を防ぐのが ${1+"$@"} です。${1+"$@"} の意味は $1 があったら(引数が 1 個以上なら)引数を展開するという意味です。(${parameter+value} というパラメータ展開は parameter が unset でないときに value に展開する)

len() { echo $#; }
len ${1+"$@"} # => 0

# 以下のコードと同じ意味
case $# in
  0) len ;;
  *) len "$@" ;;
esac

なお、この問題があるシェルは古すぎて動作確認するのが困難なので私は確認していません。もちろんこのような 40 年近く前の問題に対応する必要はないでしょう。

2. set -u の不具合の回避策

Linux の誕生が 1991 年であることを考えると 1. の問題は Linux には関係ないように思えます。しかしそこには別の問題があります。問題があるシェルで以下のようなエラーが表示されます。

$ set -u -- # nounset を有効にしつつ位置パラメータを 0 にする
$ echo "$@"
ksh: @: parameter not set

set -u は未定義の変数を参照した時にエラーを出力する機能ですが、空の位置パラメータを未定義と判断してしまいエラーが出力されてしまいます。今も見かける ${1+"$@"} はおそらくこれらのシェルのへの対策でしょう。

不具合が有るシェル

このリストは ここ に書いてあるリストを加筆再構成したものです。私が把握している内容(太字部分)と日付を追記しています。

  • all ksh88 (Solaris 10 の /usr/bin/ksh
    • Solaris 10 は 2005 年リリース、2021 年 1 月サポート終了
  • ksh93 until release t+20090501 (2009 年頃)
    • ksh93u+(2012 年)が公式の最終版
  • bash-4.0.0 ... -4.0.27 (2009 年頃)
  • ash 系
    • dash-0.4.6 ... -0.4.18 (2005 年以前)
    • NetBSD 2 ff. /bin/sh (2004 年?)
  • pdksh 系
    • pdksh-5.1.3, -5.2.14 (1999 年頃)
      • 5.2.14 が最終版でメンテナンス終了
      • FreeBSD Ports では未だにパッケージが提供されている
    • mksh before R39 (2009 年)
    • posh < 0.10 すべてのバージョン(最新版 0.14.1)
      • 0.10 では別の不具合が原因でエラーにならなかっただけ
    • NetBSD /bin/ksh (NetBSD 9.0 で確認)
    • (補足 OpenBSD sh は pdksh 系ですが問題ありません)

現在もバグが有るのは pdksh 系の一部だけです。これらはシステムシェルとして使われていませんし、わざわざ使う人もいないでしょう。その他のシェルに関しては 10 年以上前にバグは修正されています。そのようなシェルを使っている人はもうほとんどいないことでしょう。商用 Unix だと古い ksh を搭載していてまだサポートが続いているものがあるんでしょうか?もし知っていたら教えて下さい。

${1+"$@"} は別の不具合を引き起こす

あまり知られていませんが、実は ${1+"$@"} はどのシェルでも問題なく使える安全なテクニックではありません。古い zsh では逆に別の不具合を引き起こしてしまいます。これらのシェルでは "$@" と書いたほうが良いのです。

What does ${1+"$@"} mean より

${1+"$@"} is reliable and portable -- with one exception:

zsh until release 4.3.0, in sh emulation mode (which means the option 
shwordsplit is set), does word splitting on the resulting arguments.

  $ sh ./args.sh '1 2'
  arg: "1"
  arg: "2"

And: keep in mind that the traditional Bourne shell requires IFS to 
contain a blank for "$@" and ${1+"$@"} to work as expected.

docker run -it fidian/multishell を使用して自分の手で確認することができます。

zsh 3.1.9 とそれ以前 (2000 年)

# $# = 2 であるべきなのに 1 になってしまう
for i in /usr/local/bin/zsh-*; do
  $i -c 'set -- a b; len() { [ $# -eq 2 ]; }; len ${1+"$@"}' || echo $i
done

# ${1+"$@"} を使わなければ問題なし
# for i in /usr/local/bin/zsh-*; do
#   $i -c 'set -- a b; len() { [ $# -eq 2 ]; }; len "$@"' || echo $i
# done

zsh 4.2.7 とそれ以前 (2008 年)

# $# = 1 であるべきなのに 2 になってしまう
for i in /usr/local/bin/zsh-*; do
  $i -c 'setopt shwordsplit; set -- "a b"; len() { [ $# -eq 1 ]; }; len ${1+"$@"}' || echo $i
done

# ${1+"$@"} を使わなければ問題なし
# for i in /usr/local/bin/zsh-*; do
#   $i -c 'setopt shwordsplit; set -- "a b"; len() { [ $# -eq 1 ]; }; len "$@"' || echo $i
# done

すべてのシェルで動作する完璧な回避策

有名な ${1+"$@"} には実は穴がありました。この問題を解決するには以下のいずれかを使用します。

eval len ${1+'"$@"'}

# または
eval "len ${1+\"\$@\"}"
for i in /usr/local/bin/zsh-*; do
  $i -c "setopt shwordsplit; set -- \"a b\"; len() { [ \$# -eq 1 ]; }; eval len \${1+'\"\$@\"'}" || echo $i
done

この方法のデメリットは eval を行うためわずかにパフォーマンスが低下するという点です。そのため行数が増えても構わないなら case (や if)を使った方が良いかもしれません。

case $# in
  0) len ;;
  *) len "$@" ;;
esac

すべての場合に使えるわけではありませんが、for だけで使えるもう一つの回避策があります。for では引数リストが "$@" の場合は省略することができるので ${1+"$@"} を使わずとも問題なく動作します。

for i; do
  ...
done

# これと同じ意味
for i in "$@"; do
  ...
done

まとめ

POSIX に準拠しているシェルのみを対象にするならば ${1+"$@"} による回避策は不要です。問題があるシェルはほとんど使われていません。懸念が残るのは pdksh 系である pdksh、posh、NetBSD ksh だけですが果たしてこれらに対応する必要があるでしょうか?

ShellSpec ではこれらのシェルにも対応していたりします。それは開発当初、どれだけのシェルがありどこまで対応すれば良いのかわからなかったからと、古い環境からの移行をサポートするためです。可能な限り対応するという方針だったため最初の POSIX シェルである ksh88 でも動作させることに成功しました。(もちろんこれだけの対応で動作するわけではありません。POSIX に準拠してコードを書いたとしても 20年、25年、30年前の環境で特別な対応なしに動かせるわけではありません)。しかし今は影響範囲を把握できておりこの回避策が必要なシェルが少ないことがわかっています。今後私が開発しようと思っているシェルスクリプト用ライブラリでは ${1+"$@"} を必要とするシェルは切り捨てます。

古いシェルや POSIX に準拠しないままメンテナンスされないシェルへの対応を続けても意味はありません。誰も使っておらず動かす予定のない環境まで対応する価値はありません。レガシーを切り捨てなければシェルスクリプトはいつまでたっても使いやすくはなりません。不具合であればいずれ修正されます。まずはどの環境で発生する問題なのか、どの環境で必要となる回避策なのかをしっかり把握することが重要です。念の為で意味のないコード(${1+"$@"})を書くのはやめましょう。

7
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2