case/esac は、長く伸びた if/elif/else を“読みやすいスクリプト”に変えるための道具です。
拡張子ごとの処理、サブコマンドの振り分け、OS 判定のように「値がいくつかの候補に当てはまるだけ」の場面では、case を選ぶだけで可読性と安全性がぐっと上がります。本記事は if との使い分けから入り、パターン設計のコツ、そして 落とし穴の回避までをやさしく解説します。
前提は Bash 5 系、基本は set -euo pipefail。変数は必ずダブルクォートで包むという“お作法”もそのつど確認します。パターン列(a|b|c)やデフォルト節(*)の設計、shopt -s extglob で使える拡張グロブ、;;・;&・;;& の違いと安全な使い分け、nocasematch による大文字小文字の扱いまで、一歩ずつ進めます。
読み終えるころには、「これは if で書くより case のほうが読みやすい」「デフォルト節で漏れを拾い、ログと終了コードで外に伝える」という判断と手つきが身につきます。既存のスクリプトを置き換えるときにも迷わないよう、短い例と設計の意図をセットで示していきます。
if との使い分けから考える
まずは「いつ case を選ぶか」です。値がいくつかの候補のどれかに“当てはまるだけ”なら、if/elif/else を連ねるより case のほうが読みやすくなります。たとえばファイル拡張子で処理を分けるとき、if [[ "$ext" == "jpg" ]] || [[ "$ext" == "png" ]] … と並べるより、case "$ext" in … esac の形にすると、目で追いやすい地図になります。複雑な数値比較や正規表現を駆使する判定は if([[ ... ]])に譲り、列挙できる“パターンの振り分け”は case に任せる、という住み分けが基本です。
基本形と最小のサンプル
形はとてもシンプルです。変数は必ずダブルクォートで包み、各パターンの末尾は ;; で区切ります。最後の * は「どれにも当てはまらなかったとき」の受け皿です。
set -euo pipefail
handle_ext() {
local ext="$1"
case "$ext" in
jpg|jpeg|png)
echo "画像として処理します";;
mp3|wav)
echo "音声として処理します";;
*)
echo "未知の拡張子: $ext" >&2
return 1;;
esac
}
handle_ext "jpg" # => 画像として処理します
handle_ext "mp3" # => 音声として処理します
handle_ext "pdf" # => (stderr) 未知の拡張子: pdf / return 1
case 文そのものは、マッチした節のコマンドの終了コードを引き継ぎます。上の例では * 節で return 1 しているので、呼び出し側で if handle_ext "$ext"; then ... のように成否を自然に扱えます。
パターン設計のコツ
case のパターンは “glob(ワイルドカード)” です。* は任意の文字列、? は1文字、[...] は文字クラスを表します。列挙したいときは a|b|c のようにパイプでつなぎます。入力の大小文字が混ざるなら、最初に小文字へ正規化するか、shopt -s nocasematch を一時的に有効化して大文字小文字を無視するのが手堅いです。正規表現のように細かくマッチさせたい場合は、無理せず if [[ "$str" =~ 正規表現 ]] へ切り替えると迷いません。
normalize_lower() {
# tr はロケールの影響を受けることがあるため LC_ALL=C を添えると安定します
printf '%s\n' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]'
}
kind_by_name() {
local name="$(normalize_lower "$1")"
case "$name" in
@(alice|bob|carol)) echo "ユーザー";;
admin|root) echo "管理者";;
*) echo "不明"; return 1;;
esac
}
# 拡張グロブを使うので事前に有効化
shopt -s extglob
kind_by_name "Alice" # => ユーザー
kind_by_name "ROOT" # => 管理者
この例では @(alice|bob|carol) のような“拡張グロブ”を使っています。拡張グロブは shopt -s extglob をセットしてから利用します(後述)。
拡張トピック:拡張グロブと “落下” の挙動
Bash には glob を強化する拡張として、?(pat)(0回か1回)、*(pat)(0回以上)、+(pat)(1回以上)、@(pat)(ちょうど1回)、!(pat)(否定)があります。パターンを柔らかく表現できるので、列挙が多い場面ほど読みやすくなります。使う前に shopt -s extglob を忘れないようにしましょう。
もう一つ、case には “節を終えたあとどう振る舞うか” を選ぶ記号が3種類あります。ふだん使うのは停止を意味する ;; です。次の節のコマンドだけを続けて実行したいときは ;&、次の節のパターン再評価をしたいときは ;;& を使います。用途は限られますが、ログだけは共通で出したい、といったときに ;& が便利です。挙動が増えると読みづらくなるので、まずは ;; を基本にして、必要な場面でだけ使うと良いでしょう。
shopt -s extglob
case "$mode" in
dev) echo "開発モード";;& # 次のパターンも“判定”してから実行
stage) echo "ステージング";;&
prod) echo "本番";;&
*) echo "共通ログを出すだけ";; # ここまで到達した場合のみ実行
esac
実務でよく使うパターン
拡張子で処理を振り分けるのは定番です。*.{jpg,jpeg,png} のように書きたくなりますが、case のパターンには中括弧展開は使えません。jpg|jpeg|png と列挙するか、拡張グロブの @(jpg|jpeg|png) を使います。
shopt -s extglob
process() {
local path="$1" base ext
base="${path##*/}"
ext="${base##*.}"
case "${ext,,}" in # ,,: bash の小文字化
@(jpg|jpeg|png)) echo "画像: $base";;
@(mp3|wav|flac)) echo "音声: $base";;
*) echo "未対応: $base"; return 2;;
esac
}
process "photo.JPG" # 画像
process "music.flac" # 音声
process "note.txt" # 未対応(return 2)
CLI の“サブコマンド”も case に向いています。if で比較を連ねるより、コマンドの一覧がひと目で分かります。
usage() { echo "使い方: $0 {add|list|delete} [args...]" >&2; }
main() {
local sub="${1:-}"
case "$sub" in
add) shift; cmd_add "$@";;
list) shift; cmd_list "$@";;
delete) shift; cmd_delete "$@";;
""|help|--help|-h) usage; return 2;;
*) echo "未知のコマンド: $sub" >&2; usage; return 2;;
esac
}
OS 判定も書いておくと便利です。/etc/os-release の ID を参照し、デフォルト節で“未知”を拾います。
detect_os() {
local id="unknown"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
id="${ID,,}"
fi
case "$id" in
ubuntu|debian) echo "apt";;
centos|rhel|rocky|almalinux) echo "yum";;
arch) echo "pacman";;
*) echo "unknown"; return 1;;
esac
}
よくある落とし穴
変数のクォート忘れは、スペースやワイルドカードが含まれる入力で誤判定を招きます。case "$var" in を癖にしてしまいましょう。パターンを大ざっぱにしすぎると、予想外の入力まで * で飲み込みます。具体的なものから先に書き、最後に * を置く順番を守るだけでずいぶん安全になります。
… | while read -r の形で入力を渡すと、環境によっては while がサブシェルで動き、ループ内で更新した変数が外に戻りません。case そのものの問題ではありませんが、よく一緒に使われるので注意です。プロセス置換 < <(...) を使えば、同じシェルの中で安全に処理できます。
count=0
# NG の例(戻らないことがある)
# printf '%s\n' jpg png | while read -r ext; do
# case "$ext" in jpg|png) ((count++));; esac
# done
# echo "$count" # 0 のまま…
# OK の例
while read -r ext; do
case "$ext" in jpg|png) ((count++));; esac
done < <(printf '%s\n' jpg png)
echo "$count" # 2
また、;& と ;;& は便利ですが、使いどころを絞らないと“意図しない実行が増える”危険があります。まずは ;; を基本にして、ログの追記など明確な目的があるときだけ ;& を選ぶほうが読みやすさを保てます。
終了コードの設計と、軽いテスト
分岐先で何らかの処理をした結果を、どう外へ伝えるかを決めておきましょう。成功なら 0、未知や入力エラーは非 0、というお約束を崩さないのがコツです。次の例では、既知の拡張子なら 0、未知なら 2 を返しています。スクリプト全体の最後で一度だけ exit する設計にしておくと、後始末の trap などが確実に動きます。
classify() {
local ext="${1,,}"
case "$ext" in
jpg|jpeg|png) echo "image"; return 0;;
mp3|wav|flac) echo "audio"; return 0;;
*) echo "unknown"; return 2;;
esac
}
if out="$(classify "$1")"; then
echo "$out"
exit 0
else
echo "$out" >&2
exit $?
fi
テストは難しく考えなくて大丈夫です。いくつかの入力に対して期待する出力と終了コードをメモし、手で実行してみるだけでも十分に効果があります。慣れてきたら bats-core で自動化すると、将来の変更にも強くなります。
まとめ
case/esac は“列挙できる分岐”を読みやすく、安全にまとめる道具です。if と棲み分けをしながら、パターンは具体的なものから書き、最後に * で漏れを拾います。必要なら拡張グロブや小文字化で入力を整え、デフォルト節でログと終了コードを返しておけば、外側の処理も安定します。少しずつ既存の if/elif/else を case に置き換えるだけでも、コードはぐっと見通しよくなります。
