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

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

Примечание: на момент написания статьи автор использовал 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


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

Комментарии (30)


  1. kvazimoda24
    13.06.2018 11:33
    +1

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


    1. Firemoon Автор
      13.06.2018 11:35

      Когда изменял задания, забыл перенести ответ. Исправил, благодарю


  1. cagami
    13.06.2018 12:33

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


    1. Firemoon Автор
      13.06.2018 12:53

      а разве при перенаправлении потока баш хоть что-то возвращает?

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


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

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


      1. cagami
        13.06.2018 20:55

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


    1. SlavniyTeo
      13.06.2018 15:22

      а разве при перенаправлении потока баш хоть что-то возвращает?
      мне казалось, что вообще никогда ничего не выплевывает в консоль

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


      $ 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.


  1. erwin_shrodinger
    13.06.2018 12:33

    По поводу 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, так и содержит.


    1. saboteur_kiev
      13.06.2018 14:59

      Именно. Правда корректнее считать, что 1 содержал в себе не STDOUT, а уже конкретный терминал типа /dev/pts/1


  1. Firemoon Автор
    13.06.2018 12:51

    Хм. Давайте разберёмся.
    Сначала цитата про заблуждение. Я встречал людей, которые ещё недостаточно постигли работу с шеллом и поэтому думают, что перенаправление происходит именно потоков, то есть `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, пардон, я промахнулся веткой.


    1. erwin_shrodinger
      13.06.2018 13:00

      А вот тут, честно, не понял, откуда 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-тый останется без изменений


      1. saboteur_kiev
        13.06.2018 15:01

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

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


        1. erwin_shrodinger
          13.06.2018 15:06

          Да, так корректнее звучит) спасибо


  1. saboteur_kiev
    13.06.2018 14:26
    +1

    Первая же задача

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


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


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


    1. Firemoon Автор
      13.06.2018 14:31

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


      1. iig
        14.06.2018 11:49
        +1

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


        wc -l
        — всегда 1 строка.


  1. saboteur_kiev
    13.06.2018 14:36

    Задача 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
    Такая команда ничего не выведет.


  1. saboteur_kiev
    13.06.2018 14:38

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

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

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


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


  1. saboteur_kiev
    13.06.2018 15:03

    P.S. А вообще — статья полезная!
    Сейчас в комментариях разберемся как следует и накажем кого попало!


  1. Sirikid
    14.06.2018 03:01

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


    1. win32nipuh
      14.06.2018 08:49

      Что использовать вместо bash?


      1. Sirikid
        16.06.2018 04:15

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


    1. iig
      14.06.2018 12:02

      11 способов выстрелить себе в ногу bash.


    1. kvaps
      14.06.2018 13:05

      Сразу вспомнилась эта картинка :)

      Скрытый текст
      image


  1. Uranix
    14.06.2018 10:29

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


    1. saboteur_kiev
      14.06.2018 13:14

      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 (путь к ним)


      1. Uzix
        15.06.2018 12:10

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

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

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


        1. saboteur_kiev
          15.06.2018 16:59

          При первом запуске никому ничего не передается.
          Выполняется команда "-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


          1. Uzix
            15.06.2018 17:23

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

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

            2. Попробуйте следующий скрипт:

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

            3. Попробуйте следующий скрипт:
            #!/bin/bash
            
            /bin/uname
            При первом запуске, несмотря на присутствие uname в скрипте, будет напечатано лишь «SURPRISE», при последующих — «Linux».


  1. Programmer74
    15.06.2018 01:18

    Firemoon Автор, как насчет результатов работы этих команд в других нетрадиционных шеллах типа ksh?)


    1. Firemoon Автор
      15.06.2018 01:19

      Слишком нетрадиционно ;)