Практическое написание сценариев командной оболочки Bash/Функции

← Циклы Глава Эмуляция ссылочной адресации →
Функции


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

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

Код объявленной функции никак не интерпретируется до момента её явного вызова, т.е. интерпретатор, когда встречает определение функции, резервирует за ней идентификатор и помечает точку перехода в ее начало. Тем не менее, в теле функций не должно быть явных синтаксических ошибок (как бы выразились в компилируемых языках — в функции не должно быть ошибок времени компиляции).

Разные shell-интерпретаторы по разному парсят функции. Например, при всей похожести правил объявления функций в Ksh и Bash, в Ksh фигурные скобки после имени функции это часть синтаксиса интерпретатора, тогда как в Bash они, вообще говоря, не обязательны и служат только, чтобы поместить командный список в блок.

Объявление функций править

В Bash заложена обратная совместимость с Ksh, поэтому в Bash существует минимум три способа объявить функцию. Тем не менее, рекомендуется всегда быть последовательным и в пределах одного сценария придерживаться одного варианта, либо использовать только POSIX-совместимый вариант.

# POSIX-совместимый синтаксис. Рекомендован к постоянному использованию из-за портируемости.

func_name() {
   return 0
}
# Примечания: 
#    * тело функции не должно быть пустым, иначе это
#      синтаксическая ошибка.
#    * Из практических соображений рекомендуется открывающую фигурную
#      скобку оставлять на той же строке, что и имя функции.
#

# Следующий синтаксис появился в Ksh и в Bash он поддерживается.

function func_name {
   return 0
}

# Примечание:
# Обратите внимание, что в этом случае для Bash признаком функции выступает ключевое
# слово function, тогда как в POSIX совместимой записи для этого служили круглые
# скобки.
# Тем не менее, стили не запрещается мешать: в этом случае Bash будет один из признаков
# просто игнорировать.

function func_name() {
   return 0
}

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

Compound-выражение — это сложное выражение, которое либо использует командный список как свое тело, либо имеет особый синтаксис, интерпретируемый единым куском.

Признаками такого выражения служат:

  • ключевые слова, которые начинают и заканчивают такое выражение;
  • возможность перенаправить вывод этого выражения как единого целого.

Ниже приведены примеры таких выражений:

  • все циклы;
  • конструкция ветвления if..fi;
  • конструкция ветвления case..esac;
  • команда test;
  • круглые скобки в контексте выполнить командный список в подоболочке;
  • командный список в блоке.

Синтаксис Bash, вообще говоря, требует, чтобы тело функции было compound-выражением. Это означает, что фигурные скобки в Bash не являются частью синтаксиса, потому что блок порождает compound-выражение.

# Примеры однострочных функций с одним compound выражением.

# Следующая функция просто проверяет значение переданной переменной
# на равенство строке 'yes' без привязки к регистру.
isYes() [[ "${1,,}" == 'yes' ]]

read -p "Enter 'yes' or 'no': "
isYes "$REPLY" && echo "Entered 'yes'." || echo "Entered '$REPLY'"

# Следующий пример демонстрирует простой принтер. Обратите внимание, что
# интерпретатор после имени функции пытается найти первое compound-выражение
# (в нашем случае это цикл), игнорируя пустые строки. Само выражение может начинаться
# с той же строки или с последующих, потому что у циклов есть ключевые слова,
# показывающие начало и конец конструкции.
say()
for option in "$@"; do
    case $option in
       -i | --info) printf "[Info]: " ;;
       -w | --warn) printf "[Warn]: " ;;
       -e | --error) printf "[Error]: " ;;
       *) printf "$option\n" ;;
    esac
done

say -i "Hello"                     # [Info]: Hello
say -w "Undefined identifier"      # [Warn]: Undefined identifier
say --error "Something goes wrong" # [Error]: Something goes wrong
say "Bla-Bla-Bla"                  # Bla-Bla-Bla

# Примечание:
# В предыдущем примере мы используем не совсем одно compund-выражение: здесь ветвление
# case..esac вложено в цикл. Тем не менее, для функции выражение одно, а что в
# нем заключено ее по большей части не интересует.

