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

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


В оригинальном Bourne Shell не существовало метода косвенной адресации переменных (indirect addressing), т.е. когда в некоторой переменной (к которой мы обращаемся) хранится в виде значения ссылка на другую переменную, чье значение нас интересует. Другими словами, все значения переменных в сценариях командной оболочки передаются куда-либо исключительно по значению.

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

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

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

Основы править

Командная оболочка интерпретирует каждую строку в два этапа:

  1. Сначала она пытается подставить переменную(ые) в интерпретируемую строку.
  2. Если есть операция присваивания, то выполняется присваивание, либо строка исполняется как командный список, либо как compound-выражение.

По такому принципу, например, работает следующий код

STRING="Hello, World!"
COMMAND="echo $STRING" # Сначала подставит $STRING, потом присвоит
$COMMAND               # Сначала подставит $COMMAND, потом исполнит

Но такой код работать не будет

STRING="Hello, World!"
COMMAND="HELLO=$STRING"
"$COMMAND" # интерпретатор будет пытаться искать команду 'HELLO=Hello, World!'
echo "$HELLO"

Это связано с тем, что присваивание это внутренняя процедура Bash. В этом примере символ равно из подстановки значения не является признаком присваивания, так как он является частью строки после подстановки переменной, и поэтому производится попытка буквально исполнить подставленную команду.

Bash можно явно заставить интерпретировать текстовую строку как часть кода сценария с помощью внутренней команды eval (от англ. evaluate). Тогда предыдущий код нужно записать так:

STRING="Hello, World!"
COMMAND="HELLO=\"$STRING\"" # Экранированные кавычки обязательны, потому что
                            # в строке есть пробелы.
eval "$COMMAND" # Теперь команда интерпретируется как будто она была записана 
                # в сценарий изначально.
echo "$HELLO"

Обратите внимание, что мы должны сохранить буквальные кавычки во время присваивания значения переменной COMMAND, так как Bash все кавычки также интерпретирует на первом этапе. В этом примере мы их не можем опустить, потому что в противном случае после подстановки переменной $COMMAND на выходе получается строка HELLO=Hello, World!, которая формально состоит из двух инструкций:

  • HELLO=Hello, (инициализация переменной окружения команды)
  • World! (команда)

Так как команды с именем World! в системе явно не будет, то сценарий завершится с ошибкой.

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

Простая косвенная адресация править

Начнем с такого примера

#!/bin/bash

declare -a FRUITS=()
declare -a VEGETABLES=()

upvar() {
    unset -v "$1" && eval "${1}=\$2" # или можно так:     eval "$1"'=$2'
}

getType() {
    upvar "$1" 'unknown'
    case $2 in
        apple | banana | grapes | pineapple)
            upvar "$1" 'fruit'
            ;;
        potato | tomato | beans | carrot)
            upvar "$1" 'vegetable'
            ;;
    esac
}

sorter() {
    local thing=$1
    getType 'type' $thing
    case $type in 
        vegetable)
            VEGETABLES+=("$thing")
            ;;
        fruit)
            FRUITS+=("$thing")
            ;;
        *)
            echo "Unknown thing: '$thing'"
            ;;
    esac
}

for thing in potato grapes tomato banana rock pineapple beans carrot apple; do
    sorter "$thing"
done

for thing in "$(echo -e "--------\nFruits\n--------\n")" "${FRUITS[@]}" \
              $(echo -e "--------\nVegetables\n--------\n") "${VEGETABLES[@]}"; do
    echo "$thing"
done

В этом примере у нас есть куча фруктов и овощей и есть сортировочная машина, которая умеет отличать некоторые фрукты и овощи. Соответственно фрукты она будет складывать в массив с фруктами, а овощи в массив с овощами, попутно отсеивая неизвестные предметы.

Обратите внимание как работает сортировщик (функция sorter). Сортировщик обращается к функции getType, чтобы она ему вернула тип передаваемого предмета, при этом функция просит положить результат в переменную с именем type. Эта переменная передается здесь по сути по ссылке, потому что сортировщик в передаваемом параметре отражает имя переменной как данные.

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

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

