1

How do I add numbers together inside the shell using a while or for loop?

I just want a really simple program that works with standard input and files.

Example:

$ echo 1 2 | sh myprogram
3

And if a file myfile contains a list of numbers, I want to be able to do this:

sh myprogram myfile

and get the sum of the numbers as output.

Benjamin W.
  • 46,058
  • 19
  • 106
  • 116
Sphinx
  • 21
  • 2

3 Answers3

3

While this question is at its core a duplicate of the linked question, it does state additional requirements (whether they were all fully intended by the OP or not):

  • The solution should be packaged as a script.

  • The solution should be POSIX-compliant (question is generically tagged )

  • Input should either come from a file, if specified, or from stdin by default.

  • There can be multiple numbers on a single input line (e.g., echo 1 2).

  • The solution should use a while or for loop, i.e. a pure shell solution.

The solution below addresses these requirements, except for the last one - which may well be a deal-breaker for the OP, but perhaps others will find it useful.

Deviating from that requirement by using external utilities means the solution will perform well with large sets of input data - loops in shell code are slow.

If you still want a shell while-loop solution, see the bottom of this post; it also includes input validation.


Contents of myprogram (POSIX-compliant, but requires a filesystem that represents the standard input as /dev/stdin):

Note that no input validation is performed - all tokens in the input are assumed to be decimal numbers (positive or negative); the script will break with any other input. See below for a - more complex - solution that filters out non-decimal-number tokens.

#!/bin/sh

{ tr -s ' \t\n' '+'; printf '0\n'; } < "${1-/dev/stdin}" | bc
  • ${1-/dev/stdin} uses either the first argument ($1, assumed to be a file path), if specified, or /dev/stdin, which represents stdin, the standard input.

  • tr -s ' \t\n' '+' replaces any run of whitespace in the input (spaces, tabs, newlines) with a single +; in effect, this results in <num1>+<num2>+...+ - note the dangling + at the end, which is addressed later.

    • Note that it is this approach to whitespace handling that allows the solution to work with any mix of one-number-per-line and multiple-numbers-per-line input
  • printf '0\n' appends a 0 so that the above expression becomes a valid addition operation.

    • Grouping ({ ...; ...; }) the tr and printf commands makes them act as a single output source for the pipeline (|).
  • bc is a POSIX utility that can perform (arbitrary-precision) arithmetic. It evaluates the input expression and outputs its result.

With input validation: Simply ignores input tokens that aren't decimal numbers.

#!/bin/sh

{ tr -s ' \t\n' '\n' | 
    grep -x -- '-\{0,1\}[0-9][0-9]*' | 
      tr '\n' '+'; printf '0\n'; } < "${1-/dev/stdin}"  | bc
  • tr -s ' \t\n' '\n' puts all individual tokens in the input - whether they are on the same line or on their own line - onto individual lines.
  • grep -x -- '-\{0,1\}[0-9][0-9]*' only matches lines containing nothing but a decimal number.
  • The remainder of the command works analogously to the solution without validation.

Examples:

Note: If you make myprogram itself executable - e.g., using cmod +x myprogram, you can invoke it directly - e.g., .\myprogram rather than sh myprogram.

# Single input line with multiple numbers
$ echo '1 2 3' | sh myprogram
6

# Multiple input lines with a single number each
{ echo 1; echo 2; echo 3; } | sh myprogram
6

# A mix of the above
$ sh myprogram <<EOF
1 2
3
EOF
6

A POSIX-compliant while-loop based solution that tests for and omits non-numbers from the sum:

Note: This is an adaptation of David C. Rankin's answer to demonstrate a robust alternative.
Note, however, that this solution will be much slower than the solution above, except for small input files.

#!/bin/sh

ifile=${1:-/dev/stdin}  ## read from file or stdin

sum=0
while read -r i; do                          ## read each token
    [ $i -eq $i 2>/dev/null ] || continue    ## test if decimal integer
    sum=$(( sum + i ))                       ## sum
done <<EOF
$(tr -s ' \t' '\n' < "$ifile")
EOF

