Lesson 13 / 24
Arithmetic and Command Substitution
Integer arithmetic and its traps, the arithmetic command's inverted exit code, offloading decimal calculation, and turning command output into a value.
Contents
In the previous two lessons, the notations $(( ... )) and $( ... ) were
used without justification. Both are fundamental shell tools, and both
have corners that silently produce a wrong result.
The principle established in the Variables lesson still holds: every value in the shell is a character string. These two notations temporarily carry those strings into another domain and bring them back — one into the domain of numbers, the other into the domain of command output.
Arithmetic Expansion
The $(( expression )) notation evaluates the expression inside as an
integer and substitutes the result as text.
echo "$(( 7 / 2 )) $(( -7 / 2 )) $(( 7 % 2 )) $(( 2 ** 10 ))"
3 -3 1 1024
Division truncates toward zero: it gives 3 for , for . This is different from languages that round down; the distinction matters when working with negative numbers.
Variables can be written without a dollar sign inside the expression; the inside of the parentheses is already the number domain:
a=6; b=4 echo "$(( a + b )) $(( $a * $b ))"
10 24
Whitespace inside the parentheses is free, and word splitting does not
apply. The expression $(( 1+2 * 3 )) gives 7; precedence rules are the
same as the operator precedence in the Programming Fundamentals course.
The Base Trap
The shell treats a number starting with zero as octal. Values coming from zero-padded fields — hour, minute, sequence number — therefore produce an error:
echo "$(( 08 ))"
bash: 08: value too great for base (error token is "08")
08 is not valid in base eight. The base needs to be declared explicitly:
echo "$(( 10#08 ))"
8
The 10# prefix reads the value in base ten. It should be used on every
value coming from an external source that might be zero-padded.
The Arithmetic Command and the Inverted Exit Code
(( expression )) is not an expansion, it is a command: it does not
substitute a value, it only calculates and produces an exit code. The
code is the inverse of the value: if the result is nonzero, 0
(success); if the result is zero, 1 (failure).
(( 1 + 1 )); echo "result 2 -> code=$?" (( 0 )); echo "result 0 -> code=$?"
result 2 -> code=0 result 0 -> code=1
This inversion is not arbitrary, it is the reconciliation of two conventions: in C-like languages, a nonzero value is “true,” while in the shell, code 0 is “success.” The result is that arithmetic conditions can be written naturally:
(( 5 > 3 )) && echo "comparison is true"
comparison is true
The trap shows up under strict mode. The expression (( counter++ ))
returns code 1 while counter is zero; in a script with set -e active,
this stops the script. The safe form for incrementing a counter is
counter=$(( counter + 1 )). This trap will be taken up again in the
Error Handling lesson.
Operators such as ++, --, += can be used inside (( )):
counter=0; (( counter++ )); (( counter += 5 )); echo "counter=$counter"
counter=6
In older scripts, let and expr are seen for the same job. let is a
bash extension and is equivalent to (( )). expr is a separate program;
it creates a process on every call, and its operators need to be escaped
so the shell does not interpret them. Neither is needed in new scripts.
The Shell Has No Decimal Calculation
Shell arithmetic is integer-only. When a decimal result is needed, the work is handed off externally.
awk 'BEGIN { printf "%.2f\n", 4 / 30 * 100 }'
13.33
A calculator tool that computes with arbitrary precision can also be
used, but it should be kept in mind that the scale setting is applied
at every division:
echo 'scale=2; 4 / 30 * 100' | bc echo 'scale=2; 4 * 100 / 30' | bc
13.00 13.33
In the first form, the division happened first and the result was truncated to 0.13; the multiplication proceeded with this truncated value. Arranging the order of operations so that multiplication comes before division is a general rule in fixed-precision calculation.
The third way is scaled integer arithmetic. Placing the decimal point by hand gives one digit of precision without calling an external tool:
part=4; total=30 printf '%d.%d\n' $(( part * 1000 / total / 10 )) $(( part * 1000 / total % 10 ))
13.3
This method truncates, it does not round. It is sufficient for report-style output; not for monetary or scientific calculation.
Command Substitution
The $( command ) notation runs the command, captures its standard
output, and substitutes that text.
count=$(wc -l < access.log) echo "[$count]"
[ 30]
The leading spaces came through — this is the alignment difference mentioned in the first lesson. Arithmetic expansion absorbs the spaces:
count=$(( $(wc -l < access.log) )) echo "[$count]"
[30]
This nested form is the standard way to turn a command’s output into a number.
The Transformations Substitution Applies
Three behaviors need to be known.
Trailing newlines are stripped. All trailing newlines in the command’s output are discarded; the ones in between are preserved.
d=$(printf 'a\n\n\n') printf '<%s>\n' "$d"
<a>
This behavior is wanted most of the time, but it leads to loss in data where the newline is meaningful.
Unquoted writing is split. The rule that holds for variable expansion holds here too:
show_count() { printf 'count=%d\n' "$#"; }
show_count $(head -2 access.log)
show_count "$(head -2 access.log)"
count=20 count=1
In the unquoted form, the two lines were split into twenty words. Command substitution is always quoted.
The exit code is not lost by assignment, but it can be hidden. An
assignment that only does substitution leaves the command’s code in $?:
d=$(grep '999' access.log); echo "code=$?"
code=1
But if local, export, or declare precedes the assignment, the code
belongs to that command instead, and the real result does not show; this
trap was shown in the Functions and Scope lesson.
Syntax Forms
The older counterpart of the $( ... ) form is the backtick. It is not
preferred for two reasons: nested use requires a backslash for every
level, and a backslash’s meaning inside backticks is different.
$( ... ) requires no escaping at all in nested use:
echo "most requested: $(cut -d' ' -f7 "$(echo access.log)" | sort | uniq -c | sort -rn | head -1)"
most requested: 6 /api/data
To read an entire file, bash offers the $(< file) shortcut; because it
creates no process, it is cheaper than the $(cat file) form:
echo "[$(< single.txt)]"
[hello]
Cost
Command substitution creates a subshell and, usually, an external process. This is unnoticeable on a thirty-line log; in a script that makes one call per line inside a loop, process creation alone can make up the entire workload.
Rule: if command substitution is inside a loop body, a way to do the same job with a single external tool call is sought. This entire next topic is the application of this principle.
Applying It to the Script
report.sh gains a summary block and a percentage column:
log_file=access.log
percent() { # percent PART TOTAL -> "13.3"
local part="$1" total="$2"
(( total == 0 )) && { echo "0.0"; return; }
printf '%d.%d' $(( part * 1000 / total / 10 )) $(( part * 1000 / total % 10 ))
}
requests=$(( $(wc -l < "$log_file") ))
bytes=$(awk '{ t += $10 } END { print t + 0 }' "$log_file")
section "Log: $log_file"
printf ' request count : %d\n' "$requests"
printf ' total bytes : %d\n' "$bytes"
printf ' per request : %d bytes\n' $(( requests > 0 ? bytes / requests : 0 ))
./report.sh -n 3
== Log: access.log == request count : 30 total bytes : 117780 per request : 3926 bytes == Most requested 3 paths == 6 /api/data 4 /missing.html 4 /index.html == Status code classes == 2xx successful 18 %60.0 3xx redirect 3 %10.0 4xx client error 6 %20.0 5xx server error 3 %10.0
The ternary operator in $(( requests > 0 ? bytes / requests : 0 ))
prevents division by zero. In shell arithmetic, division by zero is an
error that stops the script; the divisor must always be tested.
The percentages add up to 100.0, and the numbers add up to the request count. This internal consistency is the cheapest test of the report’s correctness.
Summary
$(( ))does integer arithmetic, truncates toward zero, and lets variables inside be written without a dollar sign; numbers starting with zero are read as octal, requiring the10#prefix.(( ))is a command, and its exit code is the inverse of the value; a zero result reports failure.- Decimal calculation does not exist in the shell; it is handed off externally, or done with scaled integers.
- Command substitution strips trailing newlines and gets split in unquoted form; it is always quoted.
- Every substitution costs a subshell; calls inside a loop body should be replaced with a single bulk external tool call.
Next Step
This topic completed the script’s skeleton: the shebang, the argument
interface, input validation, functions, loops, and calculation. The
script works, but it does most of its job with blunt tools: it splits
paths with cut, counts status codes with grep -c, and reads the log
from the start for every count.
The next topic hands these jobs off to their real tools. It starts with regular expressions — the common language for splitting the log’s request line into its fields, filtering out invalid lines, and reformatting the timestamp.
To keep your progress and take notes, Log in
My notes
Log in to take notes.