Следующий пример демонстрирует, как можно создать функцию логирования, которая будет перенаправлять вывод в зависимости от типа выводимого сообщения. Ошибки и предупреждения будут перенаправляться в файл с именем error.log, а все остальное в файл с именем main.log. Мы пользуемся тем, что в compound-выражениях можно работать с файловыми дескрипторами (redirection).

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

readonly LOG_MAIN='main.log'
readonly LOG_ERRORS='error.log'

log() { 
    for option in "$@"; do
        case $option in
        -i | --info) printf "[Info]: " ;;
        -w | --warn) exec 1>&4; printf "[Warn]: " ;;
        -e | --error) exec 1>&4; printf "[Error]: " ;;
        *) printf "$option\n" && exec 1>&3 ;;
        esac
    done
    exec 1>&5
} 3>>"${LOG_MAIN}" 4>>"${LOG_ERRORS}" 5>&1 1>&3
# Примечание:
#  Блок функции перед выполняет открывает дескрипторы 3 и 4 на дозапись,
#  с которыми связывает соответственно файлы main.log и error.log. Также мы
#  копируем стандартный поток вывода в пятый дескриптор, чтобы иметь возможность
#  его восстановить, как это делается в конце блока функции.
#
#  По умолчанию все команды блока будут писать в 3 дескриптор, потому что стандартный
#  поток вывода мы направили в него.
#

log -i "Process is started."
log -w "Not found configuration. Using default values."
log -e "Error occured."
log "My string"

Механизмы ограничения видимости переменных и квалификаторы типов править

В оригинальном Bourne Shell не было возможности ограничить область видимости переменной (т.е. все переменные по сути находились в глобальной области видимости, независимо от того, где они объявляются), да и в этом по большому счету не было необходимости: сценарии были небольшими, поэтому контролировать область видимости переменной было не сложно, используя дисциплинированность самого программиста. Но со временем отсутствие механизмов ограничения видимости давало о себе знать, особенно когда один и тот же сценарий поддерживался многими разработчиками: при потере внимательности программист мог случайно затереть значение переменной, если не знал о ее существовании; исправлял значение не из того места программы по ошибке. Предпринимались попытки исправить эту ситуацию, однако к этому моменту уже существовал большой пласт программистов, привыкших к старой системе, и сценариев, которые было невыгодно переписывать.

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

Ключевое слово declare править

Declare является встроенной командой Bash. Она идентична команде typeset, введенной в Ksh, и в самом Bash typeset является псевдонимом declare для обратной совместимости с Ksh. Команда declare копирует оригинальный синтаксис typeset, вводя некоторые дополнительные возможности.

  • Старайтесь не использовать ключевое слово typeset в сценариях для Bash, так как эта возможность помечена, как устаревшая.
  • declare не определена в POSIX, поэтому его использование автоматически делает ваши сценарии не переносимыми, но если вам это и не нужно, то переживать особо не стоит.
  • declare придумана только для Bash, поэтому ваши сценарии также не будут портироваться в клонах Bourne Shell (Ksh и Zsh).

declare служит, чтобы явно обозначить объявление переменной. Команда имеет дополнительные атрибуты, которые наделяют переменную некоторыми свойствами, которые поддерживает сам интерпретатор. Свойство, которое дается переменной по умолчанию, это область видимости: если переменная объявлена вне функции, то она обладает глобальной видимостью; если переменная объявлена в теле функции, то она обладает локальной видимостью для данной функции. Это позволяет вам многократно использовать одни и те же имена для переменных, не беспокоясь об их видимости. Это аналогично концепции видимости в языке Си.

declare может использоваться также для получения некоторой технологической информации, например об уже объявленных функциях и переменных.

Существует ключевое слово local, которое идентично declare со следующими ограничениями:

  • его можно использовать только внутри функций: попытка использовать его вне функции приводит к синтаксической ошибке;
  • local с ключом -g работает также как declare с этим ключом внутри функции;
  • без аргуметов local, как и declare, выводит список объявленных переменных, но с учетом видимости области, из которой она вызывается.

