Целочисленная арифметика в bash выглядит обманчиво простой. Сложить пару чисел, увеличить счётчик в цикле, посчитать остаток от деления - кажется, что разницы между способами записи нет никакой. Но стоит скрипту попасть в продакшен, где включён строгий режим и любая ошибка должна обрабатываться явно, как выясняется, что три привычных инструмента ведут себя совершенно непохоже. Особенно ярко расхождение проявляется там, где математика встречает крайние случаи: пустую переменную, некорректное выражение, деление на ноль.
Речь о трёх конструкциях, которые в bash отвечают за работу с числами. Двойные скобки (( )) и их близкий родственник $(( )) - встроенный механизм арифметического разворачивания. Команда let - встроенный вычислитель выражений, доставшийся в наследство от ksh. И утилита expr - внешняя программа, ветеран из эпохи, когда оболочка вовсе не умела считать. Разберём, чем они отличаются на самом деле, а не в учебных примерах, где всё всегда срабатывает.
Внешний процесс expr против встроенных механизмов и цена этого различия
Главное различие закладывается ещё до первого вычисления. expr - это отдельная программа в /usr/bin/expr, и каждый её вызов означает, что оболочка порождает новый процесс. На единичном вычислении это незаметно. В цикле на десятки тысяч итераций накладные расходы превращаются в реальную задержку, потому что fork и запуск внешнего бинарника стоят на порядки дороже, чем встроенная операция.
# Внешний процесс на каждой итерации - медленно
sum=0
for i in $(seq 1 100000); do
sum=$(expr $sum + $i)
done
# Встроенная арифметика - в десятки раз быстрее
sum=0
for ((i=1; i<=100000; i++)); do
(( sum += i ))
done
Двойные скобки и let встроены в саму оболочку, отдельный процесс им не нужен. На современном железе разница в скорости часто несущественна для коротких скриптов, но она перестаёт быть теоретической, как только счёт идёт на сотни тысяч операций. К тому же поведение expr исторически плавало между разными системами, а у встроенных конструкций оно зафиксировано стандартом и одинаково ведёт себя везде, где работает bash.
Есть и синтаксическая плата за внешний характер expr. Утилита требует пробелов между всеми элементами выражения, потому что получает их как отдельные аргументы командной строки. Знак умножения приходится экранировать, иначе оболочка примет его за шаблон имён файлов и подставит список содержимого каталога.
expr 5 + 3 # верно: пробелы обязательны
expr 5+3 # вернёт строку "5+3", а не 8
expr 5 \* 3 # звёздочку нужно экранировать
Встроенные конструкции свободны от этих оговорок. Внутри (( )) пробелы необязательны, умножение пишется без обратной косой черты, а сама запись напоминает арифметику из языка C, к которой привык любой программист.
Поведение при делении на ноль вскрывает разницу между тремя подходами
Вот где начинается настоящее расхождение. Деление на ноль - классическая ситуация, в которой каждый из трёх инструментов реагирует своим способом, и понимание этих реакций отделяет надёжный скрипт от того, что молча выдаёт мусор.
Двойные скобки на делении на ноль выдают ошибку прямо в стандартный поток ошибок и возвращают ненулевой код завершения. Выражение не вычисляется, переменная не меняется.
divisor=0
(( result = 10 / divisor ))
echo $?
# bash: ((: result = 10 / divisor : division by 0 (error token is "divisor ")
# код возврата: 1
Команда let ведёт себя в точности так же по части ошибки деления, поскольку использует тот же арифметический вычислитель оболочки. Сообщение об ошибке появляется, код возврата сигнализирует о провале.
let "result = 10 / 0"
echo $?
# bash: let: result = 10 / 0: division by 0 (error token is "0")
# код возврата: 1
А вот expr реагирует иначе. Внешняя утилита тоже сообщает об ошибке деления, но делает это как самостоятельная программа со своим набором кодов выхода. Код возврата у expr равен 2 при синтаксической или вычислительной ошибке, а не 1, и это различие легко упустить, если код завершения проверяется буквально.
expr 10 / 0
echo $?
# expr: division by zero
# код возврата: 2
Различие в кодах возврата выглядит мелочью ровно до того момента, пока скрипт не пытается отличить ошибку от штатного результата по числу. Привычка проверять $? -eq 1 подведёт там, где expr вернёт двойку, и сломается логика обработки.
Двойные скобки переворачивают логику кода возврата при нулевом результате
Здесь притаилась ловушка, на которую натыкаются даже опытные авторы скриптов. Конструкция (( )) возвращает код завершения по правилам арифметики, а не по правилам команд. В арифметике ноль означает ложь, а любое ненулевое число - истину. В мире оболочки наоборот: код возврата ноль означает успех, ненулевой - ошибку. Двойные скобки склеивают эти две вселенные так, что результат удивляет.
Если выражение внутри скобок вычисляется в ноль, конструкция возвращает код завершения 1, то есть оболочечную ложь. Если результат ненулевой, код возврата равен нулю - оболочечная истина. Это намеренно сделано, чтобы арифметику можно было удобно использовать в условиях if и while.
(( 5 > 4 ))
echo $? # 0 - истина, условие выполнено
(( 3 > 7 ))
echo $? # 1 - ложь
(( 0 ))
echo $? # 1 - результат ноль трактуется как ложь
(( 7 ))
echo $? # 0 - ненулевой результат это истина
Поведение элегантно ровно до тех пор, пока скрипт не запущен в строгом режиме. И вот тут кроется самая коварная часть всей темы.
Строгий режим set -e превращает невинное присваивание в аварийный выход
Многие авторы скриптов включают set -e, чтобы оболочка немедленно прерывала выполнение при любой команде, завершившейся ненулевым кодом. Привычка здравая - она ловит ошибки рано. Но в сочетании с двойными скобками она порождает поведение, которое легко принять за необъяснимый сбой.
Представим присваивание через двойные скобки, где результат оказался равен нулю.
set -e
counter=5
(( counter -= 5 )) # результат ноль
echo "сюда мы не доберёмся"
Скрипт молча умрёт на строке с вычитанием. Причина в том, что counter -= 5 дало ноль, двойные скобки честно вернули код 1 по арифметическим правилам, а set -e увидел ненулевой код и оборвал выполнение. Намерения сделать что-то плохое не было, переменная получила корректное значение - но скрипт всё равно остановился. Это та самая ситуация, где поведение под строгим режимом перестаёт совпадать с поведением без него, и расхождение всплывает только в бою.
Команда let страдает от той же болезни по той же причине - общий арифметический движок даёт общий побочный эффект. А вот спасение известно и проверено: нужно сделать так, чтобы конструкция всегда возвращала истинный код возврата независимо от результата вычисления. Распространённый приём - добавить безопасную операцию через логическое ИЛИ либо использовать форму присваивания, которая не зависит от значения.
# Защита от ловушки set -e
(( counter -= 5 )) || true # принудительно успешный код
# Или форма $(( )), которая разворачивается в значение,
# а не возвращает арифметический код завершения
counter=$(( counter - 5 )) # set -e спокоен
Форма $(( )) принципиально отличается от голых двойных скобок именно тем, что она не команда с кодом возврата, а разворачивание в значение. Она подставляет результат прямо в строку, как подставилась бы переменная, и арифметический код завершения тут вообще не участвует. Поэтому для присваиваний $(( )) дружелюбнее к строгому режиму, чем (( )).
Обработка пустых и нечисловых переменных расходится в мелочах
Крайние случаи не заканчиваются нулём в знаменателе. Пустая переменная и переменная с буквами вместо цифр - ещё два места, где инструменты расходятся в реакции.
Внутри арифметического контекста двойных скобок пустая или неустановленная переменная тихо превращается в ноль. Это удобно для счётчиков, которые начинают с пустого значения, но опасно, если пустота возникла из-за опечатки в имени.
unset x
(( y = x + 5 ))
echo $y # 5 - пустая переменная стала нулём
Утилита expr к пустоте куда строже. Получив пустой аргумент там, где ждёт число, она выдаст синтаксическую ошибку, потому что не делает молчаливых допущений о значениях.
x=""
expr $x + 5
# expr: syntax error: unexpected argument '+'
Нечисловое значение внутри двойных скобок bash попытается интерпретировать как имя переменной и снова свести к нулю, если такой переменной нет. Это поведение бывает источником трудноуловимых багов, когда строка с буквами вместо ожидаемого числа не вызывает ошибку, а молча даёт ноль. Грамотный скрипт проверяет входные данные на числовой формат заранее, не полагаясь на снисходительность арифметического контекста.
Какую конструкцию выбирать под конкретную задачу
Картина складывается в простую практику. Для повседневной арифметики, присваиваний и работы со счётчиками двойные скобки и форма $(( )) остаются предпочтительным выбором - они встроены, быстры, читаемы и переносимы между системами. Запись в стиле C делает их понятными без дополнительных пояснений, а отсутствие требований к пробелам и экранированию избавляет от целого класса опечаток.
Команда let делает почти то же самое и сохраняет ценность там, где удобно вычислить несколько выражений подряд в одной строке. Но она считается устаревшей рядом с двойными скобками и проигрывает им в читаемости, потому что выражения часто приходится заключать в кавычки.
let a=5 b=10 c=a+b # несколько выражений за один вызов
echo $c # 15
Утилита expr сегодня оправдана редко. Её разумно вспомнить разве что в скриптах на чистом POSIX-shell, где двойные скобки недоступны, или для операций над строками, которых у арифметического разворачивания нет вовсе - например, измерение длины строки или сопоставление с шаблоном. Для целочисленной математики в bash она проигрывает встроенным механизмам по всем фронтам: медленнее из-за внешнего процесса, капризнее к пробелам и экранированию, да ещё и возвращает непривычный код выхода при ошибках.
Главный практический вывод касается не выбора инструмента, а понимания, что под строгим режимом арифметика умеет молча валить скрипт там, где никакой ошибки по сути нет. Запомнить связку двойных скобок с set -e и держать наготове защиту через логическое ИЛИ или форму $(( )) важнее, чем заучивать таблицу операторов. Числа в оболочке честны ровно настолько, насколько внимателен тот, кто их складывает - и крайние случаи всегда находят брешь там, где про них забыли.