何度も同じような操作を行いたいときや、CLIでちょっとだけな複雑な操作を実行したい場合、インストール不要で動作させられるシェルスクリプトはとても重宝します。ただ、モダンな開発言語に慣れていると、シェルは条件式が直感的ではない上、コマンドのオプションや引数、正規表現なども表現にブレがあるなど、パッと書き足しづらい言語でもあると感じます。
しばらく使わないと忘れてしまったりするので、備忘録も兼ねてオリジナルの関数やエイリアスを.zshrc
や.bashrc
などに追加していくことになるのですが、シェルスクリプトでは関数もエイリアスも基本的にはグローバルに定義されるため、数が増えてくると今度はグローバル汚染が気になってきます。
できればオリジナルな関数や変数の扱いを1つの関数内に閉じ込めて、スコープを切っておきたいと考えたのですが、関数内関数のスコープの切り方がなかなか見つけられず、色々と試行錯誤する羽目になったので、この記事に記録しておきます。
関数内変数のスコープ
変数は簡単です。変数内でlocal
やdeclare
・typeset
コマンドを使用して定義するとスコープが切られてローカル変数となります。ローカル変数として宣言できれば、関数外では参照できない、グローバルに影響も与えないため、安心して定義することができます。一度ローカル変数として宣言すれば関数内で変数を書き換えてもローカル変数のままです。
各定義コマンドの違いですが、typeset
はdeclare
と同じ、local
もdeclare
と基本同じですが、グローバルで使用するとエラーが発生します。使い分けは好みかと思いますが、個人的には意図せずグローバルに定義してしまうことがなく、コマンド名も直感的なlocal
に統一するようにしています。
example_func() {
public_var="PUBLIC"
local private_var="PRIVATE"
private_var="PRIVATE OVERWRITE"
}
echo $public_var # (未定義のため出力なし)
echo $private_var # (未定義のため出力なし)
example_func
echo $public_var # PUBLIC
echo $private_var # (スコープ外のため出力なし)
関数のスコープ
関数内でそのまま関数を宣言してもスコープは切られず、ローカル関数にはなりません。
example_func() {
example_module() {
echo "example_module is done."
}
echo "example_func is done."
}
example_module # command not found: example_module
example_func # example_func is done.
example_module # example_module is done.
使用後に関数を削除する
そのままではスコープが切られませんが、関数使用後にunset -f
で削除することで擬似的に影響範囲を限定することができます。
example_func() {
example_module() {
echo "example_module is done."
}
# ... 関数を使った処理 ...
echo "example_func is done."
unset -f example_module
}
example_module # command not found: example_module
example_func # example_func is done.
example_module # command not found: example_module
この方式のデメリットとして、処理中にreturn
で中断したりエラーが発生したりすると関数の定義が残ってしまいます。また、すでに他で定義されている関数がある場合も上書きした上で削除されてしまうため、関数名の衝突には注意が必要です。
関数が多用する場合はコードが肥大化したり、削除の取りこぼしが発生しやすくなったりするでしょう。関数のみ外部ファイルにまとめておき、処理が完了したときにgrep
・sed
コマンドを併用してまとめてunset
をかける方法もあります。
# 関数群ファイルのパス (関数のみひたすら定義したファイルを用意しておきます)
functions_path="./functions.sh"
# 関数群の読み込み
source $functions_path
# ... 関数を使った処理 ...
# 関数群の削除
unset -f $(cat $functions_path | grep -E '^ *[a-zA-Z_]+\(\) \{' | sed -e 's|^\(.*\)() {.*|\1|' | xargs -L 1)
サブシェルでスコープを切る
関数内で関数を宣言してもスコープは切られませんが、サブシェルを使うとスコープを切ることができます。関数内の処理をサブシェルとして書くと、グローバルへの影響が最小限な関数を定義できます。
example_func() {(
example_module() {
echo "example_module is done."
}
echo "example_func is done."
)}
example_func
# example_func is done.
example_module
# command not found: example_module
サブシェルの問題点
このようにサブシェルを使ってスコープを切ると、影響範囲がクリアになって、グローバルへの影響を気にする必要がなくなりますが、一方でメインシェルの操作が難しくなる側面があります。具体的には、cd
によるカレントディレクトリの移動や、メインシェルで使いたい変数や関数の宣言などです。
subshell_func() {(
local path="../"
cd ${path}
echo "Current dir: $(pwd)"
)}
subshell_func # pwdで一つ上の階層のディレクトリパスが表示されますが、カレントディレクトリは移動できません
サブシェル内から直接メインシェルを操作する方法はありません。環境変数でさえ受け渡しできません。メインシェルに影響させたい処理だけをサブシェルの外に出すことで状態が変更されます。
subshell_func() {
local path="../"
cd ${path}
(
echo "Current dir: $(pwd)"
)
}
subshell_func # pwdで一つ上の階層のディレクトリパスが表示され、カレントディレクトリも移動します
サブシェルの中は変数のスコープも切られていて、そのままでは値を持ち帰れないため、サブシェルの演算結果を使ってカレントディレクトリを移動したい場合などは、戻り値を受け取るためにちょっとした工夫が必要です。
サブシェルから終了コードを受け取る
サブシェルの終了コードを$?
で受け取ることができるため、終了コードに応じて処理を分岐することが可能です。0〜255の数値しか受け取れませんが、ある程度パターンが決まっている場合ならこの方法が利用できます。
capsule_func() {
(
# ... 関数のメイン処理 ...
getopts e option
if [[ "$option" == "e" ]]; then
# -eオプションが付加された場合はreturnで0以外を返す
return 1
fi
)
# サブシェルの終了コードを取得
local exit_code=$?
if [[ ${exit_code} == 0 ]]; then
echo "Success!"
# サブシェル正常終了のときのみホームディレクトリに移動
cd $HOME
else
echo "Error! (${exit_code})"
fi
}
capsule_func -e # Error! (1)
capsule_func # Success! (ホームディレクトリに移動)
サブシェルから数値以外の値を受け取る
$()
で関数を実行すると標準出力を一時的にバッファすることができるため、数値以外の戻り値を受け取ることができます。
ただし、出力をバッファしてしまうと処理中のメッセージ出力などができなくなるため、この方式では出力用と戻り値取得用で関数実行を分ける必要があります。
capsule_func() {
process() {(
# ... 関数のメイン処理 ...
local path="../"
# -p オプションが付加された場合はパスだけを出力
getopts p option
if [[ "$option" == "p" ]]; then
echo ${path}
return
fi
echo "Path: ${path}"
)}
# 処理の実行
process
# ローカル変数 path に移動
cd $(process -p)
}
capsule_func # Path: ../ を出力した上でディレクトリ移動
奥付
自分のスクリプトではサブシェルを使ってスコープを切った上でエラーコードを受け取る方式を採用し、特定のエラーコードだった場合は関数の引数に応じて操作するような形にしました。
to() {
(
# ... メインの処理 ...
)
# サブシェル終了後のメインシェル処理 (終了コード2の場合)
local exit_code=$?
if [[ ${exit_code} == 2 ]]; then
case $1 in
# 関数にオプションをつけて呼び出すとサブシェル内でパスだけを出力するようにしておき
# 特定の引数を受けたときはサブシェル内の演算結果を使ってメインシェルのカレントディレクトリを移動する
mkdir | .. ) cd $(to $@ --path) &> /dev/null ;;
esac
fi
}
関数内関数のスコープが切れたことで、汎用的な関数を変にユニークな名前にするなどの対応もなくなり、気兼ねなくモジュールを追加・拡張することができるようになりました。複雑なCLIを書く場合、PythonやGoなどを使った方が効率は良いかもしれませんが、よりポータビリティに優れたシェルスクリプトでもここまでできるというが分かったのは収穫でした。