local не определена в POSIX и большинство оболочек — клонов Bourne Shell — её не поддерживает. Кроме Bash, эта команда есть в Dash и BusyBox.

# Далее мы приведем практическое использование declare и ее ключей

# Общий синтаксис
#     declare [-aAfFgilrtux] [-p] [NAME[=VALUE] ...]

# Создаем два простых массива
declare -a ARR1=(1 2 3) ARR2=(4 5 6)
echo "${ARR1[@]} ; ${ARR2[@]}"

# Создаем ассоциативный массив
declare -A ARR3=([name]=John [age]=24)
echo "${ARR3[@]} ; ${ARR3[name]} ; ${ARR3[age]}"

# Объявляем переменную с глобальной областью видимости
declare GLOBAL1="Hello" GLOBAL2="World"
echo "$GLOBAL1 $GLOBAL2"

# Объявление числовой переменной
declare -i NUMBER=14 WRONG_NUMBER=abba SEQ="1,3,5,6"
echo "$NUMBER $WRONG_NUMBER $SEQ"

# Примечание:
#   В предыдущем примере переменной WRONG_NUMBER будет присвоен 0, потому
#   что строка не конвертируется в число.
#   Переменной SEQ будет присвоено значение 6, вероятно из-за того, что алгоритмы
#   просматривают строку справа налево.
#   В строке не должно быть пробельных символов.

# Выведет все переменные, объявленные на текущий момент, вместе с их атрибутами.
declare -p

# Сделать переменную константой
declare -r CONSTANT="const"
# Константу нельзя изменить. Следующий код приведет к аварийному завершению сценария.
# CONSTANT="change"

declare -x EXPORTED=hello # Аналогично инструкции export EXPORTED="hello"

func() {
   declare local_var="local"  # Переменная имеет локальную область видимости
   local   local_var1="local" # Аналогично предыдущей команде

   declare -g global_var1="global" # Создаем глобальную переменную из функции.
   local   -g global_var2="global" # Аналогично предыдущей команде

   declare -p  # Выведет только то, что видно из этой функции
}

# Объявляем dummy-функцию
func1() { :;}

func # Вызываем функцию
# и видим, что локальных переменных функции нам отсюда не видно.
echo "$local_var $local_var1 $global_var1 $global_var2"

declare -f # Выведет все функции, вместе с их кодом, объявленные на текущий момент

# Выведет только функцию func1, если она объявлена.
declare -f func1

На практике, решая что лучше, использовать только declare или совмещать ее использование с local, следует исходить из удобства этого для вас и вашей команды. Если вы пишите только для Bash, то комбинирование рекомендуется, так как визуально local лучше передает намерения разработчика внутри функций. Кроме того, это защищает вас от необдуманного копирования кода.

Ключевое слово readonly править

Встроенная команда readonly позволяет пометить переменную или функцию только для чтения. Это также запрещает использование команды unset.

Рассмотрим ее использование на конкретных примерах.

# Неизменяемый простой массив
readonly -a ARR1=(1 2 3) ARR2=(4 5 6)
echo "${ARR1[@]} ; ${ARR2[@]}"

# Неизменяемый ассоциативный массив
readonly -A ARR3=([name]=John [age]=24)
echo "${ARR3[@]} ; ${ARR3[name]} ; ${ARR3[age]}"

# Объявление константной переменной
readonly CONST1="hello"
# Объявление константной переменной через declare
declare -r CONST2="hello"

func() {
    echo "const func"
}

# Делаем функцию константной
readonly -f func
func

# Следующая команда выполнится с ошибкой, потому что функция защищена
# константностью.
unset -f func

# Выведет все константные переменные
readonly -p

readonly -pa # вывести все константные простые массивы
readonly -pA # вывести все константные ассоциативные массивы
readonly -pf # вывести все константные функции

readonly определена в POSIX, но только с опцией -p.

