2-3. 制御構造 - if / for / while / case / 関数

所要時間: 40-60分(がっつりなら2セッション分) ゴール: 条件分岐とループを使ったスクリプトが書け、関数で処理を分割できる コミット内容: 練習スクリプト群を ~/learn/linux/day203/ に保存

この章が終わるとできること

  • [[ ]] を使った数値・文字列・ファイル判定が書ける
  • for3つの形式(リスト / 連番 / C言語風)を使い分けられる
  • while IFS= read -r line でファイルを安全に読める
  • case で複数パターンを if のネストなしに振り分けられる
  • 関数を local 付きで定義し、return で exit code を返せる

Day 8-9 とのつながり

  • Day 9 で書いた set -euo pipefail の上に、今日は 「ロジックの骨」 を組み上げる
  • Day 9 で身につけた クォート の習慣が、条件分岐の [[ "$VAR" = "x" ]] でそのまま活きる
  • 関数の戻り値が exit code なのは Day 9 で見た「コマンドの exit code」と同じ仕組み

これができると何が嬉しいか

  • スクリプトで if を書こうとして詰まる」が消える ─ [ ] vs [[ ]] の罠を避けられる
  • Day 13(ログ管理)や Day 21(デプロイ)で出てくる本物のスクリプトが、行ごとに読めるようになる
  • 「30行超えそうなら別言語へ」の境界線が体感で分かる

大前提: シェルの制御構造は「他言語と微妙に違う」

シェルの iffor は、Python や Go と似ているようで違う:

  • if の条件は「コマンドの終了コード」を見る(真偽値ではない)
  • for は「リストを反復」が基本(C言語風もある)
  • 関数の戻り値は exit code(0〜255の整数)(文字列を返せない)

この違いを知らずに書くと、「動くけど挙動が分からない」スクリプトになる。本章で 「言語としてのシェル」 を整理する。


セッション①: if と比較演算子(30分)

0. 録画と作業ディレクトリ

mkdir -p ~/log ~/learn/linux/day203
cd ~/learn/linux/day203
script ~/log/linux_day203.log

