Kodomo

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

bash как язык программирования

Hash-bang

Прежде, чем рассказывать про bash, несколько слов вдогонку к теории.

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

Например, стандартный формат бинарных исполняемых файлов в линуксе называется ELF (Executable and Linkable Format), и чтобы отличить файлы этого формата, ядро смотрит на 2,3,4 байты файла, если из них складывается слово ELF, значит это он.

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

Нам сейчас важнее знать вот что: почти с самого начала существования юникс, ядро умело распознавать два формата исполняемых файлов – бинарный, и скрипты. Ядро считает файл скриптом, если он начинается с букв #! (это буквосочетание получило название shebang, hashbang1, и довольно много других синонимов).

Если файл начинается с #!, то вся оставшаяся часть строки считается интерпретатором для данного конкретного файла. Ядро разбивает эту строку по пробелам, добавляет в конце имя файла, и пытается исполнить получившуюся строку.

То есть если у нас в файле по имени script лежит:

echo Hello, world

То попытка исполнить этот файл преобразуется в попытку исполнить строку /bin/sh script.

Этой возможностью можно пользоваться не по прямому назначению, например:

Hello, world

Попытка исполнить эту программу приведёт к исполнению /bin/cat script, что в свою очередь приведёт к тому, что содержимое этого файла распечатается на экране (или там, куда был перенаправлен стандартный вывод).

А вот такой скрипт:

Hello, world

Не будет делать ничего! В UNIX имеются утилиты под названием /bin/true и /bin/false, которые обе игнорируют все свои аргументы и ничего не делают, с разницей в том, что у /bin/true код возврата буте 0 (то есть успех), а у /bin/false – 1 (то есть ошибка).

Типичный пример использования true(1):

while true; do
    ...
done

Так как эти команды в первую очередь для написания бесконечных циклов, поэтому /bin/true пользуется большей популярностью, и даже имеется встроенная команда шелла, делающая точно то же самое: :.

Ещё одно применение для этих команд – создавать пустые файлы. Типичные способы сделать это: touch file, : > file. Утилита touch(1) предназначена для того, чтобы менять atime и mtime файла, по умолчанию – выставлять текущую дату. Основное её применение – в паре с Makefile, что, видимо, выходит за рамки нашего курса.

POSIX shell vs. bash

Язык шелл – это общее название большого количества диалектов нескольких разных языков. Я рассказывал об этом в прошлый раз.

В этом рассказе я буду в первую очередь рассказывать про язык bash, притом какой-нибудь из относительно свежих диалектов (скажем, bash2 – учитывая, что сейчас везде уже есть bash3). Однако, как язык программирования, bash в значительной мере придерживается совместимости с наиболее авторитетным стандартом на UNIX – с POSIX.

Задачка номер 1, постановка

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

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

convert

Первое, что нам требуется, чтобы эту задачу решить – это какое-нибудь средство размер картинок всё-таки менять. Для этого в линуксе есть достаточно стандартное средство под названием imagemagick – это такой отвратительный и неудобный графический редактор, славный тем, что это на самом деле не графический редактор, а библиотека графических примитивов, к которой есть удобный интерфейс из нескольких языков программирования, (и шелл среди них), и даже графический интерфейс. С точки зрения шелла, нам интереснее всего, что в пакет imagemagick входит утилита convert(1). Общая идея, как этой утилитой пользоваться такая: convert настройки откуда преобразования куда.

Например, convert a.png b.jpg – конвертировать PNG в JPG. Или, ещё пример, convert a.png -resize 800x800 b.png – масштабировать a.png таким образом, чтобы он вписался в квадрат 800х800 (при этом сохраняя пропорции картинки), и сохранить результат в b.png.

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

for

Следующим делом нам нужно научиться делать циклы.

В шелле есть примерно два типа циклов (в bash – примерно три или больше, в POSIX shell – два). Нужный нам в данном случае тип циклов – это циклы for.

Цикл имеет такой синтаксис: for имя_переменной in слова; do действия; done Точка с запятой в шелле является эквивалентом переноса строки, поэтому я предпочитаю записывать циклы так:

for имя_переменной in слова; do
    действия
done

Разбиение на слова; кавычки

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

Разбиением на слова можно управлять.

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

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

Ещё на этапе разбиения текста на слова, шелл заменяет все (бэкслэш+пробел) на просто пробел, и оставляет то, что слева, и то, что справа от пробела в одном слове.

Например, echo "Hello, world!" преобразуется в два слова: echo и Hello, world!

Pathname expansion

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

Шаблоны могут содержать символы:

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

Внутри кавычек шаблоны не раскрываются. Раскрытие шаблонов можно заквотировать символом \.

Например, если у нас есть файлы a, b/c, x.txt, y.txt, то может состояться такой диалог с шеллом:

$ echo *
a b x.txt y.txt
$ echo *.*
x.txt y.txt
$ echo */*
b/c
$ echo ?
a b

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

Поэтому bash добавляет несколько возможностей, чтобы дать шаблонам выразительную мощность регулярных выражений. Включается эта возможность командой shopt -s extglob (SHell OPTions: set 'EXTended GLOB'). После неё в шаблоны добавляются:

Подстановки переменных; variable substitutions

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

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

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

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

<!> Вопрос на засыпку. Предположим, у нас в переменной x есть строка hello, world. В чём будет разница между этими тремя строками:

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

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

Например, если в переменной file лежит имя файла, то вместо  ${file%.*}  подставится имя файла без расширения, а вместо  ${file##*.}  подставится только расширение файла,  ${file##*/}  – имя файла без пути до него.

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

Задача номер 1, ответ

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

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

for file in *.jpg; do
    convert "$file" -resize 800x800 "${file%.*}-small.${file##*.}"
done

Задача номер 2.

Постановка задачи такая: мы хотим собрать вместе, в одной директории все файлы из заданного дерева директорий, содержащие общую часть имени и общую подстроку внутри. Пример конкретнее: у нас есть большая гора последовательностей в файлах с именами *.fasta, – и кроме этих последовательностей у нас есть большая гора ещё всяческого другого мусора; и мы хотим собрать в одной директории все из этих файлов, в которых лежат последовательности микотоксинов, в них имя последовательности как правило содержит слово myc.

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

find

Чтобы искать файлы по имени в UNIX есть стандартная утилита find(1). Основное применение её такое: мы ей задаём, что ей искать, и она выдаёт на стандартный вывод список найденных путей файлов. Формат командной строки у неё такой: find где-искать условия действия. Условия и действия можно перемешивать (они исполняются и проверяются для каждого файла слева-направо), где искать – список директорий – должен идти обязательно вначале.

Пример: find -name '*.fasta' – выписать все пути к файлам с именем, удовлетворяющим шаблону *.fasta, в текущей директории и её поддиректориях

<!> Вопрос: чем отличаются эти две команды:

Мы можем попросить find выполнять какие-нибудь действия для каждого найденного файла, но как правило, это не требуется.

backtick

Далее, нам нужно получить список файлов обратно от find, чтобы им воспользоваться.

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

Когда шелл встречает в строке `команду` или $(команду), он исполняет команду, отлавливает всё, что она напечатала на стандартный вывод, и подставляет этот текст вместо её вызова.

Эта подстановка случается вне кавычек и внутри двойных кавычек, но не внутри одинарных (как и большинство подстановок).

Например, если у нас в директории есть файлы a и b, то возможен такой диалог с шеллом:

$ echo 1 `ls` 2
1 a b 2

В предположении, что у нас есть файлы My Documents/t.fasta, x.fasta, сравните, что выдадут эти команды:

На этом вы должны хорошо увидеть, что обратные кавычки не годятся в ситуации, когда мы хотим получить в результате несколько слов (в смысле шелла)

xargs

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

Чтобы ими можно было не пользоваться, к find прилагается утилита под названием xargs(1).

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

Например:

$ echo a b | xargs echo c d
c d a b

Здесь xargs получила на командной строке начало команды: echo c d, затем получила со стандартного ввода текст, из которого она получила слова a и b, и дописала их в конец команды: echo c d a b, которую потом исполнила.

Без аргументов xargs разбивает входной текст по словам так же, как и шелл, что довольно бестолково, однако, у xargs есть флаг -0 – разбивать текст по символу '\0' (т.е. по нулевому байту) и у утилиты find есть двойственное к нему действие -print0 – печатать найденные файлы, разделяя их имена символами '\0'.

grep

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

Для этого в UNIX есть стандартная утилитая grep(1).

Основной формат её использования: grep флаги выражение файлы.

Нас интересует один флаг у grep: флаг -l заставляет grep имена файлов, в которых выражение нашлось, по одному файлу на строку. (Без этого флага она пишет по сообщению для каждой найденной строки в теле каждого найденного файла).

Ещё один полезный в данном случае флаг у grep: -i – игнорировать регистр букв при поиске выражения.

pipe

Надеюсь, с этим понятием все сталкивались, и здесь не нужно ничего нового говорить: когда мы пишем команда1 | команда2, то шелл запускает обе команды, но стандартный вывод первой программы присоединяет к стандартному вводу второй программы. (Если хотите технических подробностей, то происходит ровно следующее: шелл заводит новый pipe, потом fork'ается для запуска каждого из процессов, и в каждом из процессов закрывает имевшийся стандартный ввод или вывод и меняет номер файлового дескриптора этого pipe таким образом, чтобы он был 0 или 1 соотвестственно; после чего exec'ает соответствующие процессы – набор открытых файлов при этом сохраняется).