Использование функций в Bash-приложениях править

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

  • Следует по возможности весь код разбивать по функциям. Это хорошо помогает, когда дело доходит до отладки, тестирования и во время добавления новой функциональности. Неправильно работающие функции быстро вычисляются из общей массы, а неверно работающий код изолируется самой функцией.
  • Как дополнение к предыдущей рекомендации, часто повторяющиеся действия всегда должны существовать в форме функций.
  • В Bash приложения пишутся также как на Си, но в отличии от Си, вы можете вкладывать исполняемый код между функциями. Код, выполняющийся вне функций, по возможности должен быть технологическим для самого сценария, т.е. он должен проверять достигнуты ли необходимые условия, после которых можно приступить к исполнению основного алгоритма.
  • Рекомендуется сразу закладывать функцию main, как в языке Си, и вызывать ее по шаблону описанному ниже. Это поможет обойти проблему отсутствия опережающего определения функций.
  • Для удобства поставки, весь код сценария оформляется одним файлом (standalone bash application), но это не всегда возможно. Например бывает, что есть сценарии выполняющие разную работу, но использующие один и тот же инструментарий. В этом случае вы вынуждены либо укомплектовать сценарий кучей передаваемых на вход ключей, либо выполнить декомпозицию в разные файлы. В последнем случае приходится иметь дело с дублированием кода в разных файлах. При декомпозиции алгоритмов в разные файлы, общие функции следует оформлять в виде разделяемой bash-библиотеки, которую следует внедрять посредством инструкции точка или source.

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

  • script1.sh — выполняет первый сценарий.
  • script2.sh — выполняет второй сценарий. Его алгоритм не пересекается с первым сценарием, но они пользуются одинаковыми функциями.
  • common.bash — библиотека с общим набором функций, которые разделяются между сценариями, дабы не дублировать код.
  • validators.bash — еще одна библиотека с узконаправленным инструментарием (в данном примере это коллекция валидаторов). Сценарий может пользоваться ей, а может не пользоваться.

Мы придерживаемся конвенции, когда bash-библиотеки имеют расширение .bash, а исполняемый сценарий — .sh. Это не общеизвестное правило, но мы настоятельно рекомендуем использовать хоть какие-нибудь расширения, потому что они помогают в поиске файлов. Расширение .bash накладывает на библиотеку ограничение — при попытке вызова ее как сценария ничего не должно происходить, кроме, возможно, самодиагностики.

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

# Файл: common.sh
# Примечание: в файлах библиотек не обязательно использовать башенг.

# Мы используем эту проверку, чтобы предупредить попытку двойного импорта.
# Для этого мы проверяем две специальные переменные, которые определяют сигнатуру
# библиотеки. Они должны быть уникальны для данной библиотеки.
[[ ! -z "$_LIB_COMM_PREFIX" && ! -z "$_LIB_COMM_VERSION" ]] && 
    { echo "Error: ${BASH_SOURCE##*/}:${BASH_LINENO[1]}: double library importing."; exit 129; }

# Эти переменные определяют сигнатуру библиотеки. В этом примере мы используем две констатнты:
# префикс библиотеки и ее версию. Эти переменные могут быть использованы для различных проверок
# импорта, но в такие дебри мы забираться не будем.
#
# В данном случае мы пытаемся эмулировать технику препроцессора языка Си, где похожим
# образом запрещают двойной include.
readonly _LIB_COMM_PREFIX='comm'
readonly _LIB_COMM_VERSION='0.9'

# Далее идет коллекция функций данной библиотеки. Формат библиотеки очень строгий, в частности:
#    * не должно быть постороннего кода между функциями, кроме глобальных переменных;
#    * по возможности все функции должны быть изолированы от внешнего по отношению к библиотеке
#      кода. Если это невозможно, то вы должны предусмотреть механизмы контроля порядка включения,
#      либо попытаться слить две библиотеки в один файл;
#    * для всех функций рекомендуется писать комментарий по тому, как ей пользоваться.
# 

