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

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.

Definizione di funzione

Sintassi:

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. È buona prassi usare i commenti per specificare le opzioni richieste dalla funzione.

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.

Per esempio:

# Breve descrizione della funzione
# Numero argomenti: N (N+ per almeno N, oppure * per qualsiasi numero)
# $1 - descrizione del primo argomento
# $2 - descrizione del secondo argomento
# ...
# Input: se si aspetta qualcosa sullo standard input
# Output: cosa stampa sullo standard output
# Exit status: 0 se successo (possibili exit status)
nomefunzione () {
   # definisco variabili locali
   local var1; local var2; ...
   # leggo argomenti passati alla funzione
   var1=$1
   var2=$2
   ...
   # corpo della funzione
   ...
}

Valori di ritorno

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ù (e i *DUE* in più se c'è un "a capo" dopo il valore, o verrebbe preservato). Con questo metodo è possibile passare nomi di file qualsiasi, ma al solito non il carattere ASCII n. 0;
  • stamparlo come output, ricordandosi nell'assegnazione di aggiungere && printf %s X all'interno dell'espansione di comando dopo la chiamata di funzione, rimuovendo in seguito il carattere o i due caratteri finali in più. È equivalente al metodo precedente, ma può essere usato anche senza passare per un'assegnazione;
  • utilizzare una variabile globale, eventualmente con il nome della funzione per evitare doppioni (per esempio: retval_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.


Esempio: funzione che conta gli argomenti con $* e $@

  • Esempio che stampa il numero di argomenti passati allo script (mostrando la differenza tra $* e $@):
#! /bin/bash

# Conta il numero di stringhe passate come argomento
# Numero argomenti: *
# $* - stringhe qualsiasi
# Output: stampa il numero di stringhe
count () {
    printf %s\\n $#
}

# esegue la funzione con diversi argoment
# e la assegna con espansione di comando
testQ1=$(count "$*")
testN1=$(count $*)
testN2=$(count $@)
testQ2=$(count "$@")

printf %s\\n "Argomenti contati: ${testQ1},${testN1},${testQ2},${testN2}"

exit 0

Si provi a creare lo script (per esempio count.sh), rendendolo eseguibile (si veda l'introduzione) e a provare a chiamarlo con diversi argomenti. In particolare:

$ ./script.sh 1 2 3

Argomenti contati: 1,3,3,3

$ ./script.sh "1 stringa" "2 stringa" "3 stringa"

Argomenti contati: 1,6,6,3

$ ./script.sh "/*/"

Argomenti contati: 1,N,N,1 (dove N è il numero di cartelle presenti in /)

Ovviamente la seconda e la terza posizione combacia sempre perché l'espansione di $* e $@ differisce solo quando queste variabili speciali sono quotate. E soltanto "$@" (quotata) preserva la lista degli argomenti, così com'era stata passata allo script o alla funzione.

Si noti che è possibile assegnare l'output della funzione con un'espansione di comando solo perché l'output è un valore numerico, senza caratteri ASCII n. 0 e "a capo" finali.

Esempi: funzioni che restituiscono nomi di file

I nomi di file possono contenere tutti i caratteri, tranne / e il carattere ASCII n. 0, mentre i percorsi non possono contenere il solo carattere ASCII n. 0. Ne consegue che l'espansione di comando non è adatta a essere impiegata con comandi che restituiscono nomi di file, e questo si applica anche alle funzioni, salvo i file seguano convenzioni note a priori (come quelli di sistema).

Non è ovviamente il caso per quelli utente. In questa sezione si presenteranno tre metodi per definire funzioni robuste in grado di gestire nomi di file arbitrari da assegnare a una variabile, tra quelli già discussi brevemente.

  • Esempio di script che stampa la directory genitore (parent) di un dato file, gestendo eventuali "a capo" durante l'assegnazione:
#! /bin/bash

# Stampa il nome della directory parent in cui è contenuto un file
# Numero argomenti: 1
# $1 - file (permesso percorso relativo, assoluto o anche assente/implicito)
# Output: nome della directory parent (/ è considerata parent di sé stesso)
# Exit status: 0 solo se il file esiste
print_parent () {
    # variabili locali
    local file; local dir
    file=$1
    if ! [ -e "$file" ]; then
        return 1 # ritorna 1 se il file non esiste
    fi
    # dirname rimuove il nome del file dal percorso
    dir=$(dirname -- "$file" && printf %s X) # NOTA: aggiungo un carattere qualsiasi alla fine!
                                             # altrimenti eventuali "a capo" finali verrebbero rimossi
    dir=${dir%??}  # rimuovo gli ultimi *DUE* caratteri
                   # infatti va rimossa anche la nuova riga creata da dirname
                   # preservata aggiungendo X
    # readlink cerca il percorso assoluto e lo stampa sullo standard output
    readlink -e -- "$dir"  # determina l'exit status
}

if [ $# -ne 1 ]; then
    {
        printf %s\\n "Usage:"
        printf \\t%s\\n "$0 file"
    } >&2
    exit 1
fi

# assegno a una variabile, aggiungendo un carattere qualsiasi come delimitatore
# per impedire la rimozione di caratteri "a capo" finali (che sono possibili)
parent_dir=$(print_parent "$1" && printf %s X) || {
    # errore (la funzione ha restituito un exit status diverso da 0!)
    retval=$?
    printf %s\\n "ERRORE: file (\"$1\") non esistente!" >&2
    exit $retval
}
# rimuovo gli ultimi DUE caratteri (il delimitatore e il carattere "a capo" aggiunto dalla funzione)
parent_dir=${parent_dir%??}

printf %s\\n "Parent dir:"
printf %s\\n "$parent_dir"

exit 0

Per semplificare le cose è sempre bene che ogni funzione, così come fanno i comandi esterni, stampino sempre con un "a capo" finale, permettendo così di aggiungere un delimitatore e rimuovere gli ultimi due caratteri.

Si noti che sarebbe sbagliato scrivere nella funzione, o anche direttamente nel corpo principale dello script:

readlink -e -- "$(dirname -- "$file")" # SBAGLIATO!

L'espansione di comando con dirname non garantisce che i caratteri "a capo" finali siano preservati. Quando un comando qualsiasi (interno, funzione o comando esterno) restituisce un nome di file arbitrario, che non segue convenzioni note a priori (come i file di sistema), non può essere usato senza accorgimenti in un'espansione di comando.


  • Lo stesso esempio ricorrendo invece a una variabile globale per il valore di ritorno:
#! /bin/bash

# Assegna a retval_parent la directory parent di un file
# Numero argomenti: 1
# $1 - file (permesso percorso relativo, assoluto o anche assente/implicito)
# Exit status: 0 solo se il file esiste
parent () {
    # variabili locali
    local file; local dir
    # variabile globale (perché non preceduta da local)
    retval_parent="" # inizializzata ogni volta
    # leggi argomenti
    file=$1
    # inizio funzione
    if ! [ -e "$file" ]; then
        return 1 # ritorna 1 se il file non esiste
    fi
    # dirname rimuove il nome del file dal percorso
    dir=$(dirname -- "$file" && printf %s X) # aggiungo un carattere qualsiasi alla fine!
                                             # altrimenti eventuali "a capo" finali verrebbero rimossi
    dir=${dir%??}  # rimuovo gli ultimi *DUE* caratteri
                   # infatti va rimossa anche la nuova riga creata da dirname
                   # preservata aggiungendo X
    # readlink cerca il percorso assoluto e lo stampa sullo standard output
    dir=$(readlink -e -- "$dir" && printf %s X) ||
          return $?     # in caso di errori restituisce quell'exit status
    dir=${dir%??}       # rimuovo gli ultimi due caratteri
    retval_parent=$dir  # assegno il risultato alla variabile globale
}

if [ $# -ne 1 ]; then
    {
        printf %s\\n "Usage:"
        printf \\t%s\\n "$0 file"
    } >&2
    exit 1
fi

# chiamo la funzione, *SENZA* usare l'espansione di comando
parent "$1" || {
    retval=$?
    printf %s\\n "ERRORE: file (\"$1\") non esistente!" >&2
    exit $retval
}
# assegno la variabile globale (è sempre bene farlo subito, così da poter richiamare parent)
parent_dir=$retval_parent

printf %s\\n "Parent dir:"
printf %s\\n "$parent_dir"

exit 0

In questo caso è necessario ricordarsi dei due caratteri finali da rimuovere unicamente all'interno della funzione stessa, e non a ogni chiamata. Lo svantaggio è nell'uso di una variabile globale per ogni funzione, per questo è bene usare una forma non ambigua, contenente il nome della funzione e un prefisso comune, usato solo per queste variabili.

Si noti che tutte le variabili sono globali, tranne quelle in una funzione definite con local. È bene però non usare mai altre variabili globali, se non sono costante (definite con readonly</code), all'interno di una funzione, per rendere più semplice la comprensione del codice ed evitare modifiche accidentali (con effetti a catena in più parti del codice), e ricorrere invece ai parametri.


  • Forma alternativa, con controllo di errori e interruzione immediata:
#! /bin/bash

# Stampa il nome della directory parent in cui è contenuto un file
# Numero argomenti: 1
# $1 - file (permesso percorso relativo, assoluto o anche assente/implicito)
# Output: nome della directory parent (/ è considerata parent di sé stesso)
# Exit status: 0 solo se il file esiste
print_parent () {
    local dir
    dir=$(dirname -- "$1") &&
    dir=$(readlink -e -- "$dir") &&
    printf %s\\n "$dir" ||
        return $?  # è implicito, ma aumenta la leggibilità del codice
}

if [ $# -ne 1 ]; then
    {
        printf %s\\n "Usage:"
        printf \\t%s\\n "$0 file"
    } >&2
    exit 1
fi
parent_dir=$(print_parent "$1") || {
    printf %s\\n "Percorso non esistente o contenente caratteri non validi!" >&2
    exit 1
}

printf %s\\n "Parent dir:"
printf %s\\n "$parent_dir"

exit 0

Quest'ultimo metodo è più veloce da scrivere, e anche più facile da leggere e capire. Si noti però che se esistessero multipli file con lo stesso nome, a parte per la presenza degli "a capo" finali, non verrebbe mai restituito errore con nessuno di loro, ma ogni volta che si specifica uno dei file con "a capo" si finirebbe per usare sempre il file senza "a capo" finali senza possibilità di accorgersene. E il controllo sugli argomenti non garantirebbe da quelli esistenti nel percorso reale restituito da readlink. Può essere tollerabile o meno, dipende dallo scopo dello script, ma soltanto i metodi precedenti sono adatti a tutte le situazioni possibili.