[Перевод] s/bash/zsh/g

Как думаете — сработает такая команда?

bash% echo $(( .1 + .2 ))
bash: .1 + .2 : syntax error: operand expected (error token is ".1 + .2 ")


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

zsh% echo $(( .1 + .2 ))
0.30000000000000004      # Ну, "работает" в той мере, в какой работает IEEE-754.


В bash просто нельзя выполнять вычисления с дробными числами, не прибегая при этом к bc, dc или к каким-нибудь хакам. В сравнении с возможностью просто воспользоваться конструкцией вида a + b всё это кажется некрасивым, медленным и сложным.

image-loader.svg
В bash имеются и другие неприятные пробелы. Например — прекрасное свойство игнорирования NULL-байтов:

zsh% x=$(printf 'N\x00L'); printf $x | xxd -g1 -c3
00000000: 4e 00 4c  N.L

bash% x=$(printf 'N\x00L'); printf $x | xxd -g1 -c3
bash: warning: command substitution: ignored null byte in input
00000000: 4e 4c     NL


Возникает такое ощущение, что это предупреждение в bash добавили сравнительно недавно (4.4-patch 2); оно очень меня «порадовало» несколько лет назад. Тогда NULL-байты просто тихо, без выдачи предупреждения, игнорировались. Я полагаю, что предупреждение — это своего рода «улучшение» (а вот если бы этот недостаток bash, и правда, исправили бы, это было бы настоящее улучшение). Полагаю, что выполнить подобное улучшение bash не так-то просто, иначе его уже выполнили бы. Причина этой особенности bash кроется в наследии C-строк, завершающихся символом NULL. Но, честно говоря, такие вещи не должны выходить на поверхность в высокоуровневых языках вроде языка командной оболочки Linux. В этом, кроме того, есть доля иронии, так как одной из первоначальных целей Стивена Борна, разрабатывавшего bash, было избавление от необоснованных ограничений на размеры строк. Подобные ограничения были в то время распространённым явлением.

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

bash% read -rad arr < <(find . -type f -print0)


Тут имеется и множество пограничных случаев, где нужно прибегать к read или к readarray, вместо того, чтобы обойтись простым присвоением значения. В zsh же всё выглядит куда проще:

zsh% arr=(**/*(.))

zsh% IFS='\x00' arr=($(find . -type f -print0)) # Если нужно использовать find (это нужно нечасто)

zsh% arr=( "${(0)$(find . -type f -print0)}" )


Точка здесь — это шаблон поиска (glob qualifier), нужный для того чтобы выбирать лишь обычные файлы. Подробнее об этом мы поговорим позже.

И даже не думайте сделать нечто вроде следующего:

img=$(curl https://example.com/image.png)
if [[ $cond ]]; then
    optpng <<<"$img" > out.png
else
    cat <<<"$img" > out.png
fi


Конечно, этот код можно отрефакторить — чтобы избежать использования переменной (и пример это, надо признать, несколько надуманный), но такой код просто должен работать. Однажды я написал скрипт для импорта электронной почты из Mailgun API. Работал он хорошо, но иногда изображения оказывались искажёнными, а я не мог понять причину происходящего. Оказалось, что Mailgun, стремясь мне «помочь», декодирует вложения (то есть — избавляется от кодировки Base64) и отправляет бинарные данные, которые bash (в то время) просто по-тихому выбрасывал. Мне, для того чтобы в этом разобраться, понадобилось очень много времени. Я забыл уже — почему так было, но мне было сложно избежать хранения ответа в переменной. В итоге я переписал скрипт на Python, что, честно говоря, было пустой тратой времени. Этот конкретный случай сильно отбил у меня желание пользоваться bash и привёл к появлению статьи о ловушке написания скриптов для командной оболочки. Но, как бы там ни было, zsh решает многие из перечисленных здесь проблем, в том числе — эту.

***


Zsh, кроме того, решает большинство проблем с кавычками:

zsh% for f in *; ls -l $f
-rw-rw-r-- 1 martin martin 0 Oct 19 06:51 asd.txt
-rw-rw-r-- 1 martin martin 0 Oct 19 06:51 with space.txt

bash% for f in *; do ls -l $f; done
-rw-rw-r-- 1 martin martin 0 Oct 19 06:51 asd.txt
ls: cannot access 'with': No such file or directory
ls: cannot access 'space.txt': No such file or directory


Этот подход не является POSIX-совместимым, но кого это волнует? Bash, по умолчанию, во многих моментах отступает от стандарта POSIX. В обоих случаях так поступают из-за того, что в отходе от стандарта больше смысла, чем в его соблюдении, но и bash, и zsh можно настроить на совместимость с POSIX в том случае, если это, по какой-то причине, необходимо.

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

zsh% for i in *; ls -l $=i
-rw-rw-r-- 1 martin martin 0 Oct 19 06:51 asd.txt
ls: cannot access 'with': No such file or directory
ls: cannot access 'space.txt': No such file or directory


Конструкция [[ должна исправить проблемы [, но и для неё характерны странности при работе с кавычками:

zsh% a=foo; b=*;
zsh% if [[ $a = $b ]]; then
       print 'Equal!'
     else
       print 'Nope!'
     fi
Nope!

bash% a=foo; b=*
bash% if [[ $a = $b ]]; then
        echo 'Equal!'
      else
        echo 'Nope!'
      fi
Equal!


Тут мы получаем результат Equal! из-за того, что без кавычек правая часть выражения интерпретируется как шаблон. В zsh нужно использовать $~var для того чтобы в явном виде включить режим сопоставления с шаблоном. Это — гораздо более удачная модель работы, чем такая, при использовании которой необходимо помнить о том, когда нужно, а когда не нужно заключать что-то в кавычки. Иногда функционал сопоставления с шаблоном необходим, в таком случае кавычки не нужны. Но не всегда сразу ясно, будет ли выражение if [[ ... корректным при отсутствии кавычек.

Кто-то скажет, что я — недобросовестный рассказчик, так как кавычки нужно применять всегда. Знаете, я мог бы обеспечить себе безбедное существование, если бы мне платили за то, что я добавляю кавычки в чужие скрипты командной оболочки. Я уже 40 лет говорю людям о том, что им надо «всегда заключать строки в кавычки», но неоспоримые эмпирические данные показали, что этот подход попросту не работает.

Большинство людей не являются виртуозами разработки скриптов командной оболочки. Они зарабатывают на жизнь написанием программ на Python, C, Go или PHP, или, может, они являются системными администраторами, или учеными. И ещё они время от времени пишут скрипты командной оболочки. Они просто видят некий работоспособный фрагмент кода и полагают, что этот код ведёт себя адекватно, не понимая при этом тонких различий между $@, $* и «$@». И я полагаю, что это, на самом деле, вполне приемлемо, так как поведение подобных конструкций может быть странным, удивительным и запутанным.

И это — гораздо сложнее, чем простое «заключение переменных в кавычки», особенно — если пользоваться конструкцией $(..), так как подстановка команды часто тоже нуждается в кавычках, равно как и любые переменные внутри неё. Не успеешь оглянуться — и у тебя уже два, три или большее количество уровней вложенных кавычек, а если забыть об одном из их наборов — жди неприятностей.

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

И «всегда заключайте всё в кавычки», это совет, который даже нельзя назвать правильным, так как кавычки нужно использовать всегда, кроме тех случаев, когда их использовать не нужно:

zsh% a=foo; b=.*;
zsh% if [[ "$a" =~ "$b" ]]; then
       print 'Equal!'
     else
       print 'Nope!'
     fi
Equal!

bash% a=foo; b=.*
bash% if [[ "$a" =~ "$b" ]]; then
        echo 'Equal!'
      else
        echo 'Nope!'
      fi
Nope!


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

Ещё одна знаменитая «ловушка кавычек» скрывается в непонимании особенностей и различий конструкций $@, «$@», $* и «$*»:

zsh% cat args
echo "unquoted @:"
for a in $@; do echo "  => $a"; done

echo "quoted @:"
for a in "$@"; do echo "  => $a"; done

echo "quoted *:"
for a in $*; do echo "  => $a"; done

echo "quoted *:"
for a in "$*"; do echo "  => $a"; done
bash% bash args
unquoted @:
  => hello
  => world
  => test
  => space
  => Guust1129.jpg
  => IEEESTD.2019.8766229.pdf
  [.. rest of my $HOME ..]
quoted @:
  => hello
  => world
  => test space
  => *
unquoted *:
  => hello
  => world
  => test
  => space
  => Guust1129.jpg
  => IEEESTD.2019.8766229.pdf
  [.. rest of my $HOME ..]
quoted *:
  => hello world test space *


Опытный автор скриптов командной оболочки знает, что ему (почти) всегда нужно использовать «$@». Но как часто приходится видеть, что подобной конструкцией пользуются неправильно. И дело не в том, что так поступают люди, которые совершенно ничего не знают о скриптах. Если некто узнал о кавычках и о разделении слов, то использование $@ без кавычек — это совершенно логичное решение. Ведь можно ожидать, что «$@» будет восприниматься как единственный аргумент (как «$*»). И получается, что говоришь всем о том, чтобы они всегда использовали кавычки для предотвращения разделения слов, а потом добавляешь, что есть один особый случай, когда добавление кавычек вызывает особый механизм разделения слов, и этот механизм не следует ни одному из тех правил, о которых им рассказывали.

В zsh $@ и $* (и $argv) — это массивы (предназначенные только для чтения), и все они работают одинаково, ведут себя так, как можно от них ожидать, и не преподносят нам никаких сюрпризов:

unquoted @:
  => hello
  => world
  => test space
  => *
quoted @:
  => hello
  => world
  => test space
  => *
unquoted *:
  => hello
  => world
  => test space
  => *
quoted *:
  => hello world test space *


В bash, на самом деле, можно воспользоваться командой argv=(»$@»), после чего в нашем распоряжении будет массив. И, если честно, так оболочка bash и должна работать по умолчанию.

Этот массив нужно обходить в цикле:

bash% for a in "${argv[@]}"; do
        echo "=> $a"
      done


А в zsh достаточно просто воспользоваться конструкцией for a in $argv. И, если отвлечься от никчёмного [@], подумаем о том, зачем заниматься разделением слов при работе с каждым элементом массива? Конечно, у такого подхода, вероятно, где-то есть какое-то применение, но нужно это крайне редко. Лучше просто всем этим не пользоваться, за исключением случаев явного применения = и/или ~.

А вот — ещё один интересный нюанс:

zsh% n=3; for i in {1..$n}; print $i
1
2
3

bash% n=3; for i in {1..$n}; do echo "$i"; done
{1..3}

bash% n=3; for i in {1..3}; do echo "$i"; done
1
2
3


Почему это так? Предлагаю читателям самостоятельно поискать ответ на этот вопрос.

***


Помимо всяческих особенных механизмов bash, аналоги которых в zsh выглядят гораздо лучше, с использованием zsh попросту гораздо легче решать вполне обычные задачи:

zsh% arr=(123 5 1 9)
zsh% echo ${(o)arr}     # Лексический порядок
1 123 5 9
zsh% echo ${(on)arr}    # Числовой порядок
1 5 9 123

bash% IFS=$'\n'; echo "$(sort <<<"${arr[*]}")"; unset IFS
1 123 5 9
bash% IFS=$'\n'; echo "$(sort -n <<<"${arr[*]}")"; unset IFS
1 5 9 123


Мне пришлось порыться в интернете для того чтобы выяснить, как это делается в bash. Нужный мне ответ на Stack Overflow начинался так: «вам, на самом деле, не нужно так много кода». Ну не смешно ли это? Полагаю, что этот ответ был комментарием к другому ответу, в котором была приведена ужасно сложная конструкция, в которой были реализованы алгоритмы сортировки и прочее подобное на «чистом bash». Полагаю, что то, что я тут привёл, это как раз и есть пример «не такого уж большого кода». И, конечно, вся эта конструкция представляет собой настоящее минное поле в том случае, если её решено будет расширить. Для того чтобы нажить проблемы, достаточно забыть пару вложенных кавычек.

Работа с массивами в bash, в целом, выглядит достаточно неуклюже:

bash% arr=(first second third fourth)

bash% echo ${arr[0]}
first
bash% echo ${arr[@]::2}
first second


Это всё, конечно, работает, да и кода тут не так много, но зачем мне этот [@]? Может — это используется по каким-то (историческим) причинам, но в zsh то же самое реализуется в гораздо более читабельном и простом виде:

zsh% arr=(first second third fourth)

zsh% print ${arr[1]}        # Да, нумерация элементов начинается с 1. С этим придётся смириться.
first
zsh% print ${arr[1,2]}
first second


Синтаксис работы с массивами bash скопирован из ksh. Поэтому, полагаю, нам нужно винить во всём Дэвида Корна (zsh тоже такое поддерживает, если нужно пользоваться именно такими конструкциями). Но обычная работа с индексами выглядит гораздо проще.

А вот — ещё некоторые полезные возможности:

zsh% ls *.go
format.go  format_test.go  gen.go  old.go  uni.go  uni_test.go

zsh% ls *.go~*_test.go
format.go  gen.go  old.go  uni.go

zsh% ls *.go~*_test.go~f*
gen.go  old.go  uni.go


Конструкция *.go разворачивается и фильтрует шаблон после ~. В данном случае — *_test.go. На первый взгляд всё это выглядит несколько таинственно, но работать с bash-шаблонами стиля ksh гораздо сложнее:

bash% ls !(*_test).go
format.go  gen.go  old.go  uni.go

bash% ls !(*_test|f*).go
gen.go  old.go  uni.go


Конструкция !(..) означает «выбрать всё кроме шаблона». Здесь подразумевается наличие знака * (zsh поддерживает !(..) если установить ksh_glob). Хотя это и работоспособная конструкция, модель шаблон~фильтр~фильтр гораздо проще. Она, кроме того, гораздо гибче, так как её не нужно начинать с поиска всех совпадений.

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

zsh% ls **/*(.)
LICENSE         go.sum           unidata/gen.go             wasm/make*
README.md       old.go           unidata/gen_codepoints.go  wasm/srv*
[..]


А вот так можно вывести сведения о директориях:

zsh% ls -d /etc/**/*(/)
/etc/OpenCL/                      /etc/runit/runsvdir/default/dnscrypt-proxy/log/
/etc/OpenCL/vendors/              /etc/runit/runsvdir/default/ntpd/log/
/etc/X11/                         /etc/runit/runsvdir/default/postgresql/supervise/
[..]


Или — сведения о файлах, изменённых за последнюю неделю

zsh% ls -l /etc/***(.m-7)    # *** - это сокращение для **/*; для его использования нужно включить GLOB_STAR_SHORT
-rw-r--r-- 1 root root 28099 Oct 13 03:47 /etc/dnscrypt-proxy.toml.new-2.1.1_1
-rw-r--r-- 1 root root    97 Oct 13 03:47 /etc/environment
-rw-r--r-- 1 root root 37109 Oct 17 10:34 /etc/ld.so.cache
-rw-r--r-- 1 root root 77941 Oct 19 01:01 /etc/public-resolvers.md
-rw-r--r-- 1 root root  6011 Oct 19 01:01 /etc/relays.md
-rw-r--r-- 1 root root   142 Oct 19 07:57 /etc/shells


Их даже можно упорядочить по дате модификации, воспользовавшись om (***(.m-7om)), хотя особого смысла в этом нет, так как ls снова их переупорядочит. Но если осуществляется обход файлов в цикле — это может пригодиться.

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

bash% find /etc -type f -mtime 7 -exec ls -l {} +
find: ‘/etc/sv/docker/supervise’: Permission denied
find: ‘/etc/sv/docker/log/supervise’: Permission denied
find: ‘/etc/sv/bluetoothd/log/supervise’: Permission denied
find: ‘/etc/sv/postgresql/supervise’: Permission denied
find: ‘/etc/sv/runsvdir-martin/supervise’: Permission denied
find: ‘/etc/wpa_supplicant/supervise’: Permission denied
find: ‘/etc/lvm/cache’: Permission denied
-rw-rw-r-- 1 root root  167 Oct 12 22:17 /etc/default/postgresql
-rw-r--r-- 1 root root  817 Oct 12 09:11 /etc/fstab
-rw-r--r-- 1 root root 1398 Oct 12 22:19 /etc/passwd
-rw-r--r-- 1 root root 1397 Oct 12 22:19 /etc/passwd.OLD
-rw-r--r-- 1 root root  307 Oct 12 23:10 /etc/public-resolvers.md.minisig
-rw-r--r-- 1 root root  297 Oct 12 23:10 /etc/relays.md.minisig
-r-------- 1 root root  932 Oct 12 09:57 /etc/shadow
-rwxrwxr-x 1 root root  397 Oct 12 22:23 /etc/sv/postgresql/run


Не знаю точно, как заставить bash игнорировать эти ошибки, не выполняя перенаправления в stderr (больше кода!). А если вы думаете, что добавление одиночных букв в (..) после шаблона — это сложно, тогда попытайтесь разобраться со странным синтаксисом find. Шаблоны поиска — это очень здорово.

Замещение параметров в стиле csh — это весьма полезный механизм:

zsh% for f in ~/photos/*.png; convert $f ${f:t:r}.jpeg


Конструкция :t позволяет получить имя файла, конструкция :r позволяет получить директорию и имя файла (без расширения). В csh это было возможно ещё до моего рождения, но bash этого не умеет (в bash возможна подстановка истории, но не переменных). В FAQ по bash говорится, что «В Posix описан более мощный, хотя и, в какой-то степени, более сложный для понимания механизм, взятый из ksh». Интересное заявление — особенно учитывая то, что вышеприведённый пример, переписанный в bash, выглядит так:

bash% for f in ~/photos/*.png; do convert "$f" "$(basename "${f%%.*}")"; done


С технической точки зрения это «мощнее», в том смысле, что с помощью этой конструкции можно решать и другие задачи, но я не сказал бы, что она «удачнее подходит для выполнения распространённых операций» (в zsh, конечно, реализованы и %, и #).

Обратите внимание на то, что в bash нельзя строить вложенные конструкции из ${..}. То есть, например, попытка воспользоваться «${${f%%.*}##*/}» приведёт к сообщению об ошибке:

