Java – Le novità di Matcher in Java 9: Parte 2

In questa seconda parte della mia serie di articoli “Le novità di Matcher in Java 9” descriverò i due nuovi metodi appendReplacement e appendTail introdotti nella classe java.util.regex.Matcher in Java 9.

Premessa

Prima di vedere in dettaglio i due nuovi metodi è necessario fare una premessa per poter comprendere meglio l’evoluzione della classe Matcher.

Il supporto alle “espressioni regolari”, ovvero tutto ciò che è contenuto nel package java.util.regex, è stato introdotto nella versione Java 1.4. Già allora la classe Matcher aveva due metodi replaceFirst e replaceAll con la seguente forma:

  • public String replaceFirst(String replacement)
  • public String replaceAll(String replacement)

Questi due metodi permettono di effettuare un replace (sostituzione) delle occorrenze del pattern con una stringa “fissa”. La differenza tra replaceFirst e replaceAll innanzitutto è molto semplice: replaceFirst considera solo la prima occorrenza (e non prosegue la ricerca oltre) mentre replaceAll considera tutte le occorrenze.

L’unica particolarità di questi due metodi preesistenti è che la stringa di replacement può usare due caratteri “speciali” che sono "$" (dollaro) e "\" (backslash). Il dollaro serve per fare riferimento ad un “gruppo” catturato dal pattern (usando la forma $1, $2, ecc...) mentre il backslash serve in generale come carattere di escape.

Tramite il concetto dei “gruppi” e sfruttando la forma $n nella stringa di replacement, si possono fare delle sostituzioni un pochino particolari ma comunque molto basilari, con la possibilità ad esempio di togliere o scambiare dei gruppi oppure mettere qualcosa attorno ad un gruppo o in mezzo a due gruppi. Il seguente esempio mostra cosa si può fare con i gruppi:

String testo = "La festa inizierà alle 9:00 e finirà verso le 14:30.";

Pattern pattern = Pattern.compile("(\\d{1,2}):(\\d{2})");
Matcher matcher = pattern.matcher(testo);

String testo2 = matcher.replaceAll("ore $1.$2");

L’espressione regolare utilizzata è (\d{1,2}):(\d{2}) e ciascuna coppia di parentesi ( ) serve per definire quello che si chiama un capturing group (“gruppo catturante”). Il primo gruppo ha indice 1 mentre il secondo ha indice 2. Nella stringa di replacement è possibile usare $1 e $2 per indicare di inserire in quel punto il testo del gruppo catturato.

In questo esempio le occorrenze trovate sono quindi "9:00" e "14:30" che vengono sostituite rispettivamente con "ore 9.00" e "ore 14.30". Come si può notare, è stato possibile cambiare il carattere in mezzo ai due gruppi ed inserire "ore " all’inizio della occorrenza. Ma queste sono le poche cose che si possono fare.

Il limite di questi due metodi è dato proprio dal fatto di poter usare solo una stringa fissa e questo non consente di fare delle sostituzioni complesse. Per sostituzione “complessa” si può intendere qualunque tipo di sostituzione che applica una trasformazione non “banale” che richiede del codice apposito e quindi non si può esprimere solo con una semplice stringa di sostituzione.

Si possono fare diversi esempi di trasformazioni complesse da applicare alla occorrenza trovata, ad esempio trasformarla in uppercase o lowercase oppure rovesciarne i caratteri (tipo "prova" in "avorp") o altre cose ancora più particolari o sofisticate.

Per sopperire a questa limitazione, già da Java 1.4 erano stati inseriti in Matcher due altri metodi particolari:

  • public Matcher appendReplacement(StringBuffer sb, String replacement)
  • public StringBuffer appendTail(StringBuffer sb)

Questi due metodi richiedono una breve spiegazione. Innanzitutto per sfruttarli bisogna prima creare un oggetto StringBuffer, che serve come “buffer” per la stringa del risultato che viene progressivamente composta. Poi bisogna comunque fare il tipico ciclo while in cui viene testato il valore di ritorno del find() di Matcher.

I due metodi appendReplacement e appendTail in particolare servono per inserire nel buffer tutte quelle parti del testo originale che non vengono catturate dal pattern della espressione regolare. In modo specifico: appendReplacement va usato dentro il ciclo while per inserire la parte precedente al match (se presente) e la sostituzione per il match corrente mentre appendTail va usato dopo la fine del ciclo while per inserire la eventuale (se presente) parte “terminale” non catturata dal pattern.

In pratica appendReplacement e appendTail gestiscono la “logica” che serve per tenere in considerazione tutte le parti non catturate dal pattern. Questa logica non è di per sé difficile ma se dovessimo farla noi “a mano” sarebbe un lavoro in più, probabilmente “noioso” e inoltre potenzialmente anche error-prone.

A parole tutto questo potrebbe sembrare complicato ma in realtà è più facile di quanto si possa credere. Non sto ora a fare un esempio di questi due metodi perché farò già l’esempio con i due nuovi appendReplacement e appendTail, che sono praticamente quasi uguali a quelli appena visti.

I nuovi metodi appendReplacement e appendTail

In Java 9 sono stati aggiunti nella classe Matcher i due seguenti metodi:

  • public Matcher appendReplacement(StringBuilder sb, String replacement)
  • public StringBuilder appendTail(StringBuilder sb)

Notate la differenza con i due metodi preesistenti? Dovrebbe essere ben evidente: questi due nuovi metodi usano StringBuilder invece che StringBuffer.