1. if の3形態([ ] / [[ ]] / test

bash の if には条件式の書き方が 3種類 あります。歴史的経緯が混在しており、どれを使うかで事故の起こりやすさが大きく変わります。

  • 何の構文か: [ ](POSIX互換、test の別名)、[[ ]](bash/zsh 拡張、推奨)、test コマンド本体、の3形式
  • いつ使うか: bash スクリプトで条件分岐を書くすべての場面。新規スクリプトなら [[ ]] 一択、#!/bin/sh 互換が必要な時だけ [ ]
  • 解決する具体的な問題: [ ] はクォート忘れで単語分割が起きやすい、&& || が中で使えない、パターンマッチや正規表現が使えない、といった罠を [[ ]] で一気に回避できます
NAME="alice"
 
# 形態A: [ ] (POSIX互換、古い)
if [ "$NAME" = "alice" ]; then
  echo "hello alice (POSIX)"
fi
 
# 形態B: [[ ]] (bash/zsh拡張、推奨)
if [[ "$NAME" == "alice" ]]; then
  echo "hello alice (bash)"
fi
 
# 形態C: test ([ ] と同じ、より明示的)
if test "$NAME" = "alice"; then
  echo "hello alice (test)"
fi

ざっくり言うと

[ ][[ ]](( ))全部「条件式」だが種類が違う

  • [ ] = 古い test コマンド(罠多い)
  • [[ ]] = bash の予約語(賢い)
  • (( )) = 数値計算用

新規スクリプトなら [[ ]] 一択

まず普通のパターン

[ ... ] の正体は実は コマンド

[ "$a" = "b" ]

これは見た目「条件式」だけど、シェルから見ると:

[ というコマンドに、"$a", "=", "b", "]" の4つの引数を渡している
  ↓
[ は test の別名(マジで /usr/bin/[ というファイルがある)
  ↓
test "$a" = "b" と同じ
  ↓
真なら exit 0、偽なら exit 1 を返す

つまり [シェルにとっては普通のコマンド。だから引数として変数展開・単語分割が普通に起きる。

[ ] の罠

FILE=""        # 空文字
if [ -f $FILE ]; then
  echo "exists"
fi

シェル目線:

[ -f $FILE ] を評価
  ↓
$FILE が空文字に展開
  ↓
[ -f ]    ← 引数が足りない!
  ↓
[: -f: unary operator expected (構文エラー)

[[ ]] は何が違うか

[[ ]]シェルの予約語(コマンドではない)。だから中身は普通のコマンドラインルールが適用されない:

if [[ -f $FILE ]]; then    # クォート無しでも安全
  echo "exists"
fi
[[ -f $FILE ]] が来た
  ↓
予約語なので「シェルが特別に解釈」
  ↓
$FILE が空文字でも単語分割しない
  ↓
[[ -f "" ]] として正しく評価(false を返す)

(( )) は数値専用

if (( N > 10 )); then
  echo "big"
fi
(( ... )) は「数値演算式として評価」
  ↓
変数名に $ も要らない
  ↓
真なら exit 0、偽なら exit 1

C 言語と同じ感覚で > < == + - * / % && が書ける。

3つの対比表

[ ][[ ]](( ))
正体コマンド (test)シェル予約語算術評価式
用途互換性重視文字列・ファイル数値
クォート忘れ事故る安全関係なし
&& / ||×
パターンマッチ *.txt××
正規表現 =~××
数値比較-eq -lt-eq -lt== <
変数"$N"$N でOKN でOK($不要)
POSIX 互換× (bash拡張)× (bash拡張)

使い分けの目安

# 文字列・ファイル: [[ ]]
[[ -f config.yml ]]
[[ "$name" == "alice" ]]
[[ "$f" == *.log ]]
 
# 数値: (( ))
(( count > 10 ))
(( i % 2 == 0 ))
 
# #!/bin/sh で書く時のみ: [ ]
[ -f config.yml ]

一番覚えやすい説明

  • [[ ]] = 文字列・ファイル判定の本命(常にこれ
  • (( )) = 数値計算専用
  • [ ] = POSIX sh 互換が必要な時だけ
  • 迷ったら [[ ]] を書く

[ ] の事故例

FILE=""   # 空文字
 
# NG: [ ] でクォートなし
if [ -f $FILE ]; then    # → if [ -f ] になり、構文エラー
  echo "exists"
fi
 
# OK: クォート付き
if [ -f "$FILE" ]; then
  echo "exists"
fi
 
# 推奨: [[ ]] ならクォート無しでも事故りにくい
if [[ -f $FILE ]]; then
  echo "exists"
fi

2. 比較演算子

bash の比較演算子は 数値・文字列・ファイル属性 の3カテゴリーに分かれており、それぞれ書き方が異なります。混在すると思わぬバグになります。

  • 何の構文か: 数値比較(-eq -lt -gt 等)、文字列比較(== != -z -n)、ファイル判定(-f -d -r -s 等)
  • いつ使うか: 引数の個数チェック ([[ $#--eq-2-| -eq 2 ]])、設定ファイル存在確認 ([[ -f config.yml ]])、変数が空かのチェック ([[ -z "$VAR" ]])
  • 解決する具体的な問題: 「数値比較に == を使って "10""010" が一致しなくなる」「ファイル存在チェックを -e でやって空ファイルも通してしまう」を、用途に応じた正しい演算子で防ぎます
# 数値比較
N=42
[[ $N -eq 42 ]] && echo "eq: equal"
[[ $N -ne 0 ]]  && echo "ne: not equal"
[[ $N -lt 100 ]] && echo "lt: less than"
[[ $N -gt 10 ]]  && echo "gt: greater than"
[[ $N -le 42 ]] && echo "le: less or equal"
[[ $N -ge 42 ]] && echo "ge: greater or equal"
 
# 文字列比較
S="hello"
[[ "$S" == "hello" ]] && echo "string eq"
[[ "$S" != "world" ]] && echo "string ne"
[[ -z "" ]] && echo "z: empty"           # 文字列が空
[[ -n "$S" ]] && echo "n: non-empty"     # 文字列が空でない
 
# ファイル・ディレクトリ判定
[[ -e /etc/passwd ]] && echo "exists"
[[ -f /etc/passwd ]] && echo "regular file"
[[ -d /etc ]] && echo "directory"
[[ -L /etc ]] && echo "symlink"          # シンボリックリンクか
[[ -r /etc/passwd ]] && echo "readable"
[[ -w /tmp ]] && echo "writable"
[[ -x /bin/ls ]] && echo "executable"
[[ -s /etc/passwd ]] && echo "non-empty file"

比較演算子のチートシート

数値比較[[ ]] でも [ ] でも使う、必ず -eq 系)

演算子意味
-eqequal
-nenot equal
-ltless than
-gtgreater than
-leless or equal
-gegreater or equal

文字列比較

演算子意味
==等しい([[ ]])/ =[ ]
!=等しくない
< >辞書順([[ ]] で使える、[ ] だとリダイレクトと衝突)
-z空文字
-n非空文字

ファイル判定

演算子意味
-eexists(任意の種類)
-fregular file
-ddirectory
-L / -hsymlink
-r / -w / -x読み/書き/実行 可能
-ssize > 0(中身がある)
f1 -nt f2f1 が新しい
f1 -ot f2f1 が古い

よくある罠: 数値比較に == を使う

N=10
# NG(バグらないけど意図が違う)
[[ "$N" == "10" ]]   # 文字列として比較。"010" は false
 
# OK
[[ $N -eq 10 ]]      # 数値として比較。"010" も true

似た罠: [[ ]] でファイル存在チェック忘れ

# NG: 「ファイルが存在しない場合」も「存在するけど中身が空」も区別したい時
if [[ -e file.txt ]]; then
  process file.txt   # 存在するだけ。空かもしれない
fi
 
# OK
if [[ -s file.txt ]]; then
  process file.txt   # 存在 かつ 中身がある
fi

3. パターンマッチと正規表現([[ ]] の真骨頂)

[[ ]] の最大の利点は grepsed を呼ばずに パターンマッチや正規表現が使えることです。条件分岐の表現力が一気に上がります。

  • 何の構文か: glob パターン([[ "$f" == *.log ]])と正規表現マッチ([[ "$s" =~ ^[0-9]+$ ]]
  • いつ使うか: 拡張子で処理を分岐する時、ユーザー入力のバリデーション(メアド形式、数値のみ等)、ログ行のパターンフィルタ
  • 解決する具体的な問題: 「拡張子チェックのために外部コマンドを呼ぶオーバーヘッド」を解消、${BASH_REMATCH[1]} で正規表現のキャプチャグループも取得できるため、sed -E 's/.../.../' 相当の処理が bash 単体で完結します
FILE="report.tar.gz"
 
# glob パターンマッチ
if [[ "$FILE" == *.gz ]]; then
  echo "gz file"
fi
 
# 正規表現マッチ(=~)
if [[ "$FILE" =~ ^report.*\.(tar\.gz|zip)$ ]]; then
  echo "matches!"
  echo "captured: ${BASH_REMATCH[1]}"   # tar.gz
fi
 
# 複数条件
if [[ "$FILE" == *.gz && -f "$FILE" ]]; then
  echo "gz file that exists"
fi

=~ で正規表現マッチ(bash 3.0+)

標準で正規表現が使える。マッチ結果は ${BASH_REMATCH[]} 配列に:

  • ${BASH_REMATCH[0]}: マッチした全体
  • ${BASH_REMATCH[1]} 以降: グループキャプチャ

注意: 正規表現はクォートしない方がよい(クォートするとリテラル扱いになるシェルもある)。

[[ "$s" =~ ^[0-9]+$ ]]    # OK
[[ "$s" =~ "^[0-9]+$" ]]  # NG(一部の bash バージョン)

4. if-elif-else

複数の条件を順番に評価する「多分岐」構文。3分岐以上なら case を検討する目安ですが、数値の閾値判定など範囲条件には if-elif が向いています。

  • 何の構文か: if-elif-else-fi で上から順に条件評価、最初にマッチした分岐を実行
  • いつ使うか: スコアや閾値で処理を切り替える、HTTP ステータスコードを範囲で分類、エラーレベル別の処理
  • 解決する具体的な問題: 三項演算子のない bash で「もし○○なら△△、そうでなければ□□」を読みやすく書く。範囲比較なら case より if-elif が直感的
read -rp "score (0-100): " SCORE
 
if [[ $SCORE -ge 90 ]]; then
  echo "A"
elif [[ $SCORE -ge 80 ]]; then
  echo "B"
elif [[ $SCORE -ge 70 ]]; then
  echo "C"
else
  echo "F"
fi

if の本質: 「コマンドの終了コードを見る」

if grep -q "pattern" file.txt; then
  echo "found"
fi

if の後ろは「条件式」ではなく 「コマンド」。そのコマンドの終了コードが 0 なら then 節を実行。[[ ]] も「真なら 0、偽なら 1 を返すコマンド」と思えばよい。

# こんな書き方もできる(多用はしない)
if cd /tmp/nonexistent 2>/dev/null; then
  echo "moved"
else
  echo "failed to cd"
fi

セッション②: ループと case と関数(30-40分)

5. for ループの3パターン

bash の for は他言語と違い「リストを反復する」のが基本形。C言語風ループも書けますが、シェルらしい書き方は5種類のパターンに分類できます。

  • 何の構文か: for VAR in LIST; do ... done のリスト反復が基本。glob 展開、{1..N} 連番、C言語風 for ((i=0; ...)) も可能
  • いつ使うか: ディレクトリ内の全ファイルに対する一括処理、設定済みサーバー一覧への並列実行、再帰的なファイルリネーム、連番ファイル生成
  • 解決する具体的な問題: 「for f in $(ls *.txt) でスペース入りファイル名が分割される」を glob 展開 (for f in *.txt) で回避。for line in $(cat file) の代わりに while IFS= read -r line でファイル行処理を安全に
# パターンA: リスト
for fruit in apple banana cherry; do
  echo "I like $fruit"
done
 
# パターンB: glob 展開
for f in *.sh; do
  echo "shell script: $f"
done
 
# パターンC: シーケンス
for i in {1..5}; do
  echo "iter $i"
done
 
# パターンD: C言語風(bash拡張)
for ((i=0; i<5; i++)); do
  echo "c-style: $i"
done
 
# パターンE: コマンドの結果(罠多い)
for line in $(cat file.txt); do
  echo "$line"   # IFS分割で改行・スペースで切れる
done

ざっくり言うと

bash の for は他言語と違って 「リストを反復する」が基本形。 ただし「リストの作り方」が5パターンあって、選択を間違えるとファイル名にスペースが入った瞬間に壊れる。

5パターンの対比

パターン書き方安全度こういう時
リスト直書きfor x in a b c固定の値
glob 展開for f in *.txtファイル一括処理
連番for i in {1..5}カウンタ
C言語風for ((i=0; i<5; i++))算術ループ
コマンド置換for x in $(cmd)避ける

glob 展開(最も安全)のフロー

for f in *.txt; do
  echo "$f"
done
*.txt が bash の glob に展開される
  ↓
"hello world.txt" "foo bar.txt" のリストに(各要素は引用符付きで保持)
  ↓
for が1つずつ取り出す
  ↓
f = "hello world.txt"     ← スペース入りでも正しい
f = "foo bar.txt"

コマンド置換のアンチパターンのフロー

for f in $(ls *.txt); do
  echo "$f"
done
$(ls *.txt) が実行される
  ↓
"hello world.txt\nfoo bar.txt" という文字列が返る
  ↓
IFS(スペース、タブ、改行)で単語分割
  ↓
"hello" "world.txt" "foo" "bar.txt"   ← 4分割!
  ↓
for が4回回ってしまう

= スペース入りファイル名で死ぬ

「Parsing ls is wrong」名言の理由

# NG (Unix の有名アンチパターン)
for f in $(ls *.txt); do ... done
 
# OK (glob を使う)
for f in *.txt; do ... done
 
# OK (任意のディレクトリでも安全な find + read)
find . -name "*.txt" -print0 | while IFS= read -r -d '' f; do
  process "$f"
done

一番覚えやすい説明

  • 固定リストfor x in a b c
  • ファイル群for f in *.ext(glob一択)
  • 連番for i in {1..N}
  • 数値計算for ((i=0; i<N; i++))
  • $(ls)$(cat) は書かない。これだけで90%の事故が消える。

for で $(cat file) のアンチパターン

# NG: 行にスペースが含まれていると壊れる
for line in $(cat servers.txt); do
  ssh "$line" "uptime"
done
 
# OK: while read で行ごとに処理
while IFS= read -r line; do
  ssh "$line" "uptime"
done < servers.txt

6. while と read

「条件が真の間繰り返す」のが while。bash で ファイルを1行ずつ正確に読む 鉄板パターンは while IFS= read -r line; do ... done < file です。これは丸暗記レベルで覚える価値があります。

  • 何の構文か: while CONDITION; do ... done の繰り返し、read 組み込みコマンドによる行読み込み
  • いつ使うか: ログファイル/CSV/設定ファイルを行単位で処理、サーバーリストを読んで全台にコマンド実行、ユーザー入力をプロンプトで受け取る
  • 解決する具体的な問題: 「for line in $(cat file) でスペースや改行が混ざるファイルが破綻」を完全に解決。-r でバックスラッシュエスケープを無効化、IFS= で行頭末空白を保持、堅牢なファイル読み取りを実現
# 基本: 条件が真の間ループ
i=0
while [[ $i -lt 5 ]]; do
  echo "while $i"
  ((i++))
done
 
# ファイルを1行ずつ読む(鉄板)
cat > servers.txt <<EOF
web01.example.com
web02.example.com
db01.example.com
EOF
 
while IFS= read -r server; do
  echo "checking: $server"
done < servers.txt
 
# パイプから読む(サブシェル注意)
ls /tmp | while IFS= read -r f; do
  echo "found: $f"
done

while IFS= read -r line の意味

  • IFS=: 行頭・行末の空白を保持(IFS が削るのを防ぐ)
  • read: 1行読み込む
  • -r: バックスラッシュ \ をエスケープとして解釈しない(ほぼ常に必要

この組み合わせが「ファイルを行ごとに正確に読む」鉄板パターン。覚える。

パイプから while の罠(サブシェル問題)

count=0
ls /tmp | while read -r f; do
  ((count++))
done
echo "count: $count"   # → 0(変わってない!)

パイプの右側 whileサブシェルで実行されるため、変数の変更が親に伝わらない。

対策: 入力リダイレクトを使う

count=0
while read -r f; do
  ((count++))
done < <(ls /tmp)
echo "count: $count"   # → 正しい数

<(cmd)プロセス置換。cmd の出力をファイル風に見せる bash 拡張。

7. case 文

「値に応じて多分岐」をする構文。if-elif の連鎖よりも 選択肢が多い時に圧倒的に読みやすい のが特徴で、systemd の init スクリプトや CLI ツールのサブコマンド処理で必ず登場します。

  • 何の構文か: case VALUE in PATTERN1) ...;; PATTERN2) ...;; *) ...;; esac のパターンマッチング型分岐
  • いつ使うか: myapp start|stop|restart|status のような CLI サブコマンド処理、ファイル拡張子で振り分け(*.tar.gz) *.zip))、エラーコードごとの処理分岐
  • 解決する具体的な問題: 5個以上の分岐を if-elif で書くと読みづらく == の打ち間違いも増える、を解消。glob パターンや | で複数パターンマッチも可能なため柔軟性が高い
read -rp "command: " CMD
 
case "$CMD" in
  start)
    echo "starting..."
    ;;
  stop)
    echo "stopping..."
    ;;
  restart|reload)            # 複数マッチ(|区切り)
    echo "restarting..."
    ;;
  status)
    echo "checking status..."
    ;;
  *.txt)                     # glob パターンOK
    echo "txt file requested"
    ;;
  *)                         # default
    echo "unknown command: $CMD"
    exit 1
    ;;
