Продвинутые возможности bash
Язык командной оболочки bash, в принципе, является совершенно полноценным языком программирования, хотя его и не рекомендуется использовать для написания сколько-нибудь сложных программ: всё-таки основное его назначение — синхронизация процессов и автоматизация простых действий, а не исполнение нетривиальных алгоритмов. В этом смысле bash представляет самый базовый уровень т.н. скриптовых языков. К «наиболее продвинутым» скриптовым языкам (в том смысле, что они наиболее близки к т.н. языкам общего назначения) обычно относят Python и Javascript (на платформе nodejs). Где-то посередине между этими крайностями находятся (до сих пор живые!) языки perl и tcl.
В этой главе описаны некоторые возможности bash как языка программирования.
Арифметика
В bash есть рудиментарная поддержка целочисленной арифметики. Арифметическое
выражение (возможно, содержащее переменные) можно взять в «толстые скобки»
$((
и ))
. Например, так:
foo=2
echo $((foo+3))
foo=$((foo + 7))
Аналогами последней команды являются команды:
((foo = foo + 7))
let "foo = foo + 7"
Следует помнить, что bash не поддерживает работу с длинными числами и нецелые числа, поэтому в нетривиальных ситуациях рекомендуется пользоваться внешними инструментами:
foo = 123456789
python3
bar="`python3 -c "print($foo ** 9)"`"
Массивы
Каждая переменная в bash может иметь несколько значений, индексируемых
целыми неотрицательными числами (есть ещё и ассоциативные массивы, но
мы их не будем рассматривать). Например, можно переменной foo
присвоить
три разных значения по разным индексам:
foo[0]=a
foo[1]=234
foo[2]="${foo[0]}"
Если индекс не указан, то считается, что он равен 0, то есть следующие две команды эквивалентны:
foo=bar
foo[0]=bar
Стоит обратить внимание на то, что для интерполяции индексированных значений обязательно нужно использовать фигурные скобки:
foo[0]=bar
foo[5]=abc
echo $foo[5] # напечатает bar[5]
echo ${foo[5]} # <-- а вот так правильно!
Как и в Python, отрицательные индексы означают отсчёт с конца массива:
unset foo
foo[0]=1
foo[1]=2
foo[2]=3
foo[3]=10
echo "${foo[-1]}" # будет напечатано 10
У массивов есть три режима коллективной интерполяции:
foo[0]="abc def"
foo[1]="xyz"
foo[2]="1 2 3 4"
# следующие три команды эквивалентны
echo ${foo[*]}
echo ${foo[@]}
echo abc def xyz 1 2 3 4
# следующие две команды эквивалентны
echo "${foo[@]}"
echo "abc def" "xyz" "1 2 3 4"
# следующие две команды эквивалентны
echo "${foo[*]}"
echo "abc def xyz 1 2 3 4"
Следует быть аккуратным с последним режимом интерполяции: разделители внутри
слова между отдельными элементами массива определяются первым символом
переменной IFS
. Лучше про *
в аспекте интерполяции массива вообще забыть.
В заключение отметим, что в рамках сценариев можно использовать
последовательности $*
и $@
, интерполирующиеся в массив аргументов
командной строки, начиная с первого. Правила интерполяции те же, что
и описанные выше для обычных массивов.
Условия
Каждая выполненная команда оболочки может завершиться как успехом,
так и ошибкой. В случае успеха в переменную ?
автоматически записывается
число 0. В случае ошибки — какое-нибудь ненулевое число (вообще говоря,
зависящее от ошибки, но не стандартизированным образом).
В зависимости от значения ?
можно производить ветвление:
if команда; then список_команд1; else список_команд2; fi
Если команда
завершается успехом, выполняется список_команд1
. В противном
случае — список_команд2
.
Обычно в качестве команды используется test
с какими-нибудь аргументами
(см. man test
). Часто можно видеть синтаксис [ что-то ]
, вызывающий test что-то
.
Также есть конструкция [[ что-то ]]
, обрабатывающаяся самим bash.
Циклы
Из циклов чаще всего применяется «простой for
»
for переменная in слово_1 слово_2 ... слово_n; do список_команд; done
Переменная по очереди становится равна каждому из слов. В качестве
генератора списка слов часто используется команда seq
(см. man seq
).
Также изредка бывает полезен цикл while
:
while команда; do список_команд; done
По принципу работы while
аналогичен if
: перед каждой итерацией он выполняет
команду и смотрит на результат её выполнения (значение переменной ?
).
Функции
Функции в bash — механизм определения новых команд. Определение функции выглядит так:
# допустим любой из следующих вариантов:
function название { список_команд; }
название () { список_команд; }
Далее вызвать функцию можно просто как команду:
название аргумент_1 аргумент_2 ... аргумент_n
Тело функции представляет собой совершенно самостоятельный сценарий:
в частности, переменные 1
, 2
, 3
и т.д. — это аргументы,
с которыми функция вызвана, а не аргументы, с которым вызван сценарий,
вызвавший функцию. Единственная важная тонкость: поскольку функция
исполняется тем же экземпляром bash
, что и остальной сценарий,
она может изменять переменные этого экземпляра, причём такое поведение
включено по-умолчанию. Локальные для функции переменные нужно создавать
командой local
:
#!/bin/bash
foo () {
i=$(( i + 1))
}
bar () {
local i=$i
i=$(( i + 1))
}
i=1
echo $i ## 1
foo
echo $i ## 2
bar
echo $i ## 2
Дескрипторы
Для общения со внешним миром процесс использует каналы, называемые дескрипторами. Каждый дескриптор идентифицируется целым неотрицательным числом. По-умолчанию bash запускается с тремя активными дескрипторами (изначально все три привязаны к терминалу):
- дескриптор 0 (т.н. stdin), работающий на вход; всевозможные операции «считывания данных с клавиатуры» берут данные именно из него
- дескриптор 1 (т.н. stdout), работающий на выход; всевозможные операции «печати на экран» отправляют данные именно в него
- дескриптор 2 (т.н. stderr), работающий на выход; всякая информация вспомогательного плана отправляется именно в него
Все команды, запущенные из bash, наследуют его дескрипторы, если для этих
команд не указано никаких перенаправлений. Поменять связь стандартных дескрипторов
и активировать новые можно командой exec
.
Три самых распространённых режима — связь с файлом:
exec 3<foo # открывает дескриптор 3 на вход, связывает с файлом foo
exec 3>foo # открывает дескриптор 3 на выход, связывает с файлом foo
exec 3>>foo # открывает дескриптор 3 на выход, связывает с концом файла foo
Разница последних двух режимов в том, что первый из них создаёт файл foo
или
же очищает его перед активацией дескриптора. Второй же оставляет содержимое
foo
неизменным и дописывает поступившие в него данные в конец файла.
Также бывает очень полезным копирование дескриптора:
exec 4<&3 # активирует дескриптор 4 в том же режиме, что и 3
exec 5<&- # деактивирует дескриптор 5
Вообще говоря, есть ещё оператор >&
, но он в большинстве ситуаций
аналогичен <&
(несмотря на то, что в документации к bash сказано обратное).
По факту единственное различие — оператор >&
работает чуть быстрее,
но корректная работа гарантируется только для копирования дескрипторов,
активированных на выход.
Настоятельно не рекомендуется изменять режимы работы стандартных дескрипторов в интерактивном режиме: обычно это приводит к тому, что bash зависает или же вообще завершается.
Асинхронный запуск команды
Обычно при запуске любой команды bash дожидается её завершения (если команда внешняя, то он на время выполнения команды засыпает). Это называется синхронным запуском.
Любую команду можно запустить в асинхронном режиме, поставив в самом конце
команды символ &
. В этом случае команда всегда запускается как новый
процесс (являющийся потомком bash), при этом bash не входит в спящий режим.
При асинхронном запуске следует помнить две вещи:
- если запущенное приложение пытается брать данные с терминала, bash его
насильно усыпляет (разбудить приложение в таком случае можно командой
fg
) - если убить bash до завершения работы асинхронно запущенного приложения,
оно тоже насильно завершается; для того, чтобы предотвратить такое
поведение, используется команда
nohup
(см.man nohup
)
Синхронизировать между собой большое количество запущенных асинхронных
процессов можно при помощи именованных каналов — специальных
файлов, создаваемых командой mkfifo
, при помощи которых можно организовать
прямую связаь между приложениями (такую, как если бы они были связаны каналом
при помощи оператора |
).
Ниже — пример использования именованных каналов.
#!/bin/bash
ping () {
echo 1
while true; do
# read читает одну строчку и записывает её в указанную переменную
read x
echo "PING got ${x}" 1<&2
sleep 0.5
echo $(( x + 1 ))
done
}
pong () {
while true; do
# здесь используется та же переменная x, что и в ping, но эти
# функции далее запускаются в разных экземплярах bash
read x
echo "PONG got ${x}" 1<&2
sleep 0.5
echo $(( x + 1 ))
done
}
## mkfifo создаёт файл-канал
## Такой файл начинает работать, когда один процесс активирует его
## на вход, а другой процесс -- на выход.
mkfifo ping_pong_queue
mkfifo pong_ping_queue
## Тонкий момент: перенаправление > ping_pong_queue первой команды
## не завершается до тех пор, пока вторая команда не попытается сделать
## перенаправление < ping_pong_queue. Поэтому у перенаправлений
## именно такой порядок: если ping сначала попытается активировать
## ping_pong_queue, а pong -- pong_ping_queue, команды будут
## ждать друг друга бесконечно!
ping > ping_pong_queue < pong_ping_queue &
pong < ping_pong_queue > pong_ping_queue &
Конкретно этот сценарий страдает от того, что после завершения запустившей его оболочки два дочерних процесса не умирают: завершаются только те команды, которые исполнялись в момент завершения bash; циклы же продолжают работать. Более адекватная версия может выглядеть, например, так:
#!/bin/bash
PARENT_PID=$PPID # номер процесса, _запустившего_ этот сценарий
parentIsAlive () {
test `ps -o pid= $PARENT_PID`
}
ping () {
echo 1
while parentIsAlive; do
read x
echo "PING got ${x}" 1<&2
sleep 1
echo $(( x + 1 ))
done
}
pong () {
while parentIsAlive; do
read x
echo "PONG got ${x}" 1<&2
sleep 1
echo $(( x + 1 ))
done
}
cleanup () {
while parentIsAlive; do sleep 1; done
rm -f ping_pong_queue
rm -f pong_ping_queue
}
mkfifo ping_pong_queue
mkfifo pong_ping_queue
ping > ping_pong_queue < pong_ping_queue &
pong < ping_pong_queue > pong_ping_queue &
cleanup &