How to avoid errors with accessing $@ and $* after set -u in POSIX shell script

ksh88やbash 2系など古いPOSIX規格に準拠したシェルで,set -u実行後に引数がない状態($# = 0)で@*変数利用時の未定義エラーの回避方法を解説する。

結論としては,今まで"$@""$*"としていた箇所を以下のように変えればよい。

$@ and $* improvement mapping
OldNew
"$@"${1+"$@"} or ${@+"$@"}
"$*"${1+"$*"}

Introduction

シェルの組み込みコマンドsetと,シェル(sh)の起動時オプションには,-uオプションが存在する。

sh -u
set -u

このオプションを有効にすると,それ以後に未定義の変数にアクセスしたら,エラーを発生させる。変数名の入力ミスの発見を促進できる便利な機能であるため,全てのシェルスクリプトで有効にしたほうがいいと考えている。

しかし,この機能には1点問題がある。それは,「引数未指定時での,@*変数へのアクセスもエラーとなる」ことだ。

この問題は以下のコマンドを実行することで確認できる。

sh -uc "$@"

以下のようなエラーメッセージが表示されれば,この問題の確認ができたことになる。問題がなければ何も表示されない。

sh: @: parameter not set

最近のOSであれば,この問題は発生しないのだが,bash 2系やksh88など古いシェルでこの問題を確認できる。具体的には,AIX 7とSolaris 10のksh (ksh88),HP-UX 11.31 (HP-UX 11i v3)のshで確認できた。

@*は全コマンド引数の値が格納される特殊変数(Special Parameters)である。そのため,引数が一つも指定されない場合,変数は未定義となる。

実は,POSIX 2008まではset -u@*変数もエラー対象としていた。

-u
The shell shall write a message to standard error when it tries to expand a variable that is not set and immediately exit. An interactive shell shall not exit.
set – Shell Command Language – POSIX 2008

しかし,POSIX 2013からはset -u@*変数をエラー対象から外すことになっている。

-u
undefinedgt; When the shell tries to expand an unset parameter other than the ‘@’ and ‘*’ special parameters, it shall write a message to standard error and the expansion shall fail with the consequences specified in Consequences of Shell Errors.
DESCRIPTION – set – Shell Command Language – POSIX 2013

POSIX 2013でset -u@*変数をエラー対象から外すことになった経緯は,同じページのDESCRIPTIONのセクションで以下のように記載されている。

Historically, some shells applied the -u option to all parameters including $@ and $*. The standard developers felt that this was a misfeature since it is normal and common for $@ and $* to be used in shell scripts regardless of whether they were passed any arguments. Treating these uses as an error when no arguments are passed reduces the value of -u for its intended purpose of finding spelling mistakes in variable names and uses of unset positional parameters.
RATIONALE – set – Shell Command Language – POSIX-2013

参考までに,上記内容を自分で翻訳した結果を以下に掲載する。

歴史的に,いくつかのシェルは-uオプションを$@と$*を含む全パラメーターに適用していた。しかし,引数が渡されたかどうかに関わらず$@と$*が使われるのが一般的なので,標準的な開発者はこの挙動はおかしいと感じていた。

引数がないときに$@と$*をエラーとして取り扱うことは,変数名の間違いや未定義位置変数の扱いの発見という意図が目的の-uの価値を減少させる。

POSIX 2013のsetのRATIONALEの翻訳

つまい,set -u実行後に$@$*にアクセスしてエラーとなるのは都合が悪いので,POSIX規格で変更となったようだ。

しかし,Solaris 10やAIX,HP-UXなどはThe Open GroupによりPOSIX準拠と公認されており,さらにメーカーのサポートも続いている。バージョンが古くはなるが,これらもPOSIXに準拠しているので,安易に切り捨てることはできない。そこでこの問題への対策を検討した。

How to check if @ and * are defined

set -uのエラーを回避するには,@*に直接アクセスせずにこれらの変数が定義済みかどうかを知る必要がある。そこで,対策の検討の前に@*が定義済みかどうかの判定方法を検討する。

まず,以下の3通りの変数を使った方法が考えられる。

  1. $#
  2. ${@+defined}, ${*+defined}
  3. ${@-undefined}, ${*-undefined}

1の判定方法について解説する。@*にはコマンドの実行時の位置引数が格納される。$#には現在の位置引数の総数が格納されるので,この値が1以上ならば定義済み,0ならば未定義と判断できる。

2と3の判定方法は変数のパラメーター展開(Parameter Expansion)を用いたものだ。+以降の文字列は変数が定義済みのときだけ返却され,-以降の文字列は変数が未定義の時だけ返却される。これをを利用して@*が定義済みかどうかを判定できる。

また,定義済みかどうかに加えて,コマンド引数が与えられていない場合に"$@""$*"がどう展開されるかも確認する。なぜならば,POSIXの@変数の説明で以下のように位置引数がなければ,$@は0個のフィールドに展開されるとの記載があるからだ。

If there are no positional parameters, the expansion of ‘@’ shall generate zero fields, even when ‘@’ is within double-quotes;
2.5.2 Special Parameters – Shell Command Language – POSIX 2016

@変数には,このように引数がないときの挙動が明確に書かれているが,同類の*については何も書かれていない。シェルごとに挙動の違いがないかを念の為確認する。

さらに,検証を進めていく中で,引数を渡していない($#=0)にも関わらず,@*が定義されているシェルを見つけた。こうしたシェルで@*が定義済みかどうかの判定方法の参考にするため,最初の位置引数である$1が定義済みかどうかも確認する。

実際に多くのシェルで,@*が定義済みかどうかの判定方法が利用可能かどうかを確認する。

Ubuntu 16.04で検証に使ったシェルとバージョンは以下の通り。この他,サンプルコードの結果には掲載できていないが,ksh88とHP-UX sh,FreeBSD sh (ash)でも検証した。

Shell Version
sh BSD January 19, 2003 BSD ksh KSH(1) ksh93 Version AJM 93u+ 2012-08-01 pdksh @(#)PD KSH v5.2.14 99/07/13.2 mksh @(#)MIRBSD KSH R52 2016/04/09 posh 0.12.6 ash BusyBox v1.26.2 (2017-02-06 04:13:35 UTC) multi-call binary. dash BSD January 19, 2003 BSD bash GNU bash, version 4.3.46(1)-release (x86_64-pc-linux-gnu) zsh zsh 5.1.1 (x86_64-ubuntu-linux-gnu) yash Yet another shell, version 2.44

以下のシェルスクリプトcheck_if_argv_defined.shにより,$#,${@+@defined}, ${@-@undefined}, ${*+*defined}, ${*-*undefined}を表示させて確認する。

: ## \file check_if_argv_defined.sh
## x: ash (Busybox), dash, zsh yash
## o: ksh88, ksh93, pdksh, mksh, posh, ash (FreeBSD sh), bash, HP-UX sh

SHS='sh ksh ksh93 pdksh mksh posh ash dash bash zsh yash'
for sh in $SHS; do
 command -v $sh || continue
 $sh undefinedlt;undefinedlt;-'EOT'
 f()(echo "#:$#, ${@+@:  defined}${@-@:undefined}, ${*+*:  defined}${*-*:undefined}, ${1+1:  defined}${1-1:undefined}.")
 printf 'A. f,      #:0, undefined | '; f
 printf "A. f '',   #:1,   defined | "; f ''
 printf 'A. f "$@", #:0, undefined | '; f "$@"
 printf 'A. f "$*", #:1,   defined | '; f "$*"
 EOT
done

Ubuntu 16.04のbash上で上記コマンドを実行する。実行コマンドと実行結果例を以下に示す。

./check_if_argv_defined.sh
/home/senooken/.local/bin/sh
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/ksh
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/ksh93
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/pdksh
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/mksh
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/posh
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/home/senooken/.local/bin/ash
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/dash
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/bash
A. f,      #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:undefined, *:undefined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/bin/zsh
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
/home/senooken/.local/bin/yash
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.

実行結果の見方を説明する。まず,大まかに | の左に実行したコマンドと予想結果($#の値と全ての変数が定義済みかどうか)を表示しており,右側に実行結果(順に$#の値,@*1が定義済みかどうか)を表示している。さらに,シェルごとに5行ずつ出力しており,行ごとに以下の意味となる。

  1. 実行したシェルのフルパス
  2. 引数なしで実行した結果(全てundefinedと表示されると予想)
  3. 引数として空文字''を指定した実行結果(全てdefinedと表示されると予想)
  4. 引数に"$@"を指定して実行した結果(位置引数を指定していないので0個のフィールドに展開されると予想)
  5. 引数に"$*"を指定して実行した結果(位置引数を指定しておらず,POSIX規格での記載もないので,空文字''が展開されると予想)
/home/senooken/.local/bin/sh
A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f '',   #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.
A. f "$@", #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.
A. f "$*", #:1,   defined | #:1, @:  defined, *:  defined, 1:  defined.

実行結果の内,2行目を確認すると,sh (Busybox), ash (Busybox), dash, zsh, yashの実質4個のシェル(shとashは同一)で以下のように表示されている。

A. f,      #:0, undefined | #:0, @:  defined, *:  defined, 1:undefined.

つまり,引数を渡していない($#の値が0)にも関わらず,@*がパラメーター展開で定義済みと判定されている。なお,1変数については$#=0であるため,さすがに未定義と判定されている。

dashは利用者の多いDebianやUbuntu系のshの実体であるため影響範囲が大きい。一種のバグのように思えるが,これを考慮して対策を考える必要がある。

これ以外の実行結果は予想通りだった。実行結果の4行目から,どのシェルでも位置引数が存在しない状態では"$@"は展開されて消えていた。また,実行結果の5行目から,"$*""$@"と同じように消えることはなく,通常の変数と同じで空文字を返していた。

ここまでの確認結果をまとめると以下の通りとなる。

  • @*が定義済みかどうかは$#の値が0かどうか,または$1が定義済みかどうかで判断する必要がある。
  • 引数が存在しなければ,"$@"は展開されて消える。

このことを念頭において対策を検討する。

Answer 1: Do not use set -u

まず,set -uを使わないという方法が考えられる。set -uはデバッグのためのオプションであるので,デバッグが終わり,リリース時にはset -uの記述を消せばよいという考え方だ。

個人的にはこの方針に賛同できない。それは客観的なソフトウェアの品質保証という問題からだ。

set -uをシェルスクリプトの冒頭で記載することで,未定義の変数へのアクセスを確認したことを示せる。また,set -eオプションと合わせることで,万が一未定義変数にアクセスする不穏な挙動をとれば,プログラムを終了させるという安全策を施していることを明示できる。未定義変数が存在すれば,それは環境変数により挙動をプログラム外部から変更可能であり,脆弱性の元となり危険だ。

自分できっちりとテストを行い,それを示すことができるのならばよいのかもしれない。しかし,たった一行set -uを実行するだけで,未定義変数にアクセスしていないという意思を明示できるので,リリース後もset -uは残したほうがよいと考える。

Answer 2: "${@-}" or "${*-}"

set -uを使う場合に,最初に思いつく回避策として,パラメーター展開でのset -uでのエラーガードがある。
今まで"$@""$*"としていた箇所を,"${@-}""${*-}"として,@*が未定義のときに空文字を返すようにするという対策だ。

古いシェルではこの方法で問題ないのだが,bashなど最近の多くのシェルではこの方法はうまくいかない。なぜならば,引数がないときに"${@-}""${*-}"は空文字""が返すからだ。

試しに以下のコードを実行してみよう。

: ## \file print_argv_minus.sh
set -u
print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")

printf '${@-}\t'; print_argv "${@-}"
printf '${*-}\t'; print_argv "${*-}"
./print_argv_minus.sh
"${@-}" #:1, 1:, 2:, @:.
"${*-}" #:1, 1:, 2:, @:.

ksh88やHP-UX shでは#:0となり問題ないのだが,bash, dash, ksh93, ash, mkshなど多くのシェルで,"${@-}"が空文字""に展開されてしまい,$# = 1となってしまう。

${@-}のように外側を二重引用符""で囲まなければ,空文字が展開されて$# = 0となる。しかし,それをすると"1 2"などのように空白を含む引数が渡されると,単語分割により引数の個数が変わってしまう。

Answer 3: Expand @ and *

Answer 2の"${@-}"の方法を改良したものだ。"${@-}"では,引数が渡されないときに,誤った空文字が渡されてしまい,$# = 1となってしまっていたので,$# = 0のときはevalで展開することで対応する。

: ## \file print_argv_eval.sh
set -u
print_argv()(echo "$#, ${1-}, ${2-}, ${@-}.")

[ $# = 0 ] undefinedamp;undefinedamp; EXPAND_ARGV='eval' || EXPAND_ARGV=''
printf '${@-}\t'; $EXPAND_ARGV print_argv "${@-}"
printf '${*-}\t'; $EXPAND_ARGV print_argv "${*-}"

実行結果は以下のとおりとなる。

./print_argv_eval.sh; ./print_argv_eval.sh "1 2"
"${@-}"   0, , , .
"${*-}"   0, , , .
"${@-}" 1, 1 2, , 1 2. "${*-}" 1, 1 2, , 1 2.

この方法では,最初にEXPAND_ARGVという$# = 0のときならeval,それ以外は空となるマクロ変数を用意する。

既存コードの修正手順は以下の2ステップとなる。

  1. 従来$@としていたところを,"${@-}"に変更。
  2. コマンドの冒頭に$EXPAND_ARGVマクロを前置。

この方法でも問題を対応できるが,$@を使うところに常に$EXPAND_ARGVを前置する必要があり冗長である。また,コマンド引数の数によってevalを実行の有無が変わるので,$@と同時に変数を使うと変数の中身が展開される可能性があり危ない

なお,evalコマンド実行が必要であるため,forで使えないように思えるかもしれないが,以下のように,forでは in以降を省略すれば,in "$@"が適用されるので問題ない。

If no items result from the expansion, the compound-list shall not be executed. Omitting:
in word…
shall be equivalent to:
in “$@”
The for Loop – Shell Command Language – POSIX 2016

Answer 4: IFS=

この方法はAnswer 2の"${@-}"の方法を改良したものとなる。以下のように,@を使う直前にIFSを空にすることで,変数展開を抑制する。

: ## \file print_argv_IFS.sh
set -u
print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")

IFS='' # assing null to IFS
printf '${@-}\t'; print_argv ${@-}
printf '${*-}\t'; print_argv ${*-}
unset IFS # reset IFS

このコードを実行すると以下となる。

./print_argv_IFS.sh; ./print_argv_IFS.sh "1 2"
${@-}   #:0, 1:, 2:, @:.
${*-}   #:0, 1:, 2:, @:.
${@-}   #:1, 1:1 2, 2:, @:1 2.
${*-}   #:1, 1:1 2, 2:, @:1 2.

このように,引数がなくても$# = 0であり,空白を含む引数が渡されても問題なく処理できる。

しかし,この方法だと@*の利用前にIFSを空にする必要がある。また,利用後にもスクリプトが続くなら,IFSを初期化する必要もあるだろう。

IFS変数は,"$*"変数,readコマンド,パラメーター展開(Parameter Expansion),コマンド置換(Command Substitution),算術展開(Arithmetic Expansion)に影響を与える。

参考:

そのため,パイプをつないで実行する場合に,パイプ先のコマンドにも影響が出る。例えば,コマンド実行直後に| while read v1 v2; do ... doneのように,するとreadコマンドはv1にしか値を格納できない。さらに,do以降では冒頭で一度unset IFSしないと変数の扱いが変わってしまう。

さらに,*変数に使う場合は本来の意味がなくなってしまう。なぜならば,*変数は二重引用符で囲む("$*")ことでフィールド分割を抑制した場合,IFSの1文字目の値で位置引数を結合した値を返すからだ。例えば,以下のようにすれば,カンマ,で値を結合することができる。

set 1 2 3
IFS=','
echo "$*" # 1,2,3

しかし,IFSを空にするとこうした使い方だできなくなってしまう。したがって,*変数についてはこの方法は使えない

このようにIFSを空にする場合は他のコマンドにも影響を与えてしまうので,できることなら別の方法で対応したい。

Answer 5: Asign '-' to @ and *

cutやawkなど,コマンドの中には引数に-を渡されたときに,それを標準入力とみなすものがある。これを利用して,スクリプト冒頭に以下の1行を挿入し,$# = 0のときは,@*-で初期化するという方法がある。

[ $# = 0 ] undefinedamp;undefinedamp; set -- -

サンプルコードは以下となる。

: ## \file print_argv_hyphen.sh
set -u
print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")

[ $# = 0 ] undefinedamp;undefinedamp; set -- -
printf '"$@"\t'; print_argv "$@"
printf '"$*"\t'; print_argv "$*"

これを実行すると以下のようになる。

./print_argv_hyphen.sh
"$@"    #:1, 1:-, 2:, @:-.
"$*"    #:1, 1:-, 2:, @:-.

この方法は限られた場合にしか適用できず,問題の解決になっていない

まず,上記出力結果からわかるように,引数に何も与えていないにも関わらず,-が設定されてしまっている。引数の個数自体が0から1に変更されてしまい,さらに引数に-が渡されたときに標準入力と解釈するコマンドは限られているので,echoprintfなど未対応のコマンドでは誤った処理となる。

冒頭に1行にコードを記述するだけで済むので手軽ではあるが,問題の解決になっていないので推奨しない。

Answer 6: ${@+"$@"} and ${*+"$*"}

@変数についての対策としてはこの方法が回答の一つとなる。この方法では,パラメーター展開を利用する。

サンプルコードは以下となる。

: ## \file print_argv_plus.sh
set -u
print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")

printf '${@+"$@"}\t'; print_argv ${@+"$@"}
printf '${*+"$*"}\t'; print_argv ${*+"$*"}

実行結果例は以下のとおりとなる。

./print_argv_plus.sh
${@+"$@"}       #:0, 1:, 2:, @:.
${*+"$*"}       #:0, 1:, 2:, @:.

${@+"$@"}, ${*+"$*"}は問題なく実行できているようにみえる。

修正点は,今まで"$@"としていた箇所を${@+"$@"}に変更したことだ。

パラメーター展開+は変数が定義済みのときだけ,それ以降の値を返す。つまり,${@+"$@"}@変数が定義済みのときだけ,"$@"を返す。変数が未定義のときは,前半の$@が返される。この場合,変数が未定義であるので空文字が返される。変数全体が二重引用符""で囲まれていないので,展開されて消えてしまう。

余計なコードが不要であり,変数展開のみでの対応なので,どの文脈でも利用可能であり,ベストな対策方法だと思われる。

しかし,この方法には$*には適用できないという欠点がある。実際に上記コードをash(Busybox),dash,zsh,yashで試すと以下の結果となる。

${@+"$@"}       #:0, 1:, 2:, @:.
${*+"$*"}       #:1, 1:, 2:, @:.

引数がないにも関わらず,#:1となってしまっている。これは,「Check if @ and * are defined」で確認したとおり,これらのシェルでは引数がない($#=0)場合でも,@*が定義済みであるためだ。そのため,${*+"$*"}の後半部分の"$*"が展開され空文字が渡されてしまっている。

${*+"$*"}と同様にして${@+"$@"}についても同じ問題が起きそうにみえる。しかし,こちらは「Check if @ and * are defined」で説明したとおり,POSIXにより$#=0であれば,"$@"は0個のフィールドに展開されるため,例え@が定義済みでもうまくいく。

なお,二重引用符を外側全体を囲む("${@+$@}", "${*+$*}")場合,"${*+$*}"は確認した全てのシェルで$#:1となり,"${@+$@}"はash (Busybox),dash,zshで$#:1となってしまっていた。そのため,必ず${@+"$@"}を記述する必要がある。

Answer 7: ${1+"$@"} and ${1+"$*"}

これはAnswer 6を改良した方法となり,この問題へのベストアンサーとなる。

ash (Busybox),dash,zsh,yashでは引数が渡されていても,@*が定義済みとなっているため,Answer 6の方法では*に対応しきれなかった。そこで,引数が渡されていなければ確実に未定義となる最初の位置引数である1変数を,@*が定義済みかどうかの判定に使うことで対応する。

サンプルコードは以下となる。

: ## \file print_argv_1.sh
set -u
print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")
printf '${1+"$@"}\t'; print_argv ${1+"$@"}
printf '${1+"$*"}\t'; print_argv ${1+"$*"}

実行結果例は以下のとおりとなる。

./print_argv_1.sh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.

この方法が他のシェルでも問題ないことを以下のコードで確認した。

: ## \file check_print_argv_1.sh
set -u
SHS='sh ksh ksh93 pdksh mksh posh ash dash bash zsh yash'
for sh in $SHS; do
 command -v $sh || continue
 $sh undefinedlt;undefinedlt;-EOT
 print_argv()(echo "#:$#, 1:${1-}, 2:${2-}, @:${@-}.")
 printf '\${1+"\$@"}\t'; print_argv ${1+"$@"}
 printf '\${1+"\$*"}\t'; print_argv ${1+"$*"}
 EOT
done

実行結果例を以下に示す。

./check_print_argv_1.sh
/home/senooken/.local/bin/sh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/ksh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/ksh93
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/pdksh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/mksh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/posh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/home/senooken/.local/bin/ash
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/dash
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/bash
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/bin/zsh
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.
/home/senooken/.local/bin/yash
${1+"$@"}       #:0, 1:, 2:, @:.
${1+"$*"}       #:0, 1:, 2:, @:.

ksh88とHP-UX shでも確認し,同じ結果となることを確認した。検証した全てのシェルで,set -uでのエラーを回避しつつ,引数がなければ$#=0となることを確認できた。

なお,./check_print_argv_1.shには引数を渡すことができ,空文字''を指定した場合に#:1となることを確認し,空白を含む引数(例:"1 2")を渡しても期待通りの結果となることを確認した。

Summary

set -u実行後の@*変数を利用時の未定義エラーについて対策を検討してきた。

POSIX規格での定義を確認し,実際のシェルの挙動を確認する中で,ash (Busybox), dash, zsh, yashでは引数がなくても$@$*が定義状態となるという事実がわかった。

このことを踏まえて,7通りの対策を検討し,6番目(Answer 6: ${@+"$@"} and ${*+"$*"}7番目(Answer 7: ${1+"$@"} and ${1+"$*"}の方法で対応できることが分かった。

今まで"$@""$*"と記述していた箇所を,以下の表に示す通りに修正すれば,古いシェルでset -uを実行していても@*変数利用時にエラーが発生しない。

$@ and $* improvement mapping
OldNew
"$@"${1+"$@"} or ${@+"$@"}
"$*"${1+"$*"}

@に限り,${@+"$@"}の記述も利用可能だが,*と記述を統一できるので${1+"$@"}と記述したほうがよいかもしれない。

引数を持つ自作コマンドや関数の作成で利用する@*変数と,シェルスクリプトの品質を高めるためのset -uコマンドの競合問題とその対策について解説した。どちらもシェルスクリプトのベースとなる事項であり,多くの環境で動作させるうえで問題だった。特に@*変数についてはシェルによって定義未定義の挙動が異なるという新たな事実が分かった。

今後はこの調査・検証成果を利用してシェルスクリプトの作成を心がけるとよいだろう。なお,検証に利用したサンプルコードは以下でも公開している。

POSIXism/daily/2017/20170502_set-u at master · lamsh/POSIXism

この問題は2016年12月頃に気づき,対策方法を考えてきた。以下のツイートにあるように,2017-04-27に対策として${@*"$@"}を思いつき一気に進展した。${*+"$*"}は解決にならないと判明し,頓挫しかけたがどうにか解決策が見つかりよかった。

せのぺんさんのツイート: “古いシェルでset -uの後に@にアクセスするとエラーになる件。2-3ヶ月ぼーっと考えていた。答えがでたかも。こうする。 ${@+”$@”} 帰宅したら試す。外側を引用符で囲まなければ、単語分割で空文字は消える。パラメーター展開だからどの文脈でも使えるはず。”

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です