#
# Функция печати на терминал.
# 
# say -[iew] <Сообщение>
#
# Опции:
#   -e | --error
#       Печать сообщение с плашкой ошибки
#
#   -w | --warn
#       Печать сообщение с плашкой предупреждения
#
#   -i | --info
#       Печать сообщение с плашкой информации
#
# Код возврата:
#    0   в случае успеха
#
say() {
  # ...
  return 0
}

#
# Печатает сообщение об ошибке в стандартной форме.
#
# handle_error --number <Проверяемое значение>
#    Выводит сообщение об ожидании числа.
#
# handle_error --string <Проверяемое значение> <Ожидаемое значение>
#    Выводит сообщение об ожидании конкретного значения.
#
#
handle_error() {
  # ...
  return 0
}

# Следующая секция опциональна и закладывает механизм самодиагностики.
# В частности, вы можете закладывать проверку наличия нужных утилит, используемых
# в библиотеке.
__test() {
  [[ ${1:-} == 'test' ]] || return 0
  # ...
  return 0
}
__test "$@" || { echo "Error: Self test failure in \"$BASH_SOURCE\""; exit 128; }
unset -f __test

Вторая библиотека строится аналогично. Разница состоит в том, что у нее своя коллекция функций, сигнатура и функции самодиагностики.

# Файл: validators.bash
[[ ! -z "$_LIB_VAL_PREFIX" && ! -z "$_LIB_VAL_VERSION" ]] && 
    { echo "Error: ${BASH_SOURCE##*/}:${BASH_LINENO[1]}: double library importing."; exit 129; }

readonly _LIB_VAL_PREFIX='val'
readonly _LIB_VAL_VERSION='0.9'

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

#
# Проверяет, является ли передаваемая строка числом.
#
# $1   Проверяемое число
#
# Возвращаемый код:
#    0   аргумент является числом
#    1   аргумент не число
#  
val_is_number() {
   # ...
}

#
# Проверяет, является ли передаваемая строка пустой.
# Пустой также считается строка, заполненная только пробелами и табуляторами.
#
# $1   Проверяемая строка
#
# Возвращаемый код:
#    0   строка пустая
#    1   строка не пустая
# 
val_is_empty() {
   # ...
}

# Здесь мы реализуем что-то похожее на модульные тесты.
__test() {
  [[ ${1:-} == 'test' ]] || return 0
  # 
  ! val_is_number          || return 1
  val_is_number "123"      || return 1
  val_is_number "123.23"   || return 1
  ! val_is_number "1b3.2a" || return 1
  ! val_is_number "abc12"  || return 1
  # 
  val_is_empty    || return 1
  val_is_empty "" || return 1
  return 0
}
__test "$@" || { echo "Error: Self test failure in \"$BASH_SOURCE\""; exit 128; }
unset -f __test

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

  • Можно делать по старинке, а именно начинать все функции в пределах одного файла на один префикс. Обычно имя префикса по смыслу как то связано с назначением всего файла, если это библиотека. Например, все библиотечные функции библиотеки валидаторов начинаются на val_: val_is_empty, val_is_number, val_is_string и т.п.
  • Можно расширить предыдущую идею и использовать синтаксис языка С++, создавая псевдопространства имен: Validators::is_empty, Validators::is_number, Validators::is_string и т.п.
  • Если пойти еще дальше, то имена функциям можно давать в верблюжьей нотации: Validators::isEmpty, Validators::isNumber, Validators::isString и т.п.
  • Переменные и функции библиотеки, которые не должны использоваться в клиентском коде, настоятельно рекомендуется начинать на нижнее подчеркивание (_INTERNAL_VAR, __internal_function()). Это убережет программиста, который будет пользоваться библиотекой, от конфликтов имен.

Важно помнить, что в пределах одного приложения следует стараться пользоваться одним стилем, так как смешение обычно плохо сказывается на читаемости кода.

Далее мы рассмотрим как строится типичный сценарий.

#!/bin/bash
# Файл: script1.sh
#
# Мы рекомендуем инициализировать следующую переменную, чтобы понять, где хранится
# исходный файл. Этот поможет сделать сценарий независимым от текущей рабочей директории.
readonly _SOURCE_DIR=$(dirname "$(readlink -f ${BASH_SOURCE[0]})")

