Bash scripting - istruzioni composte
Bash scripting |
Sommario |
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):
if
è già stata trattata assieme all'uso di[ ... ]
nei comandi essenziali, utile anche per le condizioni del ciclowhile
;for
è già stato trattato nell' assegnazione con ciclo, 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 -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:
- interrotto da
break
; - ripreso dalla prima istruzione del blocco, dopo aver rivalutato l'exit status del comando dopo
while
, concontinue
.
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
etac
per concatenare più file, stampandoli in ordine iniziando dalla prima riga oppure in ordine inverso dall'ultima;head
etail
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, maawk
è un vero e proprio linguaggio di programmazione, che se padroneggiato può sostituire le funzionalità di buona parte degli altri comandi;sort
euniq
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.
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:
- concatenazioni con
&&
e||
; - l'uso di
!
per invertire il valore della condizione; - di cambiare la precedenza degli operatori con
(...)
; - pattern matching con
==
e!=
per la stringa a destra dell'operatore tramite i caratteri speciali del globbing (* ? [ ]
), al solito se non quotati e non preceduti dal carattere di escape\
; - confronti lessicografici tra stringhe con
<
e>
(NOTA: non esistono "<=" e ">="). In bash (non POSIX) esistono anche con[...]
, ma con quel comando vanno quotate o precedute dal carattere di escape\
(ossia:\<
e\>
) perché sono caratteri speciali e soltanto le parole chiave possono permettere regole speciali al loro interno; - l'uso di espressioni regolari estese con
=~
nel pattern matching per la stringa alla destra dell'operatore.
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:
- 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 digetopts
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 accedervi è necessario effettuare lo shift (con il comandoshift
) 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