Перейти к содержанию

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

Материал из Викиучебника — открытых книг для открытого мира
← Ветвления Глава Функции →
Циклы


В Bash имеется 4 вида циклов:

  • цикл for;
  • цикл while;
  • цикл until;
  • цикл select.

Цикл for удобен для перебора конечных множеств (списков слов; простых массивов, которые могут быть преобразованы в списки слов; ассоциативных массивов через их ключи).

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

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

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

Ниже мы рассмотрим некоторые полезные приемы использования этих циклов.

Цикл for

[править]

Задокументированный синтаксис цикла имеет следующий вид

for <NAME> in <WORDS>; do
  <LIST>
done

Здесь <NAME> имя переменной, которая работает как ссылка на текущий элемент из списка слов <WORDS>. После каждого прогона список смещается на одну позицию влево, удаляя предыдущий элемент. Цикл будет продолжаться до тех пор, пока в результате смещения обнаруживаются новые слова.

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

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

Список не обязательно должен быть заранее известен: его может подготавливать некоторая команда.

declare counter=0

# Передача списка прямым образом
for entry in word1 word2 word3 word4; do
    echo "$(( counter += 1 )): '$entry'"
done
# Вывод:
# 1: 'word1'
# 2: 'word2'
# 3: 'word3'
# 4: 'word4'

# Передача списка скобочной подстановкой
for entry in {word1,word2,word3,word4}; do
    echo "$(( counter += 1 )): '$entry'" 
done
# Вывод:
# 5: 'word1'
# 6: 'word2'
# 7: 'word3'
# 8: 'word4'


# Список передает команда find
for entry in $(find / -maxdepth 1 -type d); do
    echo "$(( counter += 1 )): '$entry'" 
done

# Вывод:
# 9: '/'
# 10: '/bin'
# 11: '/boot'
# 12: '/dev'
# 13: '/etc'
# 14: '/home'
# 15: '/lib'
# 16: '/lib64'
# 17: '/media'
# 18: '/mnt'
# 19: '/opt'
# 20: '/proc'
# 21: '/root'
# 22: '/run'
# 23: '/sbin'
# 24: '/snap'
# 25: '/srv'
# 26: '/sys'
# 27: '/tmp'
# 28: '/usr'
# 29: '/var'

Можно опускать список слов, тогда for по умолчанию просматривает $@.

func() {
    # Эквивалентно for arg in "$@"; do ...
    for arg; do
        declare -i counter
        echo "arg $(( counter += 1 )): '$arg'"
    done
}

func a b c d
# Вывод:
# arg 1: 'a'
# arg 2: 'b'
# arg 3: 'c'
# arg 4: 'd'

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

Цикл for не одинаково обрабатывает $@ и $*, если они подставлены явно. Напомним, что $@ это массив, составленный из аргументов сценария/функции, а $* это строка, составленная из аргументов сценария/функции. Если в одном из аргументов есть символы, по которым for будет разбивать список (например пробелы), то со строкой $* сработает механизм разделения строк (word splitting), в результате чего у вас появятся мнимые аргументы. Напротив, с "$@" (двойные кавычки обязательны) аргументы, составленные из нескольких слов, не будут разбиваться. Без двойных кавычек $@ будет работать как $*.

Сравните:

correct() {
    for arg in "$@"; do
        declare -i counter
        echo "arg $(( counter += 1 )): '$arg'"
    done
}

wrong() {
    for arg in $*; do
        declare -i counter
        echo "arg $(( counter += 1 )): '$arg'"
    done
}

correct one two 'forty three'
echo '---------------------'
wrong one two 'forty three '

Результат:

arg 1: 'one'
arg 2: 'two'
arg 3: 'forty three'
---------------------
arg 1: 'one'
arg 2: 'two'
arg 3: 'forty'
arg 4: 'three' <-- мнимый аргумент


Цикл for возвращает статус последней команды командного списка, если произошла хотя бы одна итерация, иначе возвращается ноль. Этим конечно сложно пользоваться для проверок, но костыльный способ есть (здесь он приводится только для примера)

