Тот, кто пришёл в bash из обычного языка программирования, спотыкается о функции почти сразу. Привычная картина проста: функция считает что-то и возвращает результат, который вызывающий код спокойно кладёт в переменную. В bash эта картина ломается на ровном месте. Команда return существует, выглядит знакомо, но возвращает она вовсе не то, что ожидается. А попытка получить из функции строку или большое число оборачивается тихим искажением данных, которое легко не заметить.

Причина в том, что функции в оболочке устроены принципиально иначе. Они ближе к маленьким командам, чем к функциям из мира C или python. И раз уж это команды, то и общаются они с внешним миром по правилам команд: через поток вывода и через код завершения. Понимание этой разницы открывает два рабочих способа вернуть результат - через печать в поток вывода и через глобальную переменную - и объясняет, почему первый обычно чище второго.

Команда return отдаёт код завершения, а не значение

Главная ловушка прячется в самом слове return. В большинстве языков оно возвращает результат вычисления. В bash оно задаёт код завершения функции - целое число строго от 0 до 255. Это статус успеха или провала, а не данные.

сложить() {
    return $(( $1 + $2 ))
}
сложить 5 7
echo $?        # 12 - случайно сработало, число влезло в диапазон

Пока числа маленькие, иллюзия держится. Но стоит результату выйти за 255, как он молча обернётся по кругу - арифметика кода завершения работает по остатку от деления на 256.

сложить 200 100
echo $?        # 44, а не 300 - значение переполнилось

Отрицательные числа, дробные, строки - всё это return вернуть не способен в принципе. Диапазон 0-255 жёстко зашит в саму природу кода завершения. Поэтому return уместен ровно для одной задачи: сообщить, удалась операция или нет. Ноль означает успех, любое ненулевое значение - конкретный вид ошибки. Использовать его для передачи данных - значит сознательно идти на риск переполнения.

Есть и вторая тонкость, связанная с кодом завершения. Он хранится в специальной переменной $?, но живёт там лишь до следующей команды. Любое действие после вызова функции перезапишет его своим собственным статусом.

проверка() { return 10 }
проверка
echo "промежуточный текст"   # эта команда перезаписала $?
echo $?                       # 0 - статус echo, а не функции

Захватывать $? нужно немедленно, первой же строкой после вызова. Промедление стоит данных.

Печать в поток вывода и захват через подстановку команды

Раз функция ведёт себя как команда, логично и забирать у неё результат как у команды - читая то, что она печатает. Функция выводит значение через echo или printf, а вызывающий код перехватывает этот вывод подстановкой команды $( ) и кладёт в переменную. Этот способ снимает все ограничения return: возвращать можно строки, большие числа, дробные значения, да что угодно.

сложить() {
    local сумма=$(( $1 + $2 ))
    echo "$сумма"
}
результат=$(сложить 200 100)
echo "Сумма равна: $результат"   # Сумма равна: 300

Здесь стоит обратить внимание на слово local перед внутренней переменной. Оно запирает переменную внутри функции, не давая ей просочиться в общее пространство имён скрипта. Без него любая внутренняя переменная стала бы глобальной и могла бы затереть одноимённую переменную снаружи - источник коварных, трудноуловимых ошибок в больших скриптах.

Подход через поток вывода считается основным именно потому, что функция остаётся самодостаточной. Она ничего не знает о внешнем мире, не трогает чужие переменные, а просто печатает результат - как делает любая нормальная утилита. Вызывающий код сам решает, куда деть этот результат. Такая развязка делает функции переносимыми между скриптами и предсказуемыми в поведении.

У способа есть две оговорки, которые полезно держать в голове. Во-первых, функция не должна печатать ничего лишнего, кроме самого результата. Любой отладочный вывод через echo подмешается к данным, и переменная получит мусор вместе с числом. Отладочные сообщения в таких функциях положено направлять в поток ошибок.

посчитать() {
    echo "идёт расчёт" >&2     # в поток ошибок, не загрязнит результат
    echo $(( $1 * $2 ))        # только это попадёт в переменную
}

