How to write a shell script to assign letter grades to numeric ranges?
You already have the basic idea. If you want to code this in bash
(which is a reasonable choice since it is the default shell on Ubuntu and most other Linuxes), you can't use case
because it doesn't understand ranges. Instead, you could use if
/else
:
#!/usr/bin/env bash
read -p "Please enter your choice: " response
## If the response given did not consist entirely of digits
if [[ ! $response =~ ^[0-9]*$ ]]
then
## If it was Quit or quit, exit
[[ $response =~ [Qq]uit ]] && exit
## If it wasn't quit or Quit but wasn't a number either,
## print an error message and quit.
echo "Please enter a number between 0 and 100 or \"quit\" to exit" && exit
fi
## Process the other choices
if [ $response -le 59 ]
then
echo "F"
elif [ $response -le 69 ]
then
echo "D"
elif [ $response -le 79 ]
then
echo "C"
elif [ $response -le 89 ]
then
echo "B"
elif [ $response -le 100 ]
then
echo "A"
elif [ $response -gt 100 ]
then
echo "Please enter a number between 0 and 100"
exit
fi
Brevity vs. Readability: A Middle Ground
As you've seen, this problem admits to solutions that are moderately long and somewhat repetitive but highly readable (terdon's and A.B.'s bash answers), as well as those that are very short but non-intuitive and much less self-documenting (Tim's python and bash answers and glenn jackman's perl answer). All these approaches are valuable.
You can also solve this problem with code in the middle of the continuum between compactness and readability. This approach is almost as readable as the longer solutions, with a length closer to the small, esoteric solutions.
#!/usr/bin/env bash
read -erp 'Enter numeric grade (q to quit): '
case $REPLY in [qQ]) exit;; esac
declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100
for letter in F D C B A; do
((REPLY <= cutoffs[$letter])) && { echo $letter; exit; }
done
echo "Grade out of range."
In this bash solution, I've included some blank lines to enhance readability, but you could remove them if you wanted it even shorter.
Blank lines included, this is actually only slightly shorter than a compactified, still pretty readable variant of A.B.'s bash solution. Its main advantages over that method are:
- It's more intuitive.
- It's easier to change the boundaries between grades (or add additional grades).
- It automatically accepts input with leading and trailing spaces (see below for an explanation of how
((
))
works).
All three of these advantages arise because this method uses the user's input as numeric data rather than by manually examining its constituent digits.
How it Works
- Read input from the user. Let them use the arrow keys to move around in the text they've entered (
-e
) and don't interpret\
as an escape character (-r
).
This script is not a feature-rich solution--see below for a refinement--but those useful features only make it two characters longer. I recommend always using-r
withread
, unless you know you need to let the user supply\
escapes. - If the user wrote
q
orQ
, quit. - Create an associative array (
declare -A
). Populate it with the highest numeric grade associated with each letter grade. - Loop through the letter grades from lowest to highest, checking if the user-provided number is low enough to fall into each letter grade's numeric range.
With((
))
arithmetic evaluation, variable names don't need to be expanded with$
. (In most other situations, if you want to use a variable's value in place of its name, you must do this.) - If it falls in the range, print the grade and exit.
For brevity, I use the short-circuit and operator (&&
) rather than anif
-then
. - If the loop finishes and no range has been matched, assume the number entered is too high (over 100) and tell the user it was out of range.
How this Behaves, with Weird Input
Like the other short solutions posted, that script doesn't check the input before assuming it's a number. Arithmetic evaluation (((
))
) automatically strips leading and trailing whitespace, so that's no problem, but:
- Input that doesn't look like a number at all is interpreted as 0.
- With input that looks like a number (i.e., if it starts with a digit) but contains invalid characters, the script emits errors.
- Multi-digit input starting with
0
is interpreted as being in octal. For example, the script will tell you 77 is a C, while 077 is a D. Although some users may want this, most probably don't and it can cause confusion. - On the plus side, when given an arithmetic expression, this script automatically simplifies it and determines the associated letter grade. For example, it will tell you 320/4 is a B.
An Expanded, Fully Featured Version
For those reasons, you may want to use something like this expanded script, which checks to ensure the input is good, and includes some other enhancements.
#!/usr/bin/env bash
shopt -s extglob
declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100
while read -erp 'Enter numeric grade (q to quit): '; do
case $REPLY in # allow leading/trailing spaces, but not octal (e.g. "03")
*( )@([1-9]*([0-9])|+(0))*( )) ;;
*( )[qQ]?([uU][iI][tT])*( )) exit;;
*) echo "I don't understand that number."; continue;;
esac
for letter in F D C B A; do
((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }
done
echo "Grade out of range."
done
This is still a pretty compact solution.
What features does this add?
The key points of this expanded script are:
- Input validation. terdon's script checks input with
if [[ ! $response =~ ^[0-9]*$ ]] ...
, so I show another way, which sacrifices some brevity but is more robust, allowing the user to enter leading and trailing spaces and refusing to allow an expression that might or might not be intended as octal (unless it's zero). - I've used
case
with extended globbing instead of[[
with the=~
regular expression matching operator (as in terdon's answer). I did that to show that (and how) it can also be done that way. Globs and regexps are two ways of specifying patterns that match text, and either method is fine for this application. - Like A.B.'s bash script, I've enclosed the whole thing in an outer loop (except the initial creation of the
cutoffs
array). It requests numbers and gives corresponding letter grades as long as terminal input is available and the user hasn't told it to quit. Judging by thedo
...done
around the code in your question, it looks like you want that. - To make quitting easy, I accept any case-insensitive variant of
q
orquit
.
This script uses a few constructs that may be unfamiliar to novices; they're detailed below.
Explanation: Use of continue
When I want to skip over the rest of the body of the outer while
loop, I use the continue
command. This brings it back up to the top of the loop, to read more input and run another iteration.
The first time I do this, the only loop I'm in is the outer while
loop, so I can call continue
with no argument. (I'm in a case
construct, but that doesn't affect the operation of break
or continue
.)
*) echo "I don't understand that number."; continue;;
The second time, however, I'm in an inner for
loop which is itself nested inside the outer while
loop. If I used continue
with no argument, this would be equivalent to continue 1
and would continue the inner for
loop instead of the outer while
loop.
((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }
So in that case, I use continue 2
to make bash find and continue the second loop out instead.
Explanation: case
Labels with Globs
I don't use case
to figure out which letter grade bin a number falls into (as in A.B.'s bash answer). But I do use case
to decide if the user's input should be considered:
- a valid number,
*( )@([1-9]*([0-9])|+(0))*( )
- the quit command,
*( )[qQ]?([uU][iI][tT])*( )
- anything else (and thus invalid input),
*
These are shell globs.
- Each is followed by a
)
that is not matched by any opening(
, which iscase
's syntax for separating a pattern from the commands that run when it is matched. ;;
iscase
's syntax for indicating the end of commands to run for a paticular case match (and that no subsequent cases should be tested after running them).
Ordinary shell globbing provides *
to match zero or more characters, ?
to match exactly one character, and character classes/ranges in [
]
brackets. But I'm using extended globbing, which goes beyond that. Extended globbing is enabled by default when using bash
interactively, but is disabled by default when running a script. The shopt -s extglob
command at the top of the script turns it on.
Explanation: Extended Globbing
*( )@([1-9]*([0-9])|+(0))*( )
, which checks for numeric input, matches a sequence of:
- Zero or more spaces (
*( )
). The*(
)
construct matches zero or more of the pattern in the parentheses, which here is just a space.
There are actually two kinds of horizontal whitespace, spaces and tabs, and often it's desirable to match tabs also. But I'm not worrying about that here, because this script is written for manual, interactive input, and the-e
flag toread
enables GNU readline. This is so the user can move back and forth in their text with the left and right arrow keys, but it has the side effect of generally preventing tabs from being entered literally. - One occurrence (
@(
)
) of either (|
):- A nonzero digit (
[1-9]
) followed by zero or more (*(
)
) of any digit ([0-9]
). - One or more (
+(
)
) of0
.
- A nonzero digit (
- Zero or more spaces (
*( )
), again.
*( )[qQ]?([uU][iI][tT])*( )
, which checks for the quit command, matches a sequence of:
- Zero or more spaces (
*( )
). q
orQ
([qQ]
).- Optionally--i.e., zero or one occurrences (
?(
)
)--of:u
orU
([uU]
) followed byi
orI
([iI]
) followed byt
orT
([tT]
).
- Zero or more spaces (
*( )
), again.
Variant: Validating Input with an Extended Regular Expression
If you prefer to test the user's input against a regular expression rather than a shell glob, you might prefer to use this version, which works the same but uses [[
and =~
(like in terdon's answer) instead of case
and extended globbing.
#!/usr/bin/env bash
shopt -s nocasematch
declare -A cutoffs
cutoffs[F]=59 cutoffs[D]=69 cutoffs[C]=79 cutoffs[B]=89 cutoffs[A]=100
while read -erp 'Enter numeric grade (q to quit): '; do
# allow leading/trailing spaces, but not octal (e.g., "03")
if [[ ! $REPLY =~ ^\ *([1-9][0-9]*|0+)\ *$ ]]; then
[[ $REPLY =~ ^\ *q(uit)?\ *$ ]] && exit
echo "I don't understand that number."; continue
fi
for letter in F D C B A; do
((REPLY <= cutoffs[$letter])) && { echo $letter; continue 2; }
done
echo "Grade out of range."
done
Possible advantages of this approach are that:
In this particular case, the syntax is a little simpler, at least in the second pattern, where I check for the quit command. This is because I was able to set the
nocasematch
shell option, and then all case variants ofq
andquit
were covered automatically.That's what the
shopt -s nocasematch
command does. Theshopt -s extglob
command is omitted as globbing isn't used in this version.Regular expression skills are more common than proficiency in bash's extglobs.
Explanation: Regular Expressions
As for the patterns specified to the right of the =~
operator, here's how those regular expressions work.
^\ *([1-9][0-9]*|0+)\ *$
, which checks for numeric input, matches a sequence of:
- The beginning--i.e., left edge--of the line (
^
). - Zero or more (
*
, applied postfix) spaces. A space does not ordinarily need to be\
-escaped in a regular expression, but this is needed with[[
to prevent a syntax error. - A substring (
(
)
) that is one or the other (|
) of:[1-9][0-9]*
: a nonzero digit ([1-9]
) followed by zero or more (*
, applied postfix) of any digit ([0-9]
).0+
: one or more (+
, applied postfix) of0
.
- Zero or more spaces (
\ *
), as before. - The end--i.e., right edge--of the line (
$
).
Unlike case
labels, which match against the whole expression being tested, =~
returns true if any part of its left-hand expression matches the pattern given as its right-hand expression. This is why the ^
and $
anchors, specifying the beginning and end of the line, are needed here, and don't correspond syntactically to anything appearing in the method with case
and extglobs.
The parentheses are needed to make ^
and $
bind to the disjunction of [1-9][0-9]*
and 0+
. Otherwise it would be the disjunction of ^[1-9][0-9]*
and 0+$
, and match any input starting with a nonzero digit or ending with a 0
(or both, which might still include non-digits in between).
^\ *q(uit)?\ *$
, which checks for the quit command, matches a sequence of:
- The beginning of the line (
^
). - Zero or more spaces (
\ *
, see above explanation). - The letter
q
. OrQ
, sinceshopt nocasematch
is enabled. - Optionally--i.e., zero or one occurrences (postfix
?
)--of the substring ((
)
):u
, followed byi
, followed byt
. Or, sinceshopt nocasematch
is enabledu
may beU
; independently,i
may beI
; and independently,t
may beT
. (That is, the possibilities are not limited touit
andUIT
.)
- Zero or more spaces again (
\ *
). - The end of the line (
$
).
#!/bin/bash
while true
do
read -p "Please enter your choice: " choice
case "$choice"
in
[0-9]|[1-5][0-9])
echo "F"
;;
6[0-9])
echo "D"
;;
7[0-9])
echo "C"
;;
8[0-9])
echo "B"
;;
9[0-9]|100)
echo "A"
;;
[Qq])
exit 0
;;
*) echo "Only numbers between 0..100, q for quit"
;;
esac
done
and a more compact version (Thx @EliahKagan):
#!/usr/bin/env bash
while read -erp 'Enter numeric grade (q to quit): '; do
case $REPLY in
[0-9]|[1-5][0-9]) echo F ;;
6[0-9]) echo D ;;
7[0-9]) echo C ;;
8[0-9]) echo B ;;
9[0-9]|100) echo A ;;
[Qq]) exit ;;
*) echo 'Only numbers between 0..100, q for quit' ;;
esac
done