mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 11/39

11. Stringhe e testo Unicode

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

Il testo è un valore, non un contenitore da modificare

Quando scriviamo String titolo = "Java", otteniamo un riferimento a un oggetto String. Una stringa è immutabile: nessun metodo ne cambia il contenuto dopo la creazione. Un'operazione come strip() restituisce una stringa con gli spazi iniziali e finali rimossi; la variabile originale continua a riferirsi al testo precedente. È una proprietà utile quando più parti del programma condividono una stringa, ma richiede di assegnare il risultato se vogliamo conservarlo.

Nel programma completo partiamo da " Ciao ". La stampa del risultato di strip() produce [Ciao], mentre una stampa successiva dell'originale produce [ Ciao ]. Le parentesi quadre servono solo a rendere visibili gli spazi. Non fanno parte del testo pulito.

Il confronto più frequente è quello di contenuto. prima.equals(seconda) è vero se le due stringhe hanno gli stessi caratteri nella stessa sequenza; prima == seconda chiede invece se sono proprio il medesimo oggetto. Il programma crea deliberatamente una seconda istanza con new String("Java"), perciò i risultati sono false e true. In codice ordinario non serve creare così una copia della stringa: qui lo facciamo per mostrare la distinzione.

Nota bene – Il pool delle stringhe. Java può condividere l'oggetto associato ai letterali uguali e dispone del metodo intern(). Questo spiega alcuni risultati di == fra letterali, ma non trasforma == in un confronto affidabile del contenuto. Per il valore del testo si usa equals; quando una variabile può essere null, si decide esplicitamente come trattarla, ad esempio con Objects.equals(a, b).

La figura 11.1 confronta i letterali con gli oggetti creati con new. Le frecce descrivono l'identità logica dei riferimenti; non indicano in quale area della memoria la JVM debba conservare il pool.

Letterali condivisi e stringa distinta
Figura 11.1 – Due letterali uguali possono indicare l'istanza canonica; new String(a) crea un'istanza distinta con lo stesso contenuto. equals confronta il testo, mentre == confronta l'identità dei riferimenti.

Dai letterali agli oggetti: quattro confronti

Un letterale di stringa è il testo racchiuso fra virgolette, per esempio "Java"; anche "" è un letterale valido e rappresenta una stringa vuota. Possiamo chiamare un metodo direttamente su un letterale: "Java".length() vale 4. La forma String titolo = "Java"; è quella ordinaria per ottenere quel valore. Scrivere new String("Java") crea deliberatamente un'altra istanza e, di solito, non porta alcun vantaggio per il programma applicativo. La usiamo qui soltanto per rendere visibile la distinzione fra contenuto e identità.

Confrontiamo quattro casi. String a = "Hello world"; String b = "Hello world"; usa due letterali uguali, che si riferiscono alla stessa istanza canonica. String c = "Hello" + " world"; è una concatenazione di espressioni costanti: il risultato è una costante di compilazione e può condividere l'istanza del letterale completo. String d = new String(a); è invece una nuova istanza, pur avendo lo stesso contenuto. Perciò a == b e a == c sono veri, a == d è falso, mentre a.equals(d) è vero. La regola da portare nel codice quotidiano resta equals per il contenuto: un testo calcolato durante l'esecuzione non va trattato come un letterale internato.

La variabile e l'oggetto hanno storie diverse. Dopo String messaggio = "prima"; messaggio = "seconda";, la variabile indica un altro valore; la stringa "prima" non è stata modificata. Se dichiariamo final String messaggio = "prima";, impediamo la seconda assegnazione, ma non rendiamo la classe String «più immutabile» di quanto già sia. È lo stesso principio incontrato con gli array, visto dall'altro lato: final regola la riassegnazione della variabile; la mutabilità dipende dal tipo dell'oggetto.

Seguire una trasformazione fino al risultato

