Bash scripting - istruzioni composte

Da Guide@Debianizzati.Org.

Bash scripting

Sommario

  1. Introduzione
  2. Comandi essenziali
  3. Variabili (stringhe)
  4. Caratteri di escape, apici e virgolette
  5. Espansioni in stringhe quotate
  6. Espansioni non quotabili
  7. Istruzioni composte
  8. Funzioni
  9. File descriptor e redirezioni
  10. Segnali

Indice

Sono istruzioni composte tutte le istruzioni che possono contenere al loro interno altre istruzioni. Tipicamente sono introdotte e terminate da parole chiave (keywords), in quanto definiscono regole speciali al loro interno, spesso facendo uso anche di parole riservate che agiscono come istruzioni.

Per esempio if e for sono due istruzioni composte (e due keywords):

Cicli

Con while è possibile eseguire un blocco di comandi se un comando ha successo e ripeterne l'esecuzione fintanto che il comando continua ad avere successo. Proprio come if, while è un'istruzione accompagnata spesso da [...], utilizzato come comando di cui valutare l'exit status.

Sintassi base (in congiunzione con [...]):

while [ espressione-booleana ]
do
   ...
done

Per esempio:

num=
# ripeti finché non è soddisfatta la condizione (si noti il ! davanti a [...])
while ! [ "$num" -gt 0 ] 2> /dev/null
do
   printf %s "Inserisci intero > 0 e premi INVIO: "
   # assegna l'input dell'utente a $num
   read -r num
done
printf %s\\n "Hai inserito: ${num}"

Ripeti per $n volte (ciclo con incremento, da 1 a $n):

i=1
while [ "$i" -le "$n" ]
do
    ...
    i=$(($i + 1))
done

Oppure al posto di [...] si può utilizzare un comando qualsiasi, per esempio per eseguire un ciclo infinito:

while :
do
   ...
done

Un ciclo può essere sempre:

Si noti che in presenza di cicli con incremento, l'uso di continue può portare a un ciclo infinito se non viene eseguito l'incremento:

i=1
while [ "$i" -le "$n" ]
do
    if [ espressione-booleana ]; then
        i=$(($i + 1))       # incremento prima di continue!
        continue            # senza incremento potrebbe portare a un ciclo infinito
    fi
    ...
    i=$(($i + 1))           # incremento
done

Alternativamente in bash (non POSIX) si può utilizzare il ciclo for con una sintassi C-like, ereditata da csh:

for ((i=1; i <= n; i++))
{
    ...
}

E non è una svista la mancanza di $ davanti al nome delle variabili all'interno di ((...)). Tale sintassi è supportata anche nell'espansione aritmetica intera, ma non è prevista da POSIX, e quindi non è mai stata usata in questa guida.

Composizione di comandi con pipe

Una pipe collega due comandi, trattando l'output generato dal primo come input per il secondo, in modo da svolgere funzioni più complesse tramite la composizione di comandi che si occupano di funzioni più limitate. Ed è una delle caratteristiche di Unix che ogni comando debba svolgere un'unica funzione nel modo migliore possibile, permettendo la comunicazione con l'utente o un altro comando attraverso un flusso di testo, considerato un'interfaccia universale.

L'exit status di una serie di pipe è dato dall'ultimo comando. L'exit status può essere invertito come per i comandi semplici mettendo ! davanti al primo comando della serie.

Sintassi:

I più comuni comandi esterni impiegati con una pipe (per informazioni sulle loro opzioni si lascia alla lettura della loro pagina di manuale):

Per esempio:

# filtra le occorrenze di video nel ring buffer del kernel
dmesg | grep -i video   # -i per ignore-case (ignora maiuscole/minuscole)

# stampa il gateway utilizzato
ip route |              # stampa la tabella di routing
    grep "^default" |   # filtra solo la riga iniziante con default
    awk "{ print \$3 }" # stampa il terzo campo ($3 non dev'essere espanso dalla shell)

Output dei comandi e carattere ASCII n. 0

Una variabile non può contenere il carattere ASCII n. 0, che è usato per indicare la fine della stringa. Non potendo gestire direttamente il carattere ASCII n. 0, questo non può essere presente nell'espansione di un comando, ma dev'essere riservato ai soli comandi mediante l'uso di una o più pipe.

Infatti non esiste un modo di contenere il carattere ASCII n. 0 in nessuna posizione:

var=$'\000'                # SBAGLIATO: $var è vuota
var=$'X\000X'              # SBAGLIATO: $var contiene X (solo la prima!)
var=$(printf %s\000%s X X) # SBAGLIATO: $var contiene XX

Si noti che mentre è rimosso dalle espansioni, nelle assegnazioni normali viene interpretato come carattere di fine stringa, con il risultato che anche tutto ciò che segue viene tralasciato.

Una possibilità è memorizzare l'output in una codifica alternativa, per esempio base64, esadecimale o ottale, richiamando un opportuno comando esterno, così da utilizzare soltanto caratteri ammissibili:

# comando con output arbitrario
output_base64=$(comando | base64) # trasformo in base64 e assegno alla variabile
# e lo posso riaccedere riusando base64
printf %s\\n "$output_base64" |   # lo invio a base64 per ritrasformarlo
    base64 -d |                   # decodifico nel formato originale
    altro_comando                 # lo invio come input a un altro comando

È ovviamente un metodo molto inefficiente, ma l'unico permesso per memorizzare in una variabile. In alcuni casi potrebbe essere preferibile una codifica esadecimale o ottale, ancora più inefficiente quanto a spazio occupato, ma più semplici da manipolare dalla shell.

Nomi di file con caratteri non comuni

Questo carattere è utile perché nemmeno i file possono averlo nel proprio nome, mentre invece permettono caratteri jolly (*, ?, ...), come già visto con le espansioni di percorso, e potrebbero contenere perfino il carattere "a capo".

L'espansione di percorso funziona normalmente, anche in presenza di "a capo", ma potrebbero sorgere problemi sfruttando l'espansione di comando. Per esempio con il comando esterno find, utilizzato per effettuare ricerche in modo ricorsivo, che di default restituisce i file trovati stampandoli uno per riga, assumendo implicitamente che non contengano il carattere "a capo".

Uno script a titolo esemplificativo:

# creo una directory e un file con "a capo" nel nome
mkdir ./prova
touch ./prova/"file contenente"$'\n'"a capo nel nome"

# SBAGLIATO! (per via del nome del file particolare)
num_file=$(find ./prova -type f | # stampo i file regolari nella directory, uno per riga
           wc -l)                 # conto il numero di righe 

printf %s\\n "La directory prova contiene ${num_file} file" # restituisce 2 invece di 1

# forma corretta (ma non la più efficiente, è solo per esemplificare l'uso del carattere)
num_file=$(find ./prova -type f -print0 | # stampo il carattere ASCII n. 0 dopo ogni file
	   tr -dc '\000' |                # rimuovo tutti i caratteri ASCII diversi dal n. 0
           wc -c)                         # conto il numero di caratteri

printf %s\\n "La directory prova contiene ${num_file} file" # restituisce 1

# pulizia
rm -- ./prova/file*nome  # elimino il file
rmdir -- ./prova         # elimino la directory (se vuota)

Comando find e carattere ASCII n. 0

Un abbinamento comune al comando esterno GNU find (dotato dell'opzione -print0) è il comando esterno GNU xargs (dotato dell'opzione -0):

find /percorso -opzione1 ... -opzioneN -print0 | # trova file che soddisfano le condizioni date
xargs -0 comando [ argomenti ... ]               # li passa come argomenti a un comando esterno

Si leggano i rispettivi manuali per maggiori informazioni. find ha la possibilità di eseguire altri comandi esterni sui file trovati direttamente con le opzioni -exec ed -execdir, ma la sintassi è più complessa e non supporta più di un processo per volta, come invece xargs.

Con bash (non POSIX) esiste un metodo per trattare indirettamente il carattere ASCII n. 0 anche con i comandi interni della shell, tramite l'istruzione read e l'opzione per impostare il carattere come delimitatore, così da replicare la funzionalità di xargs -0. In questo modo il carattere non verrà assegnato alle variabili, ma sarà usato all'interno di un ciclo per delimitare ogni valore dal successivo, permettendo la lettura dell'output generato da find (con -print0):

find /percorso -opzione1 ... -opzioneN -print0 |
    while read -r -d $'\000' file
    do
        # posso usare "$file"
        ...
    done

Si ricorda però che tutto il blocco while, facendo parte di una pipe, è eseguito in una subshell. E che quindi tutte le variabili sono reimpostate al loro valore precedente al termine del blocco.

È invece sempre sbagliato, come soluzione generale se i nomi di file non seguono convenzioni note a priori, utilizzare l'espansione di comando con find all'interno di un ciclo for:

for file in $(find ...) # NO!

Concatenazione e blocchi di comandi

Più comandi, anche contenenti redirezioni, possono essere concatenati per formarne di più complessi. L'operatore di concatenazione può essere anche l'ultimo di una riga, per facilitare la leggibilità del codice.

; 
separatore di comandi, con cui un comando verrà eseguito a prescindere dal successo del precedente (in uno script è equivalente a un "a capo" e questa è la concatenazione di default)
& 
separatore che esegue il comando precedente in background (restituisce exit status sempre positivo e non riceve input da tastiera), passando al successivo
&& 
operatore logico AND, che va posizionato tra due comandi, il secondo dei quali è eseguito solo se il primo ha esito positivo

Esempio:

cd /percorso/dir && comando

Attenzione invece alla pericolosità di:

cd /tmp/tmpdir # cambia la cartella corrente in /tmp/tmpdir
# ma se fallisce, quello successivo verrebbe eseguito comunque!
rm -- ./*      # cancella tutti i file nella directory corrente
|| 
operatore logico OR, il secondo comando è eseguito solo se il primo ha effetto negativo (si può usare anche dopo una sequenza di &&, perché ha priorità inferiore):
{ ... ; } 
esegue un blocco di comandi (dopo l'ultimo servono ";" o un "a capo"). È usata nelle funzioni ed è utile per concatenare && e ||:
# interrompe la catena se un comando fallisce
cd /tmp/tmpdir &&
rm -- ./* &&
rmdir -- /tmp/tmpdir || {
   # il blocco è eseguito solo se un comando fallisce
   retval=$?
   printf %s\\n "ERRORE (exit status: $retval)" >&2
   exit $retval
}
( ... ) 
esegue il blocco di comandi in una subshell (le variabili vengono ripristinate al loro valore precedente, alla fine del blocco).

Catturare l'exit status

Per catturare lo stato d'uscita di un comando appena eseguito è sufficiente espandere la variabile speciale $?, come già visto. Tuttavia in caso di fallimento del comando, il controllo effettuato via $? avverrebbe soltanto dopo un blocco con errore (si veda la parte introduttiva sul debug).

Per evitare che un blocco abbia un exit status diverso da zero, si possono usare le concatenazioni && e || (oppure un if):

comando &&
status=0 || # se corretto
status=$?   # se sbagliato

Condizioni complesse

Anche se bash dispone di altri metodi per unire condizioni più semplici, è possibile utilizzare la sintassi POSIX, concatenando più comandi [...] tramite gli operatori logici AND e OR logici (&& e ||, rispettivamente) e raggruppandoli con { ... ; }. Inoltre gli operatori logici sono più efficienti, in quanto verrebbero eseguiti soltanto i comandi necessari a valutare la condizione, e si possono usare per evitare errori.

Per esempio:

# -a (AND) e -o (OR) sono estensioni non presenti in POSIX, e tutta la condizione è sempre valutata
if [ "$modulo" -ne 0 -a $(($numero % $modulo)) -eq 0 ]; then    # SBAGLIATO! (se $modulo è 0)
   ...
fi

# meglio, più efficiente e POSIX-compatibile
# se $modulo è 0 la seconda parte (essendo un secondo comando) è saltata
if [ "$modulo" -ne 0 ] && [ $(($numero % $modulo)) = 0 ]; then  # sempre corretto
   ...
fi

Esempi di condizioni complesse sull'esistenza dei file, in genere utilizzate dall'istruzione if:

# Controlla che il file regolare esista e sia leggibile
[ -f "$var" ] && [ -r "$var" ]

# Controlla che il file regolare esista e sia eseguibile
# (permesso di lettura + esecuzione)
[ -f "$var" ] && [ -r "$var" ] && [ -x "$var" ]

# Controlla che la directory esista e sia possibile ottenere una lista del suo contenuto
# (per sapere se un file esiste nella directory)
[ -d "$var" ] && [ -r "$var" ]

# Controlla che la directory esista e sia possibile accedere al suo contenuto
# (leggendo o scrivendo i file, se i loro permessi lo consentono)
[ -d "$var" ] && [ -x "$var" ]

# Controlla che la directory esista e sia possibile ottenere una lista e accedere al suo contenuto
[ -d "$var" ] && [ -r "$var" ] && [ -x "$var" ]

# Controlla che la directory esista e sia possibile creare e cancellare dei file noti
# (permesso di accesso + scrittura)
[ -d "$var" ] && [ -x "$var" ] && [ -w "$var" ]

# Controlla che la directory esista e sia possibile cancellarne il contenuto (non noto)
# (permesso di lettura, per ottenere una lista del suo contenuto, + permessi del caso precedente)
[ -d "$var" ] && [ -r "$var" ] && [ -x "$var" ] && [ -w "$var" ]

Uso di una condizione più complessa, in cui si controlla che una variabile sia vuota, oppure che contenga un file regolare esistente e con permessi di scrittura:

if [ -z "$file" ] || { [ -f "$file" ] && [ -w "$file" ] ; }; then
    fai_qualcosa "${file:-/dev/null}" # se $file è vuota, usa /dev/null
                                      # (espansione di parametro con valore di default)
else
    printf %s\\n "File \"${file}\" non esistente o non modificabile!" >&2
    exit 1
fi

Per farne la negazione, se si è interessati al solo ramo else, bisogna aggiungere un altro raggruppamento, visto che ! si riferisce a un comando semplice o a una concatenazione con pipe:

if ! { [ -z "$file" ] || { [ -f "$file" ] && [ -w "$file" ] ; } ; }; then
    printf %s\\n "File \"${file}\" non esistente o non modificabile!" >&2
    exit 1
fi

ma è in genere preferibile ripensare la condizione:

if [ -n "$file" ] && { ! [ -f "$file" ] || ! [ -w "$file" ] ; }; then
    printf %s\\n "File \"${file}\" non esistente o non modificabile!" >&2
    exit 1
fi

Condizioni avanzate con pattern matching

Anche se si possono costruire condizioni arbitrarie sfruttando le sole istruzioni [...] con concatenazioni, raggruppamenti ed espansioni di parametro, tramite l'istruzione composta case è possibile scrivere confronti complessi molto più facilmente.

Viene selezionata per i confronti una stringa, di solito una variabile quotata, e diversi pattern con la stessa sintassi ammessa per l'espansione di percorso (globbing), eseguendo soltanto il blocco di comandi relativo alla prima corrispondenza trovata, e ignorando gli altri.

La shell bash ha diverse espansioni rispetto a POSIX, e anche l'uso della parola riservata select, ma questa sezione si limita alla sintassi POSIX, che è anche più comprensibile per chi è abituato ad altri linguaggi di programmazione.

I pattern devono consistere di un'unica stringa, o più stringhe separate dal carattere | e, a eccezione dei caratteri speciali per il globbing, vanno quotati per evitare espansioni accidentali o pattern non voluti.

Sintassi:

case "$var" in
 pattern1[|pattern2|...]   ) ...
                             ;;
 patternN[|patternN+1|...] ) ...
                             ;;
 ...
 ...
esac

Per esempio (le stringhe quotate possono essere anche variabili):

case "$var" in
 "stringa1"|"stringa2"|"stringa3" )
                  printf %s\\n "La variabile contiene una delle tre stringhe"
                  ;;
 "stringa"[1-3] ) # equivalente, con globbing!
                  # ma non verrà mai eseguito, perché la corrispondenza sarà
                  # sempre trovata con i pattern precedenti
                  printf %s\\n "La variabile contiene una delle tre stringhe"
                  ;;
 * ) # tutti gli altri (quelli che non hanno corrispondenza con i pattern dati)
     printf %s\\n "La variabile NON contiene una delle tre stringhe"
     ;;
esac

Va solo ricordato che nel pattern ci sono cinque caratteri speciali, il separatore di pattern e i quattro del globbing (| * ? [ ]), che come per l'espansione di percorso non possono essere quotati. È bene invece che tutto il resto sia quotato, se si usano variabili o si intende usare questi caratteri per il loro valore letterale.

bash permette anche l'uso di condizioni avanzate con le parole chiave (keywords) [[ ... ]] (non POSIX).
Supportano la sintassi base del comando interno [ ... ], quella che è stata qui presentata, ma al loro interno permettono:

Si noti che soltanto gli ultimi due operatori non hanno un equivalente POSIX, e l'ordine lessicografico dipende dalle impostazioni locali, e potrebbe restituire risultati diversi su sistemi con impostazioni diverse. Va ricordato che, anche se si applicano regole speciali per le keywords, è sbagliato non quotare le stringhe o le variabili, salvo si voglia utilizzare il pattern matching su tutta la parte non quotata:

[[ "$var" == "${prefisso}"* ]]  # successo se $var inizia con $prefisso
[ -z "${var##${prefisso}*}" ]   # equivalente (POSIX)

[[ "$var" == *"${stringa}"* ]]  # successo se $var contiene $stringa
[ -z "${var##*${stringa}*}" ]   # equivalente (POSIX)

[[ "$var" != *[!A-Za-z0-9]* ]]  # successo se $var contiene solo caratteri alfanumerici (solo lettere base e numeri)
[ -n "${var##*[!A-Za-z0-9]*}" ] # equivalente (POSIX)

Esempio: controllo sul percorso di un file

Controllare il percorso di un file, e aggiungere quello relativo se manca:

case "$file" in
 /*       ) printf %s\\n "Percorso assoluto trovato"
            ;;
 ./*|../* ) printf %s\\n "Percorso relativo trovato"
            ;;
 *        ) printf %s\\n "Percorso implicito, sostituzione!"
            file="./${file}" # aggiunto il percorso relativo
                             # per evitare errori con possibile opzioni
            ;;
esac

Se si è interessati solo all'ultimo caso e senza stampa di messaggi:

case "$file" in
 /*|./*|../* ) :  # true (non fa niente)
               ;;
 *           ) file="./${file}"
               ;;
esac

Parsing degli argomenti

Si effettua con il comando getopts (da non confondersi con getopt, un comando esterno più avanzato non POSIX utilizzato allo stesso scopo).

Ogni opzione deve consistere di una sola lettera, e può ammettere un solo argomento. Supporta inoltre l'uso di -- che permette di separare le opzioni dall'eventuale lista di altri argomenti.

Sintassi: getopts "stringa-opzioni" nomevariabile

Regole:

Esempio: script con parsing degli argomenti

#! /bin/bash

# Argomenti:
#    $0  [ -c | -x ]  { -f archivio } [--] [file1 ...]
# Opzioni:
# -c          : comprimi (implicito)
# -x          : estrai
# -f archivio : file con un argomento (e obbligatorio)

azione=c      # per rendere implicita l'azione, la definisco prima

# Risultato delle tre opzioni: "cxf:"  (per getopts)
# Per disabilitare gli errori: ":cxf:" (disabilita la stampa degli errori)

# ripeti finché trova opzioni
while getopts ":cxf:" opzione   # opzione è un nome qualunque di variabile
do
    case "$opzione" in
    "c"|"x" ) azione=$opzione   # aggiorno l'azione in base all'opzione scelta
            ;;
    "f"     ) archivio=$OPTARG  # assegno l'argomento dell'opzione a una variabile
            ;;
    # NOTA: "?" è obbligatorio quotato o dopo \ perché è un carattere speciale
    "?"   ) # gestisco l'errore
            printf %s\\n "Usage: $0  [ -c | -x ]  { -f archivio } [--] file1 ..." >&2
            exit 1
            ;;
    esac
done
# quando non trova più opzioni o incontra la stringa -- esce dal ciclo

# verifico l'esistenza dell'opzione che considero obbligatoria (ma che non lo è per getopts!)
# e per l'azione di estrazione che l'archivio esista e sia un file regolare
if [ -z "$archivio" ] || { [ "$azione" = "x" ] && ! [ -f "$archivio" ] ; }; then 
    printf %s\\n "Archivio mancante!" >&2
    exit 1
fi

# ricavo la lista dei file, rimuovendo le $OPTIND - 1 opzioni lette
shift $(($OPTIND - 1))
# ora "$@" contiene la lista delle stringhe non corrispondenti alle opzioni (o tutte quelle dopo --)

# eseguo qualcosa, per esempio banalmente tar
tar "${azione}f" "$archivio" -- "$@" || {
    printf %s\\n "Comando fallito!" >&2
    exit 2  # per distinguerlo dall'exit status di opzione sbagliata/mancante
}
exit 0

Lo script supporta per esempio tutte queste chiamate (e anche altre combinazioni), che hanno lo stesso significato, perché getopts se ne occupa automaticamente:

$ ./script.sh -c -f archivio -- file1 file2 file3
$ ./script.sh -c -f archivio file1 file2 file3
$ ./script.sh -cf archivio file1 file2 file3
$ ./script.sh -cfarchivio file1 file2 file3
$ ./script.sh -f archivio -c file1 file2 file3
$ ./script.sh -farchivio -c file1 file2 file3
Strumenti personali
Namespace
Varianti
Azioni
Navigazione
Risorse
Strumenti