Absolute integers: Bash's ternary operator and other oddities

So one day I was thinking of a function to return the absolute value of an integer. The usefulness of this is questionable, but never mind that.

It started as a small exercise in languages comparison.

First I wrote it in Python:

#!/usr/bin/env python3

# Calculate the absolute value of an integer.

import sys

def int_abs(int):
    if int<0:
        y=-1*int
    else:
        y=int
    return y

print(int_abs(int(sys.argv[1])))

raise SystemExit

Then in Haskell, which looks as clear as it can get:

-- Calculate the absolute value of an integer.

module Intabs where

intAbs :: Int -> Int
intAbs x
  | x < 0     = -x
  | otherwise =  x

Then in Emacs Lisp:

(defun int-abs (x)
  "Calculate the absolute value of an integer."
  (when (integerp x)
    (if (< x 0)
        (- x)
      x)))

Also clear.

And finally in Bash, using only its native syntax (no bc):

# Calculate the absolute value of an integer.
isInt()  [[ "$1" =~ ^-*[0-9]+$ ]]
intAbs() (isInt "$1" && echo "$(("$1"<0?10#"${1#-}":10#"$1"))")

Now, this is not quite as legible, is it? Wouldn't blame you for avoiding it.

A short example and explanation of the last construct:

echo "$((4+3))"        # 7  -- numerical evaluation
echo "$((3<5?10:20))"  # 10 -- ternary operation: if 3<5, then 10, else 20.
echo "${1#-}"          # Remove "-" from the beginning of "$1" if it exists

And that 10#? It says the number is decimal. Otherwise, if it happens to have leading zeros, Bash tries to read it as octal.

So there we have it: Bash has a C-like ternary test! You can do things like this:

foo() { : "$((x = "$1", w = 3,
              z = (x < 5 ? 10 : 20) * x + w))"
        echo "$z" ;}

foo 2   #=>  23 = 10 * 2 + 3
foo 7   #=> 143 = 20 * 7 + 3

Anyway, I wondered about different ways of writing this simple absolute integer function in Bash. I ended up with 10 different versions — all of which turned out to be... even less legible.

In the end, this became an exploration of alternatives in Bash syntax, as well as of over-engineering meta-functions to report the comparison of tests. By which I mean that if we run this:

intAbs.sh -t

we get this:

INPUT  SHOULD  intAbs1  intAbs2  intAbs3  intAbs4  intAbs5  intAbs6  intAbs7  intAbs8  intAbs9  intAbs10
12     12      12       12       12       12       12       12       12       12       12       (?) 12
-13    13      13       13       13       13       13       13       13       13       13       (?) 13
022    22      22       22       22       22       22       22       22       22       22       (?) 18
-023   23      23       23       23       23       23       23       23       23       23       (?) 19
0042   42      42       42       42       42       42       42       42       42       42       (?) 34
-0043  43      43       43       43       43       43       43       43       43       43       (?) 35
99     99      99       99       99       99       99       99       99       99       99       (?) 99
0      0       0        0        0        0        0        0        0        0        0        (?) 0
000    0       0        0        0        0        0        0        0        0        0        (?) 0
-0     0       0        0        0        0        0        0        0        0        0        (?) 0
-000   0       0        0        0        0        0        0        0        0        0        (?) 0
-----
1 2 3
3.5
-2.4
ab
a 1 b

Here is the code:

#!/usr/bin/env bash
#
# SPDX-FileCopyrightText:  © flandrew
# SPDX-License-Identifier: GPL-3.0-or-later
#
# intAbs.sh
#   If input is an integer, return its absolute value. Otherwise, nothing.
#
#   An exercise in esoteric Bashery, with tidy output of test results for
#   various alternative functions that are all supposed to return exactly the
#   same values.


## User variables

# That's the data to test
INPUT=(12 -13 022 -023 0042 -0043 "99" 0 000 -0 -000 "-----" "1 2 3" 3.5 -2.4 ab  "a 1 b")
# That's what the result should be
SHOULD=(12 13  22   23   42    43  99  0   0  0    0      ""      ""  ""   "" ""  "")
# That's how your main function should be called, plus a growing numerical
# prefix from 1 to n.
FUNCN="intAbs"


## Internal variables
# This is the $c in the available $FUNCN{1..$c} (though those braces here
# wouldn't expand...)
FUNCOUNT="$(< "$0" grep -oP "(?<=^"$FUNCN")\d+" | sort -rn | sed 1q)"


## Support functions
isInt() [[ "$1" =~ ^-*[0-9]+$ ]]