esac

ざっくり言うと

case「値に応じて多分岐」 の構文。if-elif で5個以上分岐するくらいなら全部 case の方が読みやすい。 特に glob パターン (*.log など) が使えるのが他言語の switch にない強み。

まず if-elif のパターン

if [[ "$CMD" == "start" ]]; then
  start
elif [[ "$CMD" == "stop" ]]; then
  stop
elif [[ "$CMD" == "restart" || "$CMD" == "reload" ]]; then
  restart
elif [[ "$CMD" == "status" ]]; then
  status
else
  echo "unknown"
fi

人間目線:「何を比べてるか が縦に揃ってないから読みづらい」「== を毎回書くの面倒」。

case で書き直す

case "$CMD" in
  start)            start ;;
  stop)             stop ;;
  restart|reload)   restart ;;
  status)           status ;;
  *)                echo "unknown" ;;
esac

人間目線:「比較対象が縦に揃って一目瞭然」「| で複数マッチも簡単」。

case の凄いところ: glob パターン

if ではできない芸当:

for f in "$@"; do
  case "$f" in
    *.tar.gz|*.tgz) tar xzf "$f" ;;
    *.tar.bz2)      tar xjf "$f" ;;
    *.zip)          unzip "$f" ;;
    [0-9]*)         echo "数字で始まる: $f" ;;
    *)              echo "unknown: $f" ;;
  esac
