シェルスクリプトでの末尾の exit について

いままでシェルスクリプトの最後で明示的に 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"

最も安全なのは末尾を 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)

まとめ

  • exit $? は「明示的に終了コードを返す」ための手段だが、末尾が main “$@” だけなら不要であり、source 時には危険。
  • 基本は末尾に exit は書かない。必要なときだけ、終了コードを保持して明示する。
  • これにより、単体実行時の挙動は不変のまま、再利用性と安全性が向上する。

本記事の方針は POSIX の実行モデル(シェルの終了コードは最後のコマンドに一致、exit/return の役割分担)に基づく実務的な設計である。なお、set -o pipefail は POSIX では規定されず、各シェル実装の拡張である。

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)