2-3. 制御構造 - if / for / while / case / 関数
所要時間: 40-60分(がっつりなら2セッション分) ゴール: 条件分岐とループを使ったスクリプトが書け、関数で処理を分割できる コミット内容: 練習スクリプト群を
~/learn/linux/day203/に保存
この章が終わるとできること
[[ ]]を使った数値・文字列・ファイル判定が書けるforの 3つの形式(リスト / 連番 / 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行超えそうなら別言語へ」の境界線が体感で分かる
大前提: シェルの制御構造は「他言語と微妙に違う」
シェルの if や for は、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.log1. 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 でOK | N で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. パターンマッチと正規表現([[ ]] の真骨頂)
[[ ]] の最大の利点は grep や sed を呼ばずに パターンマッチや正規表現が使えることです。条件分岐の表現力が一気に上がります。
- 何の構文か: 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"
fiif の本質: 「コマンドの終了コードを見る」
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-elif | case | |
|---|---|---|
| 分岐が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 donesystemd や 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: $?" # 2break / 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.sh11. サーバー一覧を 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" =~ ^# ]] && continueping -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 を返した -
breakcontinueを適切に使った
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| 数値比較 | [[ $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 ...; done。ls の出力をパースしない。
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を使う理由を説明できる -
returnとexitの違いを答えられる(関数を抜けるか、スクリプト全体を抜けるか)
次のレッスン: Day 11 - systemd
明日は systemd ─ Linux のサービス管理のデファクト。
今日まで「スクリプトを手で叩く」レベルだったが、本番では 「OS 起動時に自動起動」「落ちたら自動再起動」「ログを集中管理」 が必要になる。Day 6 で nohup でやっていた仮設対策の 本格版 が systemd。unit ファイル設計、journalctl でログを追う技術、自作サーバーの常駐化を扱う。
→ Day 11: systemd へ