Результат работы этой программы представлен ниже

Unknown thing: 'rock'
--------
Fruits
--------
grapes
banana
pineapple
apple
--------
Vegetables
--------
potato
tomato
beans
carrot

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

Кроме того, данный трюк (как и все трюки с eval) имеет потенциальные проблемы с безопасностью, если правая часть от равно никак не проверяется на содержание. Если это значение приходит в сценарий с клиентской стороны, то в нее потенциально может быть передан вредоносный код, который будет исполнен до присваивания значения. Поэтому такая косвенная адресация должна использоваться только тогда, когда вы, как разработчик, понимаете что делаете.

Косвенная адресация с помощью printf править

Предыдущий пример можно сделать безопаснее, если использовать внутреннюю команду printf с опцией -v. Напомним, что эта опция записывает строку в переменную, указанную после опции. Преимущество printf состоит в том, что она учитывает видимость переменной, если она была ограничена. Ниже показаны участки кода, которые нужно изменить.

# ...Без изменений

upvar() {
    printf -v "$1" "$2"
}

# ...Без изменений 

sorter() {
    local thing=$1
    local type     # ограничиваем видимость переменной type
    #
    # ...Без изменений
    #
}

# ...Без изменений 

echo "type='$type'" # Для проверки

После запуска получаем такой вывод:

Unknown thing: 'rock'
--------
Fruits
--------
grapes
banana
pineapple
apple
--------
Vegetables
--------
potato
tomato
beans
carrot
type=''

Обратите внимание, что мы поменяли всего две строчки и сохранили функциональность примера:

  • В функции upvar мы используем команду printf для записи значения по ссылке.
  • В функции sorter мы объявили переменную type и ограничили ее видимость с помощью local.

Судя по выводу программы, переменная type действительно ограничена по видимости, т.е. использование printf для косвенной адресации предпочтительно.

Хотя команда printf описана в POSIX, ключ -v для нее в нем не описан (в Bash опция появилась с версии 3.1), что автоматически делает ваши сценарии не портируемыми, если вы используете для косвенной адресации эту команду. Однако и здесь можно выкрутиться, если использовать eval, например так:

# $ref - имя переменной по ссылке
# $value - присваиваемое значение
eval "$(printf %s=%q "$ref" "$value")"

Косвенная адресация как источник данных править

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

#!/bin/bash
# Файл: authorizer.sh
declare -A emploee_card1=(
    [id]=2569
    [name]=John
)

declare -A emploee_card2=(
    [id]=1214
    [name]=Alice
)

declare USER

say_hello() {
    eval echo "Hello \"\$$USER\"!"
}

read -p "Enter your id: "

if (( ${emploee_card1[id]} == "$REPLY" )); then
    USER='{emploee_card1[name]}'
elif (( ${emploee_card2[id]} == "$REPLY" )); then
    USER='{emploee_card2[name]}'
else
    echo "Error: This id is unknown to the system."
    exit 1
fi

say_hello

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

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

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

Ниже показан пример работы сценария.

$ authorizer.sh
Enter your id: 2569
Hello John!

$ authorizer.sh
Enter your id: 1214
Hello Alice!

$ authorizer.sh
Enter your id: 1
Error: This id is unknown to the system.

Ссылки на функции править

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

#!/bin/bash

# Эта функция будет печатать аргументы вызываемой по ссылке функции.
# Функция использует переменную окружения MY_NAME, в которой она ожидает
# имя вызываемой функции. Кроме того, функция печатает переменную окружения,
# которая передается вызываемой по ссылке функции.
arg_printer() {
    local -i counter
    echo "--> Start trace -->"
    echo " Called: $MY_NAME"
    for arg; do
        echo "  ARGV[$((counter++))]: $arg"
    done
    echo "  Environment: $(env | grep -oE "EXPORT=.*")"
    echo "<-- End trace <--"
}