done

*.tar.gz のような glob、[0-9]* のような文字クラスがそのまま使える。これを if でやろうとすると正規表現 (=~) を駆使する地獄。

case の構造図

case "$VALUE" in
  PATTERN1)
    処理1
    ;;          ← ここで分岐を抜ける
  PATTERN2|PATTERN3)   ← | で複数パターン
    処理2
    ;;
  *.ext)        ← glob OK
    処理3
    ;;
  *)            ← default
    処理4
    ;;
esac

case vs if-elif 対比

if-elifcase
分岐が2-3個大げさ
分岐が5個以上読みづらい
範囲条件 ($n > 10)微妙(glob では数値範囲書きづらい)
値マッチ冗長
glob 使用×
複数マッチ||| (短い)

一番覚えやすい説明

  • 2-3分岐if-elif
  • 5個以上の値マッチcase 一択
  • 拡張子で振り分け → 反射で case
  • systemd や init.d スクリプトの start|stop|restart は全部 case で書いてある

case の実務ユースケース

# ファイル拡張子で処理を分ける
for f in "$@"; do
  case "$f" in
    *.tar.gz|*.tgz) tar xzf "$f" ;;
    *.tar.bz2)      tar xjf "$f" ;;
    *.zip)          unzip "$f" ;;
    *.gz)           gunzip "$f" ;;
    *)              echo "unknown: $f" >&2 ;;
  esac