zsh% f=~/asd.png; print "${${f%%.*}##*/}"
asd

bash% f=~/a.png; echo "${${f%%.*}##*/}"
bash: ${${f%%.*}##*/}: bad substitution


Хотя применение подобного может быстро привести к появлению весьма странной ASCII-мешанины, иногда это может и пригодиться, если пользоваться этим осторожно и с умом. А если у экранов нет детей — то можете развернуть следующий раздел и посмотреть на более продвинутый пример, который демонстрирует вывод самого длинного элемента массива. Я нашёл это в руководстве пользователя zsh.

Смотреть с осторожностью и без детей!
print ${array[(r)${(l.${#${(O@)array//?/X}[1]}..?.)}]}


***


Я долго ещё могу говорить о bash и zsh, но на том, что уже сказано, я решил остановиться. Проблемы bash не новы — большинству из них (если не всем) уже лет по 20, а может и больше. Я не знаю, почему оболочка bash стала стандартом де-факто, не знаю и о том, почему люди тратят время на создание сложных решений, направленных на обход проблем bash, когда в zsh они уже решены. Полагаю, это так из-за того, что в Linux использовалось много всего, связанного с GNU, и bash поставлялся с Linux, а GNU-утилиты использовали (и используют) bash. Не очень хорошая причина особенно — когда прошло уже 30 лет.

В zsh, конечно, есть много ограничений. Для начала, синтаксис там такой, что кое-кого он может и перепугать, да и многое другое там далеко от идеала. Но, несмотря ни на что, zsh, бесспорно, лучше bash. Я, на самом деле, не могу найти ни одной задачи, которую bash решает лучше zsh. На стороне bash лишь то, что эта оболочка уже установлена на огромном количестве систем.

Но распространённость bash — сомнительное, переоцениваемое преимущество. У zsh нет зависимостей помимо libc и curses, а размер zsh на моей системе составляет всего 970 Кб. Существуют дистрибутивы zsh практически для любых систем. (Полная установка zsh — это около 8 Мб, которые представляют собой, в основном, необязательные вспомогательные функции, с которой поставляется оболочка. А размер bash, кстати, составляет около 1,3 Мб).

Zsh, в сравнении с большинством интерпретаторов, система весьма компактная. Меньше неё лишь Lua (275 Кб). Совет придерживаться Posix sh ради совместимости хорошо звучал в 1990-е, когда встречались системы SunOS с sun-sh и ничего другого на них поставить было нельзя. Но эти дни давно прошли, и хотя кое-кто ещё работает на таких системах (иногда даже с csh!), высока вероятность того, что они не соберутся пробовать запускать у себя скрипты Docker или Arch Linux или что-нибудь ещё.

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

Использование zsh, кроме того, облегчает работу в разных системах. Дело в том, что zsh позволяет уйти от использования различных командных оболочек, сталкиваясь при этом с (потенциальными) проблемами совместимости. Явным образом устанавливая zsh в роли интерпретатора можно положиться на поведение zsh, а не надеяться на то, что /bin/sh в $некоей_системе будет вести себя так же (даже dash обладает некоторыми расширениями для POSIX sh вроде local).

Обычно я сохраняю файлы скриптов, давая им имена вроде script.zsh и добавляя в них следующее:

#!/usr/bin/env zsh
[ "${ZSH_VERSION:-}" = "" ] && echo >&2 "Only works with zsh" && exit 1


Это позволяет обеспечить запуск скрипта (./script.zsh) с помощью zsh, а если попытаться запустить его как sh script.zsh или bash script.zsh — будет выдана ошибка. Сделано так на тот случай, если кому-то, для того, чтобы понять, чем запускать этот скрипт, подсказки в виде расширения .zsh недостаточно.

В общем, желаю всем s/bash/zsh/g. Тогда всё у вас будет чуть лучше, чем прежде.

P.S. Может, кстати, fish ещё лучше zsh, но я всё не могу свыкнуться с яркими цветами и со всем тем, что выскакивает на экране. В общем, как-то всё это перегружено ненужными деталями. Мне, чтобы сделать оболочку fish более подходящей для себя, пришлось бы потратить время на отключение всего ненужного, но времени на это я не тратил. Ведь если отключаешь в программе то, что её авторы преподносят пользователям как самое главное, то она, похоже, сделана не для тебя. Но, возможно, я как-нибудь этим, всё же, займусь.

Пользуетесь ли вы zsh?

image-loader.svg

© Habrahabr.ru