ループ制御の実践|break・continue と終了コードの正しい設計(Bash)

分岐・反復

ループは Bash スクリプトの心臓部です。ところが実務では、continue を場当たり的に使って“とりあえず先へ進む”うちに、後始末が抜けたり、終了コードが伝わらずに不具合が潜る――そんな崩れ方が起きがちです。本稿は既存の「continue」解説を土台に、break の正しい使いどころ終了コード設計まで含めて、ループの“出口”を意図どおりに制御するための指針をまとめます。

対象は forwhileuntilスキップ(continue)と脱出(break)、そして関数の return/スクリプトの exitの役割分担をはっきりさせ、ネスト時の continue Nbreak N、ストリーム処理の while read -r で起きやすい罠、後始末の一元化までを、短いコード例と設計の意図で解説します。

環境は Bash 5 系、set -euo pipefail 前提。目標はシンプルです。「何をスキップし、いつ中断し、どの失敗をどこへ返すか」をあらかじめ決め、コードに落とし込めるようになること。読み終える頃には、continue を“失敗の隠蔽”にせず、break と終了コードで意図が外に伝わるループを書けるようになります。

まずは出口の整理:continue/break/return・exit

continue は「現在の1回分だけスキップして、次の反復へ進む」ふるまいです。
break は「いま居るループ(必要なら外側も含めて)から脱出する」ふるまいです。
関数の中なら return、スクリプト全体を終えるなら exit を使い、どこに結果(終了コード)を返すかを決めます。

# 例:1,2,skip,3 を処理。skip は飛ばし、3 で目的達成 → 脱出
set -euo pipefail
nums=(1 2 "skip" 3 4)

for n in "${nums[@]}"; do
  [[ "$n" == "skip" ]] && continue        # スキップ
  if (( n == 3 )); then
    echo "見つけたので脱出"
    break                                  # 脱出
  fi
  echo "処理: $n"
done

continue の基本:弱い失敗は“記録して流す”

「検証エラーはログに残して処理は続ける」という方針なら continue が向きます。“握りつぶさない” のがコツです。件数や最初のエラー原因を変数に記録し、ループの外でまとめて判断します。

set -euo pipefail