done

systemd や init.d のスクリプトの start|stop|restart|status 処理は 必ず case で書かれている

8. 関数定義

シェル関数は 「名前を付けた小さなプログラム」。スクリプトが30行を超えてきたら関数化を検討します。ただし他言語と違って「文字列を return できない」など独特の制約があります。

  • 何の構文か: func_name() { ... } で定義、func_name arg1 arg2 で呼び出し。引数は関数内の $1 $2 で受け取る
  • いつ使うか: 同じ処理を複数箇所で使う、メイン処理を意味単位に分解して可読性を上げる、テスト可能な単位に分割する
  • 解決する具体的な問題: 「処理が長くなって何をしているか追えない」を関数分割で構造化、local 宣言でグローバル変数汚染を防ぐ、return でエラー伝播(0=成功、1=失敗)させて呼び出し側で if func; then で判定可能に
# 定義方法A(POSIX互換)
greet() {
  echo "Hello, $1!"
}
 
# 定義方法B(bash 専用)
function greet2 {
  echo "Hello, $1!"
}
 
greet "alice"
greet2 "bob"
 
# ローカル変数
counter() {
  local count=0       # local が無いとグローバルになる
  for x in "$@"; do
    ((count++))
  done
  echo "$count"
}
 
result=$(counter a b c d)
echo "result: $result"   # 4
 