La classe StringBuffer esiste da sempre mentre la classe StringBuilder è stata introdotta in Java 5. Entrambe le classi rappresentano una stringa “mutabile” ed entrambe offrono la stessa API, ovvero hanno gli stessi metodi con il medesimo significato. La differenza principale è che StringBuffer è thread-safe perché i suoi metodi sono tutti “sincronizzati” tramite un lock mentre StringBuilder non è thread-safe e non acquisisce alcun lock.

La ricerca e sostituzione del testo con Matcher va fatta da un unico thread, perché è una logica sostanzialmente “sequenziale”, non ha senso farla con il multi-threading. Pertanto l’acquisizione di un lock per la “sincronizzazione” è un peso inutile che non apporta alcun vantaggio o beneficio. L’utilizzo di StringBuilder in contesti come questi risulta quindi un pochino più efficiente rispetto all’uso di StringBuffer.

Esempio completo

Ora che il concetto dei due metodi appendReplacement e appendTail è stato spiegato e dovrebbe risultare (spero) chiaro, possiamo passare ad un esempio completo.

Supponiamo di avere la seguente frase (fonte Wikipedia sulla città di Torino):

Torino dista 57 km da Asti, 79 km da Vercelli, 84 km da Biella, 93 km da Alessandria, 96 km da Novara, 98 km da Cuneo, 155 km da Verbania.

L’obiettivo dell’esempio che propongo di seguito è abbastanza semplice: catturare con una espressione regolare tutte le parti del testo che hanno la forma "nnn km" e sostituirle con l’equivalente in miglia (per la precisione, parliamo di miglia terrestri: 1 miglio = 1,609344 km). Quindi ad esempio trovare nel testo "57 km" e sostituirlo con "35,4 miglia" (per semplicità, una sola cifra decimale).

Quella che ho appena descritto è effettivamente una sostituzione “complessa” (per le espressioni regolari) e richiede della “logica” apposita da implementare con del codice. Per ogni occorrenza è necessario innanzitutto estrarre la sola parte numerica (es. "57") poi si deve fare un parsing per convertirla in un valore int (i numeri nel testo sono fortunatamente tutti “interi”), quindi si deve applicare la conversione in miglia e infine si deve formattare una nuova stringa per ottenere il testo di sostituzione (es. "35,4 miglia").

A prima vista tutto questo potrebbe sembrare non banale ma con una apposita espressione regolare e con qualche accorgimento in realtà è più semplice di quanto si possa pensare. L’esempio completo è il seguente:

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ProvaMatcherReplaceJava9 {
    public static void main(String[] args) {
        String testo = "Torino dista 57 km da Asti, 79 km da Vercelli, "
                     + "84 km da Biella, 93 km da Alessandria, 96 km da Novara, "
                     + "98 km da Cuneo, 155 km da Verbania.";

        Pattern pattern = Pattern.compile("(\\d{1,3}) km");
        Matcher matcher = pattern.matcher(testo);

        StringBuilder buffer = new StringBuilder();

        while (matcher.find()) {
            matcher.appendReplacement(buffer, formattaMigliaDaKm(matcher.group(1)));
        }

        String testo2 = matcher.appendTail(buffer).toString();

        System.out.println(testo);
        System.out.println();
        System.out.println(testo2);
    }

    private static String formattaMigliaDaKm(String kmStr) {
        int km = Integer.parseInt(kmStr);
        double miglia = km / 1.609344;
        return String.format("%.1f miglia", miglia);
    }
}

Eseguendo il programma, l’output è il seguente:

Torino dista 57 km da Asti, 79 km da Vercelli, 84 km da Biella, 93 km da Alessandria, 96 km da Novara, 98 km da Cuneo, 155 km da Verbania.

Torino dista 35,4 miglia da Asti, 49,1 miglia da Vercelli, 52,2 miglia da Biella, 57,8 miglia da Alessandria, 59,7 miglia da Novara, 60,9 miglia da Cuneo, 96,3 miglia da Verbania.

Una parte importante è rappresentata dalla espressione regolare, che è (\d{1,3}) km . Come già detto prima, le parentesi ( ) servono per definire un capturing group che in questo caso ha indice 1. Il testo catturato da questo gruppo si può estrarre facilmente invocando group(1) sull’oggetto Matcher. Se l’occorrenza trovata è "57 km", il gruppo 1 permette di estrarre "57".

L’altra parte rilevante è il ciclo while. Come detto nella precedente sezione, appendReplacement si utilizza dentro il ciclo mentre appendTail si utilizza dopo la fine del ciclo. La prima occorrenza del pattern è "57 km" ma come si può vedere c’è prima un pezzo di testo che non viene catturato, cioè quella parte "Torino dista ". Questa parte viene inserita automaticamente nel buffer dal appendReplacement. In maniera similare, c’è nel testo una parte “terminale” che è " da Verbania." e questa viene inserita automaticamente nel buffer dal appendTail.

I due metodi appendReplacement e appendTail quindi si fanno carico loro di tenere traccia di queste parti non catturate dal pattern e di inserirle nel buffer (StringBuffer o StringBuilder che sia) nel punto e modo appropriato.

Il resto del lavoro viene svolto dal metodo formattaMigliaDaKm, che dovrebbe essere semplice da comprendere. L’unica particolarità è l’utilizzo, per pura comodità, della “formattazione delle stringhe” con String.format che è una funzionalità disponibile da Java 5.

C’è un’ultima cosa interessante da notare: il numero dei km è rappresentato dalla espressione \d{1,3} che descrive “da una a tre cifre decimali”. Con questa forma possiamo quindi essere certi che la parte numerica è sicuramente convertibile in int senza problemi, pertanto non c’è da preoccuparsi di eventuali eccezioni NumberFormatException che il metodo parseInt tecnicamente è in grado di lanciare.