Раз уж я сказал про pipe, то грех не сказать про tee(1) – pipe – это труба, а tee – Т-образная насадка на трубе. Она дублирует всё, что получает со стандартного ввода на стандартный вывод, и ещё одну копию пишет в файл. Но сегодня нам это не потребуется.

С тем, что мы уже знаем, мы можем написать:

find -name '*.fasta' -print0 \
    | xargs -0 grep -li '^>.*myc'

В результате, нам вывалится на экран список путей всех файлов, у которых расширение .fasta и в теле есть строка '^>.*myc' (то есть, наверное, это и будут наши микотоксины).

while, read

Но мы хотели не получить список имён файлов, а скопировать их все в одну директорию.

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

Поэтому есть ещё одно средство: встроенная в шелл команда read. read a b rest читает строку со стандартного ввода, разбивает её по словам, первое слово кладёт в переменную a, второе слово кладёт в переменную b, а всё остальное, не трогая его устройства, кладёт в переменную rest. То есть если мы говорим просто read file, то вся строка, как она к нам пришла со стандартного ввода, попадает в переменную file. (Увы, и это неправда. Все пробелы, которые были в начале и в конце строки, при этом всё-таки потеряются; это значит, что с самыми наипротивнейшими файлами – у которых в конце имени пробелы – мы и этим способом работать не сможем; к сожалению, я не знаю в шелле способа надёжно работать с такими файлами).

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

Его вполне резонно кажется объединить с циклом while. Цикл имеет синтаксис: while команды; do команды; done.

Например,

while read line; do
    echo "$line"
done

Делает почти то же самое, что и утилита cat(1) – читает построчно стандартный ввод и распечатывает его на стандартный вывод.

Теперь мы можем написать такое:

mkdir out
find -name '*.fasta' -print0 \
    | grep -il '^>.*myc' \
    | while read filename; do
        cp "$filename" out
    done

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

Задача номер 2, вариация 1

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

Первая идея: пусть у нас в имени файла будет храниться весь путь к нему. Т.е. заменим в пути файла все / на _.

<!> Оставляю вам в качестве упражнения модифицировать скрипт соответствующим образом.

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

Задача номер 2, вариация 2

Поэтому попробуем другой подход: будем проверять, есть ли у нас выходной файл, и если он есть, будем к его имени дописывать число до тех пор, пока не наткнёмся на файл, которого ещё нету.

test

Для этого в первую очередь нам потребуется научиться проверять, существует ли файл. Для этого в UNIX есть утилита test(1), которая выполняет некоторые проверки и устанавливает код возврата в зависимости от ответа.

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

Проверки у test бывают такие:

if

Чтобы пользоваться проверками, нам в данном случае потребуются условия:

if команды; then команды; elif команды; then команды; else команды; fi

Все части, кроме первого if} и then, можно опускать.

Кроме этого, в шелле можно несколько команд сочетать таким образом: команда1 && команда2 – команда1 исполняется, и, если она успешна, исполняется команда2. Код возврата будет кодом возврата первой команды, если она была неуспешной, и кодом возврата второй команды, если она была успешной. Т.е. это "ленивый" AND, как в любом языке программирования. Аналогично, команда1 || команда2 – это "ленивый" OR.

Арифметика и переменные

Наконец, последнее, что нам потребуется – это умение работать с арифметикой в шелле.

Ни одна из этих конструкций не входит в стандарт POSIX, поэтому использовать можно любые из них в равной мере:

  1. Примерно одновременно после подстановки переменных, но до разбиения по словам происходит подстановка арифметических выражений. Строки вида $[выражение] или $((выражение)) подставляются и выражение в них вычисляется. Выражение может быть простым арифметическим выражением с числами и скобками (только из целых чисел), в выражении могут встречаться имена переменных (хотя можно использовать подстановку $переменная, тогда арифметическая подстановка уже не будет знать, что здесь была переменная).

  2. В bash есть конструкция let: let переменная=выражение, которая делает то же самое, что и набор обычных присваиваний переменных, но при этом вычисляет выражения так же, как если бы они были в $(()). Это удобно, чтобы можно было писать, например let i++.

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

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

mkdir out
find -name '*.fasta' -print0 \
    | grep -il '^>.*myc' \
    | while read filename; do
        dest="out/$filename"
        i=0
        while [ -e "$dest" ]; do
            let i++
            dest="out/${filename%.*}-$i.${filename##*.}"
        done
        cp "$filename" "$dest"
    done

Упражнение

Ответьте на все вопросы в тексте. Они пеомечены знаком <!>

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

  1. По совпадению, hash (в английском просторечии) и bhang обозначают два разных способа приготовления конопли. (1)