# Функции, которые мы будем вызывать по ссылке.
######################################################
mfunc_1() {
    MY_NAME="$FUNCNAME" arg_printer "$@"
}

mfunc_2() {
    MY_NAME="$FUNCNAME" arg_printer "$@"
}

mfunc_3() {
    MY_NAME="$FUNCNAME" arg_printer "$@"
}
######################################################

# Далее мы вызываем функции по ссылке FREF в цикле. Заготовить список для цикла
# мы можем разными путями, но во всех случаях мы пользуемся тем, что имена функций
# начинаются на mfunc_

PREFIX='mfunc_'

echo "=== FIRST APPROACH ==="

# Здесь список функций подготавливается через общий префикс и скобочную подстановку.
for FREF in ${PREFIX}{1..3}; do
    EXPORT="FREF:$FREF;" "${FREF}" a b c  # Сам вызов прост: мы просто раскрываем ссылку как обычную переменную
    
    # Обратите внимание, что перед вызовом по ссылке мы передаем экспортированную переменную EXPORT (см. в главе Команды),
    # а также передаем несколько аргументов самой функции для печати.
done

echo "=== SECOND APPROACH ==="

# Здесь список формируется немного сложнее. Мы используем команду Bash compgen, которая
# при данной опции возвращает список всех объявленных на текущий момент функций. Далее мы фильтруем
# этот список утилитой grep.
for FREF in $(compgen -A function | grep "$PREFIX.*"); do
    EXPORT="FREF:$FREF;" "${FREF}" a b c   # Вызов абсолютной такой же как и в первом подходе.
done

Результат работы сценария

=== FIRST APPROACH ===
--> Start trace -->
 Called: mfunc_1
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_1;
<-- End trace <--
--> Start trace -->
 Called: mfunc_2
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_2;
<-- End trace <--
--> Start trace -->
 Called: mfunc_3
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_3;
<-- End trace <--
=== SECOND APPROACH ===
--> Start trace -->
 Called: mfunc_1
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_1;
<-- End trace <--
--> Start trace -->
 Called: mfunc_2
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_2;
<-- End trace <--
--> Start trace -->
 Called: mfunc_3
  ARGV[0]: a
  ARGV[1]: b
  ARGV[2]: c
  Environment: EXPORT=FREF:mfunc_3;
<-- End trace <--

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

Косвенная адресация средствами Bash править

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

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

ORIGIN_VARIABLE='Some value' # Это простая переменная с некоторым значением, на которое мы сошлемся косвенно через ссылку.
REF='ORIGIN_VARIABLE' # Переменная REF (ссылка) хранит имя переменной, на которую ссылается.

# Примечание: в данном случае не обязательно ставить одинарные кавычки при присваивании имени переменной ссылке.
# Мы так делаем, потому что строковые литералы подсвечиваются другим цветом, если текстовый редактор умеет подсвечивать
# код на Bash.

# Чтобы подставить значение по ссылке, вы должны воспользоваться следующей специальной подстановкой:
echo "${!REF}" # Ссылка будет разрешена интерпретатором. Результат подстановки: "Some value"

Можно обозначить ссылку явно через команду declare с опцией -n. Эта опция аналогична команде nameref в Ksh. Явное объявление ссылок улучшает читаемость кода, так как передает ваши намерения явно.

declare -n REF='ORIGIN_VARIABLE'

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

#!/bin/bash

declare -a ARRAY=(5 6 -1 7 5 10 8 7 12 -8 4)

min_max() {
    local refarr=${1}[@] # Это ссылка на передаваемый фунции массив. Ссылки на массивы мы обсудим позже.
    local -n min=$2 # Это ссылка на выходную переменную, в которую мы запишем минимальное значение.
    local -n max=$3 # Это ссылка на выходную переменную, в которую мы запишем максимальное значение.
    min=${1}[0]
    max=${1}[0]
    for element in "${!refarr}"; do
        ((min = element < min ? element : min))
        ((max = element > max ? element : max))
    done
}

