问题
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.
回答1:
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 shell)
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
orfor
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 a0
so that the above expression becomes a valid addition operation.- Grouping (
{ ...; ...; }
) thetr
andprintf
commands makes them act as a single output source for the pipeline (|
).
- Grouping (
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 usingfor
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 withset +f
.
- It is, however, possible to disable globbing with
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 involvingtr
inside a here-document.- Using a here-document (rather than a pipeline) to provide input to
while
allows the thewhile
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).
- Using a here-document (rather than a pipeline) to provide input to
sum=$(( sum + i ))
uses arithmetic expansion to calculate the sum, which is more efficient than calling external utilityexpr
.
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"
回答2:
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
回答3:
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
来源:https://stackoverflow.com/questions/35096253/add-numbers-from-file-and-standard-input