Насколько хорошо ты знаешь bash?


    Пользуешься командным интерпретатором каждый день? Готов решить несколько логических задачек и узнать что-то новое? Добро пожаловать под кат.

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

    Примечание: на момент написания статьи автор использовал bash 4.4.12(1)-release в подсистеме Linux на Windows 10. Сложность задач различная.

    Потоки ввода-вывода


    Задача 1

    $ cat 1
    The cake is a lie!
    Wanted!
    Cake or alive
    $ cat 1 | head | tail | sed -e 's/alive/dead/g' | tee | wc -l > 1 
    

    Сколько строк будет в файле 1 после выполнения команды?

    Ответ
    1

    Объяснение
    После интерпретации команды, но до запуска всех программ bash работает с указанными потоками ввода-вывода. Таким образом файл 1 очищается перед запуском первой программы и cat открывает уже очищенный файл.

    Задача 2

    $ cat file1
    I love UNIX!
    $ cat file2
    I don't like UNIX
    $ cat file1 <file2
    

    Что будет выведено на экран?

    Ответ
    I love UNIX!

    Объяснение
    Некоторые программы забивают на stdin, когда указаны файлы.

    Задача 3

    $ cat file
    Just for fun
    $ cat file 1>&2 2>/dev/null
    

    Что будет выведено на экран?

    Ответ
    Just for fun

    Объяснение
    Есть заблуждение, что последовательность 1>&2 перенаправляет первый поток во второй, однако, это не так. Рассмотрим команду из задания. В начале интерпретации введённой команды таблица потоков выглядит так:
    0 1 2
    stdin stdout stderr

    bash обнаруживает последовательность 1>&2 и копирует содержимое ячейки 2 в ячейку 1:
    0 1 2
    stdin stderr stderr

    После обнаружения последовательности 2>/dev/null интерпретатор записывает значение в ячейку 2, оставляя другие ячейки нетронутыми:
    0 1 2
    stdin stderr /dev/null

    bash выводит так же и поток ошибок, так что на мы обнаруживаем на экране текст файла.

    Задача 4
    Как вывод stdout отправить на stderr, а вывод stderr, наоборот, на stdout?

    Ответ
    4>&1 1>&2 2>&4

    Объяснение
    Принцип ровно как и в предыдущей задаче. Именно поэтому нам требуется дополнительный поток для временного хранения.

    Исполняемые файлы


    Задача 5

    Дан файл test.sh

    #!/bin/bash
    ls $*
    ls $@
    ls "$*"
    ls "$@"
    

    Выполняются команды:

    $ ls
    1  2  3  test.sh
    $ ./test.sh 1 2 3
    

    Что выведет скрипт?

    Ответ
    1 2 3
    1 2 3
    ls: cannot access '1 2 3': No such file or directory
    1 2 3


    Объяснение
    Без кавычек переменные $* и $@ ничем не отличаются и раскрываются во все заданные позиционные аргументы скрипта, разделённые пробелом. В кавычках способ раскрытия меняется: $* превращается в "$1 $2 $3", а $@ в свою очередь в "$1" "$2" "$3". Так как файла «1 2 3» в каталоге нет, ls выводит ошибку

    Задача 6

    Создадим в текущей директории файл -c c правами 755 и таким содержимым:

    #!/bin/bash
    
    echo $1
    

    Обнулим переменную $PATH и попытаемся выполнить:

    $ PATH=
    $ -c "echo SURPRISE"
    

    Что будет выведено на экран? Что произойдет, если повторить ввод последней команды?

    Ответ
    Первый раз будет выведено SURPRISE, второй раз echo SURPRISE

    Объяснение
    При пустом PATH шелл начинает искать файлы в текущем каталоге. -с как раз находится. Так как исполняемый файл — текстовый, считывается первая строчка на предмет шебанга. Команда собирется по шаблону:

    <shebang> <filename> <args>

    Таким образом, перед выполнением наша команда выглядит так:

    /bin/bash -c "echo SURPRISE"

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

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

    Переменные


    Задача 7

    $ ls 
    file
    $ cat <$(ls)
    $ cat <(ls)
    

    Что будет выведено на экран в первом и во втором случае?

    Ответ
    В первом будет выведено содержимое файла file, во втором — имя файла.

    Объяснение
    В первом случае выполняется подстановка

    cat <file

    Во втором случае <(ls) будет заменён на именованный пайп, соединённый входом с stdout ls, и выходом с stdin cat.

    После подстановки команда приобретёт вид:

    cat /dev/fd/xx


    Задача 8

    $ TEST=123456
    $ echo ${TEST%56}
    

    Что будет выведено на экран?

    Ответ
    1234

    Объяснение
    При такой записи матчится паттерн (# — с начала переменной; ## — жадно с начала переменной; % — с конца переменной; %% — жадно с конца переменной) и удаляется при подстановке. Содержимое переменной при этом остаётся нетронутым. Таким образом, например, удобно получать имя файла без расширения.

    $ TEST=file.ext
    $ echo ${TEST%.ext}
    file
    


    Задача 9

    $ echo ${friendship:-magic}
    

    Что будет выведено на экран?

    Ответ
    Если переменная friendship определена, то содержимое переменной. Иначе — magic.

    Объяснение
    В документации эта магия называется «unset or null» и позволяет использовать заданное дефолтное значение переменной в одну строчку.

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


    Задача 10

    while true; false; do
        echo Success
    done
    

    Что будет выведено на экран?

    Ответ
    Ничего

    Объяснение
    Операторы while и if позволяют в условие запихать целую последовательность действий, однако результат (код возврата) будет учитываться только у последней команды. Так как там стоит false, цикл даже не начнётся.

    Задача 11

    $ false && true || true && false && echo 1 || echo 2
    

    Что будет выведено на экран?

    Ответ
    2

    Объяснение
    Добавим скобочек для явного порядка и упростим команду, принимая во внимание, что учитывается только код возврата последней команды:

    ((((false && true) || true) && false) && echo 1) || echo 2
    (((false || true) && false) && echo 1) || echo 2
    ((true && false) && echo 1) || echo 2
    (false && echo 1) || echo 2
    false || echo 2
    echo 2
    


    Замечания, пожелания и дополнительные задачи приветствуются в комментарии или ЛС.
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 31
      +2

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

        0
        Когда изменял задания, забыл перенести ответ. Исправил, благодарю
        0
        Firemoon
        а разве при перенаправлении потока баш хоть что-то возвращает?
        мне казалось, что вообще никогда ничего не выплевывает в консоль
        добавим в ваш код не перезапись файла, а добавление.
        cat 1 | head | tail | sed -e 's/alive/dead/g' | tee | wc -l >> 1
        :~# cat 1
        The cake is a lie!
        1
          0
          а разве при перенаправлении потока баш хоть что-то возвращает?

          Эээ. Смотря какого потока и куда.


          добавим в ваш код не перезапись файла, а добавление.

          И всё заработает, потому что баш откроет файл на дозапись, и содержимое не пострадает.

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

            Все верно, первая задача некорректно составлена.


            $ cat 1
            The cake is a lie!
            $ cat 1 | head | tail | sed -e 's/alive/dead/g' | tee | wc -l > 1 

            Ничего на экран не выведет, так же как и (здесь важно > 2 в конце):


            $ cat 1
            The cake is a lie!
            $ cat 1 | head | tail | sed -e 's/alive/dead/g' | tee | wc -l > 2

            Не зависимо от открытия/очищения файлов.
            Просто потому что &1 направлен в файл вместо stdout.

            0
            По поводу 3 задачи.

            Есть заблуждение, что последовательность 1>&2 перенаправляет первый поток во второй, однако, это не так.

            bash обнаруживает последовательность 1>&2 и копирует содержимое ячейки 2 в ячейку 1

            Ой ли?
            Здесь вот совсем другое говорят:
            i>&j
            # Redirects file descriptor i to j.
            # All output of file pointed to by i gets sent to file pointed to by j.


            В вашем примере stdout перенаправляется и в 1 и в 2 дескрипторы. Затем 2 перенаправляется в /dev/null. Но 1 как содержал в себе stdout, так и содержит.
              0
              Именно. Правда корректнее считать, что 1 содержал в себе не STDOUT, а уже конкретный терминал типа /dev/pts/1
              0
              Хм. Давайте разберёмся.
              Сначала цитата про заблуждение. Я встречал людей, которые ещё недостаточно постигли работу с шеллом и поэтому думают, что перенаправление происходит именно потоков, то есть `1>&2` в их понимании значит «слить во второй поток, второй поток сам разберётся», именно на них нацелена данная задача.

              Далее, то, что говорят [вон там](https://www.tldp.org/LDP/abs/html/io-redirection.html).
              > gets sent to file pointed to by j.

              То есть перенаправление происходит в файл, на который указывает в данный момент j-тый дескриптор. Если j-тый дескриптор станет указывать на другой файл, i-тый останется без изменений.

              >В вашем примере stdout перенаправляется и в 1 и в 2 дескрипторы. Затем 2 перенаправляется в /dev/null. Но 1 как содержал в себе stdout, так и содержит.

              А вот тут, честно, не понял, откуда stdout? В объяснении есть табличка с дескрипторами.

              UPD: erwin_shrodinger, пардон, я промахнулся веткой.
                0
                А вот тут, честно, не понял, откуда stdout? В объяснении есть табличка с дескрипторами.

                Согласен, написал некорректно. Имелось в виду вот что.
                По вашему утверждению (судя по табличке) ситуация будет такая:
                0 ~ stdin
                1 ~ stderr
                2 ~ stderr
                Я же утверждаю обратное:
                0 ~ stdin
                1 ~ stdout
                2 ~ stdout
                В таком случае, после 2>/dev/null мы не теряем stdout, так как можем получить его из 1-го дескриптора.

                Т.е. в целом мне ваш «финт» с 1>&2 кажется нефункциональным и уж тем более не копирующим содержимое ячейки 2 в ячейку 1.

                UPD: Вот тут вы сами же это подтверждаете:
                То есть перенаправление происходит в файл, на который указывает в данный момент j-тый дескриптор. Если j-тый дескриптор станет указывать на другой файл, i-тый останется без изменений
                  0
                  STDOUT и STDERR это названия потоков для программиста, который пишет программу.
                  В то время как у запущенного процесса уже нет STDIN, STDOUT, STDERR — есть открытые файловые дескрипторы 0, 1, 2 которые на при создании терминала сразу ассоциируются с конкретным устройством.

                  Поэтому переключая 2 в 1 вы переключаете не STDERR в STDOUT, а 2 дескриптор в то, куда сейчас смотрит 1-й дескриптор.
                  Посмотреть куда смотрят дескрипторы можно в директории fd процесса (file descriptors) — вот так
                  ls -l /proc/$$/fd
                    0
                    Да, так корректнее звучит) спасибо
                +1
                Первая же задача

                $ cat 1 | head | tail | sed -e 's/alive/dead/g' | tee | wc -l > 1


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


                Не совсем согласен с объяснением. Да, файл будет очищен, но данная команда в конечном счете весь вывод на экран перенаправляет в > 1, следовательно на экран не будет ничего выведено, даже если в конце перенаправить в другой файл (например > 2.txt), не затирая 1.
                  0

                  Согласен. Доберусь до компа — добавлю текста в исходный файл и перепишу задание на "сколько строчек будет в файле 1?" Так будет лучше

                    +1
                    сколько строчек будет в файле 1


                    wc -l
                    — всегда 1 строка.
                  +1
                  Задача 3
                  $ cat file 1>&2 2>/dev/null
                  just for fun

                  Объяснение
                  Есть заблуждение, что последовательность 1>&2 перенаправляет первый поток во второй, однако, это не так.


                  Не совсем согласен с объяснением. IMHO правильнее говорить, что вывод из программы у нас идет не в STDOUT и STDERR, а вот с точки зрения запущенной в консоли программы, она пользуется дескрипторами 1 и 2, которые можно посмотреть вот так
                  ls -l /proc/$$/fd
                  То можно увидеть, что эти дескрипторы — просто ссылки на /dev/pts/X.
                  Поскольку мы сперва перенаправляем 1>&2, когда у нас &2 все еще /dev/pts/X, он будет перенаправлен в /dev/pts/X (ничего не изменится), и только затем мы меняем 2>/dev/null.
                  Если же поменять перенаправления местами, то 1> тоже перенаправится в /devnull, поскольку к этому моменту дескриптор 2 будет уже ссылаться на него.
                  $ cat file 2>/dev/null 1>&2
                  Такая команда ничего не выведет.
                    0
                    Задача 4
                    Как вывод stdout отправить на stderr, а вывод stderr, наоборот, на stdout?

                    Ответ
                    4>&1 1>&2 2>&4

                    Объяснение
                    Принцип ровно как и в предыдущей задаче. Именно поэтому нам требуется дополнительный поток для временного хранения.


                    Опять не очень понятная задача — у нас нет STDERR и STDOUT с точки зрения консоли, есть открытые файловые дескрипторы 1 и 2, которые уже куда-то ссылкаются. Чтобы поменять их местами, можно просто посмотреть куда конкретно они ссылкаются и назначить. То есть да, указанная команда сработает, но объяснение — не совсем понятное почему оно так сработает.
                      +1
                      P.S. А вообще — статья полезная!
                      Сейчас в комментариях разберемся как следует и накажем кого попало!
                        +1

                        А можно было подписать "11 причин не использовать bash"

                          +2
                          Что использовать вместо bash?
                            –1

                            POSIX sh или любой адекватный скриптовой язык для переносимых скриптов, fish для терминала и личных скриптов.

                              0
                              в POSIX sh файловые дескрипторы работают точно также.
                            0
                            11 способов выстрелить себе в ногу bash.
                              0
                              Сразу вспомнилась эта картинка :)
                              Скрытый текст
                              image
                              0

                              Пожалуйста, объясните, что именно кэшируется в задаче 6, и как это на результат работы влияет?

                                +1
                                Bash кеширует полный путь ко всем выполняемым командам. Вы можете посмотреть текущий кеш командой hash (команда встроенная, справка по help hash)

                                Ключевой момент — это то, что при пустом PATH, баш начинает искать исполняемые файлы в текущем каталоге, поэтому он найдет файл -c и выполнит его, затем выполнить вторую команду (в bash можно выполнить две команды, разделенные пробелом)

                                То есть первый вызов команды
                                -с «echo Hello»
                                будет на самом деле вызов двух независимых команд:
                                "./-c" и «echo Hello»
                                Первая не выведет ничего, так как ей не будет передан аргумент, а вторая выведет Hello
                                Но в кеш у нас сохранится -c, которая находится в ./-c. И в следующий раз оно будет выполнять -c уже как вызов скрипта, а не как команду. А при вызове скрипта, ему будут передаваться аргументы

                                Во второй раз команда
                                -c «echo Hello»
                                будет выполняться уже как:
                                ./-c «echo Hello»

                                Следовательно будет выполнен echo «echo Hello» из скрипта

                                В этом примере используется как минимум три малоизвестных нюанса
                                1. Например выполнение команд через пробел. Посмотрите пример ниже:
                                скрипт show_var.sh (не забудьте chmod 755 show_var.sh)
                                #!/bin/bash
                                echo $MYVAR

                                в консоли
                                $ MYVAR=123 ./show_var
                                123
                                $ echo $MYVAR


                                2. То, что при пустом PATH поиск исполняемых файлов происходит в текущем каталоге — я сам не знал.

                                3. То, что выполненные внешние команды кешируются в bash (путь к ним)
                                  0
                                  Не соглашусь с некоторыми тезисами в объяснении. Ключевое в задаче №6 то, как происходит запуск скрипта и передача аргументов ему же. Первым аргументом команде, прописанной в shebang (#!/bin/bash) передаётся собственно имя скрипта. При первом запуске передаётся "-c", что интерпретируется как аргумент командной строки, выполняющий следующую за ним команду внутри bash. Т.е. полная команда будет выглядеть так: '/bin/bash -c «echo SURPRISE»'. При последующих запусках bash берёт уже путь из кеша, и команда будет выглядеть так: '/bin/bash ./-c «echo SURPRISE»'. Здесь "./-c" уже интерпретируется как имя скрипта для выполнения, а всё что дальше — как аргументы скрипта.

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

                                  Вообще, передача "-c" как имени файла при пустом кеше выглядит как баг.
                                    0
                                    При первом запуске никому ничего не передается.
                                    Выполняется команда "-c", а не шебанг и имя скрипта.

                                    Еще раз.
                                    1. Залогинились,
                                    2. У нас в каталоге есть исполняемый файл "-c"
                                    3. Обнулили PATH
                                    Выполняем
                                    -c «echo Hello»

                                    Как эту строку разбирает интерпретатор?
                                    Он просто начинает выполнять команды по очереди. Команды через пробел — тоже команды, и он выполняет команду "-c"
                                    На этом этапе он понятия не имеет, что это за команда — внутренней такой команды нет, внешней в кеше нет, поэтому он просто пытается ее выполнить. Так как при пустом PATH у нас происходит поиск команд в текущем каталоге, запускается файл ./-c, который не выводит ничего — ему ничего не передавали.
                                    Затем выполняется команда «echo Hello»

                                    Теперь запускаем это во второй раз.
                                    Интерпретатор видит "-c", но у него в кеше есть путь ./-c — поэтому он знает, что это внешний исполняемый файл. А если это внешний исполняемый файл, то значит ему всю оставшуюся строку нужно передать как аргументы. В прошлый раз он этого не знал, поэтому и не передавал.

                                    Собственно, если вы сомневаетесь — никто ж не мешает вам проверить на практике, И написать скрипт, который выводит не echo $1, а echo «trulyalya», и увидеть, что
                                    -c «echo Hello»
                                    при первом запуске выведет Hello, а не trulyalya
                                      0
                                      Приведу 3 примера, ставящих под сомнение утверждение о выполнении команд через пробел интерпретатором.

                                      1. Попробуйте переименовать "-c" в, например, "-r". Первый и последующие запуски будут давать одинаковый результат.

                                      2. Попробуйте следующий скрипт:
                                      #!/bin/bash --
                                      
                                      echo $1
                                      Первый и последующие запуски также будут давать одинаковый результат.

                                      3. Попробуйте следующий скрипт:
                                      #!/bin/bash
                                      
                                      /bin/uname
                                      При первом запуске, несмотря на присутствие uname в скрипте, будет напечатано лишь «SURPRISE», при последующих — «Linux».
                                0
                                Firemoon Автор, как насчет результатов работы этих команд в других нетрадиционных шеллах типа ksh?)
                                  0
                                  Слишком нетрадиционно ;)

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

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