Работа с файлами в Unix shell

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

Корень всех зол

Почему люди допускают ошибки при выполнении операций с файлами? Как правило, они просто не учитывают того, что имя файла может содержать довольно неожиданные символы. Источником проблем чаще всего являются пробелы по той простой причине, что пользователи, особенно неискушённые, используют их довольно часто. Но всё может оказаться куда более запущено, ведь Unix накладывает на имена файлов самые минимальные ограничения: в них запрещены к использованию всего два символа, имеющие специальные значения. Это косая черта / (разделитель в путях файловой системы) и нулевой символ \0 (признак конца строки, в том числе и пути). Любые другие символы, будь то знак доллара $, точка с запятой ;, амперсанд &, табуляция (горизонтальная \t или вертикальная \v), перевод строки \n, возврат каретки \r или звуковой сигнал \a, могут встречаться в легитимном имени файла.

ls: самая вредная команда

Что мы делаем, если нам в командной строке надо просмотреть список файлов в каталоге? (Кто сказал «запускаем mc»?) Конечно же, используем команду ls. Многие, особенно новички, норовят её же использовать при написании сценариев, но на деле это ни к чему хорошему не приводит. Максимум, что можно сделать с её помощью, это подсчитать число файлов в каталоге:

NUMFILES=$( ls -A | wc -l )

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

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

$ touch 'file
name'
$ ls
file?name
$

Ergo: то, что выводит ls, не обязательно является именем файла.

Кавычки, кавычки и ещё раз кавычки!

Каждого, кто решает попробовать себя на поприще написания сценариев оболочки, надо заставить тысячу-другую раз записать фразу «Я всегда буду использовать кавычки при разыменовании переменных». От руки. К клавиатуре не подпускать, пока не закончит.

Смотрите, к чему может привести пренебрежение кавычками. Допустим, в переменной FILE сохранено имя файла, содержимое которого надо прочитать и что-то с ним сделать. И допустим, это имя может оказаться чем-то вроде my file (через пробел). В таком случае команда cat $FILE будет преобразована оболочкой в cat my file, и cat попытается последовательно прочитать содержимое файлов my и file, а вовсе не my file. Результат может варьировать: либо мы получим ошибку, если таких файлов не существует, либо прочитано будет совсем не то, что ожидалось. Чтобы избежать подобного, надо использовать кавычки: cat "$FILE".

Напомню также об одной переменной, которую оболочка обрабатывает особым образом, если она заключена в кавычки. Это специальная переменная @, содержащая аргументы, переданные сценарию или функции. Без кавычек она идентична переменной *, то есть раскрывается в строку, содержащую все аргументы, разделённые пробелами. Но в кавычках "$@" раскрывается так, как если бы каждый из аргументов был заключён в кавычки.

Допустим, сценарий был запущен командой ./example.sh 'my file' 'another file', то есть получил на вход два аргумента. Тогда команда cat "$@" внутри него будет идентична cat 'my file' 'another file'cat получит те же два аргумента. Другими способами добиться этого затруднительно: cat $* или cat $@ раскроются в cat my file another file (cat получит четыре аргумента), а cat "$*" — в cat 'my file another file' (cat получит один аргумент).

Шаблоны: когда их использовать, а когда не стоит

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

for f in *.png; do
    convert "$f" "${f%.png}.jpeg"
done

Здесь всё корректно. Обратите внимание, мы не забыли про кавычки!

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

rm -f *.jpeg

Казалось бы, что тут может пойти не так? Все мы много раз применяли подобные конструкции в командной строке, и они работали, как ожидалось. Однако же могут не работать, в том числе и при запуске вручную из командной строки. Когда мы имеем дело с большим числом файлов, есть риск упереться в ограничение на размер массива аргументов процесса. Но с другой стороны, запускать rm для каждого файла в отдельности с помощью цикла довольно накладно, и работа сценария может неоправданно затянуться. Как быть?

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

find . -name '*.jpeg' -exec rm -f {} +

Синтаксис аргумента опции -name — точно такой же, как и у шаблонов оболочки, поэтому важно заключать его в кавычки или экранировать специальные символы (здесь — *), дабы оболочка не раскрыла шаблон самостоятельно, а передала его find в неизменном виде. Опция же -exec, аргументы которой завершаются символом +, делает ровно то, что нам требуется: формирует максимально длинную командную строку, подставляя имена найденных файлов на место {}, и запускает её, повторяя эту операцию, пока файлы не закончатся.

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

find . -name '*.jpeg' -delete

Они будут правы, но если бы речь шла не об удалении, а о выполнении какой-то другой операции, обойтись без -exec не получилось бы. Кроме того, опция -delete — нестандартная, и, хотя она и присутствует в реализациях find из пакета GNU Core Utilities и из операционной системы FreeBSD, в других реализациях её может не быть (например, её не понимает find из комплекта BusyBox).

Раз уж речь зашла об использовании find, стоит упомянуть о распространённой практике комбинирования его с утилитой xargs. Ничего плохого в этой практике нет, если не забывать о — смотри выше — корне всех зол. А часто забывают и пишут что-то наподобие этого:

find . -name '*.jpeg' | xargs rm -f

Поскольку xargs превращает каждую следующую строку ввода в очередной аргумент команды rm, нежданчик возникнет, если в имени файла встретится символ перевода строки. Чтобы такого избежать, надо заставить find разделять пути к найденным файлам нулевым символом (который, как мы помним, внутри пути встречаться не может), а xargs — считывать аргументы, опираясь именно на этот разделитель, но не на перевод строки. Делается сие следующим образом:

find . -name '*.jpeg' -print0 | xargs -0 rm -f

Опции -print0 для find и -0 для xargs, к сожалению, также не стандартизированы, поэтому стоит проверять их наличие на целевой системе, а когда требуется высокая переносимость — избегать их использования (следует отдать предпочтение -exec). Но всё же эти опции присутствуют в большинстве актуальных реализаций find и xargs.

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

Заключение

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