Kodomo

Пользователь

bash как язык программирования, часть 2

<chubako> с форума одного: делая это, ты принимаешь темную, я бы даже сказал, ректальную сторону силы.

— Народное творчество. Один из филиалов башорга.

/!\

При чтении конспекта держите рядом открытым окно шелла и проверяйте примеры, которые встречаете в тексте.

В прошлом занятии мы коснулись всех главных понятий языка bash (и языка POSIX sh), так что на этот раз нам остаётся лишь углубиться поподробнее в некоторые темы.

case

Для начала вспомним, какие конструкции управления ходом программы есть в bash и в POSIX sh: мы уже знаем про for, while и if. POSIX sh добавляет ещё три с половиной конструкции: один частный случай для for, case, until, select. Ещё один особый случай для for добавляет bash.

Начнём с конструкции case. Она есть в bash и в POSIX sh, и выглядит и в обоих случаях одинаково.

Конструкция case – это разновидность множественного условия. Основная идея у неё похожа на case из других языков (например java, perl, C).

В общем случае, case выглядит так:

   1 case выражение in
   2     шаблон|шаблон...|шаблон) команды;;
   3     шаблон|шаблон...|шаблон) команды;;
   4 esac

Выражение – это любое слово шелла. Шелл производит в этом слове все подстановки, как и всегда: подстановку переменных, квотирование, выполнение команд в обратных кавычках и т.п. bash пожалуется на синтакцическую ошибку, если в этом месте оказалось больше одного слова, поэтому выражение лучше всегда брать в двойные кавычки (как и везде, где мы хотим, чтобы после подстановок у нас получилось одно слово).

Когда шелл натыкается на case, он производит все необходимые подстановки в выражении, после чего идёт и по очереди пытается сопоставить каждый из шаблонов (шаблон является glob-выражением шелла – т.е. выражением со спецсимволами *, ?, [, ]), и исполняет те команды, которые указаны за ним. После этого исполнение case завершено.

Пример "подтверждение действия" -- постановка задачи

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

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

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

read: дополнительные флаги

Чтобы задавать такой вопрос, нам нужен read, а чтобы отвечать на него было удобно, нам нужно узнать пару вещей, которые легко узнать из встроенных хелпов bash (попробуйте сказать в шелле: help read).

У read есть такие приятности:

После ввода read ничего не делает с состоянием экрана, поэтому если мы хотим, чтобы после read шелл продолжал печатать с новой строки – то если пользователь нажал enter, это уже отобразилось, и мы ничего не должны делать, а если нет, то мы должны сами явным образом перенести строку. (С этим становится проще, если мы не отображаем ввод пользователя: тогда нам просто в обязательном порядке нужно перенести строку).

Пример "подтверждение действия" -- первая реализация

Комбинируя read и case, мы получаем:

   1 read -p "[yn]" -n 1 -s answer
   2 echo # чтобы дальше печатать с новой строки
   3 case "$answer" in
   4     y|Y) ok=1;;
   5     n|N) ok=0;;
   6 esac

После этого кода в переменной ok у нас окажется либо 0, либо 1, либо ничего (ой).

А что если пользователь введёт что-нибудь ещё? Например, как интерпретировать в качестве ответа e или ^[[A1?

На мой вкус, лучше всего не рисковать и переспросить у пользователя, что он имел в виду.

Дополнительное про test: -n, -z

У команды test (она же [ ... ]) есть ещё две проверки, о которых я не сказал в прошлый раз:

Пример "подтверждение действия" -- с повторными попытками

Поправим наш пример:

   1 while [ -z "$ok" ]; do
   2     read -p "[yn]" -n 1 -s answer
   3     echo
   4     case "$answer" in
   5         y|Y) ok=1;;
   6         n|N) ok=0;;
   7         *) echo "Yes or No? Try again!";;
   8     esac
   9 done

Когда какая-нибудь из веток case содержит шаблон *, это значит, что всё, что не попало в какую-нибудь из предыдущих веток, попадёт сюда. И ещё это значит, что после этой ветки что-либо писать уже смысла нет, туда исполнение не попадёт никогда.

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

Функции

Со скриптами мы разберёмся попозже (хотя в первом приближении вам и так всё должно быть понятно), сначала разберёмся с функциями.

Функции определяются в шелле так:

   1 имя_функции () {
   2 команды
   3 }

По сути, это создаёт нам новое имя для последовательности строк, это имя мы можем использовать так же, как и любую другую команду. Например:

   1 yesno () {
   2     while [ -z "$ok" ]; do
   3         read -p "[yn]" -n 1 -s answer
   4         echo
   5         case "$answer" in
   6             y|Y) ok=1;;
   7             n|N) ok=0;;
   8             *) echo "Yes or No? Try again!";;
   9         esac
  10     done
  11 }

После такого определения мы можем в нужных местах программы просто писать:

   1 yesno
   2 if [ "$ok" = 1 ]; then
   3     ...
   4 fi

{} vs. ()

Даже если мы не хоитим описывать новую функцию, а исключительно ради того, чтобы объединить вместе несколько команд (например, ради того, чтобы использовать их в пайплайне), мы можем брать в шелле команды в скобки:

   1 { echo "> cyb5_mouse"; cat sequence; } | blast

Это примерно то же, что и:

   1 print_sequence () {
   2     echo "> cyb5_mouse"
   3     cat sequence
   4 }
   5 
   6 print_sequence | blast

В шелле есть два вида скобок, в которые таким образом можно брать куски кода:

Мерзкая особенность синтаксиса шелла: когда мы закрываем фигурные скобки, мы обязаны перед ними поставить либо точку с запятой, либо перенос строки; когда мы закрываем круглые скобки, мы не имеем права ставить перед ними точку с запятой (это синтаксическая ошибка), правда, перенос строки мы поставить всё так же можем.

Чтобы понять, почему это важно, вспомним, какие у процесса есть свойства:

Наиболее интересны в этом контексте, как правило, первые два пункта.

Например:

   1 a=1
   2 cd /home
   3 { a=2; cd /; }
   4 pwd
   5 echo "a=$a"

Распечатает:

/
a=2

А полностью аналогичный фрагмент кода:

   1 a=1
   2 cd /home
   3 ( a=2; cd / )
   4 pwd
   5 echo "a=$a"

Распечатает:

/home
a=1

Аналогичным образом, и функции мы можем описывать двумя способами: f () ( команды ) и f () { команды; } . В последнем случае нужно особо отметить, что у нас не возникает понятия "локальные переменные" функции – всё, что мы меняем в фукнции, меняется и во всём шелле.

source

Если бы мы сохранили наш набор команд в скрипте, и, например, назвали бы его yesno.sh, то тогда у нас аналогичным образом было бы два варианта, как этот скрипт выполнить:

В bash для команды . есть синоним source, т.е. мы можем написать ещё и source ./yesno.sh.

Фактически, разница между этими двумя вариантами такая же, как и между yesno () ( ... ) и yesno () { ...; } .

<!> Вопрос: с нынешним описанием yesno, каким образом нам стоило бы его запускать?

Параметры командной строки

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

Для этого в шелле возникают переменные с именами:

Т.е. мы можем написать что-нибудь в духе:

   1 f () {
   2     echo "arg_1/$#=$1"
   3 }
   4 
   5 f a b c

И это напечатает нам:

arg_1/3=a

set --

С параметрами командной строки есть одна тонкость: если переменная зовётся, скажем, a, то мы можем написать a=1, чтобы присвоить в эту переменную единицу. А вот для параметров командной строки мы не можем написать: 1=1 – это синтаксическая ошибка с точки зрения шелла.

При этом зачастую (особенно, если речь идёт о POSIX sh), подменять себе параметры командной строки хочется. Для этого в шелле договорились, что встроенная команда set3 после аргумента с именем -- получает список слов, которые она выставляет в качестве параметров командной строки.

Например,

   1 f () {
   2     echo "#=$#, args=$1 $2 $3"
   3     set -- a b c
   4     echo "#=$#, args=$1 $2 $3"
   5 }
   6 
   7 f x y z w

Напечатает:

#=4, args=x y z
#=3, args=a b c

IFS

Ещё одна встроенная переменная, связанная с параметрами командной строки: * – в ней одной строкой, разделённые по умолчанию пробелом, идут все параметры.

Например,

   1 f () { echo "args=$*"; }
   2 f a b c
   3 f x y z w

Напечатает:

args=a b c
args=x y z w

А теперь пример попротивнее:

   1 backup () {
   2     for file in $*; do
   3         mv "$file" "$file.bak"
   4     done
   5 }
   6 
   7 rename "a.txt" "My Documents"

Мы передали в функцию два параметра, после чего склеили их, разделив пробелами, после чего разбили по пробелам, и у нас оказалось три слова: a.txt, My и Documents. Как будет ругаться mv в этих случаях, надеюсь, очевидно.

Из этого должно быть вполне понятно, что по прямому назначению этой переменной пользоваться не стоит. (Впрочем, если вам достался какой-нибудь Solaris, на котором стоит честный настоящий ksh, то может статься, что иногда без этого параметра обойтись всё равно никак не получится).

То, как именно в "$*" склеиваются параметры, настраивается. Для этого в шелле существует встроенная переменная IFS – internal field separator. Содержимое этой переменной воспринимается как список букв. Первая буква используется для склеивания параметров, когда мы используем "$*", и каждая из букв в IFS используется для разделения строки на слова.

Ещё один for

Чтобы обойти проблему передачи параметров командной строки, в POSIX sh (а следовательно, и в bash тоже) добавили ещё одну разновидность цикла for: если у цикла for не указано in что, то он перебирает параметры командной строки:

   1 f () {
   2     for i; do
   3         echo "arg='$i'"
   4     done
   5 }
   6 f a " b c" d

Этот пример напечатает:

arg='a'
arg=' b c'
arg='d'

Это решает нашу проблему в случае, если мы хотим параметры командной строки разбирать и сами с ними что-нибудь делать, но не решает в случае, если мы хотим их просто передать какой-нибудь команде, в духе: mv $* bak/

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

Массивы

В bash эту проблему всё-таки решили. Для этого добвили ещё одну встроенную переменную – @, – которая ведёт себя несколько магическим образом:

Это ломает все представления о шелле: до сих пор мы считали (и, например, сам Bourne тоже считал), что если мы берём что-нибудь в кавычки, то оно будет одним словом после разделения на слова. Тут это правило нарушено.

И раз нарушать, так нарушать, решили авторы bash, и в компанию к одной такой переменной сделали новое понятие: массивы.

Массивы создаются таким образом:

   1 x=(1 2 "3 4" 5)
   2 x[5]=6

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

Для массивов определены подстановки:

Для полноты картины: подстановка ${#x}  тоже определена и обозначает подставить длину строки.

Маленький примерчик:

   1 fibs=(1 1)
   2 for i in `seq 2 10`; do
   3     fibs[i]=$((fibs[i-1]+fibs[i-2]))
   4 done
   5 echo "${fibs[@]}"

Напечатает:

1 1 2 3 5 8 13 21 34 55 89

Всё, перечисленное в этом разделе, касается только bash. (Ну, ещё, может, zsh).

Как разбирать командную строку

Если мы относимся к нашей функции так же, как к функции в каком-нибудь простом языке программирования (например, си), то от функции нам достаточно знания, что, например, первым аргументом у неё передаётся имя файла, а вторым длина. (А аргументы с десятого по девятнадцатый задают матрицу преобразования).

Т.е. мы можем писать функции совсем просто:

   1 cut_file () {
   2     sed "$2,$3 p" < "$1"
   3 }
   4 
   5 cut_file hello.txt 1 5

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

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

Как можно разбирать параметры в шелле? В принципе, это довольно нетрудно.

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

   1 f () {
   2     for arg; do
   3         case "$arg" in
   4             --file=*) file="${arg#*=}";;
   5             --begin=*) begin="${arg#*=}";;
   6             --end=*) end="${arg#*=}";;
   7             -a|--all) begin=1; end=\$;;
   8             --) break;;
   9             -*) error "Unknown parameter $arg";;
  10             *) file="$arg";;
  11         esac
  12     done
  13     sed "$begin,$end p" < "$file"
  14 }
  15 
  16 f --file=hello.txt --begin=1 --end=5

Что можно сказать про такой подход?

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

Во-вторых, мы на каждой итерации смотрим только на текущий аргумент и ничего не знаем ни о предыдущем, ни о следующем, поэтому мы можем учитывать либо флаги (вида -a или GNU-style: --all), либо GNU-style параметры (например, --file=hello.txt). Мы не можем таким образом разбирать UNIX-style параметры (например, -f hello.txt).

В-третьих, в UNIX-style принято допускать, чтобы параметры могли слипаться: -af hello.txt значит то же, что и -a -f hello.txt

Мы могли бы после опций, ожидающих параметр, сохранять состояние "мы ожидаем параметр" и сделать из одного case два – и тем самым решить вторую проблему и сильно-сильно усугубить первую. Поэтому даже примера этого подхода я приводить не буду.

shift

Если бы мы могли ходить циклом по аргументам, делая иногда один, иногда два шага за итерациям, то мы могли бы относительно понятно разобраться хотя бы со второй проблемой. Для этого в POSIX sh (и в bash, разумеется, тоже) есть встроенная команда shift: как и set --, она меняет то, что шелл считает данной ему командной строкой, а именно выбрасывает из неё первый элемент (или первые несколько элементов).

Например:

   1 set -- a b c d
   2 echo $*
   3 shift
   4 echo $*
   5 shift 2
   6 echo $*

Распечатает нам:

a b c d
b c d
d

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

   1 f () {
   2     while [ "$#" != 0 ]; do
   3         case "$1" in
   4             -b) begin="$2"; shift;;
   5             -e) end="$2"; shift;;
   6             -f) file="$2"; shift;;
   7             --begin=*) begin="${1#*=}";;
   8             --end=*) end="${1#*=}";;
   9             --file=*) file="${1#*=}";;
  10             --) break;;
  11             -*) error "Unknown argument $1";;
  12             *) file="$1";;
  13         esac
  14         shift
  15     done
  16     sed "$begin,$end" < "$file"
  17 }
  18 
  19 f -f hello.txt -b 1 -e 5

Я описал довольно общий случай, и его можно зачастую несколько упростить. По сложности он вполне похож на пример с for, но позволяет разбирать ещё и UNIX-style параметры со значением.

getopt

Чтобы преодолеть третий недостаток – неумение разбирать склеенные параметры, – в GNU (т.е. в Linux) сделали утилиту под названием getopt(1), которая берёт на вход описание того, какие опции получают параметры, а какие нет, и командную строку, и возвращает упрощённую командную строку (со всеми необходимыми кавычками).

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

Например (здесь $ в начале строки символизирует приглашение командной строки),

   1 $ getopt af:b:e: -afhello.txt -b1 -e5
   2  -a -f hello.txt -b 1 -e 5 --

Добавляя это в предыдущий пример, мы получим полноценный UNIX-style и GNU-style разбор командной строки:

f () {
    set -- `getopt --longoptions=all,begin:,end:,file: af:b:e: "$@"`
    while [ "$#" != 0 ]; do
    ...
    sed "$begin,$end" < "$file"
}

f -afhello.txt --end=1

Таким образом на шелле можно не очень большой кровью писать вполне полноценные программы с человеческим интерфейсом командной строки (и, если вспомнить про read, то и полноценные интерактивные команды тоже).

Как обходиться без getopt

Единственная проблема, которую мы не решили в предыдущих подходах – это как сделать разбор параметров командной строки простым.

Если немного задуматься и отойти от традиций UNIX-style и GNU-style, передачу параметров можно сделать совсем простой.

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

Примером такого контекста является встроенная команда export. (Эта команда есть в POSIX sh и в bash). Подробнее о ней я расскажу немного попозже, сейчас нас интересует следующее эта команда может получать на вход список присваиваний или имён переменных. Если мы говорим export a=1 b=2, то после этой команды у нас будут определены переменные a и b со значениями 1 и 2 соответственно.

Поэтому мы можем написать так:

   1 f () {
   2     begin=1
   3     end=\$
   4     export "$@"
   5     sed "$begin,$end" < "$file"
   6 }
   7 
   8 f file=hello.txt end=5

Фактически, это значит, что мы написали простой-простой разбор командной строки в одну строку (плюс по строке на указание умолчаний). Мы не удовлетворили наши остальные требования: мы не укладываемся ни в традиции UNIX-style, ни в GNU-style, но каким-то образом задачу мы решили. Это очень удобный подход, но с ним нужно проявлять некоторое количество острожностей.

Во-первых, в bash команда export может получать некоторые флаги, а мы хотели бы попытку передать флаги не подразумеваемым нами способом воспринимать как ошибку. В bash можно дать export параметр --, после которого он не ожидает флаги, но это поведение не совместимо с POSIX, поэтому можно поступить иначе и прежде, чем давать в export список параметров, дать export какое-нибудь присваивание. Т.е. вместо export "$@" мы можем написать export x=x "$@".

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

В-третьих, когда export получает какое-нибудь некорректное выражение, он возвращает код ошибки 1. Мы этим можем воспользоваться, например, написать if export x=x "$@"; then ... ; else error "Bad arguments"; fi или export x=x "$@" || error "Bad arguments". Но в любом случае, мы не можем конкретизировать ошибку.

В-четвёртых, пользователь здесь может переписать _любую_ переменную шелла (кроме тех, в которые в шелле нельзя писать). Это значит, что во всём последующем коде мы можем рассчитывать на достоверность только тех переменных, которые мы самостоятельно создаём после этого присваивания! (Про все остальные переменные мы обязаны считать, что это просто пользовательские данные). Это очень важное соображение, правда обычно оно совпадает с естественными предположениями, если такую конструкцию мы написали в самом начале скрипта или блока в круглых скобках (NB. пример, который я написал выше ни в одну из этих двух категорий не попадает, и это отвратительно).

CGI

Для тех, кто знает, что такое CGI: этот же трюк (вне сомнения, такой способ разбора параметров командной строки в шелле иначе как грязным трюком назвать нельзя) можно применять для разбора параметров CGI-скриптов, если сочетать его с использованием IFS.

Пример:

   1 #!/bin/sh
   2 
   3 old_IFS="$IFS"; IFS=\&
   4 export x=x $QUERY_STRING
   5 IFS="$old_IFS"
   6 
   7 ...

/!\ На удивление, в ровно таком коде я до сих пор не смог найти ни одной уязвимости, но я отнюдь не уверен, что их нет. Очень может статься, что сюда довольно несложно скормить какую-нибудь строку, которую bash исполнит просто как команду. Не используйте это на сайтах, где вы не следите постоянно за содержимым логов CGI.

Учитывайте, что пользователь из веба может переписать вам значение переменных PATH, HOME, USER, SHELL. В сочетании с невнимательностью в теле скрипта это означает довольно богатый источник уязвимости вашего сервера.

Функции: return

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

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

Ещё в шелле (POSIX и bash) предусмотрена встроенная команда return, которая сразу завершает исполнение функции. Команда получает в качестве аргумента число, которое она и возвращает.

Например, мы можем описать свою версию true и false (скажем, ради скорости исполнения):

   1 true () { return 0; }
   2 false () { return 1; }

Теперь мы можем переписать совсем удобно и немного короче наш исходный пример про yesno:

   1 yesno () (
   2     default=
   3     prompt=
   4     export x=x "$@"
   5     propmpt="${prompt:-"$question [yn]"}"
   6     while :; do
   7         read -p "$propmpt" -s -n 1 answer
   8         echo
   9         case "$answer" in
  10             y|Y) return 0;;
  11             n|N) return 1;;
  12             "") return "$default";;
  13             *) echo "Yes or No? Answer me!";;
  14         esac
  15     done
  16 )
  17 
  18 if yesno question="Remove all files?"; then
  19     rm -rf /
  20 fi