# 戻り値は exit code(0-255)
is_even() {
  local n=$1
  if (( n % 2 == 0 )); then
    return 0   # true
  else
    return 1   # false
  fi
}
 
if is_even 4; then
  echo "4 is even"
fi
 
if ! is_even 7; then
  echo "7 is odd"
fi

シェル関数の特殊な性質

観点他言語との違い
引数$1 $2 … で受け取る(変数名でなく位置)
戻り値return N は exit code(0-255)。文字列は返せない
文字列を返す代替echo で出力して $(func) で受け取る
スコープデフォルトはグローバル変数。local で関数内に閉じる
引数の上書き関数内の $1 は呼び出し側の $1 を上書きしない

戻り値の慣習: return 0 = 成功 = true、return 1 = 失敗 = false。if function; then で判定。

よくあるアンチパターン: local 忘れ

# NG: count がグローバル変数になり、別関数と干渉する
count_lines() {
  count=0   # local なし!
  while read -r line; do ((count++)); done < "$1"
  echo $count
}
 
# OK
count_lines() {
  local count=0
  while read -r line; do ((count++)); done < "$1"
  echo "$count"
}

関数内の作業変数は 必ず local で宣言する。

9. break / continue / return

ループや関数の流れを 途中で変更する 制御文。それぞれの作用範囲を間違えると思わぬ場所で処理が継続/中断してしまいます。

  • 何の構文か: break(現在のループを抜ける)、continue(次の反復へジャンプ)、return(関数を終了)、exit(スクリプト全体を終了)
  • いつ使うか: 探したい値を見つけたら早期終了 (break)、空行をスキップして処理続行 (continue)、関数のエラーパスで早期 return
  • 解決する具体的な問題: 「条件が満たされたら無駄なループを回さず即終了したい」「関数内のエラーで exit を書いてしまい、呼び出し元のスクリプトごと終了する事故」を、用途別の正しい制御文で防ぎます
# break: ループを抜ける
for i in {1..10}; do
  if [[ $i -eq 5 ]]; then
    break
  fi
  echo "$i"
done
 
# continue: 次の反復へ
for i in {1..10}; do
  if (( i % 2 == 0 )); then
    continue
  fi
  echo "odd: $i"
done
 
# 多重ループから抜ける
for i in {1..3}; do
  for j in {1..3}; do
    if [[ $i -eq 2 && $j -eq 2 ]]; then
      break 2   # 2段階上のループまで抜ける
    fi
    echo "$i,$j"
  done
done
 
# return: 関数を抜ける(exit code を返す)
my_func() {
  for x in "$@"; do
    if [[ "$x" == "stop" ]]; then
      return 2
    fi
    echo "$x"
  done
  return 0
}
 
my_func a b stop c d
echo "func returned: $?"   # 2

break / continue / return / exit の使い分け

キーワード効果
break現在のループを抜ける
break NN段階上のループまで抜ける
continue次の反復へジャンプ
return [N]関数を終了(exit code N)。関数外で使うとエラー
exit [N]スクリプト全体を終了

関数で exit を使うと スクリプト全体が終わるので注意。関数内で「失敗を伝えて呼び出し元に処理を委ねる」なら return


セッション③(任意): 実践パターン

10. ログから特定パターンを抽出する

cat > extract_errors.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
LOG_FILE="${1:?Usage: $0 <log_file>}"
 
[[ -f "$LOG_FILE" ]] || { echo "Error: $LOG_FILE not found" >&2; exit 1; }
 
# ERROR / WARN の行を抽出して集計
echo "=== Error summary ==="
grep -E "ERROR|WARN" "$LOG_FILE" | while IFS= read -r line; do
  case "$line" in
    *"connection refused"*) ((conn_refused++)) ;;
    *"timeout"*) ((timeout++)) ;;
    *"500 Internal"*) ((server_err++)) ;;
  esac
done
 
