Stopping Early: break, continue and Counters
A counter or running total is a variable a loop adds to each time round with $(( )); continue skips the rest of one pass and goes on with the next item, and break leaves the loop early.
Apprentice · Unix · Linux · Updated
After this lesson you can
- Keep a count or a running total in a loop with $(( ))
- Skip the rest of one pass with continue, and leave a loop early with break
Key takeaways
- Start a counter at 0 before the loop and add to it inside the loop: count=$((count + 1)).
- continue skips the rest of this pass and goes on with the next item.
- break leaves the loop at once, and the script carries on after done.
- [[ -e $f ]] || continue skips the pattern itself when a wildcard matched no files, so a loop is safe to run twice.
The drive to the coast takes 20 minutes, and you want music that fills it without running over, skipping the songs you crossed out. A cashier's register keeps a running total as each item is scanned; a script that adds up an expense file or counts the errors in a log does the same. Real files also have notes, blank lines and a point where you've seen enough. This lesson gives a loop three tools: a counter, a way to skip one item, and a way to stop.
Make a practice folder with a playlist. Each line is a song's length in minutes, then its title; one song is crossed out with a #, and there's a blank line:
mkdir -p ~/terminal-practice/road-trip
cd ~/terminal-practice/road-trip
printf '%s\n' '4 Highway Tea' '3 Paper Lanterns' '# 6 Rainy Tuesday (too slow for the car)' '5 Window Down' '' '4 Gas Station Sunrise' '6 Last Exit Waltz' '3 Home by Dark' > playlist.txt
Given two names, read -r minutes title splits a line: the first word goes into minutes and the rest into title.
read -r minutes title <<< '4 Highway Tea'
echo "$minutes minutes: $title"
4 minutes: Highway Tea
There's no IFS= in this read (Line by Line: while read), because this time you want the split. <<< (read "here-string") gives read one line of text to read.
Counters: keeping count as you go
A counter is a variable a loop adds to each time round, like a tally on a scorecard. Start it at 0 before the loop, and add to it inside with $(( )) (Values from Commands: $( ) and $(( ))). A running total works the same way:
count=0
total=0
while read -r minutes title; do
count=$((count + 1))
total=$((total + minutes))
done < playlist.txt
echo "$count songs, $total minutes"
bash: #: arithmetic syntax error: operand expected (error token is "#")
3 songs, 7 minutes
Something broke. $(( )) adds numbers, and the third line's first word is #. The error stopped the loop right there, after three songs, and the blank line would have tripped it too. The loop needs to skip lines that aren't songs.
continue: skip this one
continue skips the rest of the commands for this item and goes on with the next one, the way a cashier sets aside a coupon that doesn't scan and carries on with the next item. Add a check at the top of the loop:
count=0
total=0
while read -r minutes title; do
if [[ $minutes == "" || $minutes == "#"* ]]; then
continue
fi
count=$((count + 1))
total=$((total + minutes))
done < playlist.txt
echo "$count songs, $total minutes"
6 songs, 25 minutes
Read the test aloud: "minutes is empty, or minutes starts with #". Inside [[ ]], || means "or", and == with a * outside the quotes matches a pattern, the way a wildcard matches file names: "#"* is "a # and then anything".
break: stop here
break leaves the loop at once, and the script carries on after done. Turn the picker into a small application, pick-songs.sh, that takes the drive's length in minutes as $1 (Arguments: $1, $# and "$@") and stops when the next song wouldn't fit. Write it with nano pick-songs.sh:
#!/usr/bin/env bash
# Picks songs from playlist.txt, in order, until the drive is full.
# Usage: ./pick-songs.sh MINUTES
limit=$1
count=0
total=0
while read -r minutes title; do
if [[ $minutes == "" || $minutes == "#"* ]]; then
continue
fi
if [[ $((total + minutes)) -gt $limit ]]; then
break
fi
count=$((count + 1))
total=$((total + minutes))
echo "$count. $title ($minutes min)"
done < playlist.txt
echo "$count songs, $total of $limit minutes"
Make it runnable, and plan two drives:
chmod +x pick-songs.sh
./pick-songs.sh 20
./pick-songs.sh 12
1. Highway Tea (4 min)
2. Paper Lanterns (3 min)
3. Window Down (5 min)
4. Gas Station Sunrise (4 min)
4 songs, 16 of 20 minutes
1. Highway Tea (4 min)
2. Paper Lanterns (3 min)
3. Window Down (5 min)
3 songs, 12 of 12 minutes
On the 20-minute drive, Last Exit Waltz would make 22 minutes, so the loop stops there and Home by Dark is never read. break and continue work the same in for, while and until loops.
Back at Mission Control: safe to run twice
The renaming loop in For Every File: for Loops complained when there was nothing left to rename, because bash handed it the pattern itself. continue fixes that: when no file matched, $f is the pattern, which isn't a real file, so skip it. A new telemetry file has just come in:
cd ~/terminal-practice/mission-control
touch telemetry/orbit-13.tmp
for f in telemetry/*.tmp; do
[[ -e $f ]] || continue
mv "$f" "${f%.tmp}.log"
echo "Renamed $f"
done
Renamed telemetry/orbit-13.tmp
Read [[ -e $f ]] || continue as "the file exists, or else skip it" (Checking Before You Act: File Tests). Run the same loop again and it prints nothing at all, with no error: the pattern matched no file, so continue skipped the mv.
Check yourself
2 questions to try on the lesson page.
So what
Counters, continue and break turn a loop that does the same thing to everything into one that keeps score, skips what doesn't belong and knows when it's done, which is what makes a script a tool you can trust with real files.