exit

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

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

Ещё одна версия for

Вернёмся к обзору возможностей шелла. Мы начали рассказ с того, что разбирали, какие конструкции для управления ходом программы есть в шелле. Теперь мы знаем про if, два варианта конструкции for, while, case.

Я не буду рассказывать про until (название подсказывает, что это примерно то же, что и while) и select (делает примерно то же, что и read + case, как в примере про yesno выше), так как сам не нахожу в них для себя пользы.

Поэтому осталась одна, специфичная для bash версия цикла for. Это циклы, максимально имитирующие циклы for в языке C. Напишу пример:

for((i=0; i < 10; i++)); do
    echo "$i"
done

Первая и последняя часть заголовка цикла исполняется так же, как если бы эти части были записаны в let; середина исполняется так же, как елси бы она была записана внутри [[ ... ]] (это встроенный в bash синоним test, который позволяет чуть-чуть более свободный синтаксис).

В принципе, такие циклы для простых случаев не нужны (а для сложных их в любом случае стоит избегать и заменять на более понятные while). В POSIX требуется, чтобы в UNIX имелась утилита seq, которая возвращает список числа в заданном диапазоне, по умолчанию по одному числу на строку. Поэтому аналогичный пример можно написать так:

   1 for i in `seq 0 9`; do
   2     echo "$i"
   3 done

Типы переменных

В начале рассказа про шелл, я говорил, что все переменные в шелле строковые. Сейчас ясно, что для bash это не совсем правда: в bash ещё бывают массивы строк.

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

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

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

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

Для export есть сокращённый способ вызова команд: var1=value1 var2=value1 cmd args. В bash это является синонимом примерно такой констркции: ( export var1=value1 var2=value2; cmd args ), с той разницей, что при этом не происходит создания новых процессов. Т.е. bash запоминает состояние переменных до этого и возвращает его после выполнения команды. Это очень удобно. Но, к сожалению, по POSIX эта сокращённая конструкция является в точности синонимом такой: export var1=value1 var2=value2; cmd args. (Это, безусловно, проще реализовать, но эта реализация несколько нивелирует смысл такой конструкции). POSIX предлагает для примерно bash-евского подхода ситуации пользоваться утилитой: env var1=value1 var2=value2 cmd args делает точь-в-точь то же, что и ( export var1=value1 var2=value2; cmd args )

Ещё, когда мы пишем функцию в фигурных скобках и создаём внутри функции какую-то переменную, эта переменная будет видна потом и снаружи функции. Это может быть удобно для передачи данных из функции наружу, но это почти лишает нас возможности писать, например, рекурсивные функции. (Почти – во-первых, потому, что для некоторых видов рекурсии это не требуется, а во-вторых, потому, что мы всегда можем написать функцию в круглых скобках). В bash решили для решения этой проблемы завести ещё один тип переменных: локальные. Изменения таких переменных забываются при выходе из функции. Помечаются переменные локальными (или создаются) встроенной командой local, пользоваться которой так же, как и командой export.

Несколько встроенных переменных

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

Несколько примеров:

Неизменяемые встроенные переменные:

Переменные-настройки:

Сочетая знания про IFS и про PATH, мы можем несколько упростить себе работу с PATH (изменение или поиск чего-нибудь):

   1 old_IFS="$IFS"; IFS=:
   2 set -- $PATH # NB. никаких кавычек!
   3 # теперь у нас отдельные компоненты пути лежат в аргументах
   4 # мы можем ходить по ним циклом for; удалять их shift
   5 # использовать set -- что-нибудь "$@" и т.п.
   6 PATH="$*"
   7 IFS="$old_IFS"

Инициализация шелла

Мы можем выставить много настроек в шелле, но все эти настройки потеряются с заврешением процесса шелла – у шелла нет встроенного механизма сохранения.

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