func() {
    declare -i counter
    for arg; do
        echo "arg $(( couter += 1 )): '$arg'"
        ! :
    done && echo "Function got nothing" || echo "Function got some arguments"
}

func
echo '-------------------------'
func a b c

# Вывод:
# Function got nothing
# -------------------------
# arg 1: 'a'
# arg 2: 'b'
# arg 3: 'c'
# Function got some arguments

Работа с переменной IFS

[править]

Переменная IFS (Input Field Separator) используется встроенными командами интерпретатора, чтобы разбивать входящий поток символов на отдельные слова. В языке командной оболочки можно задать сразу несколько символов, которые будут считаться разделителями слов и все они должны храниться в этой переменной. По умолчанию, переменная инициализирована тремя идущими подряд символами: символ пробела (0x20), символ табуляции (0x09) и символ переноса строки (0x0A).

Разные команды по-разному используют эту переменную. Например, цикл for разделяет строку на подстроки по всем символам, указанным в IFS и дополнительно режет их вокруг каждой подстроки (trimming); команда read использует последний символ IFS как разделитель строк, а все остальные для процедуры trimming.

Часто в пользовательских строках используются иные разделители, по которым строку нужно разбивать: например переменная PATH отделяет свои элементы двоеточием. Если вы попытаетесь передать PATH на место списка как есть, то ничего само не разделится.

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

Тем не менее, при любом изменении этой важной переменной не забывайте сохранять ее текущее значение и вовремя восстанавливать, например так

OLD_IFS=$IFS
IFS=':'
# ... ваш код
IFS=$OLD_IFS

Давайте попробуем обработать каждый элемент переменной PATH по отдельности

OLD_IFS=$IFS
IFS="${IFS}:"

for entry in $PATH; do
    echo $entry
done

IFS=$OLD_IFS

# Вывод:
# /usr/local/sbin
# /usr/local/bin
# /usr/sbin
# /usr/bin
# /sbin
# /bin
#
# ... и так далее
#

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

Перебор массивов

[править]

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

declare -a fruits=('apple' 'pear' 'banana' 'Peruvian cherry' 'orange' 'grapes' 'pineapple')

# Перебор массива целиком
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# Перебор массива целиком, но с применением индексов
for index in ${!fruits[@]}; do
    echo ${fruits[$index]}
done

