Подводные камни shell скриптинга

    Несмотря на повсеместное использование графики, shell не теряет своей актуальности и по сей день. А порой позволяет выполнять операции значительно быстрее и проще, нежели в графическом окружении. Однако есть множество вещей, о которых большинство даже не подозревает.
    Я бы не хотел привязываться к какому-то определённому шеллу, тем не менее не каждая из рассмотренных ниже возможностей может быть POSIX совместима, однако гарантировано будет работать в ksh/bash/zsh.

    1. Переменные и test

    Ни для кого не секрет, что в shell можно сравнивать строки, числа и даже переменные. (:
    [[ 2 -eq 3 ]]
    [[ "test" == "test" ]]
    [[ $VAR -eq 3 ]]

    Но вот последний вариант, после случайной опечатки (забыл $ перед VAR) заставил поподробнее изучить поведение в данном случае, т.к. к моему удивлению конструкция отработала без ошибок и значение VAR подставилось как если бы я не забыл $. Более того, если в VAR сослаться на другую переменную, то всё прекрасно работает.
    Разработчики даже от рекурсии не забыли защититься:
    $ V=V ; [[ V -eq 12 ]]
    -bash: [[: V: expression recursion level exceeded (error token is "V")

    Как оказалось, это не пасхалка, всё так и задумано. Подробности описаны в man bash.
    on the words between the [[ and ]]; tilde expansion, parameter and variable
    expansion, arithmetic expansion, command substitution, process substitution, and quote removal are performed.

    Arithmetic Expansion

    The evaluation is performed according to the rules listed below under ARITHMETIC EVALUATION.

    ARITHMETIC EVALUATION

    Within an expression, shell variables may also be referenced by name without using the parameter expansion syntax.

    2. Достать переменные из subshell'a без сторонних утилит

    Допустим у нас есть простая конструкция:
    do_something | while read LINE ; do export VAR_N=${LINE##%*} ; done
    
    Новичкам частенько приходится ломать голову, почему же VAR_N не доступна после завершения конструкции. Дело в том, что для цикла while создаётся subshell из которого переменные до родителя уже не доходят. Чтобы достать нужные нам переменные из цикла приходится изрядно попотеть. Все предлагаемые в интернетах варианты, как правило, сводятся либо к запоминанию вывода в переменную и многократное распарсивание:
    VAR=$(cycle)
    VAR_N=$(echo "$VAR"|sed 'magic_sed')
    работает, но как-то некрасиво. Да и всякие sed'ы, perl'ы и прочие радости жизни приходится многократно вызывать, что явно не в лучшую сторону сказывается на производительности. Да и зачем они все, если можно обойтись без них?
    Просто нужно корректно сформировать вывод с красивым разделителем. Например так:
    OLD__IFS="$IFS"
    IFS='~' #или любой другой разделитель
    set -- $(cycle)
    VAR_N="$1"
    VAR_NN="$2"
    IFS="$OLD__IFS"
    И гораздо нагляднее, и исправить если что в разы проще, чем каждый раз переделывать регулярки.

    3. Спрятать часть данных от пайпа

    Порой возникает ситуация, когда часть данных нужно спрятать от одного из пайпов, а потом соединить со всем потоком обратно. В такой ситуации скрытые данные можно перенаправить в stderr, а потом из stderr вернуть обратно в stdin:
    $ ( { echo DATA ; echo HIDDEN_DATA >&2 ; } | sed 's/^/MODIFIED_/' ) 2>&1 | sed s/$/_CATCHED/
    HIDDEN_DATA_CATCHED
    MODIFIED_DATA_CATCHED
    

    4. Я хочу парсить историю команд

    Наряду с любителями парсить вывод ls, находятся ещё и любители парсить историю команд (для дальнейшей автоматической обработки результата), но они даже не догадываются о чём-нибудь кроме history, более того, совершенно не принимают во внимание, что вывод history на ура кастомизируется. Тут можете задать вопрос на засыпку: «а чем же ещё можно посмотреть history?» (ответ специально под спойлер спрячу) (:
    Если вы так хотите парсить историю команд, пожалуйста,
    используйте fc (подробности в help fc, он builtin в любом шелле).

    5. Я помню, что if .. then .. else .. fi отличается от .. && .. || ..

    Некоторые любители порефакторить рвутся сделать код как можно более компактным. Особенно забавно наблюдать взаимозамены вышеназванных конструкций с последующими отладками.
    Эти две конструкции не просто разные, они совершенно разные, хотя внешне может казаться, что делают одну вещь.
    if $(condition); then com_1; else com_2; fi
    condition && com_1 || com_2
    Неочевидная разница в том, что в первом случае com_2 будет выполнена лишь в одном случае: condition вернёт false.
    Во втором случае com_2 будет запущена если condition вернёт false, а так же в том случае, если com_1 вернёт ошибку. Не верите? убедитесь сами:
     $ if true ; then false ; else echo 'hello' ; fi
     $ true && false || echo 'hello'
    hello

    Ну вот пожалуй и всё. Хотел было замолвить пару слов про любимый sed, но уже и так как-то длинно получилось. Может в другой раз (:
    Поделиться публикацией

    Комментарии 37

      +3
      Я однажды заинтересовался возможностями разбора history, в результате получил sudo для всей командной строки, включая пайпы и редиректы:

      proceed_sudo () { sudor_command="`HISTTIMEFORMAT=\"\" history 1 | sed -r -e 's/^.*?sudor//' -e 's/\"/\\\"/g'`" ; sudo sh -c "$sudor_command"; }; alias sudor="proceed_sudo # "

      Использовать так:

      $ sudor make me a sandwitch > /var/lib/sandwitch

      Несерьезно, но забавно. И нельзя вставлять в пайп.
        +2
        Залез в свои .bash*, нашел кучу старых добрых, но забытых вещей :)

        Вот например, итерация по чему-нибудь с прогресс-метками:

        finit() { count=$#; current=1; for i in "$@" ; do echo $current $count; echo $i; current=$((current + 1)); done; } alias fnext='read cur total && echo -n "[$cur/$total] " && read'

        И использование:

        finit 1 2 3 4 | while fnext item; echo $item ; done

        Пример:

        $ finit 1 2 3 4 | while fnext item; do echo $item; done
        [1/4] 1
        [2/4] 2
        [3/4] 3
        [4/4] 4
          +2
          (code зачем-то сьел переводы строк в последнем блоке)
          0
          Интересно, когда это sed успел научиться нежадными множителями? Небольшой опыт показывает, что .*? ничем не отличается от .*. Точнее от (.*)?, за исключением сохранения текста.

          В zsh можно такое устроить переопределяя accept-line widget. Тоже правда выглядит как хак, особенно вместе с функцией, которая засовывает в историю исходный текст, а не то, что будет реально выполнено. Как мне кажется, возможность работы echo abc | sudor { read def && echo $def } | read ghi можно обеспечить в обоих случаях если немного заморочиться.
          +1
          Вставлю свои «5 копеек»:
          a="echo a";
          $a
          

          В таком виде только в zsh возникают «нюансы»: команды «echo a» не существует.
          Надёжнее делать:
          a="echo a";
          eval $a
          

          С использованием eval проблем нет ни в одном из шеллов (ну, насколько мне известно...;)

          При использовании команды sudo в скриптах, почти всегда лучше использовать ключ -i: в неинтерактивном режиме, большая часть шеллов не source'ит rc и profile файлы, что может привести к проблемам в работе софта.

          Попытаюсь вспомнить ещё чего-нибудь «забавного» и допишу.
            +2
            Полезный маленький трюк:
            cd - 
            cd $OLDPWD 
            

            делают одно и то же. Последнюю переменную можно использовать для всяких mv дополнительно.

            Ещё мелкая «полезняшка», экономящая время на длинных именах файлов:
             mv file{,.bak} 


            На время отладки можно добавить ключ -x к шабангу:
             #!/bin/bash -x

            Эквивалентно set -x.
              0
              Ну это больше из популярных bash tips and tricks. Я старался показать неочевидные вещи. (:
                0
                есть же pushd и popd
                +1
                eval стоит использовать с огромной осторожностью. Некоторые любят устроить публичную порку за его использование, дескать это пропасть в безопасности, за такое руки отрубать надо (:
                +1
                А еще довольно много интересных возможных ошибок собрано тут: mywiki.wooledge.org/BashPitfalls
                После прочтения пришлось некоторые скрипты переделать
                  +5
                  Переделывать? Тогда, пожалуй, не буду читать…
                    0
                    Всякий раз когда вижу исторический документ в корне самба-шары
                    --Читать всем--.txt
                    вспоминаю пункт 3
                    +1
                    Помню, как то нужно мне было сделать перебор от 1 до нужного мне числа. Вот тогда, я и познакомился с «граблями» в Bash-е )

                    Вот пример того, как Bash с аппетитом начнет «кушать» вашу RAM:
                    #!/bin/bash
                    
                    for i in {1..10000000}; do
                        echo "item: $i";
                    done
                    

                    Итого, скрипт взял на себя 1831 Mb.

                    Чуть позже, нашел другой вариант, «полегче»:

                    #!/bin/bash
                    
                    for i in $(seq 10000000); do
                        echo "item: $i";
                    done
                    

                    Тут уже, почти в 2 раза меньше. Всего 1049 Mb. Но все равно, мне как то, не посебе, когда на такое, уходит столько памяти.
                      +1
                      Так а что вы удивляетесь? Первым делом будет раскрываться seq и создаваться безумно длинная строка «for i in перечисление всех-всех чисел», которая будет храниться в памяти. Надо было делать
                      seq 10000000 | while read i ; do echo "item: $i"; done
                      
                      проблем с потреблением памяти не возникло бы.
                        +2
                        Традиционно:

                        seq 100000|xargs -n 1 echo item
                          0
                          В свое время, этого не знал, а в примерах, в основном, только вышеперечисленные варианты.
                            +1
                            Используя такую конструкцию, данной проблемы можно избежать:

                            for ((i=0;i<10000000;i++))
                            do
                                echo "item: $i"
                            done
                            
                          0
                          Меня открывающаяся квадратная скобочка у грепа бесит:
                          echo [ > /tmp/pattern && grep -f /tmp/pattern /dev/zero
                          или grep '[' /tmp/pattern
                          почему когда патерн лежит в в файле или одинарных кавычках — его надо экранировать?
                          grep '\[' /tmp/pattern

                            +1
                            Потому что это спецсимвол регулярного выражения.
                              +1
                              Вам здесь grep ни к чему, используйте fgrep и быстрее, и экранировать не надо будет.
                                0
                                Спасибо.
                              0
                              Может кому пригодится:
                              Меняем формат мак адреса с 0000.0000.0000 (cisco like) на 00:00:00:00:00:00:
                              echo "0000.0000.0000" |sed -e 'y/./:/' | sed -r 's/([0-9,a-f]{2})([0-9,a-f]{2})/\1:\2/g'
                              


                                0
                                Зачем запускать sed два раза? Засуньте обе команды в один, разделив точкой с запятой.
                                –1
                                Хозяйке на заметку: если надоело выискивать очередной подводный камень шелла, перепишите скрипт на перле.
                                  +2
                                  И ищешь камни там, да =)
                                    0
                                    Там их найти сильно проще.
                                      0
                                      На Перле — не сильно. Сильно проще на Tcl.
                                        0
                                        Крайне мало знаю про Tcl, но думаю, что вероятность встретить его в дикой природе несколько меньше чем вероятность встретить перл, идущий зависимостью к каждой второй софтине.
                                  +1
                                  2й пункт. Да вы что !??!

                                  $ while read line; do X=$line; done < <(cat /etc/hosts | head)
                                  $ echo $X
                                  # IPv4 and IPv6 localhost aliases
                                  


                                  Проблема subshell'а решается через именованый пайп на раз-два!
                                    0
                                    Башизм же. Я старался обойтись чем-нибудь POSIX совместимым.
                                      0
                                      Ну и что? Это просто альяс к именованым пайпам. Вот на чистом злом sh, на FreeBSD:

                                      $ [ ! -e pipe ] && mkfifo pipe
                                      $ trap 'rm -f pipe' EXIT             
                                      $ cat /etc/hosts | head > pipe &
                                      $ while read line; do X=$line; done <pipe
                                      $ echo $X
                                      # IPv4 and IPv6 localhost aliases
                                      
                                    0
                                    в 3м пункте лучше отправить в 3-й файловый дескриптор все же, не трогайте stderr :).
                                      0
                                      Да вообще пример не очень — с sed'ом можно и без файловых дескрипторов обрабатывать только то, что нужно или то, что не нужно :)
                                       { echo DATA ; echo HIDDEN_DATA; } | sed -e '/HIDDEN/!s/^/MODIFIED_/g' -e 's/$/_CATCHED/'
                                      0
                                      [[ 2 -eq 3 ]]
                                      [[ "test" == "test" ]]
                                      [[ $VAR -eq 3 ]]

                                      Поначалу с виду подумал, что это пауэршелльный код, только следующее примечание про Баш всё расставило по местам.
                                        0
                                        насчет строк в test надо аккуратнее — в случае нескольких строк ( а так бывает если вывод команды ) типа
                                        VAR=`some args`
                                        if [ -n "$VAR" ]; then ...
                                           ....
                                        fi 
                                        

                                        стоит обратить внимание на кавычки вокруг $VAR

                                        А вообще shell скриптинг — это для внимательных к деталям и сахарку в неожиданных местах. Перлисты поймут.
                                        Но регулярные извраты что бы получить простую в классическом ЯП обработку вызывают смешанные чувства.
                                          0
                                          меня тоже смутил второй пункт.
                                          часто достаточно передать файл на вход while, и пайп (и собственно сабшелл) совершенно не нужен.
                                          $ while read; do a=$((a + 1)); done < 1;

                                          дополнение про mkfifo здесь, конечно, также очень полезно.

                                          Расскажите, пожалуйста, в чем разница между [[ ]] и [ ], который test?
                                          Беглый просмотр man bash не помог.
                                            0
                                            Если вкратце, то [ — builtin и совместим со всеми шеллами. [[ — keyword, присутствует только в bash, ksh, zsh и им подобных. Так же применяются разные правила парсинга того, что внутри.
                                            [ $a < $b ] # выдаст ошибку
                                            [[ $a < $b ]] # корректно отработает
                                            Более подробно можно почитать тут.

                                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                          Самое читаемое