## Core function versions

# Those are expected to be RIGHT:
intAbs1() { isInt "$1" && echo "$(($1<0?10#${1#-}:10#$1))";}
# (Is the previous one safe to leave unquoted? Not good practice.)
intAbs2() { isInt "$1" && echo "$(("$1"<0?10#"${1#-}":10#"$1"))";}
intAbs3() {
    isInt "$1" &&
        echo "$(("$1"<0
                 ?10#"${1#-}"
                 :10#"$1"))"
}
intAbs4() {
    isInt "$1" &&
        echo "$(("$1">=0
                 ?10#"$1"
                 :10#"${1#-}"))"
}
intAbs5() {
    isInt "$1" && {
        case "${1:0:1}" in
            -) : "${1:1}" ;;
            *) : "$1"
        esac
        sed -E "s/^0+/0/
                s/^0([^0])/\1/" <<< "$_"
    }
}
intAbs6() {
    isInt "$1" && {
        case "${1:0:1}" in
            -) : "${1:1}" ;;
            *) : "$1"
        esac
        case "${_:0:1}" in
            0) until [[ "$_" == "${_#0}" ]]
               do : "${_#0}"
               done
               sed "s/^$/0/" <<< "$_"
               ;;
            *) echo "$_"
        esac
    }
}
intAbs7() {
    isInt "$1" && {
        case "${1:0:1}" in
            -) : "${1:1}" ;;
            *) : "$1"
        esac
        case "${_:0:1}" in
            0) : "${_#"${_%%[^0]*}"}"
               sed "s/^$/0/" <<< "$_"
               ;;
            *) echo "$_"
        esac
    }
}
intAbs8() {
    isInt "$1" && {
        local x
        case "${1:0:1}" in
            -) : "${1:1}" ;;
            *) : "$1"
        esac
        case "${_:0:1}" in
            0) : "${_#"${_%%[^0]*}"}"
               : "${x=$_}"
               : "$((${#x}==0?0:x))"
               ;;
        esac
        echo "$_"
    }
}
intAbs9()
    if isInt "$1"; then
        local x
        case "${1:0:1}" in
            -) : "${1:1}" ;;
            *) : "$1"
        esac
        ((${_:0:1}=="0")) && {
           : "${_#"${_%%[^0]*}"}"
           : "${x=$_}"
           : "$((${#x}==0?0:x))"
        }
        printf "%d\n" "$_"
    fi

# Expected to be WRONG:
# WRONG! Because 0042 is read as \o42 (octal)...
intAbs10() { isInt "$1" && echo "(?) $(("$1"<0?"${1#-}":"$1"))";}


## Testing functions

outdata() {
    # Think only Lisp has macros?
    # To be fair, quasi-quoting (i.e. Lisp's all quoted   + selective ,var)
    # is nicer than its inverse (i.e. Bash's all unquoted + selective \$var)
    . <(cat <<EOM
        echo "$1"
        for n in "\${$1[@]}"
        do  echo "\$n"
        done
EOM
        )
}

testone() {
    # testone function testdataarray
    . <(cat <<EOM
        echo "$1"
        for n in "\${$2[@]}"
        do  $1 "\$n" | (grep . || echo)
        done
EOM
       )
}

_testall() {
    # _testall functionrootname funcount testdataarray expectedresultsarray
    echo "outdata $3"
    echo "outdata $4"
    for ((;c++<"$2";))
    do  echo "testone $1$c $3"
    done
}

testall() {
    # _testall functionrootname funcount testdataarray expectedresultsarray
    . <(_testall "$@" |
            sed -E "s/.*/<(&)/
                    1 s/^/paste\n/" |
            sed -E "s/.*/& \\\\/
                    $ s/$/\n/") | column -nts$'\t'
}


## Main

main() {
    case "$1" in
        -t|--test|test)
           testall intAbs "$FUNCOUNT" INPUT SHOULD ;;
        *) intAbs5 "$1"
    esac
}

main "$@"

exit 0

Well, I guess we have to admit that if immediate clarity is what you want, then your calculation of absolute integers needs to be in some non-Bash language...

But some of these syntaxes are handy. Could you understand it?

Look into man bash, then search there (with /STRING) for:

/Parameter Expansion
/Arithmetic Expansion
/_<SPACE>   <--- a literal space
/: \[arguments\]

And did you notice anything odd about the function intAbs9? Anything supposedly... missing?

And yet, it works. Here's why. Search man bash for:

/Shell Function Definitions
/Compound Commands

Have fun.