Практическое написание сценариев командной оболочки 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.

Косвенная адресация средствами 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 подстановки →