printf " sum : %d\n" "$sum"
  • The solution avoids use of for to loop over a single input line, as using for on an unquoted string variable makes the resulting tokens subject to pathname expansion (globbing), which can lead to unexpected results with tokens such as *.

    • It is, however, possible to disable globbing with set -f, and to reenable it with set +f.
  • To enable use of a single while loop, the input tokens are first split so that each token is on its own line, via a command substitution involving tr inside a here-document.

    • Using a here-document (rather than a pipeline) to provide input to while allows the the while statement to run in the current shell and thus for the variables inside the loop to remain in scope after the loop ends (if input were provided via a pipeline, while would run in a subshell, and all its variables would go out of scope when the loop exits).
  • sum=$(( sum + i )) uses arithmetic expansion to calculate the sum, which is more efficient than calling external utility expr.


If you really, really want do this without calling any external utilities - I don't see why you would - try this:

#!/bin/sh

ifile=${1:-/dev/stdin}  ## read from file or stdin

sum=0
while read -r line; do                          ## read each line
  # Read the tokens on the line in a loop.
  rest=$line
  while [ -n "$rest" ]; do
    read -r i rest <<EOF
$rest
EOF
    [ $i -eq $i 2>/dev/null ] || continue    ## test if decimal integer
    sum=$(( sum + i ))                       ## sum
  done
done < "$ifile"

printf " sum : %d\n" "$sum"

If you don't mind blindly disabling and re-enabling pathname expansion (globbing) with set -f / set +f, you can simplify to:

#!/bin/sh

ifile=${1:-/dev/stdin}  ## read from file or stdin

sum=0
set -f # temp.disable pathname expansion so that `for` can safely be used
while read -r line; do                          ## read each line
  # Read the tokens on the line in a loop.
  # Since set -f is in effect, this is now safe to do.
  for i in $line; do
    [ $i -eq $i 2>/dev/null ] || continue    ## test if decimal integer
    sum=$(( sum + i ))                       ## sum
  done
done < "$ifile"
set +f  # Re-enable pathname expansion

printf " sum : %d\n" "$sum"
Community
  • 1
  • 1
mklement0
  • 382,024
  • 64
  • 607
  • 775
1

This solution requires Bash, as the following features are not POSIX shell compatible: arrays, regular expressions, here strings, the compound [[ ]] conditional operator. For a POSIX compatible solution, see David's answer.

Assume we have a line with space separated numbers, and we want to sum them up. To this end, we read them with read -a into an array nums, over which we then loop to get the sum:

read -a nums
for num in "${nums[@]}"; do
    (( sum += num ))
done
echo $sum

This works for a single line entered from stdin or piped to the script:

$ echo -e "1 2 3\n4 5 6" | ./sum
6

Notice how the second line was ignored. Now, for multiple lines, we wrap this in a while loop:

while read -a nums; do
    for num in "${nums[@]}"; do
        (( sum += num ))
    done
done
echo $sum

Now it works for multiple lines piped to the script:

$ echo -e "1 2 3\n4 5 6" | ./sum
21

To make this read from a file, we can use

while read -a nums; do
   # Loop here
done < "$1"

to redirect the file given as an argument to standard input:

$ cat infile
1 2 3
4 5 6
$ ./sum infile
21

But now, piping has stopped working!

$ ./sum <<< "1 2 3"
./sum: line 7: : No such file or directory

To solve this, we use parameter expansion. We say "redirect from the file if the argument is set and non-null, otherwise read from standard input":

while read -a nums; do
   # Loop here
done < "${1:-/dev/stdin}"

Now, both standard input and a file argument work:

$ ./sum infile
21
$ ./sum < infile
21

We could add a check to complain if what we encounter is not actually a number. All together in a script that does it:

#!/bin/bash

re='^[0-9]+$'    # Regex to describe a number

while read -a line; do
    for num in "${line[@]}"; do

        # If we encounter a non-number, print to stderr and exit
        if [[ ! $num =~ $re ]]; then
            echo "Non-number found - exiting" >&2
            exit 1
        fi
        (( sum += num ))
    done
done < "${1:-/dev/stdin}"
echo $sum
Community
  • 1
  • 1