# 上位エラーをカウント(subshell 問題を避けるため awk へ)
echo
echo "=== Top error messages ==="
grep -E "ERROR|WARN" "$LOG_FILE" \
  | awk -F': ' '{print $NF}' \
  | sort | uniq -c | sort -rn | head -5
EOF
chmod +x extract_errors.sh

11. サーバー一覧を ping でチェック

cat > healthcheck.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
SERVERS_FILE="${1:-servers.txt}"
 
[[ -f "$SERVERS_FILE" ]] || { echo "no $SERVERS_FILE" >&2; exit 1; }
 
declare -i ok=0 fail=0
while IFS= read -r host; do
  [[ -z "$host" || "$host" =~ ^# ]] && continue   # 空行と # コメント行をスキップ
 
  if ping -c 1 -W 1 "$host" >/dev/null 2>&1; then
    echo "OK   $host"
    ((ok++))
  else
    echo "FAIL $host" >&2
    ((fail++))
  fi
done < "$SERVERS_FILE"
 
echo "---"
echo "ok=$ok fail=$fail"
 
# 失敗があれば exit 1(CI/監視で検知させる)
[[ $fail -eq 0 ]]
EOF
chmod +x healthcheck.sh

このスクリプトのプロっぽいポイント

  • 空行・コメント行をスキップ: [[ -z "$host" || "$host" =~ ^# ]] && continue
  • ping -c 1 -W 1: 1回だけ、1秒タイムアウト(無限に待たない)
  • >/dev/null 2>&1: 出力を捨ててステータスだけ見る
  • 失敗があれば exit 1: 末尾の [[ $fail -eq 0 ]] が終了コードを決める

アンチパターン総覧

アンチパターン: バックティック

# NG(古い書き方、ネストできない)
RESULT=`grep "x" file.txt | wc -l`
 
# OK
RESULT=$(grep "x" file.txt | wc -l)

アンチパターン: if [ -e file ] の見落とし

# NG: 「ファイルが存在する」だけチェック → 空ファイルや壊れたファイルもtrueになる
if [ -e config.yml ]; then
  load_config config.yml   # 空でもロード試みる
fi
 
# OK: 中身までチェック
if [[ -s config.yml ]]; then    # サイズ > 0
  load_config config.yml
fi

アンチパターン: 関数内で local 忘れ

# NG: i がグローバル変数に
count_files() {
  i=0
  for f in "$@"; do ((i++)); done
  echo $i
}
 
# OK
count_files() {
  local i=0
  for f in "$@"; do ((i++)); done
  echo "$i"
}

アンチパターン: for line in $(cat file)

既出だが重要なので再掲。ファイルを行ごとに読むなら while IFS= read -r line; do ... done < file


練習課題

# 課題1: 拡張子で振り分け
cat > sort_files.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
DST_DIR="${1:?Usage: $0 <dst_dir>}"
mkdir -p "$DST_DIR"/{images,docs,archives,others}
 
for f in *.{jpg,png,pdf,docx,zip,tar.gz,txt} 2>/dev/null; do
  [[ -f "$f" ]] || continue
  case "$f" in
    *.jpg|*.png)             mv "$f" "$DST_DIR/images/" ;;
    *.pdf|*.docx|*.txt)      mv "$f" "$DST_DIR/docs/" ;;
    *.zip|*.tar.gz)          mv "$f" "$DST_DIR/archives/" ;;
    *)                       mv "$f" "$DST_DIR/others/" ;;
  esac
done
EOF
chmod +x sort_files.sh
 
# 課題2: ログから日付ごとのエラー件数
cat > error_per_day.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
LOG="${1:?Usage: $0 <log_file>}"
 
# 各エラー行から日付(YYYY-MM-DD)を取り出して集計
grep "ERROR" "$LOG" \
  | awk '{print $1}' \
  | sort | uniq -c | sort -rn
EOF
chmod +x error_per_day.sh
 
# 課題3: 関数で書くシンプルなTODOリスト
cat > todo.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
TODO_FILE="${HOME}/.todo.txt"
touch "$TODO_FILE"
 
usage() {
  cat <<USAGE
Usage:
  $0 add "task"
  $0 list
  $0 done <num>
USAGE
  exit 64
}
 
add_task()  { echo "$1" >> "$TODO_FILE"; echo "Added: $1"; }
list_tasks() { nl -ba "$TODO_FILE"; }
done_task() {
  local num="$1"
  local total
  total=$(wc -l < "$TODO_FILE")
  [[ $num -ge 1 && $num -le $total ]] || { echo "Out of range" >&2; return 1; }
  sed -i.bak "${num}d" "$TODO_FILE" && rm "${TODO_FILE}.bak"
  echo "Done #$num"
}
 
