previous up

Introduzione allo Shell Scripting

index next

Subsections



4. Argomenti avanzati

In questo capitolo sono raggruppati alcuni argomenti più specialistici che semplificano la vita di chi si accinge a fare uno script con la shell Bash (Alzi la mano chi non è un po' pigro!).


4.1 Funzioni

Immaginiamo di aver fatto uno script in cui le stesse istruzioni dovrebbero essere scritte più volte all'interno del codice. Il copia ed incolla potrebbe essere una soluzione a questo fastidiosissimo problema, ma di sicuro non sarebbe elegante.




Cerchiamo di risolvere il problema pensando a ciò che facciamo durante il quotidiano. Quando andiamo a fare la spesa e compriamo divesi oggetti, mettiamo tutto all'interno di un sacchetto, anziché portare ogni cosa a mano; ecco il lampo di genio, ci serve qualcosa di analogo!




La shell Bash ci offre degli speciali sacchetti per la spesa, le funzioni (Saranno biodegradabili?) con le quali possiamo risparmiare molta fatica e conservare uno stile di programmazione elegante. La definizione schematica di una funzione Bash è

[ function ] nome_funzione () {
    COMANDI
}
Analizziamo brevemente quanto scritto; la parola chiave function è tra parentesi quadre poiché opzionale, infatti possiamo equivalentemente scrivere
function nome_funzione () {
    COMANDI
}
o
nome_funzione () {
    COMANDI
}
L'unica differenza tra le due forme dichiarative sta nel fatto che la scrittura di function consente di omettere le parentesi tonde (), altrimenti obbligatorie. Altra cosa importante da notare è che le parentesi graffe devono essere separate dal testo che le precede tramite uno spazio, un TAB ( $\mathtt{\backslash}\mathtt{t}$) o un NEWLINE ( $\mathtt{\backslash}\mathtt{n}$).




Definendo una funzione, si ha l'opportunità di richiamare i comandi in essa contenuti in maniera più semplice all'interno di uno script, possiamo anche passarle argomenti sotto forma di parametri posizionali (sezione 2.3) e ritornare allo script un valore attraverso il quale scegliere cosa fare successivamente4.1. Nel seguito analizzeremo altre peculiarità delle funzioni.
È ora di smettere con le parole e considerare un esempio.

Esempio 4.1.1

#!/bin/bash
#
# Un primo esempio di funzione

EXIT_SUCCESS=0
EXIT_FAILURE=1
CONTINUA=2

fai_questo () {

  echo "Hai scritto: $1"
  echo "Vuoi smettere?" 
  read RISPOSTA

  case "$RISPOSTA" in
    si|s|yes|y)
      return $EXIT_SUCCESS
      ;;
    no|n)
      return $CONTINUA
      ;;
    *)
      echo "Non ho capito cosa hai scritto, termino subito!" 
      exit $EXIT_FAILURE
      ;;
  esac

}


while true; do
  echo "Scrivi una parola: "
  read PRESS

  fai_questo $PRESS
  RITORNO=$?

  case $RITORNO in
    $EXIT_SUCCESS)
      exit $RITORNO
      ;;
    $CONTINUA)
      :
      ;;
  esac
done

Come funziona:

Abbiamo a che fare con la nostra prima funzione, ovvero con fai_questo(), che utilizziamo per raggruppare una serie di comandi.




Procediamo con ordine, innanzi tutto vediamo che la funzione fa uso del parametro posizionale $1, ciò vuol dire che fai_questo() accetta un solo argomento che si passa alla funzione sotto forma di parametro posizionale. Successivamente viene letto un valore dalla tastiera ed in base alla risposta data si ritorna, attraverso l'istruzione interna return, un determinato valore o si conclude lo script nel caso in cui la risposta non fosse chiara.




Finito il corpo della funzione, abbiamo il resto dello script che consiste in un ciclo infinito, \fbox{\texttt{while true; do ...}}. Il comando true (Del pacchetto GNU Shell Utils) non compie assolutamente alcuna azione, ma ritorna all'ambiente di esecuzione un codice di successo, quindi il ciclo continua indefinitamente. L'uscita o meno dal ciclo dipende dal valore di ritorno della funzione fai_questo(). Se il ritorno è $EXIT_SUCCESS, allora viene interrotto non solo il ciclo, ma anche l'esecuzione dello script tramite la chiamata \fbox{\texttt{exit
\$RITORNO}}. Se invece il ritorno è $CONTINUA, allora viene eseguito il comando \fbox{\texttt{:}}, che non è altro se non l'equivalente di true interno alla shell.


4.1.1 Funzioni e variabili locali

Può accadere che sia necessario utilizzare all'interno di una funzione un nome di variabile già utilizzato nel codice dello script. Ciò accade spesso negli script di grosse dimensioni, in cui si vuole che le variabili abbiano nomi espressivi.




Per evitare di modificare il valore della variabile globale (Globale? Si, rispetto a quella presente nella funzione, ma in realtà si tratta sempre di una variabile la cui esistenza è limitata all'esecuzione dello script), la shell Bash consente di dichiarare delle variabili che ``vivono'' solo all'interno del corpo della funzione, tali variabili vengono dette locali e vengono dichiarate attraverso l'istruzione \fbox{\texttt{local [VARIABILE[=VALORE]]}}.




Come al solito, le parentesi quadre rappresentano argomenti facoltativi, infatti local può essere richiamato da solo (sempre all'interno di una funzione) con il risultato di stampare sullo standard output una lista di variabili locali definite in una funzione. Vediamo di chiarire le nostre idee con un esempio.

Esempio 4.1.2

#!/bin/bash
#
# Uso di local

EXIT_SUCCESS=0

funzione () {

    local VARIABILE="Sono all'interno della funzione"

    echo -e "\t$VARIABILE"

}

VARIABILE="Sono all'esterno della funzione"

echo $VARIABILE

funzione

echo $VARIABILE

exit $EXIT_SUCCESS

Come funziona:

Dopo i soliti convenevoli, abbiamo definito la funzione funzione() (Bel nome, vero?) ed all'interno di questa la variabile locale $VARIABILE (Quelli che usiamo, sono sempre nomi espressivi!), assegnadole il valore ``Sono all'interno della funzione''. Nel seguito, definiamo per tutto lo script la variabile $VARIABILE (locale allo script, ma globale rispetto alla funzione) assegnandole il valore ``Sono all'esterno della funzione''. Se eseguiamo lo script, ci accorgiamo che l'assegnazione fatta all'interno di funzione() non intacca minimamente il valore ``globale'' di $VARIABILE, infatti otteniamo:

[nico@deepcool nico]$ ./local.sh 
Sono all'esterno della funzione
        Sono all'interno della funzione
Sono all'esterno della funzione


4.1.2 Funzioni ricorsive

La shell Bash consente di scrivere funzioni che richiamano sè stesse. Tali funzioni vengono dette ricorsive. Una funzione ricorsiva si rende particolarmente utile quando all'interno di una funzione occorre applicare ad un dato la funzione stessa, in questo modo, anziché realizzare complessi passaggi di valori, si può semplicemente risolvere il problema consentendo alla funzione di richiamarsi.




Chiariamo il tutto con un esempio. Capita spesso, scambiando file con amici, di averne alcuni con estensioni in maiuscolo (file.JPG, file.HTM) sparsi per il proprio disco fisso. Sarebbe bello escogitare un sistema per far cambiare automagicamente tutte queste estensioni in minuscolo. Proviamo a vedere come fare!

Esempio 4.1.3

#!/bin/bash 
#
# Una funzione ricorsiva

EXIT_SUCCESS=0
EXIT_FAILURE=1

utilizzo () {

    echo "$(basename $0) <estensione> [directory di partenza]"
    exit $EXIT_FAILURE

}

sostituisci_estensione () {

    E=$1
# Un'abbreviazione per ESTENSIONE
 
    if [ -z "$2" ]; then
        DIR=$PWD
    else
        DIR=$2
    fi

    N_E=$(echo $E | tr [A-Z] [a-z])
# Un'abbreviazione per NUOVA_ESTENSIONE

    cd $DIR

    for ELEMENTO in $(ls) ; do
        if [ -d "$ELEMENTO" ]; then
            sostituisci_estensione "$E" "$ELEMENTO"
        elif [ -n "$(echo $ELEMENTO | grep -E "\.${E}$")" ]; then
            mv $ELEMENTO ${ELEMENTO%$E}$N_E
        fi
    done

    cd ..

}

if [ $# -eq 0 -o $# -gt 2 ]; then
    utilizzo
# Se lo script è stato lanciato con 0 o più di 2 argomenti,
# mostriamo come si lancia e ritorniamo un errore.
fi

echo "INIZIO QUI!"

sostituisci_estensione "$1" "$2"

echo "HO FINITO!"

exit $EXIT_SUCCESS

Come funziona:

Come prima cosa, definiamo una funzione utilizzo() che ci sarà utile per dire con cortesia che il programma è stato utilizzato male. Per ottenere il nome dello script, utilizziamo il comando basename sul parametro posizionale $0. Successivamente, definiamo la funzione sostituisci_estensione(); questa accetta 2 valori, il primo è l'estensione (in maiuscolo!) che vogliamo sostituire, il secondo è, invece, la directory dalla quale partire ad applicare lo script (qualora non si fornisse il secondo valore, la directory di partenza sarebbe $PWD). Questi valori vengono poi assegnati rispettivamente alle variabili $ESTENSIONE e $DIR. Tramite il comando tr (guardarne la pagina manuale!!) si ottiene l'estensione in caratteri minuscoli e la si deposita nella variabile $NUOVA_ESTENSIONE.




Ora inizia il lavoro sporco! Si entra nella directory di partenza (cd $DIR) e si analizzano ad uno ad uno i file presenti. Se uno di questi è una directory, allora gli si riapplica la funzione (sostituisci_estensione $ESTENSIONE ``$ELEMENTO``), altrimenti si verifica che il file abbia l'estensione data ( \fbox{\footnotesize{\texttt{echo \$ELEMENTO \vert grep -E
\lq\lq $\mathtt{\backslash.}$\$\{ESTENSIONE\}\$''}}}), man grep e man 7 regex per informazioni su grep e le espressioni regolari). Se il file ha l'estensione cercata, allora viene fatto il cambio, altrimenti si prosegue con il ciclo. Una volta finiti gli elementi da controllare, si ritorna nella directory superiore. Qui finisce la funzione ricorsiva.




Il resto dello script non fa altro che controllare che siano stati forniti da uno a due parametri, per poi, in caso affermativo, richiamare la funzione sostituisci_estensione() sui parametri passati.




Come ulteriore esempio sulle funzioni ricorsive, consideriamo una implementazione dell'algoritmo di Euclide per il calcolo del massimo comun divisore (mcd) di due numeri. Questo algoritmo consiste nella relazione per ricorrenza (valida per $m$ ed $n$ naturali):

\begin{displaymath}
\left\{
\begin{array}{lll}
\mathtt{mcd}(0, n) &= n &\\
\...
...{mod}  m, m) \quad & \mbox{per} \quad m>0
\end{array}\right.
\end{displaymath}

dove con mod abbiamo indicato genericamente l'operatore %.

Esempio 4.1.4

#!/bin/bash 
#
# Funzioni ricorsive: calcolo del massimo comun divisore
# di due numeri tramite l'algoritmo di Euclide

EXIT_SUCCESS=0
EXIT_FAILURE=1

utilizzo () {

    echo "$(basename $0) <primo numero> <secondo numero>"
    exit $EXIT_FAILURE

}

mcd () {

    m=$1
    n=$2

    if [ $n -eq 0 -a $m -eq 0 ]; then
        echo "mcd(0,0) non è definito!"
        exit $EXIT_FAILURE
    elif [ $m -eq 0 ]; then
        return $n
    elif [ $n -eq 0 ]; then
        return $m
    fi

    mcd $(( $n % $m )) $m

}

if [ $# -ne 2 ]; then
    utilizzo
fi

mcd $1 $2

MCD=$?

echo "Il massimo comun divisore dei numeri \"$1\" \"$2\" è: $MCD"

exit $EXIT_SUCCESS

Come funziona:

L'implementazione consiste in un'applicazione ricorsiva della funzione mcd() fino a quando $n o $m risulti essere uguale a zero, in tale caso, infatti, la relazione di ricorrenza ci dice che il calcolo del mcd è semplicissimo. La funzione controlla, dunque, che $n o $m non valgano entrambe zero (mcd(0,0) non è definito) e continua effettuando dei test su $n e $m. Se una di queste vale zero, si ritorna il valore dell'altra.




Qualora non si fosse in uno di questi casi, la funzione si richiama, applicandosi ai valori ``$n % $m'' e $m fino a quando non si giunge al caso semplice ($n o $m uguali a zero). In ultimo, trovato il mcd dei due numeri, la funzione ne fa il suo valore di ritorno, infatti, per ottenerlo abbiamo usato l'assegnazione \fbox{\texttt{MCD=\$?}}.


4.2 Array o vettori

Nella sezione 2.2 abbiamo visto che le variabili sono dei contenitori in cui conserviamo valori. Continuando con la nostra analogia, se andassimo in un negozio di casalinghi, potremmo acquistare dei set di contenitori, che, magari, potrebbero essere legati l'uno all'altro: questi sono gli array o vettori (Noi cercheremo di chiamarli sempre vettori).




Per il momento cerchiamo di capire meglio cosa siano i vettori e per farlo, aiutiamoci con la figura 4.1.

Figura 4.1: Array e variabili
\includegraphics[]{fig/array}
Nella figura schematizziamo una variabile come un ``contenitore singolo'' (VARIABILE), mentre un vettore viene rappresentato come una sequenza ordinata di contenitori caratterizzata dall'avere ``cognome'' e ``nome''. Spieghiamo subito cosa si intende con cognome e nome: tutti gli elementi del vettore hanno in comune la parola ARRAY (il cognome) e vengono distinti l'uno dall'altro attraverso un indice sequenziale (il nome) che parte sempre da zero. Se qualcuno, guardando la figura, vi dicesse «Ti presento ``ARRAY[0]''!» (Nome: 0, Cognome: ARRAY), non sarebbe in vena di scherzi, ma vi starebbe solo indicando il primo elemento del vettore ARRAY.




Ritorniamo a parlare della shell e chiariamo subito che Bash, al momento della scrittura, mette a disposizione soltanto vettori monodimensionali, quindi non dobbiamo aspettarci di avere a che fare con oggetti del tipo \fbox{\texttt{ARRAY[0][1]}}. Forse in futuro, oltre a nome e cognome, ci sarà anche il codice fiscale ed avremo dunque vettori bidimensionali.




Consideriamo qualcosa di più specifico. Bash consente di dichiarare esplicitamente un vettore attraverso il comando \fbox{\texttt{declare -a}}, tuttavia qualunque riga di codice del tipo $\qquad$ \fbox{\texttt{ARRAY[INDICE]=VALORE}} creerà automaticamente un vettore. Un'altra forma legale per dichiarare un vettore è l'assegnazione esplicita di tutti i suoi elementi nella forma:
\fbox{\texttt{ARRAY=(VALORE\_1 VALORE\_2 ... VALORE\_N}}.
OCCORRE FARE MOLTA ATTENZIONE AL FATTO CHE L'INDICE DI UN VETTORE PARTE SEMPRE DA ZERO!




Riassumendo, creiamo un vettore in questi modi:

# Usando declare
declare -a VETTORE
declare -a ALTRO_VETTORE[10]

# Nel secondo caso abbiamo creato un vettore di 10 elementi, il 
# primo è ALTRO_VETTORE[0], mentre l'ultimo ALTRO_VETTORE[9]


# Mediante assegnazione di un elemento, in questo caso il primo
# per ricordare che L'INDICE PARTE DA 0!
VETTORE[0]=10


# Attraverso l'assegnazione di tutti i suoi elementi
VETTORE=(11 24 32 88 55)

# Provate a giocare la cinquina!

Ora che abbiamo capito come dichiarare un vettore, dobbiamo anche vedere come poter ottenere i valori in esso contenuti. Per evitare di dar luogo ad ambiguità, occorre sempre utilizzare la forma \fbox{\texttt{\$\{VETTORE[INDICE]\}}}, in questo modo la shell capirà che stiamo facendo riferimento all'(INDICE+1)-esimo elemento del vettore VETTORE e lo sostituirà con il suo valore, proprio come accade con ${VARIABILE}.

Esempio 4.2.1

#!/bin/bash
#
# uso dei vettori

declare -a VETTORE[3]

VETTORE[0]="Tutto è andato bene"
VETTORE[1]="Hai inserito un numero maggiore di 20"
VETTORE[2]="Hai inserito un numero minore di 10"

inserisci_numero () {

    echo "Inserisci un numero compreso tra 10 e 20"

    read NUMERO

    if [ "$NUMERO" -lt 10 ]; then
        return 2
    elif [ "$NUMERO" -gt 20 ]; then
        return 1
    else
        return 0
    fi

}

inserisci_numero
RITORNO=$?
echo ${VETTORE[$RITORNO]}

exit $RITORNO

Come funziona:

All'inizio del codice, dichiariamo tramite declare il vettore a 3 elementi VETTORE e successivamente lo popoliamo con delle stringhe. Proseguendo definiamo la funzione inserisci_numero() alla quale diamo il compito di leggere dalla tastiera un numero e di stabilire se questo è compreso tra 10 e 20 o meno. Qualora il numero inserito non soddisfacesse tale richiesta, la funzione ne stabilirebbe il perché e ritornerebbe un opportuno valore per caratterizzare l'evento.




In ogni caso, il valore di ritorno della funzione viene immagazzinato nella variabile $RITORNO che viene usata come indice per il vettore VETTORE. In base al valore di $RITORNO, tramite la linea di codice \fbox{\texttt{echo \$\{VETTORE[\$RITORNO]\}}} viene stampato sullo schermo un messaggio a seconda dell'esito delle operazioni effettuate da inserisci_numero().




Continuiamo ad esaminare l'argomento ``vettori''. È possibile utilizzare come indice di un vettore i caratteri ``*'' e ``@''; la scrittura ${VETTORE[*]} espande il vettore in un'unica stringa composta dagli elementi del vettore separati dal primo carattere presente in $IFS, mentre ${VETTORE[@]} espande ogni elemento in una stringa separata. Alcuni delle tecniche di espansione di parametri possono essere applicate con successo non solo ad ogni elemento di un vettore, ma anche all'intero vettore stesso. Ad esempio, \fbox{\texttt{\texttt{\$\{\char93 VETTORE[*]\}}}} e \fbox{\texttt{\texttt{\$\{\char93 VETTORE[@]\}}}} possono essere usati per ottenere il numero di elementi presenti in un vettore. FIXME: Inserire una spiegazione dettagliata a riguardo


4.3 Reindirizzamento dell'Input/Output

Nella sezione 2.4.5 abbiamo già avuto a che fare con gli operatori di input/output (Nel seguito abbreviato con ``I/O''); in particolare, ci siamo soffermati su ``|'', ``<'' e la coppia ``> - »''. In questa sezione, cercheremo di estendere ciò che già conosciamo, introducendo alcuni nuovi concetti.




Prima che un comando venga eseguito, è possibile redirigere sia il suo canale di input che quello di output, utilizzando gli operatori di reindirizzamento. Il reindirizzamento può essere anche utilizzato per chiudere ed aprire file. Il reindirizzamento ha una sintassi bene precisa, che la shell interpreta da sinistra verso destra; inoltre, dato che questo può avvenire utilizzando più di una espressione, è importante l'ordine con cui queste vengono impartite alla shell.


4.3.1 I file in un sistema Unix

Ci si può chiedere perché dedicare addirittura un'intera sezione a questo argomento, che all'apparenza sembrerebbe piuttosto semplice. In un sistema Unix, tuttavia, i file occupano un ruolo assai importante; questi, infatti, forniscono un'interfaccia semplice e consistente ai vari servizi del sistema operativo ed ai dispositivi in genere. Finalmente, capiamo il significato del principio ``everything is a file'' a cui abbiamo fatto riferimento nella sezione 2.4.4.




Ciò significa che i programmi, in generale, possono usare file su disco, porte seriali, connessioni di rete, stampanti ed altri dispositivi tutti nello stesso modo. Nella realtà, dunque, quasi tutto è reso disponibile sotto forma di file o in alcuni casi file particolari, con differenze minime rispetto al caso generale, in modo da non violare il principio espresso in precedenza.




Prima di procedere, occorre introdurre una nozione fondamentale. Un descrittore di file è una entità (Nel nostro caso, la shell, un numero) che identifica in maniera univoca un file aperto in una particolare modalità, quale ad esempio lettura o scrittura. In questo modo, ogni azione su un file può essere intrapresa agendo sul suo descrittore.




Se non istruita diversamente, ad esempio, per ogni processo la shell associa sempre dei descrittori di file allo standard input (Descrittore 0), standard output (Descrittore 1) e standard error (Descrittore 2).


4.3.2 Apertura di descrittori di file in lettura/scrittura

La shell Bash consente l'apertura di descrittori di file personalizzati, in modo da gestire in maniera efficiente l'I/O nei propri script, utilizzando ulteriori descrittori oltre a 0, 1 e 2. Per effettuare questa operazione si utilizza l'operatore \fbox{\texttt{<>}}, la sintassi è la seguente:
[n]<>parola
L'operatore \fbox{\texttt{<>}} causa l'apertura in lettura/scrittura del file identificato dall'espansione di parola sul descrittore di file n. Se non è passato alcun descrittore di file, la shell assume che il descrittore di file sia 0. Qualora il file non esistesse, verrebbe creato.


4.3.3 Reindirizzamento dell'Input

La sintassi generale per effettuare un reindirizzamento dell'input è:
[n]<parola
Anche qui, il parametro opzionale n rappresenta il descrittore di file su cui indirizzare il risultato dell'espansione di parola. Nel caso in cui non venisse fornito il descrittore di file n, il reindirizzamento viene effettuato su descrittore 0, ovvero lo standard input.




Ad esempio, quando abbiamo visto il comando \fbox{\texttt{mail nico <
/etc/passwd}}, non avendo specificato alcun descrittore di file, intendendevamo redirigere il contenuto di /etc/passwd sullo standard input del comando mail nico.


4.3.4 Reindirizzamento dell'Output

Analogamente ai casi precedenti, la sintassi di questo particolare reindirizzamento è:
[n]>parola
Ciò fa sì che il file risultante dall'espansione di parola venga aperto (o creato nel caso non esista) in scrittura e vi si memorizzino i dati provenienti dal descrittore di file n. Nel caso in cui n manchi, si assume che si voglia reindirizzare lo standard output, descrittore 1.




È possibile anche aprire un file solo per aggiungere dati, senza perderne il contenuto; per effettuare tale operazione è sufficiente utilizzare l'operatore \fbox{\texttt{»}}, la cui sintassi è analoga al precedente:

[n]>>parola




Abbiamo visto che un processo possiede due canali di output, uno dedicato in generale alla rappresentazione del risultato (lo standard output), l'altro agli errori incorsi durante l'esecuzione del processo (lo standard error). In genere, in un calcolatore i due output vengono mostrati sullo schermo, con il risultato di confonderli entrambi. Tuttavia, esistono casi in cui è necessario doverli separare; questa operazione può essere eseguita facilmente reindirizzando lo standard error, ad esempio:

[nico@deepcool nico]$ ls -lh bg?.{jpg,png} non_esisto
ls: non_esisto: No such file or directory
-rw-rw-r--    1 nico     nico         160k Apr 15 01:20 bg1.jpg
-rw-rw-r--    1 nico     nico         249k Apr 15 01:25 bg2.jpg
-rw-rw-r--    1 nico     nico         198k Mar 30 13:47 bg2.png
-rw-rw-r--    1 nico     nico          86k May 14 11:50 bg3.png

[nico@deepcool nico]$ ls -lh bg?.{jpg,png} non_esisto 2>/dev/null 
-rw-rw-r--    1 nico     nico         160k Apr 15 01:20 bg1.jpg
-rw-rw-r--    1 nico     nico         249k Apr 15 01:25 bg2.jpg
-rw-rw-r--    1 nico     nico         198k Mar 30 13:47 bg2.png
-rw-rw-r--    1 nico     nico          86k May 14 11:50 bg3.png
Come possiamo vedere, la prima esecuzione del comando
\fbox{\texttt{ls -lh
bg?.\{jpg,png\} non\_esisto
2>/dev/null}}4.2 contiene anche un messaggio di errore (inviato sullo standard error) che ci avverte del fatto che il file non_esisto non si trova nella directory corrente. Nella seconda esecuzione, aggiungiamo anche un \fbox{\texttt{... 2>/dev/null}}, ciò significa che tutto ciò che verra inviato sullo standard error (descrittore di file 2) sarà rigirato nella nostra pattumiera, /dev/null.




FIXME: completare.


4.3.5 Duplicare un descrittore di file

Bash consente di duplicare i descrittori di file in modo da organizzare il più funzionalmente possibile i propri script. Ad esempio, può essere necessario assegnare temporaneamente un descrittore aggiuntivo allo standard input, in modo da facilitare l'elaborazione dei dati dopo complesse operazioni di reindirizzamento.




FIXME: Completare.


4.4 Here document

Un Here document (Nel seguito sarà abbreviato con HD, che non sta per hard disk!) è un tipo particolare di reindirizzamento dell'I/O che consente alla shell di leggere delle righe di testo fino ad una parola delimitatrice e trasformarle in standard input di un comando.




La sintassi di questa speciale caratteristica è la seguente:

comando <<[-]PAROLA
... 
(CORPO dell'HD)
... 
DELIMITATORE
Figura 4.2: Schematizzazione di un here document (EOF è il delimitatore).
\includegraphics[]{fig/here}
Su PAROLA non viene fatta alcun tipo di espansione, l'unica cosa da tenere a mente è il fatto che, qualora contenesse un qualsiasi carattere quotato (catattere preceduto da un backslash, $\mathtt{\backslash}$), allora DELIMITATORE sarebbe il risultato della rimozione dei backslash da PAROLA e tutte le righe comprese non sarebbero soggette ad alcuna espansione. Se, invece, PAROLA non fosse quotata, sarebbe effettuata espansione di parametri, comandi ed aritmetica su tutte le linee contenute nell'HD.




Il ``-'' facoltativo serve a rendere più ``graziosa'' la vista di un HD, in questo modo, infatti, le righe possono essere indentate rispeto all'inizio di una riga di un carattere TAB, la sinstassi pertanto diverrebbe:

comando <<-PAROLA
        ... 
        (CORPO dell'HD)
        ... 
        DELIMITATORE
Ciò è possibile poiché l'operatore di redirezione «- istruisce la shell a rimuovere tutti i caratteri TAB che precedono le righe dell'HD e del DELIMITATORE.




Vediamo come applicare quanto appreso con un esempio che fa uso di alcuni semplici comandi previsti dal protocollo SMTP (Simple Mail Transfer Protocol) e del comando nc (netcat).

Esempio 4.4.1

#!/bin/bash
#
# Spedire e-mail con uno shell script
EXIT_SUCCESS=0
EXIT_FAILURE=1

# indirizzo del server di posta
SMTP_SERVER="localhost"

# percorso completo dell'eseguibile di nc
NC="$(which nc)"

# indirizzo del mittente
MITTENTE="nico@localhost"

utilizzo () {

    echo "$(basename $0) <destinatario> \"<testo>\" \"<oggetto>\""
    exit $EXIT_FAILURE

}

if [ $# -ne 3 ]; then
    utilizzo
fi

DESTINATARIO=$1
TESTO=$2
OGGETTO=$3


$NC -w 5 "$SMTP_SERVER" "25" &> /dev/null <<EOF
HELO $(hostname)
MAIL FROM: <$MITTENTE>
RCPT TO: <$DESTINATARIO>
DATA
Subject: $OGGETTO
To: $DESTINATARIO
From: $MITTENTE

$TESTO

.
EOF

exit $EXIT_SUCCESS

Come funziona:

Innanzitutto, vediamo come lanciare lo script.

[nico@deepcool nico]$ ./mail.sh nico@deepcool "ecco una e-mail inviata                                                
> con uno shell script" "e-mail con shell script"
[nico@deepcool nico]$
Come mostrato nella funzione utilizzo(), lo script richiede tre parametri per essere eseguito correttamente, rispettivamente: indirizzo e-mail del destinatario, breve testo racchiuso tra virgolette in modo da poterlo estendere su più linee, oggetto della e-mail, anch'esso racchiuso tra virgolette.




Passiamo ad analizzare il codice. Oltre alle solite variabili per definire i valori di ritorno, notiamo subito la presenza di alcune variabili per ``configurare'' lo script: $SMTP_SERVER, $NC e $MITTENTE. Il significato di queste variabili è, rispettivamente: indirizzo del server smtp da utilizzare, percorso completo dell'eseguibile di netcat (tramite l'utility which) e indirizzo e-mail del mittente.




Successvamente, lo script memorizza i parametri posizionali forniti con $1, $2 e $3 nelle variabili $DESTINATARIO, $TESTO e $OGGETTO per maneggiarli più comodamente. Arrivati a questo punto, iniziano le novità. La riga \fbox{\footnotesize{\texttt{\$NC -w 5
\lq\lq \$SMTP\_SERVER'' \lq\lq 25'' \&> /dev/null «EOF}}} identifica un HD in cui tutte le righe comprese tra \fbox{\texttt{«EOF}} ed \fbox{\texttt{EOF}} sono trasformate in standard input del comando \fbox{\texttt{\$NC -w 5
\lq\lq \$SMTP\_SERVER'' \lq\lq 25'' \&> /dev/null}}.




Le righe dell'HD sono comandi standard del protocollo SMTP4.3; in particolare, la riga
\fbox{\texttt{HELO \$(hostname)}} è una stringa di ``presentazione'' che invia al server di posta il nostro hostname, ottenuto tramite il comando hostname.


4.5 Opzioni passate ad uno script

Nella sezione precedente abbiamo analizzato un semplice script che ci consentiva di inviare e-mail. Lo script aveva il difetto di dover esser lanciato con una sintassi molto rigida, infatti occorreva richiamarlo fornendo esattamente tre parametri in un ben preciso ordine. Questa restrizione fastidiosa e limitativa potrebbe essere evitata se si potessero usare le opzioni come nei programmi compilati (Ad esempio ls, rm, tar e molti molti altri).




Ancora una volta, Bash ci viene in aiuto (Anche altre shell lo fanno), mettendoci a disposizione il comando interno getopts.




Andiamo con ordine ed introduciamo alcune peculirità; utilizzando getopts all'interno di uno script, vengono implicitamente create le variabili $OPTIND (L'indice dell'argomento in esame, che non viene reimpostato automaticamente) e $OPTARG (il contenuto del facoltativo argomento di un'opzione). Vediamo ora un quadro che riassume la sintassi del comando:

getopts STRINGA_DELLE_OPZIONI NOME
STRINGA_DELLE_OPZIONI rappresenta la sequenza di opzioni che il nostro script deve avere; ad esempio, se volessimo fornire allo script le opzioni -a, -b e -c, la seguenza sarebbe \fbox{\texttt{\lq\lq abc''}}. Se volessimo anche far in modo che l'opzione -b accetti un argomento, allora dovremmo cambiare la sequenza delle lettere, aggiungendo \fbox{\texttt{:}} dopo la lettera b, otterremo dunque \fbox{\texttt{\lq\lq ab:c''}}. Da qui impariamo una regola generale, ovvero, per informare getopts del fatto che una particolare opzione accetta un argomento, occorre far seguire la lettera che la identifica nella STRINGA_DELLE_OPZIONI da \fbox{\texttt{:}}. Porre \fbox{\texttt{:}} all'inizio di una stringa equivale a sopprimere ogni messaggio di errore relativo a getopts4.4.




NOME, invece, rappresenta la variabile in cui salvare la lettera dell'opzione in esame. Qualora getopts incontrasse un'opzione non presente in STRINGA_DELLE_OPZIONI, inserirebbe in NOME il carattere \fbox{\texttt{?}}.




Dovrebbe esser ormai chiaro che getopts analizza le opzioni una alla volta, dunque per analizzare i valori passati occorre utilizzarla insieme con un ciclo. Tra quelli studiati nella sezione 3.1, il ciclo while è senz'altro il più indicato. Non finisce qui! A seconda dell'opzione esaminata occorre prendere decisioni differenti, quindi dobbiamo effettuare una selezione, in questo caso, case è l'istruzione più appropriata. Vediamo dunque come combinare le cose nel caso:

... 
while getopts ":ab:c" OPZIONE; do
    case $OPZIONE in
        a)
            fai_qualcosa_con_a
            ;;
        b)
            VARIABILE_PER_b=$OPTARG
            fai_qualcosa_con_b
            ;;
        c)
            fai_qualcosa_con_c
            ;;
        ?) 
            echo "Opzione non valida"
            ;;
        *)
            echo "C'è stato qualche errore!" 
            ;;
    esac
done

shift $(( $OPTIND - 1 ))
Vediamo dunque come modificare l'esempio della sezione precedente in modo da renderlo più utilizzabile.

Esempio 4.5.1

#!/bin/bash
#
# Spedire e-mail con uno shell script (2)
EXIT_SUCCESS=0
EXIT_FAILURE=1

# indirizzo del server di posta
SMTP_SERVER="localhost"

# percorso completo dell'eseguibile di nc
NC="$(which nc)"

# l'indirizzo del mittente
MITTENTE="nico@localhost"

utilizzo () {

    echo "$(basename $0) -d <destinatario> -t <testo> -o <oggetto>"
    exit $EXIT_FAILURE

}

while getopts ":d:t:o:" OPZIONE; do
    case $OPZIONE in
        d)
            DESTINATARIO=$OPTARG
            ;;
        t)
            TESTO=$OPTARG
            ;;
        o)
            OGGETTO=$OPTARG
            ;;
        *)
            utilizzo
            ;;
    esac
done

shift $(( $OPTIND - 1 ))

if [ -z "$DESTINATARIO" -o -z "$TESTO" -o -z "$OGGETTO" ]; then
    utilizzo
fi

$NC -w 5 "$SMTP_SERVER" "25" &> /dev/null <<EOF
HELO $(hostname)
MAIL FROM: <$MITTENTE>
RCPT TO: <$DESTINATARIO>
DATA
Subject: $OGGETTO
To: $DESTINATARIO
From: $MITTENTE

$TESTO

.
EOF

exit $EXIT_SUCCESS

Come funziona:

L'unica novità rispetto alla precedente versione è, come era da aspettarsi, la presenza di getopts. Abbiamo passato a getopts la stringa \fbox{\texttt{\lq\lq :d:t:o:''}}, in questo modo, grazie al : iniziale abbiamo soppresso ogni messaggio di errore, inoltre abbiamo istruito il comando ad analizzare i parametri posizionali alla ricerca delle opzioni -d (destinatario), -t (testo) e -o (oggetto), ognuna delle quali necessita di un argomento. Attraverso l'istruzione case assegnamo l'argomento di una opzione ad una specifica variabile, in modo da poterlo riutilizzare; inoltre, dato che non ci interessa essere molto specifici riguardo ad un eventuale errore, abbiamo tralasciato il caso ?), dato che questo può essere inglobato in quello di default *).


4.6 . (source)

Alle volte potrebbe rendersi necessario includere in uno script del testo presente in un altro file; questo testo potrebbe essere del codice e quindi dovrebbe anche essere interpretato correttamente dalla shell. La questione può essere risolta facilmente utilizzando il comando interno \fbox{\texttt{source}} o \fbox{\texttt{.}}.




Un tale tipo di inclusione può essere necessario qualora decidessimo di dotare un nostro script di un file di configurazione. Ad esempio nell'esempio precedente esistono delle variabili dipendenti dal calcolatore su cui viene eseguito, sarebbe auspicabile, dunque, avere un sistema per spiegare allo script, ad esempio, quale server di posta utilizzare, oppure quale argomento passare al comando SMTP HELO.




Vediamo come affrontare questa evenienza.

Esempio 4.6.1

Innanzi tutto, stabiliamo il percorso del file di configurazione, ad esempio \fbox{\texttt{/etc/bash\_mail.conf}} e scriviamoci:
# Questo è un commento

# server SMTP da utilizzare
SMTP_SERVER="il_mio_server_di_posta"

# HELO da utilizzare
# Modificare a seconda delle esigenze
HELO="il_mio_host"

# fine di /etc/bash_mail.conf




Potremmo anche consentire ad ogni singolo utente di modificare i paramentri di configurazione dello script; per fare ciò, prevediamo anche l'esistenza di un file \fbox{\texttt{\$HOME/.bash\_mail.conf}}

# File di configurazione per utente

# server SMTP da utilizzare
# SMTP_SERVER="un_altro_server"

# HELO da utilizzare
# Modificare a seconda delle esigenze
# HELO="un_altro_helo"

# Indirizzo del mittente
MITTENTE="Domenico Delle Side <nicodds@Tiscali.IT>"

# fine di $HOME/.bash_mail.conf




Ora riprendiamo l'esempio e modifichiamolo in modo da utilizzare i file di configurazione appena scritti.

#!/bin/bash
#
# Spedire e-mail con uno shell script (3)
EXIT_SUCCESS=0
EXIT_FAILURE=1

NC=$(which nc)

if [ -f "/etc/bash_mail.conf" ]; then
    . /etc/bash_mail.conf
# oppure 
# source /etc/bash_mail.conf
else
    SMTP_SERVER="localhost"
    HELO=$HOSTNAME
fi

if [ -f "$HOME/.bash_mail.conf" ]; then
    . $HOME/.bash_mail.conf
else
    MITTENTE="<${USER}@${HOSTNAME}>"
fi

utilizzo () {

    echo "$(basename $0) -d <destinatario> -t <testo> -o <oggetto>"
    exit $EXIT_FAILURE

}

while getopts ":d:t:o:" OPZIONE; do
    case $OPZIONE in
        d)
            DESTINATARIO=$OPTARG
            ;;
        t)
            TESTO=$OPTARG
            ;;
        o)
            OGGETTO=$OPTARG
            ;;
        *)
            utilizzo
            ;;
    esac
done

shift $(( $OPTIND - 1 ))

if [ -z "$DESTINATARIO" -o -z "$TESTO" -o -z "$OGGETTO" ]; then
    utilizzo
fi

$NC -w 5 "$SMTP_SERVER" "25" &> /dev/null <<EOF
HELO $HELO
MAIL FROM: $MITTENTE
RCPT TO: <$DESTINATARIO>
DATA
Subject: $OGGETTO
To: $DESTINATARIO
From: $MITTENTE

$TESTO

.
EOF

exit $EXIT_SUCCESS

Come funziona:

Lo script in sè non è radicalmente cambiato, rispetto alla versione precedente abbiamo solo aggiunto una nuova variabile ($HELO) e modificato il codice per consentire l'inclusione dei file di configurazione. Come prima cosa, lo script verifica la presenza del file di configurazione generico \fbox{\texttt{/etc/bash\_mail.conf}}, se presente, lo include, altrimenti assegna dei valori di default alle variabili $SMTP_SERVER e $HELO.




Successivamente viene verificata la presenza del file di configurazione dell'utente che lancia lo script, \fbox{\texttt{\$HOME/.bash\_mail.conf}} ($HOME viene espansa dalla shell nella home directory dell'utente che esegue lo script). Qualora il file fosse presente, ne sarebbe incluso il contenuto, in questo modo le variabili generiche eventualmente impostate con l'inclusione precedente verrebbero reimpostate ai valori presenti nel file dell'utente. Se invece il file fosse assente, lo script si limiterebbe ad impostare la variabile $MITTENTE.




Per il resto, lo script è analogo all'esempio della sezione precendente.


4.7 Subshell


4.8 trap

trap è un comando interno alla shell che consente di definire un comando da eseguire allorché lo script in esecuzione riceva un segnale.




In breve, un segnale è un evento generato da un sistema Unix al verificarsi di una particolare condizione. Un segnale è in genere utilizzato dal sistema operativo per dialogare con i processi; un processo che riceve un segnale, ad esempio, può a sua volta decidere un'azione da intraprendere.




Ritorniamo a trap e analizziamone la sintassi:

trap [-lp] [AZIONE] [SEGNALE]
Procediamo con ordine, le opzioni facoltative (-l, -p) fanno agire trap in maniera particolare; l'opzione -l viene passata da sola a trap e consente di ottenere la lista di tutti i segnali supportati, -p, invece, consente di vedere le azioni associate ad ogni segnale, o, qualora fossero specificati dei segnali, solo quelle associate a questi. Ad esempio, il comando trap -l ci fornisce il risultato:
[nico@deepcool nico]$ trap -l     
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL
 5) SIGTRAP      6) SIGABRT      7) SIGBUS       8) SIGFPE
 9) SIGKILL     10) SIGUSR1     11) SIGSEGV     12) SIGUSR2
13) SIGPIPE     14) SIGALRM     15) SIGTERM     17) SIGCHLD
18) SIGCONT     19) SIGSTOP     20) SIGTSTP     21) SIGTTIN
22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO
30) SIGPWR      31) SIGSYS      32) SIGRTMIN    33) SIGRTMIN+1
34) SIGRTMIN+2  35) SIGRTMIN+3  36) SIGRTMIN+4  37) SIGRTMIN+5
38) SIGRTMIN+6  39) SIGRTMIN+7  40) SIGRTMIN+8  41) SIGRTMIN+9
42) SIGRTMIN+10 43) SIGRTMIN+11 44) SIGRTMIN+12 45) SIGRTMIN+13
46) SIGRTMIN+14 47) SIGRTMIN+15 48) SIGRTMAX-15 49) SIGRTMAX-14
50) SIGRTMAX-13 51) SIGRTMAX-12 52) SIGRTMAX-11 53) SIGRTMAX-10
54) SIGRTMAX-9  55) SIGRTMAX-8  56) SIGRTMAX-7  57) SIGRTMAX-6
58) SIGRTMAX-5  59) SIGRTMAX-4  60) SIGRTMAX-3  61) SIGRTMAX-2
62) SIGRTMAX-1  63) SIGRTMAX
Il parametro AZIONE (anch'esso opzionale) serve a specificare, come chiarisce il nome stesso, l'azione da intraprendere al ricevere del segnale SEGNALE (opzionale), in genere questa è una lista di comandi da eseguire. Il segnale SEGNALE può essere specificato sia tramite una delle costanti simboliche definite in <signal.h>4.5, sia attraverso il numero che identifica il segnale. In tabella 4.1 è presente una breve lista di segnali standard.

Tabella 4.1: Alcuni segnali più importanti definiti dallo standard X/Open
Segnale Numero Descrizione
SIGHUP 1 Hangup, il processo che riceve il segnale viene terminato e fatto ripartire
SIGINT 2 Interrupt, il procevo che riceve il segnale viene terminato (CTRL-C)
SIGQUIT 3 Quit (CTRL-$\backslash$)
SIGABRT 6 Abort, inviato ad un processo in caso di gravi errori in esecuzione
SIGALRM 14 Alarm, inviato in caso di time-out
SIGTERM 15 Terminate, inviato dal sistema in caso di spegnimento





Ad esempio, se volessimo far sì che nel nostro terminale venga stampato un messaggio ogni volta che si riceva un segnale di interrupt, basterebbe scrivere:

trap 'echo -e "\nQuesto un messaggio impostato con trap (SIGINT)"' SIGINT
Dopo averlo eseguito, infatti, ad ogni pressione della combinazione di tasti CTRL-C otteniamo il messaggio scritto:
[nico@deepcool nico]$ trap 'echo -e "\nQuesto un messaggio impostato 
> con trap (SIGINT)"' SIGINT
[nico@deepcool nico]$ (CTRL-C)
Questo un messaggio impostato 
con trap (SIGINT)

[nico@deepcool nico]$ (CTRL-C)
Questo un messaggio impostato 
con trap (SIGINT)

[nico@deepcool nico]$ (CTRL-C)
Questo un messaggio impostato 
con trap (SIGINT)

[nico@deepcool nico]$ (CTRL-C)
Questo un messaggio impostato 
con trap (SIGINT)
Se ora volessimo tornare alla situzione iniziale, basterebbe digitare il comando:
trap SIGINT
Ciò riporterebbe l'azione da intraprendere al ricevimento di un interrupt al default della shell.

Esempio 4.8.1

#!/bin/bash
#
# Uso di trap

EXIT_SUCCESS=0

TMPFILE=$HOME/.tmpfile.$$

trap 'rm -f $TMPFILE' SIGINT

echo "Creo il file $TMPFILE..."
touch $TMPFILE
echo "Premi CRTL-C per uscire"

while [ -f $TMPFILE ]; do
    echo "$TMPFILE esiste ancora"
    sleep 1;
done

echo "$TMPFILE non esiste più"

exit $EXIT_SUCCESS

Come funziona:

All'inizio dello script definiamo la variabile $TMPFILE e definiamo un azione da compiere su questo al ricevere di un segnale di interrupt: \fbox{\texttt{trap 'rm -f \$TMPFILE' SIGINT}}. Successivamente, dopo alcuni messaggi di cortesia, creiamo il file temporaneo $TMPFILE con l'utility touch ed entriamo in un ciclo nel quale la condizione è data da \fbox{\texttt{-f \$TMPFILE}}. Ad ogni iterazione del ciclo viene stampato un messaggio che conferma l'esistenza del file temporaneo e si manda in pausa per 1 secondo l'esecuzione dello script grazie al comando sleep.




Alla pressione della combinazione di tasti CTRL-C, il file temporaneo verrà cancellato, dunque la condizione valutata dal ciclo while sarà falsa, causandone la fine. Lo script termina stampando un ulteriore messaggio e ritornando alla shell il valore $EXIT_SUCCESS.

previous up

Introduzione allo Shell Scripting

index next

Domenico Delle Side 2002-09-24