Proviamo a leggere tre righe senza eseguirle: String origine = " Java "; String pulita = origine.strip(); String maiuscola = pulita.toUpperCase();. Dopo la prima riga, origine indica un testo con uno spazio prima e uno dopo. Dopo strip(), pulita indica il risultato senza quegli spazi, ma origine conserva il valore iniziale. La terza riga produce "JAVA" e non cambia né origine né pulita. Se stampiamo i tre valori fra parentesi quadre, vediamo [ Java ], [Java] e [JAVA]. Questo piccolo percorso è il modo migliore per evitare l'errore di chiamare un metodo che restituisce una nuova stringa e ignorarne il risultato.

Il confronto con gli array di caratteri del C aiuta a mettere a fuoco una differenza. La distinzione utile a chi legge Java è che una String non è un array char[] a cui possiamo assegnare una nuova lettera in una posizione. Possiamo ottenere un carattere o una parte del testo attraverso i metodi della classe, ma le operazioni producono risultati nuovi quando cambiano il contenuto. La rappresentazione interna scelta dal JDK non è il contratto pubblico della String; non dobbiamo dedurre dalla parola «immutabile» un particolare formato fisico in memoria.

Lunghezza in Java e caratteri che vediamo

Una String usa indici di unità di codice UTF-16. length() conta queste unità, non sempre i caratteri che una persona vede sullo schermo. L'emoji sorridente U+1F600 è rappresentata da due unità UTF-16 e da un solo code point Unicode: per questo il programma stampa 2 e poi 1. La figura 11.2 visualizza le due unità come una coppia che rappresenta un unico code point.

Unità UTF-16 e code point
Figura 11.2 – Il simbolo dell'esempio occupa due posizioni UTF-16. length() restituisce 2; codePointCount(0, length()) restituisce 1.
String simbolo = "\uD83D\uDE00";
System.out.println(simbolo.length());
System.out.println(simbolo.codePointCount(0, simbolo.length()));

Le sequenze \uD83D e \uDE00 scrivono nel sorgente le due unità UTF-16 in forma esadecimale: D83D è il surrogato alto, DE00 quello basso. Il carattere u introduce l'escape Unicode; ogni cifra successiva rappresenta quattro bit. Il valore combinato corrisponde al code point U+1F600. Scrivere gli escape nel listato evita anche che un font privo del glifo emoji nasconda l'esempio nella pagina stampata.

Un code point è un valore dell'insieme Unicode. Non coincide sempre con ciò che percepiamo come un carattere: alcuni simboli visibili sono sequenze di più code point, per esempio una lettera con un segno combinante o certe emoji composte. Per un'interfaccia che deve contare i caratteri percepiti dall'utente servono regole ulteriori; codePointCount risolve soltanto il passaggio dalle unità UTF-16 ai code point. La specifica di String definisce entrambi i metodi.

charAt(i) restituisce la singola unità char all'indice i; su un simbolo supplementare, le due chiamate restituiscono separatamente le parti della coppia. Se vogliamo attraversare i code point, usiamo API come codePoints() o codePointAt, prestando attenzione agli indici. Un indice fuori dall'intervallo provoca un'eccezione, non un carattere vuoto.

Approfondimento – Tre modi di contare il testo. Per "A", length(), codePointCount(0, length()) e il numero di simboli percepiti dal lettore coincidono: valgono uno. Per il simbolo U+1F600 dell'esempio, le unità UTF-16 sono due e i code point uno. Per una lettera seguita da un accento combinante, i code point possono essere due anche se il lettore vede una sola lettera accentata. La domanda «quanto è lunga la stringa?» deve quindi indicare che cosa vogliamo contare. Gli indici di substring e charAt restano espressi in unità UTF-16; non basta sostituire ogni length() con codePointCount() senza rivedere gli indici.

Cercare e trasformare senza cambiare l'originale

Prima di trasformare una stringa, spesso vogliamo cercare un pezzo di testo. titolo.contains("Java") risponde con un booleano; titolo.indexOf("Java") restituisce l'indice UTF-16 della prima occorrenza oppure -1 se non la trova. Il numero -1 non è un indice valido, ma un segnale di assenza. substring(inizio, fine) produce una porzione il cui inizio è incluso e la cui fine è esclusa: "Java".substring(0, 2) vale "Ja". Il secondo limite non è la lunghezza della porzione, bensì la posizione alla quale fermarsi. Un indice che taglia fra i due char di una coppia surrogata può produrre una stringa difficile da interpretare come testo Unicode completo; per questo non equipariamo gli indici UTF-16 a «lettere».