# Перебор части массива
# Напомним, что нумерация в массивах начинается с 0, поэтому 2
# здесь соответствует 'banana', а $(( ${#fruits[@]}-2 )) соответствует 'grapes'
for index in $(seq 2 $(( ${#fruits[@]}-2 )) ); do
    echo ${fruits[$index]}
done

# Примечание:
# Команда seq генерирует список из чисел.

Массив можно преобразовать в список двумя способами: ${fruits[@]} и ${fruits[*]}. Без кавычек разницы нет никакой, но вариант "${fruits[@]}" позволяет учесть пробельные символы внутри самих элементов массива. Так, в нашем примере в массиве с фруктами без кавычек, элемент 'Peruvian cherry' разделился бы на два, что неправильно. Вариант "${fruits[*]}" вернет массив одной строкой без разделения.

Для перебора ассоциативных массивов необходимо преобразовать в список их ключи.

declare -A mesg=(
	[date]=$(date --rfc-3339='seconds')
	[message]="Hello."
	[author]="John Smith"
	[additional comment]="urgent"
)

for key in "${!mesg[@]}"; do
     echo "Key: $key      Value=${mesg[$key]}"
done

Опять же, чтобы сохранить пробелы в именах ключей вы должны использовать вариант с кавычками "${!mesg[@]}", иначе в списке окажется два несуществующих ключа. На практике обычно пробелы в ключах не используют, но лучше лишний раз перестраховаться.

Цикл с заданным числом повторений

[править]

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

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

declare RETRY_TIMES=5

do_something() {
    echo "retry: $1"
}

# Генерация списка через скобочную подстановку
for retry in {1..5}; do
    do_something $retry
done

echo "------------------"

# Генерация списка через скобочную подстановку, когда одна из границ
# меняется. Этот метод небрежный: рекомендуется использовать функцию seq.
for retry in $(eval echo {1..$RETRY_TIMES}); do
    do_something $retry
done

echo "------------------"

# Генерация списка через функцию seq.
for retry in $(seq 1 $RETRY_TIMES); do
    do_something $retry
done

Запись цикла for в стиле языка Си

[править]

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

Общий синтаксис таков:

for (( <EXPR1> ; <EXPR2> ; <EXPR3> )); do
  <LIST>
done

Точку с запятой после закрывающей скобки можно опускать, потому что (( <EXPR1> ; <EXPR2> ; <EXPR3> )) полностью самостоятельная конструкция, а не командный список. Тем не менее, для единообразия этого лучше не делать.

  • на позиции <EXPR1> пишется одна или несколько инициализирующих переменных, перечисленных через запятую. Эта часть выполняется один раз до самой первой итерации;
  • на позиции <EXPR2> пишется условие продолжения цикла, т.е. пока условие истинно, то цикл продолжается. Эта часть выполняется перед каждой итерацией;
  • на позиции <EXPR3> пишется выражение, влияющее на условие, которое выполняется в конце каждой итерации.
# Посчитать от 0 до 4
for (( i=0; i < 5; i++ )); do
    echo "$i"
done

# Посчитать от 10 до 0
for (( i = 10; i >= 0; i-- )); do
    echo "$i"
done

# Вывести только четные числа в промежутке от 0 до 10
for (( i=0; i <= 10; i+=2 )); do
    echo "$i"
done

# Вывести числа вертикальной змейкой
for (( incr = 1, n=0, times = ${2:-4}, step = ${1:-5}; (n += incr) % step || (incr *= -1, --times);)); do
    printf '%*s\n' "$((n+1))" "$n"
done

#Вывод:
# 1
#  2
#   3
#    4
#     5
#    4
#   3
#  2
# 1
#0
# 1
#  2
#   3
#    4
#     5
#    4
#   3
#  2
# 1

Эта запись цикла автоматически делает ваш сценарий не портируемым. Данная запись поддерживается в Ksh, Bash и Zsh. Во всех этих интерпретаторах у нее одинаковый синтаксис.

Циклы while и until

[править]

Циклы while и until похожи. Они используются, когда число повторений выражается условием остановки. Базовый синтаксис обоих циклов в целом похож, разница состоит только в интерпретации кода возврата последней команды командного списка условия.

[ while | until ] <LIST1> ; do
  <LIST2>
done
  • Цикл while выполняется (т.е. исполняет командный список <LIST2>) до тех пор, пока последняя команда списка <LIST1> возвращает нулевой код, т.е. ИСТИНУ. Напротив, цикл until выполняется до тех пор, пока последняя команда списка <LIST1> возвращает не нулевой код, т.е. ЛОЖЬ. Можно использовать такой мнемонический прием: while повторяет свои действия пока не сломается, а until — пока не получится.
  • Оба цикла возвращают 0, если ни одной итерации не происходило, иначе они возвращают код последней команды списка <LIST2> последней исполненной итерации.
  • Исполнением этих циклов часто управляют из <LIST2> с помощью команд continue и break.

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

declare RETRY_TIMES=6

while : $(( RETRY_TIMES -= 1 )); [[ $RETRY_TIMES -gt 0 ]] && 
      echo -n "Retry $RETRY_TIMES: " ||
      echo "While has finished its work."; [[ $RETRY_TIMES -gt 0 ]]
do
    echo "Do very important things"
done

# Вывод:
# Retry 5: Do very important things
# Retry 4: Do very important things
# Retry 3: Do very important things
# Retry 2: Do very important things
# Retry 1: Do very important things
# While has finished its work.

В предыдущем примере в <LIST1> записано три команды:

  • : $(( RETRY_TIMES -= 1 )); — уменьшает счетчик повторений в начале новой итерации.
  • [[ $RETRY_TIMES -gt 0 ]] && echo -n "Retry $RETRY_TIMES: " || echo "While has finished its work."; — делает печать в начале каждой итерации.
  • [[ $RETRY_TIMES -gt 0 ]] — именно код этой команды анализируется, чтобы прервать цикл.

Аналогично тот же алгоритм можно реализовать через цикл until.

declare RETRY_TIMES=6

until : $(( RETRY_TIMES -= 1 )); [[ $RETRY_TIMES -gt 0 ]] && 
      echo -n "Retry $RETRY_TIMES: " ||
      echo "While has finished its work."; [[ $RETRY_TIMES -le 0 ]]
do
    echo "Do very important things"
done 

# Примечание:
#   Мы просто поменяли последнюю команду на [[ $RETRY_TIMES -le 0 ]].

Обычно придумывать условие в негативном ключе until сложнее, чем в позитивном для while, поэтому цикл until встречается реже.

Бесконечный цикл

[править]

Бесконечный цикл обычно используется, когда сценарий прерывается асинхронным событием (например сигналом), либо когда условие выхода строго не детерминировано по времени. Также когда условий выхода несколько или само условие слишком сложное, чтобы его выразить в <LIST1>.

Сделать бесконечный цикл можно как на основе while, так и на основе until.

# Первый способ
while :; do
    # Программа, выполняемая бесконечно
done

# Второй способ
# Можно вызвать команду true (эквивалентно ':')
while true; do
    # Программа, выполняемая бесконечно
done

# Третий способ
until ! :; do
    # Программа, выполняемая бесконечно
done

# Четвертый способ
# Можно вызвать команду false
until false; do
    # Программа, выполняемая бесконечно
done

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


Бесконечный цикл можно оформить также циклом for в стиле Си

for (( ; ; )); do
    # Программа, выполняемая бесконечно
done

но такой вариант крайне не рекомендуется.

Цикл в стиле do...while

[править]

В Bash нет цикла в стиле do...while, но его при желании можно эмулировать. Есть несколько подходов сделать это.

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

while :; do
    do_something
    # ...
    [[ condition ]] || break
done

Пример

do_something() { echo "$*"; : $(( it += 1 ));}

it=0
while :; do
    do_something "$it"
    (( it < 5 )) || break
done

Второй метод более экзотический, но тем не менее рабочий. Достаточно <LIST2> поместить на место <LIST1>, а тело цикла сделать пустым.

do_something() { echo "$*"; : $(( it += 1 ));}

it=0
while 
    do_something "$it"
    (( it < 5 ))
do
    :
done

В обоих примерах результат будет одинаковый.

Цикл select

[править]

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

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

Общий цикл команды такой

select <NAME> in <WORDS>; do
  <LIST>
done
  • В параметре WORDS вы передаете список из возможных вариантов ответов. Этот список можно опустить, тогда будет использован "$@" подобно тому, как это делается в цикле for. Если список будет пустым, то цикл целиком пропустится.
  • Когда дело доходит до цикла, то исполнение сценария блокируется в ожидании пользовательского ввода, при этом цикл сам выведет меню из возможных вариантов ответов в виде списка и приглашение ввода.
  • Выход из цикла нужно предусмотреть в его теле. Номер варианта из меню, который ввел пользователь, сохраняется в переменной $REPLY. Если этот номер валидный, то в переменную NAME будет записано фактическое значение варианта, иначе цикл перезапустится. Цикл перезапускается даже, если пользователь выбрал валидный номер, но с той разницей, что NAME инициализируется валидным значением. Именно поэтому выход из цикла должен программироваться в его теле.
  • Варианты нумеруются начиная с единицы, слева направо.

Ниже представлен небольшой пример.

#!/bin/bash
# Файл: restart.sh
echo "Do you want restart your system?"
select answer in "yes" "no"; do
    answer=${answer,,} # чтобы не зависеть от регистра
    if [[ $answer == 'yes' ]]; then
        echo "Starting to restart..."
    elif [[ $answer == 'no' ]]; then
        echo "Continue."
    fi
    case "$REPLY" in
        [1-2]) break ;;
        *) echo "Wrong choice. Please, try again." ;;
    esac
done
$ restart.sh
Do you want restart your system?
1) yes
2) no
#? oops
Wrong choice. Please, try again.
#? 1
Starting to restart...

$ restart.sh
Do you want restart your system?
1) yes
2) no
#? 2
Continue.



← Ветвления Функции →