How to write a shell script to assign letter grades to numeric ranges?

Solution 1:

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

Solution 2:

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

  1. 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 with read, unless you know you need to let the user supply \ escapes.
  2. If the user wrote q or Q, quit.
  3. Create an associative array (declare -A). Populate it with the highest numeric grade associated with each letter grade.
  4. 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.)
  5. If it falls in the range, print the grade and exit.
    For brevity, I use the short-circuit and operator (&&) rather than an if-then.
  6. 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 the do...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 or quit.

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 is case's syntax for separating a pattern from the commands that run when it is matched.
  • ;; is case'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 to read 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 (+( )) of 0.
  • Zero or more spaces (*( )), again.

*( )[qQ]?([uU][iI][tT])*( ), which checks for the quit command, matches a sequence of:

  • Zero or more spaces (*( )).
  • q or Q ([qQ]).
  • Optionally--i.e., zero or one occurrences (?( ))--of:
    • u or U ([uU]) followed by i or I ([iI]) followed by t or T ([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 of q and quit were covered automatically.

    That's what the shopt -s nocasematch command does. The shopt -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) of 0.
  • 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. Or Q, since shopt nocasematch is enabled.
  • Optionally--i.e., zero or one occurrences (postfix ?)--of the substring (( )):
    • u, followed by i, followed by t. Or, since shopt nocasematch is enabled u may be U; independently, i may be I; and independently, t may be T. (That is, the possibilities are not limited to uit and UIT.)
  • Zero or more spaces again (\ *).
  • The end of the line ($).

Solution 3:

#!/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