Manipolare flussi di testo

In molti esempi sui comandi presentati, si è visto che questi generano (oppure accettano in input) dei flussi di testo. Si pensi ad esempio al comando cat che permette di stampare il contenuto di un file (o di concatere il contenuto di più file), o al comando grep che invece permette di filtrarlo cercando alcune righe.

Questi esempi non sono un caso isolato. La maggior parte dei comandi sui sistemi Unix è pensato per poter prendere in input del testo semplice. In questo esercizio vedremo alcuni esempi significativi, che permetteranno poi di affrontare altri esercizi più complessi.

Sostituire il testo

Prima di iniziare, ricordiamo che il comando echo permette di stampare del testo, e che possiamo usare l'output di un comando come input per un altro attraverso il comando |. Ad esempio, se volessimo dare l'output di echo in pasto a grep, useremmo la seguente sintassi:

$ echo test | grep prova
che non ritornerebbe nulla, perché la stringa "prova" non viene trovata all'interno della stringa "test".

Consideriamo ora un task relativamente semplice, quello della sostituzione di caratteri. A questo scopo, possiamo usare il comando tr. Questo prende come input due argomenti, un carattere da cercare, e quello da sostituire a tutte le occorrenze trovate. L'input viene letto da stdin, e stampato sullo stdout.

Si consideri nuovamente il testo della Divina Commedia salvato in /home/f.durastante/dante.txt, e si produca un file dal nome dante-xxx.txt dove tutte le vocali siano state sostituite da una x.
Il comando tr può essere usato anche per eliminare dei caratteri dal flusso di testo, usando il flag -d.
Si produca un secondo file dante-xxxn.txt, in cui sono stati rimossi tutti i caratteri per andare a capo, che si possono indicare con '\n'.

Il comando sed

Il comando tr è molto efficiente, ma è adatto solo alle operazioni più semplici. Supponiamo di voler riscrivere la storia raccontata da Dante, e di voler rinominare Beatrice con il nome di Giovanna. Ottenere questo effetto con tr sarebbe impossibile.

Il comando sed (stream editor) permette di manipolare un flusso di testo, effettuando varie operazioni, fra cui delle sostituzioni. Questo comando utilizza dei sottocomandi; ad esempio, per sostituire la stringa "test" con la stringa "prova" dovremmo usare le linea:

$ sed "s/test/prova/"
dove s indica il comando "sostituzione", mentre / viene usato per separare gli argomenti del comando. Si sarebbe potuto utilizzare un qualsiasi altri simbolo, ad esempio il comando
$ sed "s|test|prova|"
sarebbe stato completamente equivalente. Si può scegliere di volta in volta il simbolo da usare in modo da evitare che appaia nelle stringhe da sostituire o da rimpiazzare.
Si usi il comando sed per generare un file dante-2.0.txt con Beatrice sostituita da Giovanna. Si osservi che la stringa "Beatrice" appare solo al più una volta per riga; se così non fosse stato, sed avrebbe sosituito solo la prima occorrenza. Per sostituirle tutte si sarebbe aggiungere il flag "g" in fondo al comando, ad esempio sed "s/test/prova/g". Si noti che anche in assenza della g lo slash finale va lasciato.
Come in molti comandi già visti, il pattern da matchare può essere definito tramite un'espressione regolare. Inoltre, è possibile definire dei gruppi nel pattern da cercare usando le parentesi (che vanno indicate con \( e \) per evitare che vengano interpretate da bash), e riusare questi gruppi indicandoli con \1, ..., \9 nella stringa da sostituire.

Consideriamo un esempio. Supponiamo di aver scritto del codice in un linguaggio di programmazione dove abbiamo usato più volte l'istruzione a + b + x, dove a e b possono essere numeri qualunque, e x è una variabile. Per qualche ragione, vorremmo trasformare queste istruzioni in b - a + x. Possiamo matchare questo input con l'espressione regolare [0-9] + [0-9] + x. Per preservare i valori di a,b, dobbiamo creare dei gruppi accanto a loro, e fare la sostituzione. Ad esempio:

$ echo "2 + 3 + x" | sed "s/\([0-9]\) + \([0-9]\) + x/\2 - \1 + x/"
3 - 2 + x
Si utilizzi il comando sed per trovare tutte le occorrenze della forma WWWWW Virgilio, dove WWWWW rappresenta una qualunque parola, e se ne scambi l'ordine nel testo, trasformandole in Virgilio WWWWW.

Affettare il testo

Consideriamo ora un altro problema comunque: ci viene data una stringa composta da più parti, che vogliamo separare. Ad esempio, ipotizziamo di avere un file contenente delle date nella forma:

19-10-2020
19-04-2023
4-12-1987    
21-10-2020
23-01-2021
24-04-2021
28-08-2022
Supponiamo di voler estrarre gli la lista degli anni presenti nel file. Un primo passo sarebbe quello di eliminare le parti che non ci interessano, ovver le date e i mesi. A questo scopo, possiamo usare il comando cut che, dato un delimitatore, spezzetta ogni riga in base al numero di occorrenze del delimitatore.