Во-вторых, подстановка команды запускает функцию в подоболочке. Любые изменения глобальных переменных, сделанные внутри при таком вызове, не переживут возврата. Если функция задумана и печатать результат, и менять общее состояние - второе при захвате через $( ) потеряется.

Глобальная переменная как обходной путь и его цена

Второй способ обходится без печати вовсе. Функция записывает результат в глобальную переменную, а вызывающий код просто читает её после вызова. Никакого захвата, никакой подоболочки.

результат_расчёта=""
посчитать() {
    результат_расчёта=$(( $1 + $2 ))
}
посчитать 5 7
echo $результат_расчёта   # 12

На вид проще некуда, и в этом его соблазн. Но за простотой тянется известный шлейф проблем. Глобальные переменные в больших скриптах - источник трудноуловимых багов. Две функции, случайно использующие одно имя для своего результата, начнут затирать данные друг друга. Отследить, кто и где поменял глобальную переменную, в разросшемся скрипте бывает мучительно. Чем больше скрипт, тем дороже обходится каждая лишняя глобальная переменная.

Поэтому глобальный способ оправдан в узких случаях: когда функция возвращает сразу несколько значений и заворачивать их в один поток неудобно, либо когда захват через подоболочку нежелателен из-за побочных эффектов. Возврат массива - как раз такой случай, потому что протащить массив через подстановку команды сложнее, чем просто присвоить его глобальной переменной.

Гибридная схема с именем переменной от вызывающего

Существует приём, объединяющий оба подхода и снимающий главную претензию к глобальным переменным - жёстко зашитое имя. Вызывающий код сам передаёт имя переменной, в которую функция положит результат. Функция при этом не знает заранее, как переменная называется, и потому не может случайно затереть чужую.

посчитать() {
    local имя_результата=$1
    local значение=$(( $2 + $3 ))
    if [[ "$имя_результата" ]]; then
        printf -v "$имя_результата" '%s' "$значение"
    else
        echo "$значение"     # запасной путь: печать, если имя не задано
    fi
}
посчитать итог 5 7
echo $итог                   # 12

Конструкция printf -v записывает значение в переменную, чьё имя лежит в другой переменной - это безопаснее устаревшего приёма с eval, который подставляет произвольный текст и при неосторожности открывает дыру для нежелательного исполнения. Запасная ветка с echo делает функцию гибкой: если имя переменной не передали, она ведёт себя как обычная печатающая функция, и результат можно захватить через $( ). Такая функция работает в обоих режимах и не навязывает вызывающему коду единственный способ забрать данные.

В современном bash есть и специальный механизм для того же - ссылка на имя через local -n, которая создаёт псевдоним для внешней переменной прямо внутри функции. Он читается чище, но требует версии оболочки посвежее и понимания, что псевдоним указывает на оригинал, а не на копию.

Какой способ выбрать под конкретную задачу

Картина складывается в ясную иерархию. Для возврата одного значения - числа, строки, результата вычисления - основным выбором остаётся печать в поток вывода с захватом через $( ). Функция получается самодостаточной, не трогает чужое пространство имён и ведёт себя как нормальная утилита. Это самый чистый и переносимый путь.

Команда return оставляется строго для своего назначения: сообщить успех или ошибку кодом от 0 до 255, который немедленно проверяется через $?. Пытаться протолкнуть через неё данные - значит напрашиваться на тихое переполнение.

Глобальная переменная оправдана, когда значений несколько, когда возвращается массив, или когда подоболочка от подстановки команды съедает нужные побочные эффекты. Но каждую такую переменную стоит заводить осознанно, помня, во что обходится их избыток в большом скрипте.

А гибридная схема с переданным именем переменной - удачный компромисс для библиотек функций, которые планируется переиспользовать. Она убирает жёсткую привязку к имени и оставляет вызывающему коду свободу выбора.

Главный вывод прост: функция в bash не возвращает значение, она его печатает или куда-то кладёт. Стоит принять эту разницу как данность, а не бороться с ней по привычке из других языков, и весь набор приёмов выстраивается в понятную систему. Оболочка честна о своей природе - команды общаются потоками и статусами, и функции тут не исключение.