Bash scripting - istruzioni composte

Versione del 23 lug 2014 alle 18:07 di HAL 9000 (discussione | contributi) (rimosso template autori)
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 usata spesso in congiunzione con [...] 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 [ condizione ]; 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 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.

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 per concatenare più file;
  • head e tail per leggere la parte iniziale e 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 (ritorna 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

Funzioni

Una funzione è un raggruppamento di comandi eseguito ogni volta che il nome della funzione è utilizzato come un comando. Per convenzione le definizioni delle funzioni precedono il corpo principale dello script.

All'interno del blocco di comandi (il corpo della funzione), le variabili speciali relative agli argomenti passati allo script ($# $1 $2 ... $* $@) fanno riferimento a quelli passati alla funzione.

Sintassi della definizione:

nomefunzione () {
   ...
}

I comandi contenuti nel corpo della funzione sono eseguiti ogni volta che nomefunzione (una singola stringa senza spazi con le stesse limitazioni dei nomi delle variabili) è scritto come comando. Si noti che () dopo il nome della funzione serve a identificare l'istruzione composta come una definizione di funzione, e non accetta argomenti al suo interno come altri linguaggi di programmazione, ma resta sempre () invariata.

In bash è possibile premettere function al nome della funzione, anche senza bisogno di (), ma questa possibilità non è prevista da POSIX.

Nel corpo di una funzione è possibile utilizzare il modificatore local prima di un'assegnazione per rendere la variabile locale, ossia non accessibile fuori dal corpo della funzione. È bene evitare le variabili globali il più possibile (se non sono costanti), ricorrendo ai passaggi di parametri, per evitare la modifica accidentale di variabili esistenti fuori dalla funzione. Questo modificatore non è previsto da POSIX, dove ogni variabile è globale, ma è comunque supportato da ogni moderna shell compatibile.

Il valore di ritorno di una funzione è quello dell'ultimo blocco eseguito. In alternativa è possibile specificarlo all'interno dei blocchi direttamente con la parola riservata return seguito dall'exit status (un valore qualsiasi tra 0, l'unico per "successo", e 255), facendo terminare la funzione immediatamente.

Se la funzione deve restituire un valore diverso da successo/insuccesso (o comunque per cui basterebbe l'exit status), è possibile:

  • stamparlo come output, così che sia assegnabile a una variabile con l'espansione di comando, ma soltanto se non contiene caratteri non ammissibili (il carattere ASCII n. 0 e possibili "a capo" finali, che verrebbero rimossi). Si noti che questo metodo non è possibile in generale per i nomi di file, a meno che non seguano convenzioni note a priori;
  • stamparlo come output seguito da un carattere qualsiasi (per esempio X), così che sia assegnabile a una variabile con l'espansione di comando, rimuovendo in seguito il carattere finale in più. Con questo metodo è possibile passare nomi di file qualsiasi, ma al solito non il carattere ASCII n. 0;
  • utilizzare una variabile globale, eventualmente con il nome della funzione per evitare doppioni (per esempio: return_nomefunzione), contenente il valore da restituire nella funzione. Si noti che anche in questo caso il carattere ASCII n. 0 non può essere assegnato.
  ATTENZIONE
Esiste anche la possibilità di ricorrere a eval, ma il suo uso non è trattato in questa guida per la sua potenziale pericolosità: possibile code injection e privilege escalation, se l'input non è controllato (sanitized). Su bash è molto meglio ricorrere all'espansione di parametro per l'accesso indiretto, per accedere a una variabile il cui nome è contenuto in un'altra; e ad array associativi per gli altri casi.