Benjamin W.
  • 46,058
  • 19
  • 106
  • 116
  • 1
    The bash solutions are good, but `[[..]]`, `=~`, *arrays*, and *here-strings* are non-POSIX and will not work in Unix shell. See the [**POSIX Programmers Guide**](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html) *Note:* this may be what the OP is looking for, but the question is labeled *[shell]* (just something to watch for) – David C. Rankin Jan 30 '16 at 06:38
  • @David Good point, my solution is in fact Bash only. I'll amend that. – Benjamin W. Jan 30 '16 at 07:01
0

To sum within a while loop, you will need a way to separate values on each line and confirm that they are integer values before adding them to the sum. One approach for the POSIX shell in script form would be:

#!/bin/sh

ifile=${1:-/dev/stdin}  ## read from file or stdin
sum=0

while read -r a || test -n "$a" ; do                ## read each line
    for i in $a ; do                                ## for each value in line
        [ $i -eq $i >/dev/null 2>&1 ] || continue   ## test if integer
        sum=$(expr $sum + $i)                       ## sum
    done
done <"$ifile"

printf " sum : %d\n" "$sum"

exit 0
David C. Rankin
  • 81,885
  • 6
  • 58
  • 85
  • Please don't parse a list of unknown tokens with `for`; consider what happens if `$a` contains `1 2 *`. – mklement0 Jan 31 '16 at 18:14
  • I do appreciate the pointer, but I'm trying to understand the crux of your concern. Yes, I understand the `'*'` issue, but would not that also apply equally to any unknown list passed to `bc` or `tr` by `myprogram` as you suggest? Secondly, regardless of what is contained, even if `'*'` were for some reason passed in the list of numbers (pulling in filenames, etc.), if it were not an `int` it would be skipped (unless filenames were `###`). What additional protections do you see in passing an unknown list to `tr` and rewriting `spaces` with `+`? Just curios really. – David C. Rankin Jan 31 '16 at 19:58
  • Leaving aside the wisdom of allowing `*` to expand to all filenames first only to weed them out later with individual tests, `unless filenames were ###` is precisely the point: if those filenames happen to be valid numbers, you'll include them in your calculation, so this is not a robust solution. Generally, using `for` with an unquoted variable reference is a bad practice to encourage, at least without an explanation of the pitfalls. – mklement0 Jan 31 '16 at 20:34
  • The `tr` / `bc` combo in my solution is _not_ vulnerable to filename expansion. It also makes no attempt to weed out non-numbers, because that's not what the OP asked for. It's great that you opted to solve that additional problem, but you didn't solve it robustly. – mklement0 Jan 31 '16 at 20:37
  • This is really one of those damned if you do, damned if you don't circumstances where you have to fit your solution the input set at hand. Whether my loop or your `tr` / `bc` neither are guaranteed to work in all circumstances. If we are limiting the discussion to a list of numbers, then neither is subjected to any issue. Don't get me wrong, I do see, understand and agree with your point, but if we aren't considering non-numbers, the point is merely an academic point. – David C. Rankin Jan 31 '16 at 22:19
  • No, it's neither a case of damned-if-you-do... nor purely academic: when answering, you have the following choices, in ascending order of desirability: (a) answer the question as asked; (b) in addition, point out pitfalls and what needs to be done to make the solution robust; (c) in addition, _imperfectly_ implement what's needed to make the solution robust, _and point out the limitations of the attempt_; (d) ideally, _robustly_ implement what's needed to make the solution robust. Your solution falls into category (c), yet doesn't mention the limitations. – mklement0 Jan 31 '16 at 23:22
  • As for a pragmatic solution: you could surround your `for` loop with `set -f` (disable pathname expansion) and `set +f` (re-enable pathname expansion). Still not great, because you're changing global state unconditionally. The - more cumbersome - alternative would be to use `read` to parse each line incrementally, using a here-document to provide the input (with the remainder of the list in each iteration). There may be better approaches I'm missing. – mklement0 Jan 31 '16 at 23:33
  • I've added what I consider to be a _robust_ adaptation of your solution to the bottom of my answer. Btw, you code reads into `$a` and later switches to `$i`. – mklement0 Feb 01 '16 at 04:52