POSIX準拠シェルスクリプトではローカル変数(local)が使えない⇒スタックを実装して解決してみた

## はじめに

シェルスクリプトで変数をローカル変数にする `local` コマンドは移植性がありません。移植性がなく各シェルで細かい違いがあるため [POSIX シェルの標準規格](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sh.html)でも標準化されていません。ksh では実装されておらず、2013 年より長い間標準化への[議論](https://www.austingroupbugs.net/view.php?id=767)が続いていたのですが、各シェルの実装で合意がとれないだろうということで、2022-08-08 に Rejected になってしまったようです。残念です。一応最新の ksh 93u+m では [local コマンドの実装が計画](https://github.com/ksh93/ksh/issues/123)されているので将来標準化の可能性はまだ残っているとは思いますが。

実際には ksh 以外のシェルには `local` コマンドは実装されているので、ksh 対応を諦めて細かい違いに気をつければ使えるのですが、POSIX に厳密に準拠しようとなると困ります。そこでスタックを使ったローカル変数を実装してみました。

## 使い方

`stash_push` と `stash_pop` の実装は後半です。基本的な使い方は、おそらく想像出来るとおりだと思いますが、変数を使う前に現在の状態をスタックに push して、戻す時に pop するだけです。

```sh
#!/bin/sh

. ./stash.sh

foo() {
    stash_push var1 var2 var3
    var1="a" var2="b" var3="c"
    # ... some codes ...
    stash_pop
}

var1=1 var2=2 var3=3
foo
echo "$var1, $var2, $var3" # => 1, 2, 3
```

関数を抜けると自動的に pop したりする機能はないので `stash_push` を実行したら `stash_pop` も実行しなければいけません。したがって途中で `return` する場合は、その直後でも呼び出す必要があります。

```sh
foo() {
    stash_push var1 var2 var3
    var1="a" var2="b" var3="c"
    # ... some codes ...
    if false; then
      stash_pop
      return 1
    fi
    # ... some codes ...
    stash_pop
}
```

この時に問題になるのが終了ステータスです。`stash_pop` 関数の呼び出しによって `$?` が書き換わってしまうため、その対応が必要になります。いちいち変数に `$?` を保持するのは面倒なので、`stash_pop` 関数にそのサポート機能を追加しています。

```sh
foo() {
    stash_push var1 var2 var3
    var1="a" var2="b" var3="c"
    # ... some codes ...

    # bar || return $? のようなコードは以下のように置き換える
    bar || {
      stash_pop $? && : # 後半の && : は set -e 設定時の中断を無視するため
      return $?
    }

    # ... some codes ...
    stash_pop
}
```

使用するローカル変数が多い場合、`stash_push` を複数回に分けて実行したい場合もあるかもしれません。その場合、最初の引数を `+` にすることでスタックの深さを変えずに（言い換えると`stash_pop` の実行回数を増やさずに）スタックに追加することが出来ます。長い行は `\` で改行すればいいだけなので、この機能は不要かなとも思っています。

```sh
foo() {
    stash_push var1 var2 var3
    stash_push + var4 var5 var6 # 前のスタックのリストに追加される
    var1="a" var2="b" var3="c"
    # ... some codes ...
    stash_pop # stash_pop の回数は増やさなくて良い
}
```

## 補足

以下のように書くことが出来ると少し嬉しいかもしれません。

```sh
foo() {
    # stash_push var1 var2 var3
    # var1="a" var2="b" var3="c"
    stash_push var1="a" var2="b" var3="c"

    # ... some codes ...
    echo "$var1"

    stash_pop
}
```

上記のコードの実装はさほど難しくないのですが、これだと ShellCheck でコードを静的解析した時に、`var1` に代入してないのに使用していると `echo` のところでエラーになるという問題があります。一応回避策はあって以下のようにすることで、ShellCheck を騙すことが出来ます。

```sh
alias local="stash_push"

foo() {
    local var1="a" var2="b" var3="c"

    # ... some codes ...
    echo "$var1"

    stash_pop
}
```

実装するか悩む所ですが、しばしば見かけるのが以下のように一行ごとに `local` で変数宣言をしコメントを入れるという書き方です。

```sh
alias local="stash_push"

foo() {
    local var1="a"   # var1 のコメント
    local + var2="b" # var2 のコメント
    local + var3="c" # var3 のコメント

    # ... some codes ...
    echo "$var1"

    stash_pop
}
```

このような問題を考えると、以下のような形で十分なのではないのだろうか？というのが今の私の結論です。

```sh
foo() {
    stash_push var1 var2 var3
    var1="a" # var1 のコメント
    var2="b" # var2 のコメント
    var3="c" # var3 のコメント

    # ... some codes ...
    echo "$var1"

    stash_pop
}
```

他、注意点として変数に付加された属性（`typeset -i` など）は何も考慮していません。変数が未定義の場合には対応しています。

## 実装コード

ライセンスは CC0 としますので自由にコピーして（独自の修正を加えたりして）使用して構いません。プロジェクトページはこちら（[stash](https://github.com/ko1nksm/stash)）です。

```sh
STASH=0 STASH_i=1

stash_push() {
    if [ "${1:-}" = "+" ]; then
        shift
        STASH="${STASH%:*}:$(($# + ${STASH##*:}))"
    else
        STASH="$STASH:$#"
    fi
    while [ $# -gt 0 ]; do
        eval "STASH_$STASH_i=$1\${$1+:}\${$1:-}"
        STASH_i=$((STASH_i + 1))
        shift
    done
}

stash_pop() {
    set -- "${1:-0}" $((STASH_i - ${STASH##*:}))
    while [ "$STASH_i" -gt "$2" ]; do
        STASH_i=$((STASH_i - 1))
        eval "set -- \"\$1\" \"\$2\" \"\$STASH_$STASH_i\""
        case $3 in
            *:*) eval "${3%%:*}=\${3#*:}" ;;
            *) unset "$3" ;;
        esac
        unset "STASH_$STASH_i"
    done
    STASH="${STASH%:*}"
    return "$1"
}
```

なお、使用している変数は `STASH`（スタックに追加した変数の数を `:` 区切りで表現したもの）, `STASH_i`（次のインデックス番号）, `STASH_[インデックス番号]` で、変数を保持するごとに、`STASH_[インデックス番号]` に値を入れて、インデックス番号をインクリメントしています。

## その他の実装

詳細は省きますが、POSIX 準拠シェルでローカル変数が使えない問題を回避する方法は他にもあります。

### modernish の例

例えば [modernish](https://github.com/modernish/modernish) プロジェクトでは [LOCAL...BEGIN...END](https://github.com/modernish/modernish#user-content-use-varlocal) ブロックを[`alias` コマンドを使ったコード書き換えで実装](https://github.com/modernish/modernish/blob/master/lib/modernish/mdl/var/local.mm)しており、以下のような簡潔な書き方でローカル変数を使うことが出来ます。（[参考](https://github.com/modernish/modernish/blob/master/share/doc/modernish/tests/local-test.sh
)）

```sh
LOCAL X=2 Y=4; BEGIN
  # ... some codes ...
END
```

ただし途中で `break` や `continue` を使ったらだめという注意点があります（`return` にも注意が必要だと思うのですが未検証）。あとコードはそれなりに長いです。もっとも長い理由の一つはシェルのオプション設定（noglob など）のローカル化もしているからですが。

### localvar の例

他にも私は、以下のような実装も作っていたりします。

```sh
localvar foo x y z
_foo() {
  # ... some codes ...
}

# ↓ localvar は以下のようなコードを生成する

# ksh 以外の場合
foo() { local x y z; _foo "$@"; }
# ksh の場合
function foo { typeset x y z; _foo "$@"; }
```

案外悪くないと思っているのですが、関数定義部分が初めて見る人には少し分かりづらいかもしれません。あと（今更対応は必要ないと思いますが）ksh88 で、徐々に速度が遅くなる問題（メモリリークしてる？）が発生してるので少し確認が必要です。

またローカル変数を実現するのに `local` か `typeset` のどちらかを利用するので、完全に POSIX 準拠の範囲で実装しているわけではありません。`localvar` の実装を今回作ったスタックの実装に置き換えることで完全に POSIX 準拠にすることができるはずです。

## さいごに

厳密な POSIX シェルでローカル変数が使えないという問題を、最終的にどの方法で解決するかの私なりの結論はまだ出していないのですが、ひとまずスタックを使った解決方法の紹介でした。