# Включаем зависимые библиотеки. Мы предполагаем, что они лежат в том же каталоге, что сценарий.
source "$_SOURCE_DIR/common.bash"
source "$_SOURCE_DIR/validators.bash"

# Мы рекомендуем начинать сценарий с функции usage, которая выводит на терминал правила использования
# сценария. Хорошо когда она находится вверху - ее может видеть разработчик не пролистывая далеко вниз.
usage() {
printf "Usage:
    $0 [-h]

      Пример шаблона для строки использования.

Опции:
     -h    Вывести строку использования и выйти.

"
}

# Следующая функция разбирает строку с аргументами, переданными сценарию.
# Функция может строиться самым разным образом. Мы покажем типичный код для этой функции.
process_flags() {
    while getopts ':h' flag; do
       case "${flag}" in
           h)  usage
               die 0
               ;;
           \?) # Секция обработки неизвестного аргумента
               ;;
            :) # Секция обработки пропущенного аргумента
               ;;
       esac
    done
    return 0
}

# Это главная функция сценария. Вы должны вызывать весь последующий код
# исключительно из нее.
main() {
   process_flags "$@" || die 1
   say "Doing very important things..."
   val_is_number 123 && say -i "123 is a number." 
   exit 0
}

# После функции main мы можем объявлять другие вспомогательные функции,
# которые нужны только в этом сценарии. Мы можем объявлять их в любом порядке
# так как мы обходим проблему опережающего определения ниже.
die() {
    [[ $# -eq 0 ]] && exit 1
    local exit_code=$1
    local message=$2
    [[ ! -z $message ]] && 
        say -e "$message"
    exit $exit_code
}

# Наконец мы вызываем функцию main. Следующая строка должна быть ВСЕГДА последней.
# Мы используем проверку BASH_SOURCE, чтобы, при желании, этот файл можно было бы
# включить в другой и использовать его функции повторно. Ниже мы рассмотрим как переопределить
# существующую функцию main и сделать код многократно используемым во многих сценариях.
[[ "$0" == "$BASH_SOURCE" ]] && main "$@"

Предположим что script1.sh является подмножеством script2.sh, т.е. script2.sh включает все, что есть в script1.sh, и отличается функцией main и возможно вводит еще функции. Мы могли бы просто скопировать общие части из script1.sh, но это приведет к ненужному дублированию кода, тем более, если сценарии нужно поддерживать одинаковыми в части, в которой они пересекаются.

Вместо этого мы можем просто включить script1.sh целиком в script2.sh и переопределить функцию main, избавив нас от двойной работы. Покажем как это сделать.

#!/bin/bash

# Выполняем включение script1.sh. Проверка на последней строке script1.sh не позволит функции
# main исполниться, так как условие будет ложным.
_p=$(dirname "$(readlink -f ${BASH_SOURCE[0]})")
source "$_p/script1.sh" # Файл должен лежать в том же каталоге, что и данный файл.

# Удаляем функцию main.
unset -f main

# Определяем новую функцию main.

main() {
    # ... Все функции, доступные в script1.sh доступны и здесь ...
    process_flags "$@" || die 1
    echo "The main function has been overriden."
    exit 0
}

#
# ... Определяем функции, которые нужны нам здесь ...
#

[[ "$0" == "$BASH_SOURCE" ]] && main "$@"

У данного подхода есть свои недостатки:

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

Обычно, если цели автоматизации выходят за рамки манипуляций с файлами, рекомендуется перейти на другой скриптовый язык, вместо дальнейшего усложнения сценария на Bash, например Python, Groovy, Perl и другие. В синтаксис этих языков включены более продвинутые приемы для поддержки многофайлового приложения.

Вкладывание функций и возможности мета-программирования править

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

Тем не менее, вкладывание функций в Bash имеет полезное свойство, основанное на том, что тело функции не интерпретируется до момента ее реального вызова. Это позволяет нам управлять сценарием на мета-уровне: иметь функции с одинаковым именем, но разной реализацией. Конкретная реализация может привязываться к условиям, т.е. программа может составлять себя на основе мета условий.

Рассмотрим такой пример.

outer() {
    echo "Called $FUNCNAME function (${1:-parent}). PID: $BASHPID";
    inner() {
        echo "Called $FUNCNAME function";       
    }
}

Казалось бы интерпретатор при первом проходе должен увидеть две функции outer() и inner(), однако до реального вызова функции outer(), будет зарегистрирована всего одна функция outer(). Чтобы убедиться в этом, вы можете попробовать сделать такой вызов после объявления

inner || true # Мы используем функцию true, чтобы исполнение не прерывалось
# Результат: line 10: inner: command not found

# Теперь вызовем outer
outer
# Результат: Called outer function (parent). PID: 931

# и снова inner
inner
# Результат: Called inner function

Таким образом, функции inner() не существует, пока интерпретатор не исполнит тело функции outer().

Приведем несколько примеров как это можно использовать в ваших сценариях. Самый наверное очевидный сценарий применения, это изменение реализации функции на ходу.

#!/bin/bash
# Файл: probe.sh
#
# Возвращает 0, если переданный идентификатор уже занят ранее объявленной функцией.
#
isDefined() [[ -n $(declare -f "$1") ]]

set_default_implementation() {
    probe() {
       echo "$FUNCNAME: default implementation."
    }
}

if ! isDefined "probe" && [[ $# -eq 0 ]]; then
    set_default_implementation
elif ! isDefined "probe" && [[ $# -ne 0 ]]; then
    case "$1" in
      --first)
           probe() {
              echo "$FUNCNAME: first implementation."
           }
      ;;
      --second)
           probe() {
              echo "$FUNCNAME: second implementation."
           }
      ;;
      *) set_default_implementation ;;
    esac
fi

probe

В этом примере реализация функции probe зависит от того, как запущен сценарий:

$ ./probe.sh
probe: default implementation.

$ ./probe.sh --first
probe: first implementation.

$ ./probe.sh --second
probe: second implementation.

$ ./probe.sh unknown
probe: default implementation.

Интересно то, что если бы функция была объявлена до проверки ее существования, например через процедуру внедрения в этот файл другого, то проверка была бы опущена и произошел бы вызов уже объявленной функции. Добавьте такой код до конструкции if..fi

probe() {
    echo "$FUNCNAME: My implementation."
}

и снова попробуйте вызвать

$ ./probe.sh
probe: My implementation.

$ ./probe.sh --first
probe: My implementation.

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

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

#!/bin/bash
# Файл: meta-code.sh
#
# Переопределяет реализацию существующей функции (вариант 1)
#
# $1   Имя функции.
# $2   Код функции.
#
reDefine1() {
    [[ $# -eq 2 ]] || return 1
    local fname=$1
    local fbody=$2
    unset -f "$fname"
    eval "$fname" "$fbody"
}

#
# Переопределяет реализацию существующей функции (вариант 2)
# Тело функции получается из стандартного потока ввода.
#
# $1   Имя функции.
# 
#
reDefine2() {
    [[ $# -eq 1 ]] || return 1
    local fname=$1
    local fbody
    while read -r; do
        fbody="${fbody}\n${REPLY}"
    done
    fbody=$(echo -e "$fbody")
    if [[ -n $fbody ]]; then
        unset -f "$fname"
        eval "$fname" "$fbody"
    else
        return 1
    fi
    return 0
}

reDefine1 "meta()" '{ 
    echo "$FUNCNAME: Hello, World! First" 
}'

meta

reDefine2 "meta()" <<<'{
    echo "$FUNCNAME: Hello, World! Second"
}'

meta

Вызов сценария даст такой результат:

$ meta-code.sh
meta: Hello, World! First
meta: Hello, World! Second

А теперь представьте, что тело поставляется в функцию через какой-то файл.

Конечно подобные фокусы редко пригождаются на практике, тем не менее shell-сценарии могут быть не так просты.



← Циклы Эмуляция ссылочной адресации →