invalid=0
while IFS= read -r line; do
  [[ -z "$line" || "$line" == \#* ]] && continue  # 空行・コメントは弱い無視

  if ! printf '%s\n' "$line" | grep -qE '^[a-z0-9_-]{3,}$'; then
    ((invalid++))
    printf 'WARN: 無効な名前: %s\n' "$line" >&2
    continue
  fi

  echo "OK: $line"
done < <(printf '%s\n' "alice" "" "#comment" "too-long-&&" "bob")

# ループ後にまとめて判断
if (( invalid > 0 )); then
  echo "無効行: $invalid 件" >&2
fi

continue を使うと後始末が飛びがちなので、後片付けは trap で一元化しておくと安心です。

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT   # continue でも break でも必ず実行される

break の基本:目的達成・上限到達で“きれいに抜ける”

「最初の成功で十分」「一定回数で打ち切る」など、処理を中断する条件がはっきりしているときは break が合います。見つけた結果を変数に保存してから抜けると、ループ外の処理が書きやすくなります。

set -euo pipefail

found=""
for host in 10.0.0.{1..10}; do
  if ping -c1 -W1 "$host" >/dev/null 2>&1; then
    found="$host"
    break
  fi
done

if [[ -n "$found" ]]; then
  echo "最初に応答したホスト: $found"
  # ここで found を使う処理 …
else
  echo "応答なし" >&2
  exit 1
fi

ネストしたループでは break 2 のように段数を指定できます。ただし深くなるほど読みづらくなるので、関数に切り出して return で早期退出する方が保守しやすいことが多いです。

search() {
  local target="$1"
  local i j
  for i in {1..3}; do
    for j in {a..c}; do
      [[ "$i$j" == "$target" ]] && { printf '%s\n' "$i$j"; return 0; }
    done
  done
  return 1
}

if result="$(search "2b")"; then
  echo "見つかった: $result"
else
  echo "見つからない" >&2
fi

return と exit:どこに結果を返すのか

関数の成否return の終了コードで返し、呼び出し元で判断します。スクリプト全体の成否は exit で返します。ループの中から直接 exit してしまうと、片付けやログが飛ぶことがあるため、基本は break→外で判断→必要なら exit の順がおすすめです。

process_item() {
  local x="$1"
  [[ "$x" =~ ^[0-9]+$ ]] || { echo "NG:$x" >&2; return 1; }
  echo "OK:$x"
}

final=0
for x in "42" "oops" "7"; do
  if ! process_item "$x"; then
    final=1             # 失敗は記録して続ける
    continue
  fi
done

exit "$final"           # 最終結果として一度だけ外へ返す

ストリーム処理:while read -r とパイプの罠

… | while read の形は、多くの環境で while がサブシェルになり、ループ内で更新した変数が外へ戻りません。回避策として プロセス置換 < <(...) を使うと安全です。

set -euo pipefail

count=0
# 悪い例: 変数が外に反映されない可能性
# printf '%s\n' a b c | while read -r s; do ((count++)); done
# echo "$count"  # 0 のまま…

# 良い例: プロセス置換で同一シェル内
while IFS= read -r s; do
  ((count++))
done < <(printf '%s\n' a b c)

echo "件数: $count"   # 3

失敗の設計:終了コードをどう決めて、どう伝えるか

終了コードは「0=成功、非0=失敗」が約束事です。ループや複数処理のときに大切なのは、どんなポリシーで最終結果を決めるかを先に決めることです。例として三つの考え方を示します。

「全件成功なら0」
どれか1つでも失敗したら非0にする、もっとも厳格な判定です。

final=0
for path in "${files[@]}"; do
  if ! do_task "$path"; then
    final=1
  fi
done
exit "$final"

「最初の失敗コードを返す」
最初に起きた失敗の種類(終了コード)を外へ伝えたいときに向きます。

first_fail=0
for path in "${files[@]}"; do
  if ! do_task "$path"; then
    first_fail=$?    # 0 以外が入る
    break            # ここで打ち切る方針もあり
  fi
done
exit "$first_fail"

「1件でも成功したら0」
フェイルオーバーや候補列の試行で、成功が一つでもあれば OK とする判定です。

ok=1
for endpoint in "${candidates[@]}"; do
  if try_connect "$endpoint"; then
    ok=0
    break
  fi
done
exit "$ok"

ミニ実践:continue と break を組み合わせる

入力データの検証エラーはスキップしつつ、目的が達成できたら脱出、最後に結果を一度だけ返す流れです。

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

target="admin"
found=""
invalid=0

while IFS= read -r user; do
  [[ -z "$user" || "$user" == \#* ]] && continue

  if ! [[ "$user" =~ ^[a-z][a-z0-9_-]{2,}$ ]]; then
    ((invalid++))
    printf 'WARN: 無効ユーザー: %s\n' "$user" >&2
    continue
  fi

  if [[ "$user" == "$target" ]]; then
    found="$user"
    break
  fi
done < users.txt

if [[ -n "$found" ]]; then
  echo "見つかった: $found"
  exit 0
fi

printf 'INFO: 見つからず(無効行 %d 件)\n' "$invalid" >&2
exit 1

この形だと、continue は「弱い失敗の整理」、break は「目的達成の早期終了」、最終の exit は「外へ結果を1回だけ伝える」という役割分担になります。
後始末が必要なら、先に触れたように trap を仕掛けておくと、どの出口を通ってもクリーンに終われます。

まとめ

continue は“先へ進むためのスキップ”、break は“ここで終えるための脱出”です。そして returnexit は「どこへ結果を返すか」を決めるスイッチです。

まずは、何をスキップし、いつ中断し、どの失敗をどこへ返すかを言葉で決めてからコードに落とし込んでみてください。trap による後始末の一元化、while read -r とプロセス置換の組み合わせ、集計変数による終了コード設計――この三点を押さえるだけで、ループはぐっと壊れにくく、読みやすくなります。

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする