Bash scripting - istruzioni composte

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

Istruzioni composte

Sono istruzioni composte tutte le istruzioni che possono contenere al loro interno altre istruzioni. Per esempio if e for sono due istruzioni composte:

  • if è già stata trattata assieme all'uso di [ ... ] nei comandi essenziali, utile anche per le condizioni del ciclo while;
  • for è già stato trattato nell' assegnazione con ciclo nella sezione delle Variabili, con la sintassi tipica di for each di alcuni linguaggi di programmazione e l'unica prevista da POSIX.

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 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:

  • interrotto da break;
  • ripreso dalla prima istruzione del blocco, dopo aver rivalutato l'exit status del comando dopo while, con continue.

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:

  • comando1 | comando2: pipe che invia lo standard output del comando precedente al successivo. Tutti i comandi di una serie di pipe (anche l'ultimo) sono eseguiti in una subshell, e possono stare su righe diverse con la pipe come ultimo carattere;
  • comando1 2>&1 | comando2: pipe che invia standard output e standard error (si veda la parte sui file descriptor) del comando precedente al successivo. Tutti i comandi di una serie di pipe (anche l'ultimo) sono eseguiti in una subshell, e possono stare su righe diverse con la pipe come ultimo carattere.

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

  • cat e tac per concatenare più file, stampandoli in ordine iniziando dalla prima riga oppure in ordine inverso dall'ultima;
  • head e tail per leggere la parte iniziale oppure finale di un file;
  • grep per filtrare le righe di un file in base a un dato pattern;
  • sed per modificare le parti corrispondenti a un dato pattern con altre stringhe;
  • cut per selezionare dei campi da ogni riga, ma in base a un determinato carattere delimitatore (di default la tabulazione), conteggiato anche se ripetuto;
  • awk per selezionare dei campi da ogni riga, separandoli in base a spazi e tabulazioni (ignorando quelli consecutivi). Questa è la sua funzione base, ma awk è un vero e proprio linguaggio di programmazione, che se padroneggiato può sostituire le funzionalità di buona parte degli altri comandi;
  • sort e uniq per ordinare le righe in base a determinati campi e saltare quelle doppie;
  • wc per conteggiare il numero di caratteri, parole e/o linee;
  • tr per rimuovere o trasformare i caratteri di una data classe.

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.

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, e 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 -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, combinando il comando condizionale [...], le concatenazioni con AND e OR logici (&& e ||, rispettivamente), e il raggruppamento { ... ; }. 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

Verifica che una variabile contenga un intero (maggiore o minore di zero):

if [ "$numero" -ne 0 ] 2> /dev/null || # fallisce solo se $numero è 0 oppuer se non è intero
   [ "$numero" = 0 ]                   # copre il caso in cui $numero è 0
then
    printf %s\\n "Il numero inserito (${numero}) è intero"
else
    printf %s\\n "ERRORE: il numero inserito non (${numero}) è intero!" >&2
fi

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 selezioanta 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.

È un'istruzione molto usata per implementare un menù e per la lettura (parsing) degli argomenti passati allo script.

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 utilizzata 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:

  • nomevariabile è un nome di variabile non preceduto da $ a cui sarà assegnata la prima opzione trovata oppure ? in presenza di errori;
  • l'exit status del comando è negativo solo se non ci sono altre opzioni da assegnare;
  • se la stringa delle opzioni inizia con il carattere : non vengono stampati messaggi di errore, se un argomento non è trovato;
  • la stringa delle opzioni conterrà i caratteri, tutti scritti di seguito, di ciascuna opzione possibile; ma nessuna si considera obbligatorio, se lo è bisognerà specificarlo in un altro modo;
  • il carattere delle opzioni che richiede un argomento ausiliario va seguito dal carattere : e l'argomento sarà assegnato alla variabile $OPTARG, se trovato, altrimenti verrà stampato un errore (se non disabilitati) e la variabile scelta conterrà il carattere ?;
  • la variabile $OPTIND è aggiornata a ogni esecuzione di getopts per tenere traccia dell'indice dell'ultimo parametro letto partendo da 1 (per esempio $OPTIND a 5 significa che sono state lette le variabili speciali $1 ... $4, infatti $4 è in quinta posizione);
  • tutto ciò che non è un'opzione o un argomento di un'opzione, e tutto ciò che segue --, è considerato una lista di argomenti. Per accederli è necessario effettuare lo shift (con il comando shift) di tutte le stringhe già lette, ossia: $OPTIND - 1.

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