Per capire la sintassi del comando cut, supponiamo di voler separare i campi di un file input.txt usando il simbolo :, e selezionando la terza colonna. Potremmo scrivere:

$ cut -d ':' -f3 < input.txt
dove -d seleziona il delimitatore, e -fN l'N-esima colonna. Osserviamo che si è usato la redirezione di input.txt come input usando l'operatore <, ma si sarebbe potuto usare cat combinato con |, o altro.
Si crei un file date.txt con il testo riportato sopra. Si utilizzi il comando cut per estrarre solamente gli anni.

Riordinare le righe

Sebbene l'output del comando precedente possa essere soddisfacente, alcuni anni vengono ripetuti più volte, perché esistevano varie date all'interno di quell'anno. È abbastanza chiaro che questo comportamento potrebbe essere fastidioso su una lista più lunga. Inoltre, gli anni non sono in ordine cronologico, ma vengono riportati come sono trovati nel file.

Per risolvere questi due problemi ci possiamo affidare a due programmi: sort e uniq. Il loro nome descrive abbastanza bene il loro comportamento.

Si utilizzino sort e uniq per riordinare gli anni ed eliminare i doppioni. Si ordino gli anni a partire dal più grande al più piccolo (si legga il manuale di sort per vedere come fare).

Awk

Sulla maggior parte dei sistemi Unix è a disposizione un programma, di nome awk, che permette di manipolare facilmente dati in forma tabellare.

Si crei un file con il seguente contenuto, e lo si chiami mailing_list.txt:

# Nome		Email		Telefono	Età
Mario Rossi	rossi@tin.it	+39 347 1276354	30
Giovanni Verdi	verdi@gmail.com	+39 348 7162451	46
Luca Tonelli	tonelli@dm.unipi.it	+39 050 139232	12
Leonardo Fibonacci	fibo@outlook.com	+39 050 563821	65
Tommaso Ligabue	ligabue@live.com	+39 348 9985634	56
Attenzione: i campi nome, e-mail, telefono sono separati da tabulazioni (TAB), e non da spazi.

Il comando awk permette di definire un'azione da effettuare per ogni riga che corrisponde ad una data espressione regolare. Come vedremo, ci sono anche azioni speciali che si possono definire perché vengano eseguite prima e/o dopo la lettura del file.

La sintassi base del comando è:

$ awk '/pattern/ { action }'
dove pattern è l'espressione regolare da cercare, e action specifica l'azione da fare. L'utilizzo di /pattern/ è opzionale: se non specificato, il comando eseguirà l'azione su tutte le righe. In maniera analoga agli altri comandi finora considerati, è possibile specificare un file da cui leggere il testo, oppure non specificare nulla ed in quel caso verrà letto dallo stdin.

Si provi ad eseguire il comando
$ awk '{ print $0 }' mailing_list.txt
che chiede di stampare ogni riga ($0 è un riferimento speciale alla riga che viene considerata in un dato momento). Il comportamento dovrebbe essere analogo a cat solo più complicato da scrivere. Si modifichi poi l'esempio per stampare solo le righe che iniziano per L; questo richiederà di scrivere un'espressione regolare.

La caratteristica più utile di awk è che permette di riferirsi alla singole colonne del testo. Ad esempio, supponiamo di voler estrarre solo gli indirizzi e-mail. Potremmo allora utilizzare il comando { print $3 } per stamparli. La variabile $3 si riferisce alla terza colonna. In questo esempio awk separa l'input ogni volta che trova uno spazio o una tabulazione, e dunque considera separatamente nome e cognome.

Si scriva una linea di awk che permetta di stampare gli indirizzo e-mail, escludendo però la prima riga che inizia con un # (è necessario scrivere un'opportuna espressione regolare per escluderla).
Il fatto che awk separi i campi con gli spazi in questo esempio è un po' fastidioso: cosa succederebbe se avessimo un nome senza cognome, oppure con un cognome composto? Per fortuna, possiamo concincere awk a considerare solo le tabulazioni come separatore dell'input, usando il flag -F.
Si modifichi il comando precedente per separare l'input usando le tabulazioni, e stampando dunque la seconda colonna invece che la terza. Si provi ad aggiungere un nuovo nome alla mailing list con un cognome composto (ad esempio: Di Nardo) e si verifichi che la nuova linea funzioni correttamente.

awk è in grado di riconoscere gli input numerici, ed effettuare operazioni aritmetiche. Per queste è spesso utile specificare più regole, ed in particolare due regole speciali che vengono eseguite prima di iniziare (BEGIN) e alla fine END.

Possiamo considerare il seguente esempio per sommare tutte le età contenuto nel file:

$ awk -F '\t' 'BEGIN { x = 0 } { x += $4 } END { print x }'
  mailing_list.txt
Il pattern BEGIN viene usato per inizializzare la variabile x, mentre END viene usato per stampare il risultato alla fine.
Si modifichi l'esempio precedente per calcolare l'età media della mailing list, ovviamente supponendo di non sapere a priori il numero di iscritti (ricordate che la prima riga va in qualche modo ignorata, perché non è relativa ad un iscritto, ma contiene l'intestazione del file).