Per togliere spazi ai margini possiamo usare strip(), che considera i caratteri di spazio secondo le regole Unicode dell'API. Il più vecchio trim() ha un criterio diverso, basato sui valori dei caratteri fino a U+0020. Se il testo viene da un modulo o da un file, scegliamo la regola che corrisponde ai dati attesi, e proviamo un input concreto. toUpperCase() e toLowerCase() restituiscono nuove stringhe; il risultato può dipendere dalla locale se usiamo le forme senza parametro. Per chiavi tecniche che devono comportarsi allo stesso modo indipendentemente dalla lingua dell'utente, una scelta esplicita della locale è più affidabile. Inoltre la conversione non equivale sempre a sostituire una singola lettera con un'altra: alcune trasformazioni possono cambiare la lunghezza del testo.

Per eliminare semplici spazi potremmo usare replaceAll. replaceAll interpreta il primo argomento come espressione regolare, un modello per cercare sequenze di testo. Se vogliamo sostituire il carattere spazio letterale, replace(" ", "") comunica meglio l'intenzione e non introduce una grammatica nuova. replaceFirst usa invece un'espressione regolare e cambia soltanto la prima corrispondenza. Nella classe String di Java 25 non esiste un metodo replaceLast: per l'ultima corrispondenza occorre un'altra strategia, per esempio trovare la posizione con lastIndexOf e costruire il risultato. Questo è un caso in cui una tabella di nomi plausibili sarebbe dannosa: il lettore proverebbe codice che non compila.

Definizione – Espressione regolare. È un linguaggio per descrivere insiemi di sequenze testuali. Il modello [A-Za-z0-9]+ indica una o più lettere latine senza accenti o cifre; non indica «qualsiasi parola di qualsiasi lingua». Quando usi replaceAll, i caratteri speciali del modello hanno un significato che replace non attribuisce loro. Prima di adottare una regex, verifica esempi che devono corrispondere ed esempi che non devono corrispondere.

Una ricerca guidata, dal risultato alla scelta del metodo

Prendiamo il testo "Java Mattone dopo Mattone" e cerchiamo "Mattone". indexOf restituisce la posizione della prima occorrenza, misurata in unità UTF-16 a partire da zero; lastIndexOf trova l'ultima. Qui la prima comincia a 5 e l'ultima a 18. contains risponde soltanto alla domanda «esiste almeno un'occorrenza?», senza darci la posizione. Se la posizione serve per estrarre una parte, controlliamo prima che non sia -1: usare direttamente substring(-1) non significa «nessun risultato», ma provoca un'eccezione. La scelta del metodo nasce quindi dalla domanda che dobbiamo risolvere, non dalla somiglianza dei nomi.

Per estrarre la prima parola, int separatore = testo.indexOf(' '); individua il primo spazio e testo.substring(0, separatore) restituisce "Java". Questo funziona per il dato scelto; se la stringa non contiene spazi, separatore vale -1 e occorre decidere se restituire tutto il testo, nulla o un errore. Se il testo contiene un simbolo fuori dal piano BMP prima dello spazio, l'indice ottenuto rimane corretto per substring perché entrambi usano unità UTF-16. Mescolare quell'indice con un numero di code point senza conversione produrrebbe un limite sbagliato.

Distinguiamo replace, replaceFirst e replaceAll sul testo "uno uno uno". replace("uno", "due") cambia tutte le occorrenze letterali e produce "due due due". replaceFirst("uno", "due") cambia la prima e produce "due uno uno"; il primo argomento, però, è una regex. replaceAll("uno", "due") produce di nuovo "due due due", ma interpreta anch'esso una regex. Con la parola uno la differenza fra letterale e modello non si vede; con un punto si vede subito: replace(".", "!") cambia soltanto i punti reali, mentre replaceAll(".", "!") usa . come modello che corrisponde a molti caratteri. Se vogliamo davvero una regex per un punto letterale dobbiamo eseguire l'escaping del modello. Finché non serve il potere delle regex, replace è la scelta che un lettore può comprendere senza un'altra grammatica.