case "${1:-}" in
  add)   shift; [[ $#--ge-1-| -ge 1 ]] || usage; add_task "$*" ;;
  list)  list_tasks ;;
  done)  shift; [[ $#--ge-1-| -ge 1 ]] || usage; done_task "$1" ;;
  *)     usage ;;
esac
EOF
chmod +x todo.sh
 
./todo.sh add "Linuxを勉強する"
./todo.sh add "Goでサーバー書く"
./todo.sh list
./todo.sh done 1
./todo.sh list

締め: git で証跡

exit   # script 終了
 
cd ~/learn/linux/day203
git init -q 2>/dev/null
git add -A
git commit -m "feat(linux): if/for/while/case/関数の基礎パターン"

チェックリスト

  • [[ ]] を使った([ ] ではなく)
  • -eq -ne -lt -gt の数値比較を使った
  • == != -z -n の文字列比較を使った
  • -f -d -s のファイル判定を使った
  • for f in *.txt の glob 展開を使った
  • while IFS= read -r line でファイルを読んだ
  • case で複数パターンを処理した
  • 関数内で local を使った
  • return で exit code を返した
  • break continue を適切に使った

詰まった時のチートシート

やりたいことコード
数値比較[[ $n -eq 0 ]]
文字列等価[[ "$s" == "abc" ]]
空判定[[ -z "$s" ]] / [[ -n "$s" ]]
ファイル存在[[ -f file ]] / [[ -d dir ]]
正規表現マッチ[[ "$s" =~ ^[0-9]+$ ]]
グロブマッチ[[ "$f" == *.log ]]
リストでループfor x in a b c; do ... done
連番でループfor i in {1..10}; do ... done
C言語風ループfor ((i=0; i<10; i++)); do ... done
ファイル読み込みwhile IFS= read -r l; do ... done < file
分岐case "$v" in a) ... ;; b) ... ;; *) ... ;; esac
関数定義func() { local x=$1; ... ; }
関数の戻り値return 0 / return 1
出力を返す関数result=$(func arg)

「実務OK」基準

  • [[ ]] を反射で使う: [ ] を見かけたら違和感を覚えるレベル
  • while read でファイル処理ができる: for $(cat) を書かない
  • 関数の戻り値が exit code であることを理解している
  • local を忘れない: 関数内変数のスコープを意識
  • case で拡張子振り分け などのパターンが体に染みている
  • 失敗時に exit 1 で抜ける: CI/監視で検知される設計

さらに深掘りするなら

  • 公式: man bash の CONDITIONAL EXPRESSIONS / COMPOUND COMMANDS
  • 書籍: 『Bash クックブック』の Chapter 6-8 / 『シェルスクリプトマガジン』
  • OSS: nginx の init スクリプト(/etc/init.d/nginx)、systemd と並んで歴史を感じる構造
  • チャレンジ: Advent of Code を bash で解いてみる(変態的だが制御構造の理解が深まる)

アンチパターン / 初心者やらかし事例

NG 1: for f in $(ls) を書く

for f in $(ls); do echo "$f"; done   # 空白入りファイル名で壊れる

→ 対策: glob を直接使う: for f in *; do ...; donels の出力をパースしない。

NG 2: while read でファイル末尾の1行が消える

while read line; do echo "$line"; done < file.txt   # 最終行に改行が無いと無視される

→ 対策: while IFS= read -r line || [[ -n "$line" ]]; do で末尾行も拾う。-r でバックスラッシュを保持、IFS= で前後の空白を保持。

NG 3: 関数で local を忘れて呼び出し元の変数を破壊

i=10
loop() { for i in 1 2 3; do echo $i; done; }
loop; echo $i   # 期待: 10、実際: 3

→ 対策: 関数内変数は 必ず local: local i; for i in 1 2 3; do ...

NG 4: case;;; と書いて構文エラー

case "$x" in
  a) echo "A" ;   # NG: 2個必要
  b) echo "B" ;;
esac

→ 対策: ;; セミコロン 2個case の終端。覚え方: 「ケース・ダブル・セミ」。


自己評価チェックリスト

  • [[ ]][ ] の違い(拡張機能の有無)を答えられる
  • for x in *.log の glob 展開を体感した
  • while IFS= read -r line のおまじないを暗記している
  • 関数内で local を使う理由を説明できる
  • returnexit の違いを答えられる(関数を抜けるか、スクリプト全体を抜けるか)

次のレッスン: Day 11 - systemd

明日は systemd ─ Linux のサービス管理のデファクト。

今日まで「スクリプトを手で叩く」レベルだったが、本番では 「OS 起動時に自動起動」「落ちたら自動再起動」「ログを集中管理」 が必要になる。Day 6 で nohup でやっていた仮設対策の 本格版 が systemd。unit ファイル設計、journalctl でログを追う技術、自作サーバーの常駐化を扱う。

Day 11: systemd