Resolvendo Problemas de Expansão de Variáveis em Bash de Forma Eficaz
Scripts Bash frequentemente falham devido a erros sutis de expansão de variáveis. Este guia abrangente analisa problemas comuns como citação incorreta, tratamento de valores não inicializados e gerenciamento de escopo de variáveis em subshells e funções. Aprenda técnicas essenciais de depuração (`set -u`, `set -x`) e domine modificadores poderosos de expansão de parâmetros (como `${VAR:-default}`) para escrever scripts de automação robustos, previsíveis e à prova de erros. Pare de depurar strings vazias misteriosas e comece a criar scripts com confiança.
Resolvendo Problemas de Expansão de Variáveis em Bash de Forma Eficaz
Bugs de expansão de variáveis em Bash geralmente parecem comportamento aleatório: um caminho com espaços se torna dois caminhos, um curinga em um nome de arquivo se expande para metade do diretório, uma variável definida dentro de um loop desaparece ou uma variável de ambiente ausente silenciosamente se transforma em uma string vazia. O shell não está sendo aleatório. Ele está seguindo regras de expansão que são fáceis de esquecer quando você está focado na tarefa que o script deve executar.
O modelo mental útil é este: o Bash não simplesmente substitui $nome por texto e executa o comando. Ele expande variáveis, pode dividir o resultado em palavras, pode expandir globs e, em seguida, executa um comando com a lista de argumentos resultante. A maioria das correções vem do controle dessas etapas.
Variáveis não definidas se tornam vazias, a menos que você as impeça
Por padrão, este script imprime um valor vazio e continua:
printf 'Implantando %s\n' "$APP_VERSION"
Se APP_VERSION era obrigatório, isso é um bug. Use a expansão de parâmetros quando a variável for obrigatória:
: "${APP_VERSION:?APP_VERSION deve ser definida}"
printf 'Implantando %s\n' "$APP_VERSION"
O : inicial é o comando nulo. A expansão faz a verificação. Se a variável não estiver definida ou estiver vazia, o Bash imprime a mensagem e sai de um shell não interativo.
Para valores opcionais, torne o padrão óbvio:
nivel_log=${LOG_LEVEL:-INFO}
qtd_tentativas=${RETRY_COUNT:-3}
Os dois pontos são importantes. ${VAR:-default} usa o padrão quando VAR não está definida ou está vazia. ${VAR-default} usa o padrão apenas quando VAR não está definida. Essa distinção é importante se uma string vazia for um valor de configuração válido.
set -u também pode capturar variáveis não definidas:
set -u
É útil em muitos scripts, mas não substitui uma validação clara. Também pode surpreender ao trabalhar com parâmetros posicionais opcionais, arrays ou variáveis que são intencionalmente verificadas quanto à existência. Use ${1:-} quando um argumento pode estar ausente:
modo=${1:-ajuda}
Coloque variáveis entre aspas, a menos que queira divisão e globbing
Este é o problema de expansão mais comum:
arquivo="Relatório Trimestral *.txt"
rm $arquivo
Sem aspas, o Bash primeiro expande $arquivo, depois o divide nos espaços e então trata * como um curinga. O comando pode receber vários argumentos que você não pretendia. Com aspas, ele recebe exatamente um argumento:
rm -- "$arquivo"
O -- protege os comandos de valores que começam com um traço. Isso é importante para nomes de arquivo como -rf.
Use aspas duplas para variáveis, substituições de comando e a maioria das expansões de parâmetros:
cp "$arquivo_origem" "$diretorio_destino/"
printf 'Usuário: %s\n' "$nome_usuario"
Aspas simples são diferentes. Elas impedem a expansão completamente:
printf 'Home é $HOME\n' # imprime o texto literal
printf "Home é $HOME\n" # imprime o valor
Se você vir um script construindo strings como 'prefixo-$valor', isso é provavelmente um bug. Use aspas duplas quando o valor deve ser expandido.
Arrays resolvem muitos problemas de construção de argumentos
Muito Bash quebrado vem de armazenar várias opções de comando em uma única string:
opts="-a --delete --exclude *.tmp"
rsync $opts "$origem/" "$destino/"
Isso depende da divisão de palavras e pode quebrar quando um argumento de opção contém espaços. Use um array:
opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$origem/" "$destino/"
"${opts[@]}" expande cada elemento do array como seu próprio argumento. Isso é exatamente o que a maioria das construções de comando precisa.
O mesmo se aplica ao coletar nomes de arquivo:
arquivos=("$diretorio_relatorios"/*.txt)
for arquivo in "${arquivos[@]}"; do
[[ -e $arquivo ]] || continue
processar_relatorio "$arquivo"
done
A proteção [[ -e $arquivo ]] || continue lida com o caso em que nenhum arquivo correspondeu e o glob permaneceu literal, dependendo das opções do shell.
A substituição de comando remove novas linhas finais
$(comando) captura a saída padrão, mas o Bash remove os caracteres de nova linha finais. Geralmente, isso é bom para uma string de versão e errado para dados onde as novas linhas finais são importantes.
versao=$(git describe --tags --always)
printf 'Versão: %s\n' "$versao"
Para saída orientada a linhas, prefira mapfile quando precisar de um array:
mapfile -t nomes < <(find "$diretorio_base" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for nome in "${nomes[@]}"; do
printf 'log=%s\n' "$nome"
done
Evite for item in $(ls). Isso quebra com espaços em branco, caracteres glob e nomes de arquivo incomuns. Faça loop sobre globs ou use find com delimitadores cuidadosos.
Variáveis em pipes podem estar em um subshell
Isso pega as pessoas porque o loop parece ser executado corretamente:
contagem=0
printf '%s\n' a b c | while IFS= read -r linha; do
contagem=$((contagem + 1))
done
printf 'contagem=%s\n' "$contagem"
Em muitas configurações do Bash, o loop while em um pipe é executado em um subshell. O incremento acontece, mas a variável contagem do shell pai permanece inalterada.
Use a substituição de processo:
contagem=0
while IFS= read -r linha; do
contagem=$((contagem + 1))
done < <(printf '%s\n' a b c)
printf 'contagem=%s\n' "$contagem"
Ou faça o pipe produzir o valor que você precisa e capture esse valor diretamente.
Variáveis locais evitam sobrescrições acidentais
Variáveis em funções Bash são globais, a menos que sejam declaradas como local. Isso pode transformar uma função auxiliar em uma fonte de estranhos bugs de expansão:
ambiente=producao
carregar_config() {
ambiente=desenvolvimento
}
carregar_config
printf '%s\n' "$ambiente" # desenvolvimento
Use local para valores temporários:
carregar_config() {
local ambiente=desenvolvimento
printf 'padrões carregados para %s\n' "$ambiente"
}
local é um recurso do Bash. Isso é bom em scripts Bash, mas é outra razão pela qual o script não deve ser executado com sh.
Use chaves quando os nomes tocam em outro texto
$prefixo_arquivo significa uma variável chamada prefixo_arquivo, não $prefixo seguido por _arquivo. Use chaves para tornar o limite claro:
prefixo=app
printf '%s\n' "${prefixo}_arquivo"
Chaves também são necessárias para muitas operações de expansão de parâmetros:
caminho=/var/log/nginx/access.log
printf 'dir=%s\n' "${caminho%/*}"
printf 'arquivo=%s\n' "${caminho##*/}"
${caminho%/*} remove o sufixo mais curto correspondente. ${caminho##*/} remove o prefixo mais longo correspondente. Eles são úteis, mas não os use em excesso quando dirname ou basename tornariam o script mais claro para sua equipe.
Depure a expansão imprimindo os argumentos reais
set -x mostra os comandos após a expansão. Melhore o rastreamento com números de linha:
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $arquivo $diretorio_destino
set +x
O rastreamento revelará se o comando se tornou mv Relatório Trimestral *.txt /tmp/saida ou mv 'Relatório Trimestral *.txt' /tmp/saida. Mantenha o xtrace longe de segredos.
Para uma verificação manual mais segura, imprima valores com %q:
printf 'arquivo=%q\n' "$arquivo" >&2
printf 'diretorio_destino=%q\n' "$diretorio_destino" >&2
%q torna espaços e caracteres especiais visíveis de uma forma mais fácil de ler do que echo simples.
Uma lista de verificação prática
Quando uma variável Bash se expande incorretamente, verifique estas coisas em ordem:
- O script está sendo executado sob Bash, não
sh? - A variável está realmente definida? Use
${VAR:?mensagem}para valores obrigatórios. - Toda expansão está entre aspas, a menos que a divisão seja intencional?
- Você está usando um array para múltiplos argumentos?
- Um pipe colocou seu loop em um subshell?
- Uma função sobrescreveu uma variável global porque
localestava faltando? - As chaves são necessárias para separar o nome da variável do texto próximo?
Essas verificações são chatas da melhor maneira possível. Elas transformam a maioria dos bugs de expansão de "Bash é estranho" em uma regra específica e corrigível.
Expansão indireta e namerefs merecem cuidado extra
O Bash pode expandir uma variável cujo nome está armazenado em outra variável:
nome=APP_ENV
printf '%s\n' "${!nome}"
Isso imprime o valor de APP_ENV. É poderoso, mas torna os scripts mais difíceis de ler e pode se tornar inseguro se o nome da variável vier da entrada do usuário. Se você só precisa de um mapeamento de nomes para valores, um array associativo é mais claro:
declare -A endpoints=(
[dev]='https://dev.example.test'
[prod]='https://api.example.com'
)
printf '%s\n' "${endpoints[$ambiente]:?ambiente desconhecido}"
O Bash também tem namerefs com declare -n, frequentemente usados em funções auxiliares. Eles são úteis em scripts no estilo de bibliotecas, mas podem criar efeitos colaterais surpreendentes. Use-os apenas quando passar um array ou variável por referência realmente simplificar o código.
Remoção de padrão não é correspondência de expressão regular
Operadores de expansão de parâmetros como ${arquivo%.log} e ${caminho##*/} usam padrões de shell, não expressões regulares. Essa diferença é importante.
arquivo='access.log'
printf '%s\n' "${arquivo%.log}"
Isso remove um sufixo .log. Não significa "remova qualquer coisa que corresponda a uma regex". Para verificações de regex, use [[ ... =~ ... ]]:
if [[ $porta =~ ^[0-9]+$ ]]; then
printf 'numérico\n'
fi
Mesmo aí, cite com cuidado. O lado direito de =~ geralmente é deixado sem aspas quando você deseja que seja tratado como uma regex. A variável do lado esquerdo não deve precisar de aspas dentro de [[ ]], porque [[ ]] não realiza divisão de palavras da mesma forma que [ ].
Exporte apenas o que os processos filhos precisam
Definir uma variável no Bash não a disponibiliza automaticamente para os comandos que o script inicia:
APP_ENV=producao
./executar-app
executar-app não verá APP_ENV a menos que seja exportada ou fornecida inline:
export APP_ENV=producao
./executar-app
# ou
APP_ENV=producao ./executar-app
Esta é uma fonte comum de confusão quando um script imprime o valor correto, mas um processo filho se comporta como se o valor estivesse faltando. A variável existe no shell; ela nunca foi colocada no ambiente para o filho.
O inverso também é verdadeiro: um processo filho não pode alterar as variáveis do shell pai. Se um script auxiliar imprime export TOKEN=..., executá-lo normalmente não atualizará o chamador. Você teria que usar source, e o uso de source deve ser reservado para código shell confiável.
Uma revisão do mundo real antes de enviar
Antes de considerar um script ou configuração de contêiner finalizado, leia-o uma vez como se você fosse a próxima pessoa que terá que depurá-lo às 2 da manhã. Isso muda o que você percebe. Um prompt que fazia sentido enquanto escrevia o script pode ser ambíguo quando aparece em um log de CI. Um nome de serviço Docker que parecia óbvio pode não corresponder ao nome da variável no aplicativo. Um padrão Bash pode ser seguro para desenvolvimento e perigoso para produção.
Gosto de fazer um teste rápido com valores deliberadamente estranhos. Use um caminho com espaços. Use um valor opcional vazio. Tente um nome de arquivo que comece com um traço. Execute o script de um diretório de trabalho diferente. Inicie o contêiner sem uma variável de ambiente esperada. Esses testes não são sofisticados, mas capturam as suposições que geralmente quebram primeiro.
Verifique também a mensagem de falha. Se a única saída for falhou, o conselho do artigo não chegou à implementação. Uma falha útil diz qual valor foi usado, qual verificação falhou e o que o operador pode alterar. Isso não significa despejar todas as variáveis de ambiente ou imprimir segredos. Significa ser específico onde a especificidade ajuda: o caminho de configuração, o nome do comando ausente, o nome da rede, o nome do host do serviço ou a porta que o processo tentou vincular.
O hábito final é manter os exemplos próximos da forma como o sistema é realmente executado. Se a produção usa Compose, teste com Compose. Se um script é iniciado por systemd, teste com systemd ou com um ambiente igualmente mínimo. Se um comando deve ser seguro para copiar e colar, inclua as aspas, separadores -- e validação no próprio exemplo. Os leitores copiam padrões de trabalho com mais frequência do que copiam avisos.
Essa revisão não é burocracia. É como a pequena automação permanece chata. Chato é o que você quer de prompts de shell, carregadores de configuração, expansão de variáveis, diagnósticos de contêiner e rede Docker. Quanto menos surpreendente for o comportamento, mais fácil será para o próximo operador confiar nele.
Para expansão de variáveis especificamente, adicione mais um hábito a essa revisão: imprima a contagem de argumentos quando um comando se comportar de forma estranha. Um pequeno auxiliar pode tornar o invisível visível:
mostrar_args() {
local i=1
for arg in "$@"; do
printf 'arg[%d]=%q\n' "$i" "$arg" >&2
i=$((i + 1))
done
}
mostrar_args mv $arquivo $diretorio_destino
mostrar_args mv "$arquivo" "$diretorio_destino"
A primeira chamada mostra o que o comando quebrado receberia; a segunda mostra a versão corrigida. Depois de ver a lista de argumentos, os bugs de citação deixam de ser misteriosos.