Você não entende o shell se não sabe como ele processa os comandos

Você não entende o shell se não sabe como ele processa os comandos

Não importa quanto anos de experiência você tenha, nem quantos comandos internos, utilitários e respectivas opções você conheça de cor: se você não sabe como o shell processa os comandos, você entendeu nada.

Note, eu não estou dizendo nada como:

  • Você não entende nada de shell!
  • Você é incapaz de operar ou criar scripts!

O ponto aqui é (1) se você entende ou não o shell e (2) a sua predisposição a cometer erros e perder horas preciosas tentando entender porque o seu script, ou sua linha de comando, não funciona como esperado. Eu me refiro àquelas situações que, de tão sobrenaturais, nós chegamos a pensar que se trata de um bug no shell: mas, como sempre será mais provável, o erro é inteiramente nosso.

Tudo é texto (até deixar de ser)

O primeiro mal-entendido comum, talvez induzido pela velocidade em que as coisas acontecem, é acreditar que as linhas de comandos farão imediatamente aquilo que nós queremos e da forma como nós digitamos. Na verdade, o que nós digitamos passará por uma série de transformações até ser, finalmente, executado pelo shell. Além disso, nem essas transformações ocorrerão ao mesmo tempo: elas acontecerão em etapas muito bem definidas e em uma ordem rígida.

Etapas de processamento

De modo simplificado, nós podemos descrever as etapas de processamento das linhas de comandos da seguinte forma:

Etapa Descrição
Etapa 1 O shell percorre todos os caracteres digitados para localizar operadores e palavras. Nesta busca, a separação de palavras obedecerá as regras de citação e, ainda nesta etapa, o shell processará a expansão dos apelidos (alias).
Etapa 2 Com as palavras registradas, o shell aplicará algumas regras para compor comandos simples, complexos e compostos. Neste ponto, um comando é determinado pelo conjunto contendo uma palavra relativa ao que deverá ser invocado e seus respectivos argumentos. Quem delimita esses conjuntos são as ocorrências de palavras reservadas, operadores de controle ou ambos.
Etapa 3 Todas as demais expansões serão processadas e os caracteres de citação que não forem resultantes dessas expansões serão removidos. O processamento das expansões também segue uma ordem fixa.
Etapa 4 Neste ponto, o shell já terá uma versão definitiva (provavelmente) bem diferente daquilo que digitamos e poderá providenciar o acesso aos recursos de escrita e leitura necessários para o processamento de eventuais redirecionamentos e pipes.
Etapa 5 É só aqui, caso não tenha acontecido algum erro nas etapas anteriores, que os comandos localizados serão executados. O shell entrará em modo de espera (wait) até ser sinalizado sobre o término da execução de todos os comandos. Aqui, finalmente, ocorrerá a captura de um estado de saída.

