いままでシェルスクリプトの最後で明示的に exit $? していたが、それを避けることにした。以下、その判断の根拠と実装指針をまとめる。
背景
従来はスクリプトの末尾に exit $? を置き、直前のコマンドの終了コードを明示的に返していた。しかし、再利用性(他のスクリプトからの source 実行)や POSIX の実行モデルを踏まえると、末尾が main “$@” のみであれば exit $? は冗長であり、むしろリスクとなる場合がある。
結論
- 末尾が main “$@” のみであれば、exit を書かない(最後に実行されたコマンドの終了コードがスクリプトの終了コードになる)。
- source され得るファイルでは、末尾の exit 禁止。必要なら関数から return する。
- 後処理が必要で終了コードを保持したい単体実行ツールのみ、終了コードを退避して最後に exit “$status” を明示する。
POSIX 観点の要点
- exit は「現在のシェル実行環境」を終了する(引数が終了ステータス)。
- スクリプトの終了ステータスは、原則として最後に実行されたコマンドの終了ステータス。
- return は関数および dot script(source)から呼び出し元に戻る。
実行形態ごとの挙動
- 独立プロセス(sh script.sh / ./script.sh): exit はそのサブシェルのみ終了。親シェルは継続。
- source 実行(. script.sh / source script.sh): exit は現在のシェルを終了し、呼び出し元を巻き込む。
- 関数内: exit はシェル全体を終了。return と目的が異なる。
末尾に exit $? を書く利点と欠点
利点
- 後処理が末尾にある場合でも、意図した終了コードを確実に返せる。
欠点
- source されたときに親を終了させるリスク。
- 末尾が main “$@” のみなら冗長(省略で等価)。
- ラッパーやテストで再利用性が低下。
よくある落とし穴
- 末尾に別コマンドがあると終了コードが上書きされる。
main "$@"
echo "done" # スクリプトの終了コードは 0 になる対策: 終了コードを退避して明示的に exit “$status” するか、末尾を main “$@” のみにする。
- パイプライン: POSIX ではパイプの終了コードは最後のコマンド。set -o pipefail は非標準(bash 等の拡張)。
# 例: 最後のコマンドの終了コードだけが返る
cmd1 | cmd2 - シバンなしのファイルは source されやすく、exit が親を落とし得る。
- trap EXIT を使う場合、exit 呼出により終了時処理の順序に影響が出る。
推奨パターン
1) 再利用性重視(最小)
main "$@"
2) 後処理が必要で終了コードを保持したい単体実行ツール
status=0<br>main "$@" || status=$?
# 必要な後処理…
exit "$status"
# 必要な後処理…
exit "$status"
最も安全なのは末尾を main “$@” のみにすること。どうしても分岐が必要なら、実行形態を検出して source 時は exit しない設計にする(ただし POSIX では検出手段が限定的)。
使い分けチェックリスト
- このスクリプトは source されうるか → Yes なら末尾に exit を書かない。
- 末尾以外に後処理が必要か → Yes なら終了コードを退避し、単体実行専用なら最後に exit “$status”。
- 末尾がパイプになっていないか → 必要なら設計を見直す。
- trap の順序に注意が必要か。
今回の方針変更
全シェルスクリプトから末尾の exit $? を撤去し、再利用性と安全性を優先する方針に統一した。単体実行ツールで後処理が必要な場合は、終了コード退避のうえで明示的な exit を用いる。
補足: 拡張子なしファイルを含む自動修正
拡張子なしの実行ファイルを含めつつ、シェルスクリプトだけを対象にする簡潔な Python 例(出力やバックアップなし)。
#!/usr/bin/env python3
import pathlib, re
shebang_re = re.compile(r'^#!.*\b(sh|bash|dash|ksh|zsh)\b')
def is_shell_file(p: pathlib.Path) -> bool:
if p.suffix == '.sh':
return True
if p.suffix == '' and p.is_file():
try:
with p.open('r', errors='ignore') as f:
first = f.readline()
return bool(shebang_re.match(first))
except Exception:
return False
return False
for path in pathlib.Path('.').rglob('*'):
if not is_shell_file(path):
continue
text = path.read_text(errors='ignore')
lines = text.rstrip().splitlines()
if lines and lines[-1].strip() == 'exit $?':
new_text = '\n'.join(lines[:-1]).rstrip() + '\n'
path.write_text(new_text)
import pathlib, re
shebang_re = re.compile(r'^#!.*\b(sh|bash|dash|ksh|zsh)\b')
def is_shell_file(p: pathlib.Path) -> bool:
if p.suffix == '.sh':
return True
if p.suffix == '' and p.is_file():
try:
with p.open('r', errors='ignore') as f:
first = f.readline()
return bool(shebang_re.match(first))
except Exception:
return False
return False
for path in pathlib.Path('.').rglob('*'):
if not is_shell_file(path):
continue
text = path.read_text(errors='ignore')
lines = text.rstrip().splitlines()
if lines and lines[-1].strip() == 'exit $?':
new_text = '\n'.join(lines[:-1]).rstrip() + '\n'
path.write_text(new_text)
まとめ
- exit $? は「明示的に終了コードを返す」ための手段だが、末尾が main “$@” だけなら不要であり、source 時には危険。
- 基本は末尾に exit は書かない。必要なときだけ、終了コードを保持して明示する。
- これにより、単体実行時の挙動は不変のまま、再利用性と安全性が向上する。
本記事の方針は POSIX の実行モデル(シェルの終了コードは最後のコマンドに一致、exit/return の役割分担)に基づく実務的な設計である。なお、set -o pipefail は POSIX では規定されず、各シェル実装の拡張である。