Где шелл ищет скрипты зависит от того, под каким именем шелл запущен. Когда шелл запускается из sshd или из окна логина, к имени шелла (параметру $0) запускающая его программа присоединяет в начале минус. Этот странный трюк используется для того, чтобы сообщить шеллу, что он является "логинным". Изначально предполагалось, что только в логинных шеллах нам нужно выставлять переменные среды (они всё равно будут наследоваться), а во всех остальных достаточно поправить только те мелочи, которые через среду не настраиваются. Или ещё один пример, в логинном шелле мы можем дописать чего-нибудь в начало или конец пути, а в остальных мы путь менять не будем (иначе мы будем снова и снова дописывать в путь одни и те же директории). Так как на самом деле ситуация, когда вы запустили шелл из него запустили шелл, из него запустили шелл – и так хотя бы три раза – уже встречается очень редко, то на самом деле, большого смысла в том, чтобы разделять эти две ситуации, как правило не бывает.

При загрузке login-shell читает:

При загрузке не login-shell читает:

При завершении login-shell читает:

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

Подстановки: ~, {}

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

~ сама по себе или если за ней идёт слэш заменяется на путь к домашней директории (содержимое переменной среды HOME).

~dendik заменяется на путь к домашней директории пользователя dendik. (Как правило, эта информация получается из /etc/passwd, но, например, на kodomo всё настроено немножко похитрее и за этой информацией шелл – и не только шелл – ходит в базу данных).

a{b,c,d}e заменяется на abe ace ade. Дословно так. Это не имеет отношения к именам файлов, эту подстановку можно использовать для чего угодно. К сожалению, этот тип подстановок есть только в bash (а также zsh, csh и много где ещё, – но не в POSIX).

Например, если мы напишем: rm {,.}*.{o,swp} , то это будет преобразовано сначала в rm *.o *.swp .*.o .*.swp, затем произойдёт разбиение на слова, подстановка имён файлов, и в конце может получиться что-нибудь вроде rm hello.o *.swp .*.o .hello.c.swp, на что rm поругается на несуществующие файлы *.swp и .*.o, и удалит всё, что от него и требовалось.

Средтсвами выражений glob мы можем добиться подобного только в режиме extglob: shopt -s extglob; rm ?(.)*.@(o|swp) – в этом примере команда rm сначала будет разбита по словам, потом в процессе подстановки путей glob-выражение заменится на пути файлов, получится: rm hello.o .hello.c.swp

Ещё в bash совсем недавно появилась версия подстановки фигурных скобок, которая подставляет диапазон чисел: echo {1..10}  делает то же, что и echo `seq 1 10`. Кроме крайне редких случаев, я не вижу смысла пользоваться bash'евской подстановкой вместо seq.

Фоновый режим исполнения команд

Обычно когда мы запускаем команду в шелле, он делает fork (порождает новый процесс) и ожидает его завершения. А в новом процессе запускает команду, которую мы просили запустить.

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

В этом режиме после завершения команды шелл выставляет в переменную ? код возврата команды. Это может нам понадобиться, если мы хотим о завершении команды знать больше, чем успех/неуспех или в случае, если мы хотим запомнить результат работы одной команды, выполнить ещё несколько (например, подчистить за собой), а потом вспомнить и посмотреть, что же с той командой было.

Когда мы пишем в шелле команда &, шелл делает fork и продолжает исполнять скрипт (или разговаривать с вами). А в новом процессе запускает команду. Это называется фоновым режимом запуска программы. Кроме этого, шелл выставляет в переменную ! pid процесса, который он только что запустил. Мы можем, например, послать этому процессу сигнал, поглядеть, что у него написано в /proc или, выполнив ещё сколько-нибудь действий, остановиться и ждать, пока он завершится – для этого в шелле есть встроенная командая wait.

Это нужно довольно редко, поэтому примеров тому я приводить не буду.

nohup