min_max ARRAY minval maxval # Вызов функции.
# Обратите внимание, что мы передаем функции имена объектов, с которыми функция внутри работает через ссылки,
# что позволяет не привязываться к конкретным именам в реализации функции. 

echo "Min value: $minval"
echo "Max value: $maxval"

Результат работы:

Min value: -8
Max value: 12

Как мы уже показали в предыдущем примере, можно делать ссылки на массивы и на их отдельные элементы. К сожалению по таким ссылкам можно только читать данные, что очень ограничивает их применение.

declare -a ARRAY_STRS=('John Doe' 'Bill Watson') # Простой массив
declare -A PERSON=([name]="John" [age]=23)       # Ассоциативный массив

# Ссылка на простой массив выглядит так, при этом вариации 'declare -n' для массивов нет
REF_1=ARRAY_STRS[@]           # Ссылка на массив
declare REF_1=ARRAY_STRS[@]   # Допустимо

# Следующий вариант формально является ссылкой на массив, но он НЕ РЕКОМЕНДУЕТСЯ из-за нежелательного побочного эффекта,
# связанного с механизмом word splitting
WRONG_REF_1=ARRAY_STRS[*]

# На отдельный элемент простого массива ссылка создается похожим образом, но нужно указать индекс
REF_EL_1=ARRAY_STRS[1]          # Ссылка на элемент с индексом 1
declare REF_EL_1=ARRAY_STRS[1]  # Допустимо

# В ассоциативных массивах обычно ссылаются на конкретные элементы
REF_NAME=PERSON[name]
REF_AGE=PERSON[age]

# Разрешаются ссылки на массивы и их элементы все тем же образом
echo "${!REF_1}"         # John Doe Bill Watson
echo "${!WRONG_REF_1}"   # John Doe Bill Watson
echo "${!REF_EL_1}"      # Bill Watson
echo "${!REF_NAME}"      # John
echo "${!REF_AGE}"       # 23

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

printf "<%s> " "${!REF_1}"; echo # Вот так можно распечатать все элементы массива, применяя один формат к каждому элементу

Результат

<John Doe> <Bill Watson>

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

#!/bin/bash

declare -a ARRAY_STRS=('John Doe' 'Bill Watson' 'Bart Simpson' 'Bugs Bunny' 'Homer Simpson') # Простой массив
declare -a NUMS=({-10..10}) # Массив, сгенерированный через скобочную подстановку (см. в след. главе)

# Следующая функция печатает элементы простого массива в определенном формате, начиная с определенного элемента (если указано во-втором аргументе)
# или с начала
print_from() {
    local ref_element=${1}[index]
    local index=${2:-0}
    while printf "<%s> " "${!ref_element}"; ((index++)) ; [[ -n ${!ref_element+_} ]]; do true; done
    echo
}

print_from ARRAY_STRS
print_from ARRAY_STRS 2

print_from NUMS
print_from NUMS 6

Результат

<John Doe> <Bill Watson> <Bart Simpson> <Bugs Bunny> <Homer Simpson> # с начала
<Bart Simpson> <Bugs Bunny> <Homer Simpson>                          # с третьего элемента
<-10> <-9> <-8> <-7> <-6> <-5> <-4> <-3> <-2> <-1> <0> <1> <2> <3> <4> <5> <6> <7> <8> <9> <10> # с начала
<-4> <-3> <-2> <-1> <0> <1> <2> <3> <4> <5> <6> <7> <8> <9> <10>                                # с седьмого элемента

Обратите внимание как функция объявляет универсальную ссылку на элемент массива. Для этого мы используем счетчик index, который увеличивается в цикле и двигает ссылку как итератор по массиву. Изменяя начальное значение для index, мы определяем с какого элемента начать. Чтобы цикл остановился, мы используем хитрое условие [[ -n ${!ref_element+_} ]], которое возвращает ИСТИНУ всякий раз, когда ссылку удается раскрыть.



← Функции Bash подстановки →