Per verificare – Output prima del codice. Scrivi su carta il risultato atteso di " Java ".strip(), "Java".substring(1, 3) e "uno uno".replaceFirst("uno", "due"). Poi esegui le tre espressioni e controlla rispettivamente spazi, limite finale escluso e numero di sostituzioni. Se una previsione è sbagliata, correggi la regola che avevi usato, non soltanto la risposta.

Costruire un testo per passi

L'operatore + unisce stringhe ed è comodo per espressioni brevi. Se però un algoritmo aggiunge molti frammenti in un ciclo, StringBuilder rende esplicito un contenitore modificabile che poi produce una String finale. Nel nostro esempio i due append formano Java.

StringBuilder costruttore = new StringBuilder();
costruttore.append("Ja").append("va");
System.out.println(costruttore.toString());

Non serve trasformare ogni piccola concatenazione in un builder: scegliamo lo strumento in base a ciò che il codice sta facendo, e misuriamo se le prestazioni contano. StringBuilder non è progettato per essere modificato contemporaneamente da più thread senza coordinamento, tema che incontreremo nel capitolo sulla concorrenza.

L'operatore + può concatenare testo e valori di altri tipi. In "Voti: " + 3, il numero entra nella rappresentazione testuale e il risultato è "Voti: 3". Ma l'ordine delle espressioni conta: "Totale: " + 2 + 3 produce "Totale: 23", mentre "Totale: " + (2 + 3) produce "Totale: 5". Le parentesi dichiarano che prima vogliamo fare la somma numerica. concat(String) è un altro metodo che produce una nuova stringa; non modifica l'originale, come nell'esempio. Per costruzioni ripetute in un ciclo, StringBuilder evita di usare la variabile come se fosse una stringa modificabile, senza obbligarci ad affermare che ogni concatenazione con + sia lenta.

Java offre anche i text block, delimitati da tre virgolette, per stringhe che occupano più righe nel sorgente. Restano oggetti String: cambiano il modo di scrivere il letterale, non il modello dei valori. La formattazione dipende dalle regole dei rientri e delle interruzioni di riga, perciò va verificata con l'output reale quando il testo deve essere esatto. Le string template apparse come funzionalità preview in versioni precedenti sono state ritirate: non le presentiamo come sintassi disponibile in Java 25. Il riepilogo delle modifiche del linguaggio documenta lo stato delle funzionalità.

Il programma del text block contiene due righe e stampa Ciao, e Java! su righe separate. La riga che contiene il delimitatore di chiusura determina anche se resta un'interruzione di riga finale. Gli spazi comuni davanti alle due righe del sorgente sono trattati come rientro incidentale, non stampati prima di Ciao e Java!.

Un esempio HTML mostra bene il guadagno di leggibilità: senza text block servivano più concatenazioni, \n e virgolette escapate; con tre virgolette il testo assomiglia di più al documento che vogliamo produrre. Il delimitatore di apertura deve essere seguito dal terminatore di riga prima del contenuto. Il delimitatore di chiusura decide dove il testo termina e influisce sugli spazi comuni rimossi. Un text block è comunque una String: se l'HTML richiede dati variabili, bisogna costruirli con le normali API, tenendo conto anche delle regole di escaping del formato di destinazione. I text block sono stabili da Java 15.

String messaggio = """
        Ciao,
        Java!
        """;
System.out.print(messaggio);

Per verificare

Compila il sorgente completo con javac --release 25 -Xlint:all -d build DemoStringhe.java ed esegui java -cp build DemoStringhe. Prova a sostituire simbolo con una lettera semplice e poi con una sequenza di più code point: annota ogni volta length() e codePointCount. L'attività C11 aiuta a interpretare i numeri senza chiamarli genericamente “numero di caratteri”.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