☺ sorria, você está sendo programado
| About

# Jornal dos Pindoramas #5: Noite quente de verão e sem luz: como ler valores com espaço para um array no Bash (ou ksh93)

Note for non-lusophones: If you can’t read any Portuguese, click here.

Caso eu ainda não tenha dito: sim, estou de férias! E em pleno litoral, olha só.
E, como diria algum jornalista/escritor/intelectual cujo nome não me lembro (e cuja frase em questão eu li no Twitter), férias são uma desculpa muito cara para poder se ler um livro. No meu (ou melhor, nosso) caso, são uma ótima desculpa para continuar trabalhando — e também ler um livro, por que não?
A questão é que, apesar de ser um ótimo mar, não é um mar de rosas: descobri que no litoral gaúcho a falta de luz consegue ser mais recorrente e duradoura do que em Gramado durante o verão (o que eu confesso ter pensado não ser possível), e o, digamos, “suporte técnico” aqui, da CEEE Equatorial, não é tão responsivo quanto o da Serra Gaúcha pelo o que me disseram, então escrevo, nesse exato momento, em breu total (e sem um dicionário por perto para verificar minha escrita numa parte ou outra). Dito tudo isso, podemos finalmente começar o artigo.

Eu resolvi voltar a polir a Mitzune para usar no sistema de montagem do Copacabana — até porque eu a destinei para isso ainda lá em 2021, mas nunca tinha chegado no estágio da automação até agora. Caso você ainda não saiba, a Mitzune é um script de automação para o comando chroot, mas não vou entrar em muitos detalhes para não alongar a conversa. Inclusive, se você for tolerante a mau áudio e problemas de codec, eu fiz um vídeo (também lá em 2021) que mostrava o funcionamento prático da Mitzune como containerizador.
Como todo projeto relativamente antigo que fiz, tem falhas. O código em questão até que é um bom exemplo de como escrever shell, modéstia à parte, mas muita coisa foi feita no espírito do hack, ou seja, sem considerar pormenores que não tomariam cinco ou seis linhas de código para resolver e que poderiam causar vários problemas em qualquer ambiente Linux mais heterodoxo (ou caso alguém use espaços em nome de arquivos).
Dentre esses pormenores, tem o arquivo que funciona como um “proto-banco de dados” (sendo de enorme pretensão minha ao chamá-lo assim), que guarda os chroots que a Mitzune tem acesso e suas respectivas configurações, o prefixes. Esse arquivo, originalmente, tem nove colunas, todas separadas por… espaços. Sim, espaços, afinal foi o mais simples de implementar na época:

function show_prefix_info {
    # [...]
    prefix_info=($(grep "$prefixName" "$mitzune_prefix/prefixes"))
    prefix_partition=$(df -H "${prefix_info[2]}" | awk 'FNR==2 {print $1}')

A questão é que os tempos mudam e, apesar de não ser essencial para o sistema de montagem do Copacabana, eu creio que seja uma boa hora para começar a se consertar isso — e também porque eu andei a implementar umas poucas coisas novas nesse projeto para se adequar às necessidades do Copacbana.
Como novo caractere para se delimitar os elementos, escolhi o |, pois é a escolha mais sã nesse caso. Além disso, também vou testar no terminal antes de mexer direto no código da Mitzune para poupar tempo.

A primeira coisa que alguém pensaria é em usar o sed:

sed 's/|/" "/g; s/\(.*\)/"\1"/'

Simples, só substituir as barras verticais por aspas e depois fechar tudo com aspas no começo e no fim. Ou seja, uma entrada hipotética assim:

a|b c|d|e|f g h|i|j k|l|m n o p

Ficaria primeiro assim (s/|/" "/g):

a" "b c" "d" "e" "f g h" "i" "j k" "l" "m n o p

E, por fim, assim (s/\(.*\)/"\1"/):

"a" "b c" "d" "e" "f g h" "i" "j k" "l" "m n o p"

Missão cumprida!

Ou será que não?

S145% t=($(echo 'a|b c|d|e|f g h|i|j k|l|m n o p' | sed 's/|/" "/g; s/\(.*\)/"\1"/'))
+ sed 's/|/" "/g; s/\(.*\)/"\1"/'
+ echo 'a|b c|d|e|f g h|i|j k|l|m n o p'
+ t=( '"a"' '"b' 'c"' '"d"' '"e"' '"f' g 'h"' '"i"' '"j' 'k"' '"l"' '"m' n o 'p"' )
S145% print -v t
+ print -v t
(
        '"a"'
        '"b'
        'c"'
        '"d"'
        '"e"'
        '"f'
        g
        'h"'
        '"i"'
        '"j'
        'k"'
        '"l"'
        '"m'
        n
        o
        'p"'
)

Tudo que fizemos foi por água abaixo quando o shell avalia os elementos e coloca-os no array.
“Pois trate de mudar o tipo de aspas!”

S145% t=($(echo 'a|b c|d|e|f g h|i|j k|l|m n o p' | sed 's/|/'\'' '\''/g; s/\(.*\)/'\''\1'\''/'))
+ echo 'a|b c|d|e|f g h|i|j k|l|m n o p'
+ sed $'s/|/\' \'/g; s/\\(.*\\)/\'\\1\'/'
+ t=( $'\'a\'' $'\'b' $'c\'' $'\'d\'' $'\'e\'' $'\'f' g $'h\'' $'\'i\'' $'\'j' $'k\'' $'\'l\'' $'\'m' n o $'p\'' )
S145% print -v t
+ print -v t
(
        $'\'a\''
        $'\'b'
        $'c\''
        $'\'d\''
        $'\'e\''
        $'\'f'
        g
        $'h\''
        $'\'i\''
        $'\'j'
        $'k\''
        $'\'l\''
        $'\'m'
        n
        o
        $'p\''
)

E sim, o mesmo ainda ocorre se usar " \' \' " ao invés de ' '\'' '\'' ' no sed.
E sim², também vai acontecer se você tentar fazer o trabalho do sed de forma manual. E você talvez se pergunte como daria para se fazer esse trabalho do sed de forma manual, e é assim:

sed 's/|/\
/g' | while read l; do
            printf '"%s"\n' "$l"
    done

Ainda usa o sed para substituir os separadores por quebras de linha, mas é “manual”, ora pois. Enfim, dá o mesmo resultado que acima, e é pior de ler dentro de um subshell dentro de uma declaração de um array.
Um iniciante empolgado e um tanto pretensioso (não no mau-sentido) possivelmente tentaria isso.

Então voltamos à estaca zero: como podemos lidar com esses separadores e ler os elementos corretamente?
Simples, fazer como se faria numa linguagem normal. Ao invés de jogar os elementos dentro de um subshell dentro de uma declaração de array, podemos adicionar elemento por elemento no array:

sed 's/|/\     
/g' | for ((elem=0;; elem++)); do
        if read l; then
            t[$elem]="$l"
        else
            break
        fi
    done

Sim, ainda usamos o sed para substituir os separadores por quebras de linha e então passar para o laço for, para então ser lido linha por linha pelo read e adicionado no array:

+ echo 'a|b c|d|e|f g h|i|j k|l|m n o p'
+ ((elem=0))
+ ((1))
+ read l
+ sed $'s/|/\\\n/g'
+ t[0]=a
+ ((elem++))
+ ((1))
+ read l
+ t[1]='b c'
+ ((elem++))
+ ((1))
+ read l
+ t[2]=d
+ ((elem++))
+ ((1))
+ read l
+ t[3]=e
+ ((elem++))
+ ((1))
+ read l
+ t[4]='f g h'
+ ((elem++))
+ ((1))
+ read l
+ t[5]=i
+ ((elem++))
+ ((1))
+ read l
+ t[6]='j k'
+ ((elem++))
+ ((1))
+ read l
+ t[7]=l
+ ((elem++))
+ ((1))
+ read l
+ t[8]='m n o p'
+ ((elem++))
+ ((1))
+ read l
+ break
S145% print -v t
+ print -v t
(
        a
        'b c'
        d
        e
        'f g h'
        i
        'j k'
        l
        'm n o p'
)

Funciona, ficou limpo e simples. Até já daria para parar por aqui.
Eu implementaria assim, assim como creio que a maioria dos programadores também faria assim. Mas ainda dá para melhorar.
Sim, dá para trocar a quebra de linha literal (vulgo “POSIX”) no sed pela quebra de linha que teríamos no printf (uma extensão originada no KornShell 93 conhecida como ANSI C strings), ficando, assim, $'s/|/\\\n/g'; mas e se desse para não usar o sed?
Agora é a hora que o pessoal do C, Go (e a maioria das linguagens, francamente) arregalará os olhos:

s='a|b c|d|e|f g h|i|j k|l|m n o p'
elem=0
for ((c=0; c<${#s}; c++)); do
    curchar="${s:$c:1}"
    if [[ "$curchar" == '|' ]]; then
        ((elem+= 1))
        continue
    fi
    t[$elem]+="$curchar"
done

Sim, talvez seja um bocado mais lento e é argumentavelmente mais frágil do que a implementação acima — afinal estamos, agora, incrementando caracteres no elemento do array ao invés de fazer uma nova associação —, mas é puramente em shell, sem chamar nada de fora. É lógica pura.

Mas bem, quando disse que é “mais frágil”, é porque, se você rodaresse código de novo sem dar um unset no array criado, teremos a mesma informação duplicada:

S145% print -v t
(
        aa
        'b cb c'
        dd
        ee
        'f g hf g h'
        ii
        'j kj k'
        ll
        'm n o pm n o p'
)

Para isso da duplicação, podemos simplesmente inicializar o próximo elemento do array com uma string vazia:

# [...]
    if [[ "$curchar" == '|' ]]; then
        ((elem+= 1))
        t[$elem]=""
        continue
    fi
# [...]

Mas isso levanta o problema de que o primeiro elemento nunca será limpo, então apenas o primeiro elemento se torna duplicado enquanto os outro seguem corretos:

S145% print -v t
(
        aa
        'b c'
        d
        e
        'f g h'
        i
        'j k'
        l
        'm n o p'
)

A solução em si? Bem, infelizmente não podemos fazer a inicialização do primeiro elemento na declaração do laço, pois o shell trata tudo dentro das parênteses duplas como uma expressão matemática, logo não temos como trabalhar com strings aqui:

+ ((t[0]="", c=0))
/usr/bin/ksh: elem=0, t[0]="", c=0: arithmetic syntax error

Mas nem tudo está perdido. Temos diversas soluções para isso, mas a primeira que me veio à cabeça foi:

for ((c=0; c<${#s}; c++)); do
    curchar="${s:$c:1}"
    [[ "$curchar" == '|' ]] && ((elem+= 1))
    [[ (($c == 0)) || "$curchar" == '|' ]] && t[$elem]=""
    [[ "$curchar" == '|' ]] && continue
    t[$elem]+="$curchar"
done 

Não é absolutamente lindo, mas funciona bem. Tá, para melhorar visualmente até daria para escrever assim:

for ((c=0; c<${#s}; c++)); do
    curchar="${s:$c:1}"
    (($c == 0)) && t[$elem]=""
    if [[ "$curchar" == '|' ]]; then
        ((elem+= 1))
        t[$elem]=""
        continue
    fi
    t[$elem]+="$curchar"
done 

Também temos de zerar o inteiro que guia a posição no array (no caso sendo o $elem), mas isso creio que dê para fazer na própria declaração do laço for:

for ((elem=0, c=0; c<${#s}; c++)); do
# [...]

Caso contrário, mesmo dando unset no array criado, teremos a informação posicionada à frente do índice que desejamos:

S145% print -v t
(
        [8]=a
        [9]='b c'
        [10]=d
        [11]=e
        [12]='f g h'
        [13]=i
        [14]='j k'
        [15]=l
        [16]='m n o p'
)

O código corrigido para ambos esses problemas seria assim:

for ((elem=0, c=0; c<${#s}; c++)); do
    curchar="${s:$c:1}"
    (($c == 0)) && t[$elem]=""
    if [[ "$curchar" == '|' ]]; then
        ((elem+= 1))
        t[$elem]=""
        continue
    fi
    t[$elem]+="$curchar"
done 

Funciona, é portável, ficou bonitinho, mas não é tão rápido…
A pergunta de milhões: tem como fazer isso de forma mais rápida, portável (ao menos entre o Broken-Again Shell e o KornShell 93) e em uma linha?
Sim, tem!

IFS='|' read -r -A t <<< "$s"

Exatamente: só o read.
O problema é quando a gente chega no Bash:

bash: read: -A: invalid option
read: usage: read [-ers] [-a array] [-d delim] [-i text] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd] [name ...]

A solução é tão besta quanto o problema: usar -a ao invés do -A.
A opção -a se tornou sinônimo para a -A do Bash no ksh2020, mas não fui atrás da versão exata, apenas da nota no arquivo NEWS do repositório atual do KornShell 93. Isso pode ser resolvido de duas formas: usando uma condição para verificar se estamos no KornShell ou no Bash ou simplesmente usando a opção -a e ignorando versões antigas do KornShell:

if [[ -n ${.sh.version} ]]; then
    IFS='|' read -r -A t <<< "$s"
else # Bash
    IFS='|' read -r -a t <<< "$s"
fi

E bem, o quão mais rápido isso é? Não sei, vejamos juntos.
Para fazer o teste, usarei o método do prof.º Júlio Cezar Neves, que é rodar a mesma coisa 200 vezes com o time:

S145% cat teste1.ksh                                                      
s='a|b c|d|e|f g h|i|j k|l|m n o p'
for ((i=0; i<200; i++)); do
        IFS='|' read -r -A t <<< "$s"
done
localhost% cat teste2.ksh
s='a|b c|d|e|f g h|i|j k|l|m n o p'
for ((i=0; i<200; i++)); do
        for ((elem=0, c=0; c<${#s}; c++)); do
            curchar="${s:$c:1}"
            (($c == 0)) && t[$elem]=""
            if [[ "$curchar" == '|' ]]; then
                ((elem+= 1))
                t[$elem]=""
                continue
            fi
            t[$elem]+="$curchar"
        done
done

Cá os resultados:

S145% time ksh teste1.ksh   

real    0m00.03s
user    0m00.01s
sys     0m00.01s
S145% time ksh teste2.ksh 

real    0m00.10s
user    0m00.09s
sys     0m00.00s

Em suma, a nossa implementação “manual”, puramente algorítmica, foi três vezes mais lenta do que o read.

Para fechar com chave de ouro (e ser justo), também vou comparar com a nossa primeira implementação usando o sed, que achei ser mais rápida que a nossa “manual”:

S145% cat teste3.ksh
for ((i=0; i<200; i++)); do
        printf 'a|b c|d|e|f g h|i|j k|l|m n o p' |
                sed $'s/|/\\\n/g' |
                for ((elem=0;; elem++)); do
                        if read l; then
                            t[$elem]="$l"
                        else
                            break
                        fi
                done
done

E o resultado é que eu estava redondamente errado:

S145% time ksh teste3.ksh

real    0m01.30s
user    0m00.90s
sys     0m00.66s

Isso foi bom para vermos que nem sempre chamar um programa “de fora” do shell vai ser mais rápido do que fazer uma operação interna, mesmo que seja num SSD.
Ela não foi minimamente mais lenta, ela foi 13 vezes mais lenta do que a “manual”, essa que já era lenta comparada com o read. Numa comparação dessa implementação diretamente com o read, ela é cerca de 43 vezes mais lenta.
Organizando tudo isso numa tabela, só pelo frufru (e para facilitar a vida dos “leitores skimmers”), temos:

tempo real user sys
read + IFS='|' 0m00.03s 0m00.01s 0m00.01s
Manual (caractere por caractere) 0m00.10s 0m00.09s 0m00.00s
sed + for + read 0m01.30s 0m00.90s 0m00.66s

Nota de 14/01/2026: Acredito que valha a pena falar sobre o quão “à prova de balas” esses métodos são, já que essa é uma preocupação recorrente de qualquer um que esteja ganhando experiência com shell. Em suma, todos são “à prova de balas” no sentido de não termos interpretação de variáveis/subshells que venham a estar presentes (maliciosamente ou não) na linha a ser “parseada”.

Talvez uma forma decente de terminar esse artigo seja mostrando como isso fica no código da Mitzune.

function show_prefix_info {
        # Transforms the line containing the $prefixName information
        # in an array.
        grep "$prefixName" "$mitzune_prefix/prefixes" |
                IFS='|' read -r -a prefix_info

É, ficou bom. Talvez eu ainda precise mexer na expressão do grep para “pegar” estritamente apenas o prefixo passado, e não similares (ex.: copacabana e copacabana-0.4), mas isso é coisa pouca.

À altura em que escrevo, a luz já voltou e não está mais tão calor. De fato, já termino de escrever hoje, dia 11, às 20h.
Preciso otimizar não apenas código, mas também minha escrita. :^)

E bem, depois de todo o artigo, talvez você ainda esteja se perguntando por que tem um artigo técnico no meio do JdP sendo que até agora só foi usado para resumir semanas (ou meses) de progresso no projeto Pindorama e se este artigo não deveria estar em qualquer outra parte do blog. A resposta é que decidi passar a escrever esse tipo de artigo como parte do JdP sempre que se tratar de algo do Pindorama — ou seja, quase todos os novos artigos técnicos serão parte do JdP, presumo.

Agradeço pela leitura e até a próxima.