Если программа присоединяется к терминалу (сообщает терминалу о том, что ей нужны какие-нибудь особые настройки), то терминал запомнит об этом и когда мы его будем закрывать, пошлёт этой программе сигналы SIGHUP и SIGCONT. На сигнал SIGCONT действие по умолчанию состоит в том, чтобы ничего не делать, поэтому это безобидно, а на сигнал SIGHUP по умолчанию программа завершается. Это первая проблема при запуске фоновых программ, которые работают очень долго. Вторая проблема состоит в том, что когда мы запускаем программу, по умолчанию, у неё стандартный ввод, вывод и поток ошибок присоединены к терминалу. Поэтому после того, как терминал был закрыт, попытка писать на стандартный вывод будет приводить к сообщению программе об ошибке (файл, куда она пытается писать, уже не существует). Более того, это может происходить не сразу, а спустя некоторое время – после того, как переполнятся буферы вывода. Некоторые программы в ответ на такую ошибку предпочитают завершиться. В этом вторая проблема.

Чтобы решить их обе, написали утилиту nohup, которая перехватывает у процесса сигнал SIGHUP, перенаправляет все выводы в nohup.out и запускает данную ему программу.

Иногда эта штука бывает весьма полезна.

Перенаправления ввода-вывода

В шелле есть несколько совсем простых перенаправлений и одно странное.

С простыми перенаправлениями вы знакомы:

Можно перенаправлять не обязательно стандартный вывод:

Все перенаправления шелл выполняет по очереди.

<!> Задача: в чём разница между ls 2>&1 >/dev/null и ls >/dev/null 2>&1 (скорее всего, чтобы решить задачу, нужно нарисовать картинку, что куда указывает и посмотреть, как эта картинка меняется).

Для тех, кто не может эту задачу решить, bash с недавних пор включает в себя перенаправления ls >& file и ls &> file – они оба делают одно и то же: перенаправляют в файл и стандартный вывод, и стандартный поток ошибок.

ls >> file.txt открывает файл, переходит на его конец, и запускает ls, подставив ему этот файл вместо стандартного вывода (аналогично можно указать, что нужно перенаправлять стандартный поток ошибки). Суть: дописывать стандартный вывод ls в конец файла.

Это всё довольно простые вещи.

Путаются многие на таком перенаправлении:

Когда мы пишем cat <<word, это обозначает, что следующие строки bash читает до тех пор, пока не найдёт строку с текстом word, после чего запускает cat и подаёт ему на стандартный ввод эти строки.

Следующие два фрагмента эквивалентны:

   1 {
   2     echo A
   3     echo B
   4 } | cat
   5 
   6 cat <<EOF
   7 A
   8 B
   9 EOF

Строки между <<word и word называются here-document, т.е. документ "тут", прямо внутри скрипта.

Если мы пишем перенаправление как <<END (без кавычек), то прежде, чем передавать текст на стандартный ввод программе, шелл делает в нём все стандартные подстановки (наиболее интересно, что он делает там подстановки переменных и обратных кавычек). Если мы пишем перенаправление как <<"END", то тогда шелл в тексте подстановок не делает совсем.

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

На этом, пожалуй, завершается наше знакомство с шеллом как языком программирования.

Упражнение

Решите задачи, помеченные в тексте знаком <!>.

Напишите шелл-скрипт, который получает на вход шаблон на имена файлов (а ваш вкус, либо регулярные выражения, либо glob), и ищет в пути программы с таким именем (и распечатывает их названия).

  1. Очень может статься, что примерно такую строку сгенерирует терминал на нажатие стрелки вверх на клавиатуре. (1)

  2. В пайплайне bash запускает все, кроме первой команды в отдельных процессах, так что там фигурные скобки вряд ли отличаются от круглых. Дабы вы на это не слишком рассчитывали: zsh наоборот запускает в отдельных процессах все команды пайплайна, кроме первой. В силу определения понятия пайпа, шелл не может запускать команды пайпа в том же процессе. (2)

  3. В прошлом занятии мы её использовали для того, чтобы менять настройки шелла, я говорил про флаги -e и +e (3)