Módulo 1: A linguagem
Aula 4: Um para você, um para mim
Conteúdo da aula
Problema 4
Escreva um script que receba uma LISTA DE NOMES, tanto por argumentos quanto pela entrada padrão, e imprima para cada nome da lista: “Um para NOME, um para mim”. Caso não seja passado nenhum nome, ou se não houver dados na entrada padrão, a mensagem deve ser: “Um para você, um para mim”.
Entendendo o problema
Desta vez, nós precisaremos descobrir quais são os recursos que o shell oferece para que possamos escrever scripts capazes de lidar com tomadas de decisão, mas não é só isso! Nesta aula, nós também aprenderemos:
- Como passar argumentos para os nossos scripts e como acessá-los.
- Como determinar se temos dados na entrada padrão.
- Como fazer a leitura de uma ou mais linhas de texto na entrada padrão.
- Como definir a execução condicional de comandos.
Como os nossos problemas estão começando a ficar mais complexos, esta é uma boa hora para aprendermos a resolvê-los de uma forma que, além de nos levar às soluções, também nos ajude a transformar cada desafio, presente ou futuro, em oportunidades para a produção e consolidação de conhecimento.
4.1 - Transformar problemas em conhecimento
No nosso contexto, problema é qualquer situação que requeira uma solução implementada na forma de uma linha de comandos escrita de uma forma que o shell seja capaz de interpretar. Em qualquer contexto, porém, todo problema terá que ser encarado com método, mesmo que este método já tenha se tornado uma espécie de "segunda natureza" e nós nem pensemos mais nele.
Imagine-se, por exemplo, tendo que atravessar uma rua. Já está consagrado que o melhor método para solucionar esse problema é:
- Certificar-se de estar a mais de 3 metros das esquinas;
- Olhar para os dois lados para verificar a aproximação de veículos;
- Aguardar até que não haja nenhum veículo se aproximando;
- Atravessar a rua perpendicularmente à calçada sem correr.
Dependendo da rua, nós poderemos passar mais ou menos tempo em algumas dessas etapas; pode ser até que tenhamos que correr ou, talvez, não seja possível estar a mais de 3 metros da esquina, mas sempre será possível dosar o grau de atenção de acordo com a consciência dos riscos.
Contudo, sempre haverá aquela pessoa mais ousada que dirá: "Eu nunca fiz nada disso e nunca me aconteceu nada" ─ mas isso não significa que os riscos não existem e nem que eles são baixos o bastante para que o método seja ignorado. Na verdade, a menos que a pessoa viva no interior, em uma cidadezinha em que o movimento de carros seja mínimo, sua afirmação só atesta a sua sorte: e sorte não tem nada a ver com método.
Do método ao truque e de volta
Dominando o método para solucionar o problema de atravessar ruas, é possível que alguém tenha desenvolvido estratégias que funcionam muito bem para alguns tipos de situações particulares. O finado professor de matemática Augusto Morgado chamava essas abordagens de "truques": boas ideias que resolvem casos particulares de um problema.
No aprendizado do shell (e de quase todas as linguagens de programação), não é diferente. Nós somos frequentemente tentados a confundir o domínio de certos truques com algum tipo de superioridade de conhecimento e domínio da área. Nós temos até um nome para as pessoas que conquistam nossas admirações (e as nossas curtidas) com esse tipo de expediente: são os famosos ninjas das mais diversas áreas.
Contudo, como dizia o professor Morgado:
Truque é uma boa ideia que resolve um problema; método é uma boa ideia que resolve vários problemas.
Os ninjas da aldeia do shell
Isso quer dizer que, dominado o método, não há problema algum em nos divertirmos experimentando e descobrindo soluções mágicas para casos particulares do problema que o método soluciona: o que não devemos fazer, é super valorizar os truques ou priorizá-los enquanto ainda estamos aprendendo. Um exemplo disso, é o uso das aspas na expansão de parâmetros. Quantas vezes nós já não ouvimos especialistas em shell afirmando que: por garantia, sempre devemos usar aspas quando expandirmos uma variável?
Este é um mito tão difundido, que até o linter (programa que verifica códigos)
do shell, o shellckeck
, tem um alerta específico para isso, o SC2086:
Double quote to prevent globbing and word splitting.
Em tradução livre:
Use aspas duplas para evitar expansões de nomes de arquivos e separação de palavras.
De fato, a ausência de aspas pode ter as consequências advertidas, mas nem sempre isso é relevante ou é uma coisa ruim. Observe este exemplo:
:~$ uname -v #1 SMP PREEMPT_DYNAMIC Debian 6.9.9-1 (2024-07-13)
O que nós temos aqui, é a versão do kernel em uso no meu sistema. Nesta linha, composta por várias palavras, a última é a data da compilação feita pelos mantenedores do Debian. Se eu quisesse obter especificamente essa informação (a data da compilação), e conhecesse alguns utilitários da base do sistema, eu poderia fazer algo assim:
:~$ uname -v | rev | cut -d' ' -f1 | rev (2024-07-13)
Não se preocupe com o funcionamento dessa linha, o ponto aqui é outro.
Outra possibilidade seria:
:~$ uname -v | awk '{print $NF}' (2024-07-13)
Ou então, apenas com recursos do Bash:
:~$ : $(uname -v); echo $_ (2024-07-13)
Neste último caso, repare que estou expandindo (propositalmente) a substituição
de comandos. O motivo é simples e não tem nada de mágico: as palavras da linha
expandida serão passadas como argumentos do comando nulo (:
) e, em seguida, eu
pretendo obter a última palavra com a variável especial _
(underlined), que
expande o último argumento do último comando executado. Como você pode ver,
minha abordagem funcionou perfeitamente, mas o que aconteceria se eu desse
ouvidos aos ninjas:
:~$ : "$(uname -v)"; echo "$_" #1 SMP PREEMPT_DYNAMIC Debian 6.9.9-1 (2024-07-13)
Como a substituição de comandos está entre aspas, toda a linha foi passada para
o comando nulo como um único argumento, e foi isso que $_
expandiu. Além
disso, as aspas na expansão do _
poderiam ser importantes, mas não neste caso,
porque, uma das coisas que precisamos garantir antes de adotar qualquer abordagem
na solução de problemas com o shell, é que nós conhecemos os dados com que
estamos trabalhando: e eu tenho certeza de que não há nenhuma potencial expansão
de nomes de arquivos e de que é impossível acontecer uma separação de palavras
neste caso!
Regras e truques não substituem o método e o conhecimento
Para quem não conhece o shell, pode parecer que eu recorri a um truque para solucionar o problema, mas isso não é verdade: eu utilizei um método e o meu conhecimento de como o Bash funciona para chegar a essa implementação em código. Seria um truque, se a minha solução se aplicasse a um caso particular do problema "imprimir a última palavra de uma linha expandida utilizando apenas recursos do Bash", mas não, esta é uma solução perfeitamente aplicável a qualquer circunstância do problema. Tanto que eu poderia até formalizá-la com a sintaxe geral:
- Expandir a última palavra de uma linha
: EXPANSÂO; echo $_
Sendo assim, o caso particular seria quando a última palavra de EXPANSÃO
fosse
um curinga (um caractere que expande nomes de arquivos). Nesta situação, as
aspas seriam totalmente indiferentes, pois nomes de arquivos seriam expandidos
de qualquer forma, e eu teria que procurar outra solução.
Observe:
:~$ var='banana laranja $BASH_VERSION' :~$ : $var; echo $_ $BASH_VERSION
Mesmo sem aspas, não houve expansão da variável BASH_VERSION
, mas seria
diferente com uma expansão de nomes de arquivos:
:~$ var='banana laranja *' :~$ : $var; echo $_ www
O último arquivo do meu diretório pessoal é o diretório www
.
Independente das aspas, a última palavra expandida por var será *
e, como as
expansões de nomes de arquivos são processadas depois das expansões de
parâmetros, não importa o que eu faça, elas acontecerão. Já as expansões de
parâmetros (como no exemplo anterior), substituições de comandos e as expansões
de expressões não são processadas simplesmente porque nenhuma expansão é
recursiva, ou seja: expansões não podem ser expandidas em expansões do mesmo
tipo.
Este conhecimento, foi o método que me deu
Do problema de "imprimir a última palavra de uma linha apenas com recursos do Bash" à formulação geral da solução, incluindo as ressalvas e as limitações, não houve mágica nem atalhos: eu tive que recorrer a uma ferramenta que me ajudasse a encontrar uma solução que pudesse ser consolidada como conhecimento – e o nome dessa ferramenta é método!
Se, em algum sentido, métodos são ferramentas para produzir conhecimento, é justo esperar que um método aplicado à solução de problemas computacionais não se limite a “resolver” esses problemas: o método que nos interessa tem que ser capaz de nos levar além do resultado para que seja possível transformar problemas em conhecimento!
Inspirados no método em quatro etapas do matemático e professor húngaro Georges Pólya, nós elaboramos o método que temos recomendado já há alguns anos. Com ele, a ideia é conduzir a pessoa que está diante de um problema para resolver com o shell (e outras ferramentas disponíveis no sistema) ao longo de passos conscientes e seguros na direção de resultados que possam ser testados e consolidados, tanto como solução, quanto como conhecimento.
O problema é todo seu!
Antes de conhecermos o método, porém, é importante enfatizar a importância de um passo anterior, facilmente ignorado, mas que é decisivo para a solução de qualquer problema, seja com que método for:
Diante de qualquer problema, assuma para si mesmo a posição inalienável, intransferível, de quem é a única pessoa no universo que tem a responsabilidade de solucioná-lo!
Sem adotar firmemente essa postura, será muito difícil construir a motivação e o nível de envolvimento, necessários para aguçar os sentidos e o pensamento crítico e levar todo o processo de solução até o fim. Neste ponto, pode ser útil adotar algumas estratégias:
- Estabelecer um “limite de competência”
- Tudo bem recorrer imediatamente a manuais e buscas na web, mas as perguntas em fóruns e grupos de ajuda só devem ser feitas após um tempo mínimo limite que você mesmo estabelecer.
- Divida o problema em problemas menores
- Boa parte dos problemas não será resolvida apenas com uma técnica ou com apenas uma ferramenta – então, comece tentando identificar o que talvez você já conheça e vá montando o quebra-cabeça aos poucos.
- Fortaleça a sua base conceitual
- Entender como as coisas funcionam e por que funcionam não é perda de tempo – boa parte dos problemas podem ser resolvidos com a simples observação de como os conceitos e princípios básicos serão postos em ação.
- Cada problema é um novo problema
- Por mais paradoxal que pareça, olhar cada problema com espanto, como se fosse algo absolutamente novo, tem o poder de despertar todo o nosso arsenal de soluções anteriores, ao passo que, pressupondo tratar-se de algo já visto e aprendido, quase sempre nos leva belos tropeços e escorregões.
4.2 – O método em quatro etapas
Nosso método propõe enfrentar os problemas em quatro etapas que devem ser cumpridas em sequência, a que chamamos, respectivamente, de: enunciado, modelagem, implementação e consolidação.
Etapa 1: Enunciado
Partindo do pressuposto de que, no mundo real, os problemas nunca vêm com enunciados, nosso primeiro papel será analisar o problema até que ele possa ser descrito na forma de um enunciado que nós mesmos elaboramos. Aqui, vale qualquer técnica pessoal que nos leve a um bom enunciado, mas pode ser uma boa ideia verificar se ele poderia ser compreendido por uma criança de 5 anos.
Etapa 2: Modelagem
Na modelagem, nós tentamos descrever o enunciado na forma de uma lista de procedimentos que, eventualmente, será um modelo da própria solução. Por exemplo, imagine que o problema seja: “faça um misto-quente”. Por mais simples que este enunciado pareça, ele envolve questões bastante complexas! Veja, foi a familiaridade com o ato de fazer mistos-quentes que ocultou a complexidade, mas ela ainda está lá. Então, quando alguém nos pede um misto-quente, nós já sabemos que:
- Teremos que pegar duas fatias de pão de forma;
- Passar manteiga numa das faces de cada uma delas;
- Pegar duas fatias de presunto e duas fatias de queijo;
- Colocar as fatias entre as faces amanteigadas do pão de forma;
- Ligar a boca do fogão onde o sanduíche será feito;
- Colocar o sanduíche frio numa chapa ou numa sanduicheira;
- Controlar o aquecimento da chapa para que o sanduíche não queime;
- Retirar o sanduíche da chapa quando estiver pronto.
Eu poderia ser até mais detalhado, mas acho que você entendeu a ideia. O que eu preciso que você perceba, é que, no conjunto, o meu procedimento equivale diretamente ao enunciado: “faça um misto-quente” – ou seja, dizer uma coisa ou a outra teria o mesmo efeito. Como tanto o enunciado quanto o procedimento apontam para a realização do fato (ter um misto-quente), nós podemos dizer que ambos descrevem a solução, só que, no caso do enunciado, a solução está na forma de um problema, enquanto o procedimento é um modelo da solução.
Está bem, eu poderia ter dito que a segunda etapa do método é onde nos elaboramos um procedimento, mas qual seria a graça?
Antes de passarmos à próxima etapa, a modelagem tem uma regra importante: jamais se pergunte como os processos poderão ser realizados! A ideia aqui é determinar o que deve ser feito: o “como” é assunto para a implementação.
Etapa 3: Implementação
É nesta etapa que nós descobrimos o que poderá ser escrito para realizar cada um dos procedimentos descritos na etapa da modelagem. Sendo assim, nada melhor do que tentarmos aplicar o que já vimos do método ao nosso problema:
Escreva um script que receba uma LISTA DE NOMES, tanto por argumentos quanto pela entrada padrão, e imprima para cada nome da lista: “Um para NOME, um para mim”. Caso não seja passado nenhum nome, ou se não houver dados na entrada padrão, a mensagem deve ser: “Um para você, um para mim”.
Elaborando um enunciado
O que podemos depreender do enunciado (já que temos um)?
- Haverá uma lista de nomes;
- A lista pode ser passada como argumentos na linha do comando;
- A lista pode ser passada como linhas de texto pela entrada padrão;
- Cada
NOME
deve resultar na impressão da mensagem: “Um para NOME, um para mim.”; - Se não houver argumentos nem dados na entrada padrão, a mensagem será: “Um para você, um para mim.”
Modelagem da solução
Hora de elaborar um plano de ação:
- Verificar a presença de argumentos;
- Verificar a presença de dados na entrada padrão…
Aqui, nós temos um problema: e se houver dados tanto na entrada padrão quanto nos argumentos? Como o enunciado não fala nada sobre isso, a decisão é nossa: e eu voto por aceitarmos nomes de ambas as origens!
Já que você concordou comigo, vamos começar novamente a modelagem:
- Passo 1: Verificar a presença de argumentos
- Se houver, iterar cada argumento imprimindo a mensagem e seguir para o procedimento seguinte.
- Se não houver, seguir para o procedimento seguinte.
- Passo 2: verificar a presença de dados na entrada padrão
- Se houver, iterar cada linha imprimindo a mensagem e terminar o script.
- Se não houver, imprimir “Um para você, um para mim.” e terminar o script.
Implementando a solução
Excelente plano de ação, agora é só escrever o código!
Não?
Como assim, você não sabe nem por onde começar?
Se esta é a etapa dos “comos”, nós não precisamos ficar paralisados esperando as respostas caírem do céu: nós temos que elaborar perguntas e, como já elaboramos um ótimo plano de ação, esta será uma tarefa muito simples!
- O que nós já sabemos
- Argumentos na invocação do script podem ser acessados pelos parâmetros posicionais.
- A quantidade de argumentos pode ser expandida com o parâmetro especial
#
($#
). - Uma lista de todos os argumentos pode ser expandida com os parâmetros especiais
@
e*
. - Textos podem ser impressos com os comandos internos
echo
eprintf
. - A expansão condicional valor padrão expande uma string quando a uma variável não tem um valor ou não está definida.
- Perguntas a serem respondidas
- Como verificar a presença de argumentos na invocação de um script?
- Como verificar a presença de dados na entrada padrão de um script?
- Como escrever estruturas de repetição (loops) com o Bash?
- Como escrever estruturas de decisão com o Bash?
Viu como você conhece mais coisas do que desconhece? Essa é a ideia da etapa de implementação: não se trata de sair escrevendo código, só porque temos um bom plano de ação, mas de fazer as perguntas certas na hora certa – e a hora certa de perguntar os “comos” é só depois de determinarmos muito bem os “porquês”.
Por enquanto, só estamos entendendo as etapas do método: nós voltaremos à implementação da solução em seguida.
Estratégias de implementação
Depois de encontradas as respostas, ou até ao longo da pesquisa, existem algumas estratégias que podem ser muito úteis:
- Experimentação no terminal
Nós não precisamos adivinhar como as coisas funcionam: elaborar e executar pequenos experimentos no terminal é uma estratégia poderosa, tanto para a antevisão de comportamentos, quanto para nos acostumar a compreender o que para nos acostumar a compreender o que significa dividir um grande problema em problemas menores.
Por exemplo, na nossa modelagem, nós decidimos verificar se existem dados na entrada padrão da sessão do shell iniciada para executar o nosso script. Nas pesquisas, nós certamente encontraremos a sugestão de utilizar o comando
[[
com o operador-t
e o operando0
::~$ help test ... -t FD True if FD is opened on a terminal. ...
Sabendo que pipes e redirecionamentos ligam arquivos ao descritor de arquivos
0
do processo do shell, substituindo o terminal, você pode fazer alguns testes::~$ [[ -t 0 ]]; echo $? 0
A expansão da interrogação (
?
) resulta no estado de término do último comando executado. Como a afirmação no teste do Bash ([[
) foi avaliada como verdadeira (o descritor de arquivos0
está ligado a um terminal), o teste terminou com sucesso e o valor expandido foi0
.Para experimentar a situação do shell estar ligado a um pipe, nós podemos fazer assim:
:~ $ : | [[ -t 0 ]]; echo $? 1
Aqui, o teste foi executado em um pipe com o comando nulo (
:
), que não faz nada e só termina com sucesso. Como a entrada padrão agora está ligada a um arquivo (pipes são arquivos), o teste resultou em erro (valor diferente de0
).Isso não demonstra que existem dados na entrada padrão, mas é suficiente para sabermos que o shell está ligado a um arquivo para ler a entrada padrão.
Nós ainda veremos em detalhes como isso se encaixa na solução: o ponto aqui é a estratégia do experimento para investigar comportamentos simples que, eventualmente, poderão ser úteis na solução do problema.
- Prova de conceito
Ainda tendo o nosso plano de ação como exemplo, está claro que, de algum modo, nosso script terá que ler várias linhas de um arquivo ou diretamente de um pipe. Nós já sabemos que o comando interno
read
lê uma linha de dados na entrada padrão, mas como fazer para que ele leia várias linhas?Pesquisando, você descobriu que ele pode ser executado como comando testado do comando composto
while
(nós voltaremos a isso, não se preocupe), desta forma:while raed OPÇÕES VAR; do BLOCO DE COMANDOS done
Repare que, desta vez, não se trata mais de experimentar um simples comando, mas uma combinação de vários comandos e estruturas para formar um procedimento um pouco mais complexo. Ainda seria possível elaborar experimentos no terminal, mas a criação de um script temporário pode ser mais interessante, porque nos permitiria alterar alguns parâmetros e observar melhor o que muda e quais seriam as possibilidades de aplicação no nosso problema.
Sendo assim, eu criaria o meu script testes desta forma:
:~$ vim /tmp/testes.sh
Você pode criar o seu com o editor da sua preferência, o importante aqui, foi que eu criei o meu script no diretório
/tmp
, porque o próprio sistema se encarrega de apagar os arquivos que forem criados ali. No meu arquivo, eu experimentaria algo assim:# Terminar o script se não houver pipe ou redirecionamento de leitura... [[ -t 0 ]] && exit # Ler todas as linhas de dados na entrada padrão... while read linha; do echo $linha done
Salvando, eu testaria assim:
:~$ bash /tmp/testes.sh :~$
Ótimo! Como eu não executei meu script num pipe ou com um redirecionamento de leitura, ele simplesmente terminou sem fazer mais nada. Então, vamos criar um arquivo com nomes para fazer o teste de leitura:
:~$ cat << FIM > /tmp/nomes.txt > Helena > Luis Carlos > Nena > Blau > FIM :~$
Pronto, agora é só testar meu script lendo os nomes do arquivo
nomes.txt
::~$ bash /tmp/testes.sh < /tmp/nomes.txt Helena Luis Carlos Nena Blau :~$
Funcionou direitinho, mas eu posso brincar mais um pouco, por exemplo, alterando meu script para concatenar a linha lida com a mensagem que o problema pede:
# Terminar o script se não houver pipe ou redirecionamento de leitura... [[ -t 0 ]] && exit # Ler todas as linhas de dados na entrada padrão... while read linha; do echo "Um para $linha, um para mim." done
Salvando e testando novamente, este seria o resultado:
:~$ bash /tmp/testes.sh < /tmp/nomes.txt Um para Helena, um para mim. Um para Luis Carlos, um para mim. Um para Nena, um para mim. Um para Blau, um para mim. :~$
Aproveitando o embalo, eu ainda faria outros testes: por exemplo, verificando o que aconteceria se existisse uma linha em branco no meu arquivo (digamos que entre
Luis Carlos
eNena
). Alterando o arquivonomes.txt
, eu teria o seguinte resultado::~$ bash /tmp/testes.sh < /tmp/nomes.txt Um para Helena, um para mim. Um para Luis Carlos, um para mim. Um para , um para mim. Um para Nena, um para mim. Um para Blau, um para mim. :~$
Alterando mais uma vez meu script para expandir condicionalmente um valor padrão para a variável
linha
, ele ficaria assim:# Terminar o script se não houver pipe ou redirecionamento de leitura... [[ -t 0 ]] && exit # Ler todas as linhas de dados na entrada padrão... while read linha; do echo "Um para ${linha:-você}, um para mim." done
Testando mais uma vez…
:~$ bash /tmp/testes.sh < /tmp/nomes.txt Um para Helena, um para mim. Um para Luis Carlos, um para mim. Um para você, um para mim. Um para Nena, um para mim. Um para Blau, um para mim. :~$
Repare que isso não solucionou o problema completamente, mas me permitiu analisar boa parte dele e realizar vários experimentos que, além de me darem uma boa ideia do que poderá ser utilizado na implementação, me deram a noção exata dos comportamentos que eu posso esperar.
- Só escrever quando souber exatamente no que irá resultar
Sintetizando as duas estratégias anteriores, perceba que ambas têm o mesmo objetivo: a previsibilidade dos resultados, e isso é importante porque…
O script em que estamos escrevendo a solução não é lugar para testes!
Em outras palavras, no script final, nós só escrevemos aquilo que sabemos, com segurança, em que resultará. Poucas coisas são tão desastrosas para a solução de um problema do que o acúmulo de erros causados pelo efeito em cascata de uma série de concepções equivocadas sobre como o shell deveria se comportar. Lembre-se disso:
O shell nunca erra! Se o resultado não foi o esperado, foi você que esperou o resultado errado.
Com o tempo, talvez você não tenha que utilizar tanto o terminal ou criar scripts temporários para experimentar e conhecer os resultados, mas estas são etapas essenciais para que essas ocasiões se tornem cada vez mais frequentes. Você ainda cometerá erros, mas eles serão muito mais fáceis de serem encontrados e resolvidos.