Repare nos grifos

  • Operadores do shell (metacaracteres): sequências de uma ou mais ocorrências não citadas dos caracteres especiais: espaço, tabulação, quebra de linha, |, &, ;, (, ), < e >. Todos esses caracteres separam palavras.

  • Operadores de controle: componentes léxicos que separam palavras e comandos desempenhando alguma função de controle. Os operadores de controle são a quebra de linha e qualquer um dos seguintes operadores: ||, &&, &, ;, ;;, ;&, ;;&, |, |&, ( e ).

  • Palavras: quaisquer sequências de caracteres delimitadas por operadores do shell.

  • Citação (quoting): o ato de remover o significado especial que certos caracteres possuem para o shell, alterando a forma como ele irá separar palavras e comandos na primeira etapa de processamento.

  • Regras de citação: a forma de atuação de cada mecanismo de citação:

    • Contra-barra (\): torna literal o caractere seguinte.
    • Aspas simples ('...'): torna literais todos os caracteres entre elas.
    • Aspas duplas ("..."): torna literais todos os caracteres entre elas, exceto o acento grave, $ e, condicionalmente, \ e !.
    • Comentário (#): nada que vier depois dele será processado.
  • Expansões: mecanismo pelo qual o shel insere uma ou mais palavras na linha de comando em troca da ocorrência de determinados componentes léxicos (nomes de variáveis precedidos pelo $, apelidos, padrões de nomes de arquivos, etc...).

  • Comando: um conjunto de palavras contendo aquilo que se pretende invocar e seus respectivos argumentos.

  • Comando simples: um comando contendo apenas da palavra relativa ao que se quer executar e seus argumentos.

  • Comandos complexos: comandos que exigem a criação de subprocessos (geralmente pipes e subshells) ou que condicionem a execução do comando seguinte ao estado de saída do último comando executado.

  • Comandos compostos: comandos agrupados ou iniciados por palavras reservadas do shell ou entre parêntesis: (...) e ((...)).

  • Palavras reservadas: palavras com significado especial para o Bash. São utilizadas para iniciar e delimitar comandos compostos:

if      then    elif     else    fi        time
for     in      until    while   do        done
case    esac    coproc   select  function
{       }       [[       ]]      !

Por que isso importa?

A seguir, nós veremos alguns exemplos de como o desconhecimento da ordem de processamento das linhas dos nossos comandos pode nos levar a esperar comportamentos que o shell simplesmente irá ignorar para fazer do seu próprio jeito.

Apelidos: a primeira etapa

Imagine que você criou o apelido abaixo para o tmux:

alias tmux='tmux -2 attach -t default &> /dev/null || tmux -2 new -s default'

Sempre que seu comando invocar o tmux, o apelido será expandido logo na primeira etapa do processamento da linha do seu comando e, como esperado, o tmux será executado segundo o que está definido. Mas, imagine que você queira executar um comando invocando o tmux sem as definições do seu apelido. Neste caso, basta impedir que o primeiro caractere da linha digitada não pertença à invocação do apelido, o que normalmente se faz (sem saber por quê) "escapando" o primeiro caractere:

:~$ \tmux

Geralmente, isso é apresentado como "o modo de executar o comando original em vez do apelido". Não está errado, mas também não é nenhuma convenção do shell e nem um truque de mágica: existe uma causa para este efeito.

Na verdade, isso funciona apenas porque a remoção dos caracteres de citação só acontece no final da terceira etapa de processamento das linhas de comandos (processamento das expansões). Ou seja, até lá, a primeira palavra da linha digitada será \tmux, o que não é um apelido e, portanto, não será expandido na primeira etapa.

Nós teríamos o mesmo efeito com:

:~$ 'tmux'
:~$ "tmux"

Quer comprovar?

:~$ alias whoami='echo Eis o apelido!'
:~$ whoami
Eis o apelido!
:~$ \whoami
blau
:~$ "whoami"
blau
:~$ 'whoami'
blau

Isso demonstra duas coisas:

  • O shell realmente transforma a linha digitada antes de processá-la.
  • Muitas soluções difundidas como "regras" ou mecanismos do shell não passam da replicação impensada de uma das soluções possíveis.

Em outras palavras: conhecendo os mecanismos de processamento da linha do comando, qualquer um poderia chegar à mesma solução sem depender de gurus ou de uma busca complicada e demorada na internet.

Comandos simples e compostos

Na segunda etapa de processamento das linhas de comandos, acontece a classificação de comandos em simples, complexos e compostos. Um comando simples é, basicamente, aquele cuja primeira palavra representa o que efetivamente será invocado (o comando em si) e as demais palavras, se houver alguma, serão seus argumentos:

COMANDO arg1 arg2 arg3 ...

Um dos comandos implementados internamente no Bash é o test, que pode ser invocado de duas formas:

test ARGUMENTOS

ou

[ ARGUMENTOS ]

O interessante da segunda forma ([), é que o último argumento sempre terá que ser o ]. Quer dizer, não se trata de uma estrutura iniciada e terminada por colchetes, mas de um comando que se chama [, seguido de vários argumentos e terminado obrigatoriamente com o argumento ].

Por outro lado, o Bash possui um comando interno equivalente ao test, ao qual nós, frequentemente, nos referimos como "test do Bash". Trata-se do comando composto:

[[ EXPRESSÃO ]]

Aqui, [[ não é o nome de um comando ou binário executável, mas uma palavra reservada do Bash, tal como o ]] no final.

Como já dissemos, palavras reservadas iniciam e delimitam comandos compostos. Diferente dos comandos simples, o que, efetivamente, é tratado como comando é o conjunto delimitado pelas palavras reservadas:

[[ EXPRESSÃO ]]         [ ARGUMENTOS ]
↑             ↑         ↑
+------+------+         |
       |                |
   O COMANDO        O COMANDO

Tanto é verdade, que os redirecionamentos de entradas e saídas são compartilhados por todos os comandos participantes do agrupamento em um comando composto: para o shell, comandos compostos são vistos como um todo inseparável.

Fora as diferenças documentadas entre o test e o test do Bash, existe um detalhe que só é possível depreender conhecendo os mecanismos de processamento das linhas de comandos, observe:

:~$ var=

:~$ [ -n $var ] && echo sucesso || echo erro
sucesso

:~$ [[ -n $var ]] && echo sucesso || echo erro
erro

Afinal de contas, é sucesso ou é erro?!

Aqui, o operador (ou argumento, no primeiro caso) -n serve para construir uma expressão onde nós afirmamos que a expansão de var não resultará em uma string vazia. Acontece, porém, que -n é o operador padrão de ambos os comandos test e, portanto, pode ser omitido.

O operador -n significa non-zero, com relação ao comprimento da string. Em oposição, -z significa zero.

Observe novamente:

:~$ var=

:~$ [ $var ] && echo sucesso || echo erro
erro

:~$ [[ $var ]] && echo sucesso || echo erro
erro

Aqui, como esperado, ambos os testes resultaram em erro, visto que a variável var realmente expande uma string vazia. A que se deve, então, o resultado "inesperado** do primeiro exemplo?

  • Causa 1: no comando composto, -n não é um argumento, mas um operador. Desta forma, a sua ausência ou presença são tratadas internamente no comando [[...]] e não pelo shell, como no comando [.

  • Causa 2: podendo ser omitido e sendo um argumento comum, na ausência de algo mais expandido depois dele, o próprio -n será visto pelo comando [ como a string a ser avaliada, resultando no estado de saída de sucesso.

Além disso, sendo um operador no comando composto [[, o -n exigirá algo a ser expandido, seja ele omitido ou não:

:~$ [[ -n ]] && echo sucesso || echo erro
bash: argumento inesperado `]]' para operador unário condicional
bash: erro de sintaxe próximo a `]]'

:~$ [[  ]] && echo sucesso || echo erro
bash: erro de sintaxe próximo a `]]'

Já o test...

:~$ [ -n ] && echo sucesso || echo erro
sucesso

:~$ [ ] && echo sucesso || echo erro
erro

Mas o mistério não está totalmente solucionado, observe:

:~$ var=

:~$ [ -n "$var" ] && echo sucesso || echo erro
erro

:~$ [ "$var" ] && echo sucesso || echo erro
erro

Desta vez, expandindo a variável var entre aspas, o comando [ comportou-se como esperado, saindo com erro com ou sem o argumento -n.

Isso acontece porque, como vimos anteriormente, as aspas só serão removidas no último passo da etapa de processamento das expansões (terceira etapa), que é quando o shell, efetivamente, monta a lista dos argumentos encontrados, mesmo que o argumento seja uma string vazia (também chamada de string nula ou string de comprimento zero).

Ter uma string vazia como argumento é diferente da ausência de um argumento. Mesmo quando não possui nenhum caractere visível, uma string só será uma string se possuir o caractere nulo \0 como terminador: e é isso que será preservado até o momento final, quando as aspas finalmente forem removidas.

O mesmo vale para o comando composto [[:

:~$ [[ -n "" ]] && echo sucesso || echo erro
erro

:~$ [[ "" ]] && echo sucesso || echo erro
erro

Finalizando a análise desse caso, não há como deixar de concluir:

  • A sequência de processamento importa na decisão sobre qual comando utilizar e nas formas como iremos lidar com a nossa escolha.

  • Não existe isso de "usar aspas duplas por precaução": o uso das aspas deve ser consciente e determinado pelo conhecimento das causas e consequências de seu uso.

Sequências frustradas

Outra fonte de problemas, igualmente derivados da incompreensão de como o shell processa comandos, é a suposição de que as expansões (da terceira etapa) acontecem todas ao mesmo tempo.

Aqui está a ordem geral em que ocorre o processamento das expansões do shell:

  1. Expansão de chaves;
  2. Expansão do til;
  3. Expansão de parâmetros, expansões aritméticas e substituições de comandos e processos;
  4. Divisão de palavras;
  5. Expansões de nomes de arquivos;
  6. Remoção de aspas.

Repare que as expansões de parâmetros, aquelas que envolvem as variáveis, estão no terceiro passo da ordem de processamento de expansões, sendo antecedidas da expansão de chaves ({...}) e da expansão do til (~). Sendo assim, não é possível contar com uma expansão de chaves que contenha uma expansão de parâmetros, porque as chaves são expandidas antes e não chegam a "ver" o resultado de uma expansão que ainda não aconteceu.

Por exemplo:

:~$ echo {1..5}
1 2 3 4 5

:~$ a=10
:~$ echo {1..$a}
{1..10}

Observe o que acontece, passo a passo, na transformação desse último comando:

:~$ echo {1..$a}
        |
        ↓               Primeiro passo:
    echo {1..$a}   <--- Não é uma expansão de chaves válida!
        |
		↓               Terceiro passo:
    echo {1..10}   <--- Opa, tem uma expansão de parâmetros!

{1..10}            <--- O 'echo' imprime o argumento recebido.

Algo semelhante aconteceria com a expansão do til, observe:

:~$ echo ~root
/root

:~$ echo ~blau
/home/blau

:~$ u=blau

:~$ echo ~$u
~blau

Contudo, como as expansões de parâmetros acontecem antes das expansões subsequentes, nós podemos utilizar variáveis tranquilamente em todas elas.

Por exemplo, em conjunto com uma expansão de nomes de arquivos:

:~$ d=gits
:~$ printf '%s\n' $d/*
gits/artigos
gits/debxpress
gits/dotfiles
gits/fuzzy-tools
gits/fuzzy-tools-plugins
gits/slideshell

Redirecionamentos

A alocação de recursos para redirecionamentos acontece na quarta etapa de processamento, o que também pode levar a resultados inesperados. Observe este comando bastante trivial:

:~$ ls > arquivos.txt
:~$

Sem conhecer as etapas de processamento, nós seríamos levados a crer que o conteúdo de arquivos.txt será apenas a lista de arquivos e diretórios existentes no momento em que o ls for executado. Contudo, o ls só será executado depois da alocação do recurso para a escrita no arquivo arquivo.txt: e isso envolve a criação do arquivo!

Logo, arquivo.txt já estava presente no diretório quando o ls foi executando, resultando em:

:~$ cat arquivo.txt
adm
arquivo.txt <--- Olha ele aqui!
aud
bin
dld
doc
git
lib
mus
pic
prj
tmp
vid
www

Conclusão

Espero que este artigo tenha demonstrado a diferença que faz saber ou não como o shell processa as linhas de comandos que nós digitamos. Na minha opinião, e eu baseio todo o meu método de ensino do shell nisso, são esses pequenos detalhes e conceitos que determinam o nível de desenvoltura e autonomia de operadores e programadores do shell.

Quando nos concentramos apenas em decorar comandos, regras e macetes, não existe cérebro no mundo capaz de assimilar (ou prever) a completa diversidade de situações que resultarão na escrita de uma linha de comando ou de um script. Consequentemente, estaremos indefinidamente condenados a dependermos sempre de gurus, mestres ninja, buscadores e sites. Isso não é eficiente e, para mim, sequer é aprender. Pior ainda quando você é profissional e está sempre sob a pressão do relógio e de um patrão que pouco se importa com o que você fará para resolver o problema.

Portanto, sugestão: respire fundo, dê um passo para trás e reserve um tempo para conhecer o shell com outros olhos. Aprenda a pensar como ele em vez de tentar moldá-lo ao que você acha que ele deveria fazer. Se você já ministra cursos ou escreve sobre o shell, reavalie o que você ensina sob esta ótica que estou propondo. Eu garanto que você ficará surpreso com os resultados e, se precisar de ajuda, pode contar comigo.


Para aprender mais sobre o shell

Formas de apoio

Músico, programador, designer e apaixonado pelos ideais do Software Livre.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Post comment