mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 4/39

4. Sintassi, variabili, operatori e array

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 →

Diamo un nome ai dati

Il primo programma stampava una frase sempre uguale. Un programma interessante, invece, riceve dati, li conserva per il tempo necessario e produce un risultato. Cominciamo con tre voti: 24, 27 e 30. Prima di decidere come calcolarne la media dobbiamo capire che cosa sono una variabile, un tipo e un'espressione. Sembrano mattoni piccoli, ma un errore qui si propaga in quasi tutto il codice che verrà.

Un'espressione viene valutata e produce un valore, come primoVoto + secondoVoto. Un'istruzione è un'unità eseguibile del programma: int somma = primoVoto + secondoVoto; è una dichiarazione con inizializzazione. Non tutte le istruzioni terminano con ;: un blocco delimitato da { e } e un if hanno una struttura diversa. Non è neppure vero che un'espressione non possa avere effetti: una chiamata a metodo può produrre un valore e, nello stesso tempo, modificare uno stato. Distingueremo il risultato dagli effetti quando leggeremo il codice.

Java distingue tipi primitivi e tipi di riferimento. I primitivi comprendono boolean, i tipi interi byte, short, int, long e char, e i tipi a virgola mobile float e double. Una variabile di tipo primitivo contiene un valore di quel tipo; una variabile di tipo di riferimento contiene un riferimento a un oggetto oppure null. Un array è un oggetto, quindi anche int[] è un tipo di riferimento, benché i suoi elementi siano interi primitivi. Questa distinzione è descritta dalla specifica dei tipi di Java 25.

La specifica chiama null type il tipo speciale dell'espressione null: non gli diamo un nome in una dichiarazione come faremmo con String, ma possiamo assegnare quel valore a una variabile di tipo riferimento. null non indica un oggetto vuoto; indica che quel riferimento non designa un oggetto. Se poi proviamo a chiamare un metodo attraverso di esso, il problema compare a esecuzione avviata. void risponde a un'altra domanda ancora: nella firma di un metodo indica che quel metodo non restituisce un valore utilizzabile dal chiamante. Non è un nono tipo primitivo da scegliere per una variabile.

Definizione – Tipo e variabile. Il tipo stabilisce quali valori e operazioni sono ammessi. Una variabile è un nome associato a una posizione che può contenere un valore compatibile con quel tipo. Il nome scelto dal programmatore non cambia il significato delle operazioni, ma può renderlo molto più evidente a chi legge.

La tabella dei tipi primitivi chiarisce due equivoci frequenti: boolean non ha una dimensione in memoria fissata a un bit dal linguaggio, e void non è un tipo di variabile. Le dimensioni degli interi e dei formati a virgola mobile sono definite dalla specifica, indipendentemente dal sistema operativo.

Tipo Valori o intervallo Quando lo incontreremo
boolean true oppure false Condizioni di if e cicli; non è un numero.
byte Da −128 a 127, intero con segno a 8 bit Dati binari e API che lavorano su byte.
short Da −32.768 a 32.767, intero con segno a 16 bit Alcuni formati e API; per i piccoli contatori spesso resta più semplice int.
int Da −2.147.483.648 a 2.147.483.647, intero con segno a 32 bit Indici, conteggi e calcoli interi ordinari.
long Da −9.223.372.036.854.775.808 a 9.223.372.036.854.775.807, intero con segno a 64 bit Conteggi che possono superare l'intervallo di int.
char Da \u0000 a \uffff, unità UTF-16 senza segno a 16 bit Elaborazione di unità di codice, non di ogni simbolo visibile.
float Numeri nel formato binario IEEE 754 a 32 bit Dati numerici che accettano questa precisione.
double Numeri nel formato binario IEEE 754 a 64 bit Calcoli con frazioni quando la rappresentazione approssimata è adatta.

Leggi la tabella come una scelta di significato, non come una gara a usare il tipo più piccolo. Se un contatore arriva al massimo a cento, un byte potrebbe contenere il valore, ma molte operazioni aritmetiche su byte producono un int: il codice richiederebbe conversioni che non aiutano a capire il problema. int è spesso la scelta più semplice. Se invece stai leggendo un formato binario che definisce esattamente un byte, quel tipo racconta qualcosa del formato. Con long devi ricordare il suffisso L per un letterale che non rientra nell'intervallo di int; senza il suffisso, scrivere un numero troppo grande non diventa automaticamente una dichiarazione long valida.

La tabella non assegna una dimensione a boolean, perché il linguaggio ne definisce i due valori, non un ingombro universale di un bit in memoria. Non mette neppure void fra i valori che una variabile può contenere: void appartiene alla dichiarazione di un metodo che non restituisce un risultato. Queste due distinzioni erano confuse nel materiale precedente. Correggerle non elimina la domanda del lettore; la rende più precisa. Se devi sapere quanta memoria occupa una particolare struttura in una JVM concreta, non ricavare il dato dalla sola tabella dei tipi: entrano in gioco anche rappresentazione degli oggetti, allineamento e implementazione.

Proviamo a leggere alcuni valori concreti prima di scrivere altre dichiarazioni. 127 entra in un byte, 128 no; 32_767 entra in uno short, 32_768 no. Il limite non riguarda il numero di cifre che vediamo sullo schermo, ma l'intervallo del tipo. La scrittura 3_000_000_000L indica un long: la lettera finale fa parte del letterale Java, mentre i trattini bassi separano visivamente gruppi di cifre e non modificano il valore. La forma 3.0 indica invece un valore a virgola mobile di tipo double. Pur rappresentando matematicamente un intero, non può essere assegnata direttamente a una variabile int: Java non decide di scartare da sé l'eventuale parte frazionaria.

Per float e double la domanda non è soltanto «qual è il valore massimo?». La precisione è finita: fra due numeri consecutivi rappresentabili esistono altri numeri reali che il computer deve approssimare. Se scrivi 0.1 + 0.2, non puoi dare per scontato che il confronto esatto con 0.3 sia vero. Questo non rende inutili i tipi a virgola mobile; li rende adatti ai problemi che ammettono un errore di arrotondamento controllato. Il conteggio dei centesimi di una fattura, invece, richiede un modello diverso, per esempio un numero intero di centesimi o BigDecimal con regole di arrotondamento esplicite. Non useremo queste alternative come scorciatoia prima di aver capito il problema che risolvono.

Approfondimento – Dimensione e significato. Gli interi con segno seguono intervalli fissati dal linguaggio. char è diverso: i suoi 16 bit rappresentano un'unità UTF-16 senza segno. boolean rappresenta una scelta fra due valori logici; la specifica non lo trasforma in un intero sul quale fare somme. Tre tipi possono quindi richiedere tutti una forma di memorizzazione, ma offrire operazioni e significati molto diversi.

Alcune regole sintattiche

Una dichiarazione semplice come int voto = 24; contiene il tipo, il nome, un valore iniziale e un punto e virgola che termina l'istruzione. Le parentesi graffe delimitano blocchi; le parentesi tonde racchiudono, fra le altre cose, argomenti e condizioni. Java distingue maiuscole e minuscole: voto e Voto sono due nomi diversi. Non scegliere nomi che differiscono soltanto per una lettera maiuscola; il compilatore li accetterebbe, ma il lettore dovrebbe guardarli due volte.

I nomi possono contenere lettere Unicode e altri caratteri ammessi dalla specifica, ma negli esempi useremo nomi italiani semplici e senza accenti per evitare problemi di lettura e strumenti. Le parole chiave del linguaggio, come class e int, non possono diventare nomi di variabili. Le parentesi e il punto e virgola non sono ornamenti: cambiarne uno può modificare la struttura del programma o impedirne la compilazione.

L'indentazione non cambia normalmente la grammatica di un blocco Java: sono le parentesi graffe a delimitarlo. Rimane però fondamentale per una persona che legge. Un programma scritto tutto su una riga può compilare e risultare quasi impossibile da verificare. Una piccola eccezione pratica riguarda il testo di alcuni letterali e commenti, dove gli spazi fanno parte del contenuto: «gli spazi non contano» è quindi una semplificazione da usare con cautela.

Per costruire la sintassi, possiamo partire da tre regole schematiche e arrivare a un if dentro un altro if. Il punto è che un blocco può contenere istruzioni, fra le quali un altro blocco o una decisione. Per esempio, quando controlliamo prima se un voto è valido e poi se è sufficiente, la seconda domanda ha senso soltanto all'interno del primo ramo:

int voto = 24;
if (voto >= 0 && voto <= 30) {
    if (voto >= 18) {
        System.out.println("Esame superato");
    }
}

Partendo dalla prima graffa, il compilatore incontra una seconda istruzione if e poi il blocco che contiene la stampa. Le due graffe di chiusura terminano prima il blocco interno e poi quello esterno. Se il voto è -1, non si arriva nemmeno alla domanda sulla sufficienza; se vale 17, si entra nel blocco esterno ma non in quello interno. Questa lettura passo dopo passo conserva la funzione dell'esempio storico senza presentare come grammatica completa tre formule che non descrivono tutte le istruzioni Java.

Un punto e virgola dopo una semplice assegnazione termina quell'istruzione; un punto e virgola messo immediatamente dopo if (voto >= 18) costruirebbe invece un'istruzione vuota. Il blocco che segue non sarebbe più il ramo condizionato, anche se l'indentazione lo facesse sembrare tale. Le graffe sono una piccola spesa di caratteri che evitano un grande equivoco. Nel prossimo capitolo torneremo su if, else e sull'annidamento; qui ci interessa vedere come blocchi, espressioni e istruzioni si incastrano davvero.

Buona pratica – Un nome racconta lo scopo. sommaVoti è più utile di x quando il valore serve per una media. Un nome breve come i può andare bene per un indice locale il cui significato è visibile in due righe; fuori da quel contesto richiede troppe supposizioni.

Dichiarazione, inizializzazione e ambito

Una variabile locale viene dichiarata dentro un metodo o un blocco. Prima di leggerla deve avere un valore assegnato in ogni percorso possibile fino a quel punto. Questo controllo si chiama definite assignment: il compilatore impedisce che una variabile locale venga usata quando potrebbe non essere inizializzata. I campi di un oggetto seguono regole diverse e ricevono valori iniziali predefiniti; non sfruttare questa differenza per nascondere un'inizializzazione dimenticata.

Possiamo dichiarare più variabili dello stesso tipo nella stessa istruzione, come int primo = 24, secondo = 27;. È sintassi lecita, ma due righe separate diventano più facili da modificare se i valori acquistano significati diversi. La dichiarazione introduce un nome; l'assegnazione primo = 25; sostituisce il valore che quel nome contiene. Nella matematica il segno = afferma un'uguaglianza; qui indica un'azione. Per questo primo = primo + 1; ha senso in Java: si legge il vecchio valore, si calcola il nuovo e lo si memorizza nella stessa variabile.

Una dichiarazione collocata dentro le graffe interne non è disponibile dopo la loro chiusura. Se dichiarassimo int somma = 51; soltanto nel blocco interno dell'esempio precedente, una stampa di somma nel blocco esterno non compilerebbe. Se usassimo anche variabili non inizializzate, introdurremmo due guasti nello stesso frammento. Per isolare la regola, inizializziamo i dati e spostiamo la dichiarazione di somma nel blocco che deve leggerla. Non confondiamo la visibilità del nome con la memoria fisica: la JVM non è obbligata a rappresentare ogni variabile locale come una casella immobile sullo stack, e un oggetto può sopravvivere all'ambito del nome locale che lo ha creato.

Per vedere la differenza, immagina int totale; dentro main. La dichiarazione introduce il nome e il tipo, ma leggere totale prima di un'assegnazione produce un errore di compilazione. Se invece crei new int[3], i tre elementi dell'array esistono già e valgono inizialmente 0. Un campo int di un nuovo oggetto riceve a sua volta il valore predefinito 0; un campo di tipo riferimento parte da null e un campo boolean da false. Questo non significa che 0, false o null siano sempre valori sensati per il modello. Una classe Prestito, per esempio, non dovrebbe considerare valida una data mancante soltanto perché il campo riferimento è stato inizializzato automaticamente a null. La regola del linguaggio e la regola del problema restano due cose distinte.

int primoVoto = 24;
int secondoVoto = 27;
int sommaVoti = primoVoto + secondoVoto;

L'ambito, o scope, dice in quali punti del sorgente un nome può essere usato. Le parentesi graffe di un blocco delimitano spesso l'ambito di una variabile locale. Ambito non significa durata dell'oggetto: un oggetto creato in un blocco può restare raggiungibile anche dopo la fine del blocco se un riferimento è stato conservato altrove. Per il momento, la regola operativa è semplice: dichiara una variabile vicino al punto in cui serve e non allargare inutilmente l'area in cui può essere modificata.

Il tipo dichiarato e var

In alcune dichiarazioni locali il compilatore può ricavare il tipo dal valore iniziale. Scrivere var somma = 24 + 27; non crea una variabile «senza tipo» e non permette di trasformarla poi in una stringa: il tipo inferito è int. Il nome var si usa soltanto nelle posizioni previste per l'inferenza locale, non al posto del tipo di un campo o di un parametro ordinario.

Nota bene – var non è un tipo dinamico. Dopo var numero = 3;, un'assegnazione come numero = "tre"; non compila. Usa l'inferenza quando il tipo resta evidente dal contesto; scrivi il tipo per esteso quando aiuta il lettore a capire il significato del dato.

final e la parola «costante»

Una variabile dichiarata final può ricevere un valore una sola volta. Questo è utile quando una riassegnazione sarebbe un errore:

final int numeroVoti = 3;

Se il valore è un riferimento a un oggetto modificabile, final blocca la riassegnazione del riferimento, non le modifiche dell'oggetto. final int[] voti = {24, 27, 30}; impedisce di far puntare voti a un altro array, ma permette voti[0] = 25;. Per parlare di immutabilità occorre controllare anche lo stato accessibile attraverso il riferimento. Torneremo su questo punto quando progetteremo le classi.

Il problema emerge quando passiamo l'array a un metodo che dovrebbe soltanto calcolare una media. Scrivere final davanti al parametro impedisce al metodo di assegnargli un altro array; non gli impedisce di eseguire voti[0] = 0. Se il chiamante si aspetta che i tre voti restino invariati, deve esserci un contratto rispettato dal metodo oppure una copia difensiva dei dati al confine opportuno. Possiamo verificarlo con una variante piccola: dopo la chiamata, stampiamo voti[0] e controlliamo se è ancora 24. Una parola nella dichiarazione non sostituisce questa prova.

Per trasferire il mattone. Quando incontrerai una lista non modificabile nel capitolo sulle collezioni, chiediti di nuovo che cosa non può essere modificato: la variabile che contiene il riferimento, la struttura della lista o gli oggetti che contiene. Sono tre promesse distinte. final risponde soltanto alla prima; il tipo di collezione e il contratto degli elementi rispondono alle altre.

Numeri, caratteri e testo

int è una scelta naturale per piccoli contatori e valori interi; long serve quando occorre un intervallo più ampio. Per numeri con frazione useremo spesso double. Il fatto che un valore possa essere scritto con la virgola non significa però che ogni numero decimale sia rappresentabile esattamente in binario: calcoli monetari o confronti numerici delicati richiedono scelte specifiche, che discuteremo più avanti. boolean contiene true o false e permette di rappresentare una condizione.

Il tipo char merita una pausa. Non corrisponde sempre a un «carattere» nel senso in cui una persona vede un simbolo sullo schermo: rappresenta una unità di codice UTF-16 di 16 bit. Alcuni caratteri Unicode richiedono due unità di codice, e una sequenza visibile può contenere più punti di codice. Per manipolare testo ordinario useremo String; quando servirà contare o trasformare i punti di codice lo diremo esplicitamente.

Nei letterali Java alcune sequenze iniziano con una barra rovesciata e rappresentano caratteri che non scriviamo comodamente come testo normale. \n è un a capo, \t una tabulazione, \\ una barra rovesciata e \" una virgoletta doppia dentro una stringa. Un letterale char usa apici singoli, come 'A', mentre una String usa virgolette doppie, come "A". La notazione Unicode \uXXXX richiede quattro cifre esadecimali e ha regole di elaborazione proprie; non va confusa con qualsiasi sequenza preceduta da \.

Approfondimento – Una misura può rispondere a domande diverse. La lunghezza di una String conta unità di codice UTF-16. Il numero di punti di codice e quello dei simboli percepiti dal lettore possono essere diversi. Perciò «questa stringa ha cinque caratteri» è ambiguo finché non diciamo che cosa stiamo contando.

I letterali possono essere resi più leggibili: 1_000_000 è lo stesso intero di 1000000; 0b1010 usa la base due; 0xFF la base sedici. Il suffisso L rende esplicito un valore long, per esempio 3_000_000_000L; il suffisso F identifica un letterale float. Queste forme non cambiano il valore matematico, ma aiutano a esprimere il contesto.

Le basi diverse sono modi di scrivere lo stesso tipo di valore. 10, 0b1010 e 0xA indicano tutti l'intero dieci. La forma binaria fa vedere quali bit sono attivi, quella esadecimale compatta gruppi di quattro bit e la forma decimale resta la più comoda per una quantità ordinaria. In un protocollo che attribuisce un significato a ciascun bit, 0b1010 racconta più di 10; nel numero di studenti di una classe accadrebbe il contrario. I trattini bassi possono separare gruppi, ma non si collocano arbitrariamente accanto al prefisso di base o al punto decimale: prova sempre un letterale nuovo nel compilatore prima di adottarlo in molti esempi.

Per il testo, 'A' e "A" non sono intercambiabili: il primo è un valore char, il secondo un oggetto String. Anche "", la stringa vuota, è diverso da null: la prima esiste e ha lunghezza zero, il secondo indica che manca un riferimento a una stringa. Questa distinzione diventerà importante quando confronteremo o concatenaremo valori. Se scriviamo "Voto: " + 24, l'operatore + produce una stringa; 24 + 3 è invece una somma numerica. Basta spostare le parentesi per cambiare il risultato: "Somma: " + (24 + 3) stampa Somma: 27, mentre "Somma: " + 24 + 3 stampa Somma: 243. Java valuta da sinistra verso destra le due concatenazioni già determinate dalla struttura dell'espressione.

Operatori e conversioni

L'operatore = assegna un valore, mentre == confronta due valori. += combina un'operazione con l'assegnazione, per esempio somma += voto;. Gli operatori aritmetici più comuni sono +, -, *, / e %. Con due interi, / produce una divisione intera: 5 / 2 vale 2; 5 % 2 vale 1, il resto. Per ottenere un risultato con frazione, almeno un operando deve essere convertito in un tipo a virgola mobile, come vedremo nell'esempio della media.

L'ordine delle operazioni conta. In 3 + 4 * 2, la moltiplicazione precede l'addizione e il risultato è 11; in (3 + 4) * 2, le parentesi fanno calcolare prima la somma e il risultato è 14. Una tabella di precedenze può aiutare, ma conviene anzitutto imparare a riconoscere le espressioni e usare parentesi quando l'intenzione non è immediata. La tabella completa e normativa resta nella specifica delle espressioni di Java 25.

Per non ridurre quella tabella a un rinvio, leggiamo le famiglie più frequenti nell'ordine in cui ci servono. Le parentesi esplicitano il raggruppamento; gli operatori unari +, -, ! e ~ si applicano a un operando; *, / e % precedono + e - binari. I confronti numerici come < precedono i confronti di uguaglianza come ==; && precede ||; l'assegnazione arriva dopo il calcolo del lato destro. Non è necessario imparare a memoria una lunga fila di simboli prima di fare il primo esercizio. Se due operatori diversi convivono nella stessa riga e l'ordine non salta agli occhi, le parentesi fanno lavorare meno chi legge senza cambiare l'intenzione del programma.

L'associatività risponde a una domanda diversa dalla precedenza: che cosa accade quando due operatori dello stesso livello compaiono insieme? 20 - 5 - 3 si raggruppa come (20 - 5) - 3 e vale 12, non 18. L'espressione a = b = 5, invece, assegna prima 5 a b e poi il valore dell'assegnazione ad a. È lecita, ma in una spiegazione introduttiva due istruzioni separate rendono più facile vedere quali variabili cambiano. La priorità grammaticale non sostituisce neppure l'ordine di valutazione: Java valuta gli operandi seguendo regole precise, e un metodo chiamato dentro un'espressione potrebbe avere effetti. Per questo è meglio non nascondere più modifiche di stato in un'unica riga aritmetica.

La divisione intera va osservata insieme al resto. 7 / 3 vale 2 e 7 % 3 vale 1, perché 7 si può scrivere come 3 * 2 + 1. -7 / 3 vale -2, poiché la divisione intera tronca verso zero; -7 % 3 vale -1. Non dare dunque per scontato che % 2 restituisca sempre 0 o 1 se l'operando può essere negativo. Con un divisore intero uguale a zero, / e % sollevano ArithmeticException durante l'esecuzione quando l'espressione vi arriva. Una media deve controllare che il numero degli elementi non sia zero prima di dividere, come faremo nell'esempio del capitolo successivo.

Java applica conversioni numeriche in vari contesti. In long grande = 3; il valore int può essere allargato a long. Il percorso opposto può perdere informazione e richiede una conversione esplicita, detta cast: int piccolo = (int) grande;. Il cast non è una promessa che il valore rientri nell'intervallo. Se la conversione restringe troppo, il risultato può sorprendere; prima di usarla, verifica quale informazione stai accettando di perdere. La specifica delle conversioni distingue i contesti e le regole precise.

Consideriamo un esempio numerico con un risultato che possiamo prevedere. Long.MAX_VALUE vale 9_223_372_036_854_775_807; il cast a int conserva soltanto la parte rappresentabile secondo le regole del linguaggio e produce -1. Al contrario, assegnare Integer.MAX_VALUE a un long non perde informazione: 2_147_483_647 rientra nell'intervallo del tipo più ampio. Anche char lettera = 'A'; int codice = lettera; è una conversione numerica: in questo caso codice vale 65. La parola codice qui indica l'unità UTF-16 di A, che coincide con il suo punto di codice Unicode.

long grande = Long.MAX_VALUE;
int ristretto = (int) grande;        // -1
long ampliato = Integer.MAX_VALUE;  // 2147483647
int codice = 'A';                    // 65

Il frammento mostra tre operazioni, ma non è un programma completo. Nei materiali del capitolo ConversioniNumeriche.java contiene anche il metodo main e stampa i valori, così puoi controllare la previsione senza affidarti alla sola spiegazione.

Nota bene – La somma può cambiare tipo. Un'operazione aritmetica su byte o short viene normalmente promossa almeno a int. Perciò byte a = 1; byte b = 2; byte c = a + b; non compila, benché il risultato matematico sia piccolo. Il compilatore guarda il tipo dell'espressione, non soltanto il valore che ci aspettiamo in questo caso.

Calcoliamo 1_500 * 1_000_000_000. Se dichiariamo entrambi gli operandi come int, il prodotto viene eseguito come int e supera il suo limite: assegnarlo dopo a un long non ripara il valore già calcolato. Se almeno un operando è long, la promozione numerica porta il prodotto a long prima dell'operazione:

int quantita = 1_500;
int prezzoInCentesimi = 1_000_000_000;
long errato = quantita * prezzoInCentesimi;
long corretto = (long) quantita * prezzoInCentesimi;

Il nome errato esprime l'errore rispetto alla quantità matematica attesa, non un errore di sintassi: entrambe le istruzioni compilano. corretto vale 1_500_000_000_000L; il cast dell'operando sinistro basta perché la moltiplicazione avvenga in long. In un programma reale chiederemmo anche se un long è abbastanza ampio per tutti i dati previsti. L'overflow intero ordinario non lancia automaticamente un'eccezione; se superare il limite deve essere un errore riconoscibile, le API Math.multiplyExact e Math.addExact possono segnalarlo.

Una conversione che allarga un intero, come da int a long, conserva il suo valore. Non tutte le conversioni verso un tipo apparentemente «più grande» conservano ogni dettaglio: un long molto grande trasformato in double può perdere cifre meno significative, perché il formato a virgola mobile ha una precisione finita. Una conversione che restringe, come da long a int, richiede normalmente un cast e può perdere parte del valore. Infine, una costante intera letterale può essere assegnata a byte, short o char se il suo valore rientra nell'intervallo: byte piccolo = 100; compila, mentre byte troppoGrande = 128; no. La somma di due variabili byte resta comunque un'espressione int; non estendere la regola delle costanti a qualunque risultato.

Concetto chiave – Il tipo dell'espressione nasce prima della destinazione. In long risultato = a * b; il tipo della variabile finale non decide retroattivamente come moltiplicare a e b. Osserva gli operandi, individua la promozione applicata e solo dopo chiediti se il risultato può essere assegnato. È lo stesso metodo che useremo per la media di un array.

Gli operatori di confronto <, <=, >, >=, == e != producono un boolean. && e || combinano condizioni e valutano il secondo operando soltanto quando serve; ! nega una condizione. Gli operatori & e | possono anche agire su valori interi bit per bit; usati con valori booleani valutano entrambi gli operandi. Gli spostamenti <<, >> e >>> lavorano sui bit degli interi: sono strumenti utili in protocolli e formati binari, ma non migliorano automaticamente un normale calcolo. L'elenco degli operatori nella specifica è la fonte per precedenza e semantica completa.

La piccola tabella seguente permette di controllare le combinazioni dei due valori booleani. && è vero soltanto quando entrambi sono veri; || è vero quando almeno uno è vero; ^ è vero quando i due valori sono diversi. L'operatore unario ! inverte un solo valore: !true vale false.

a b a && b a \|\| b a ^ b
true true true true false
true false false true true
false true false true true
false false false false false

L'effetto di && e || si chiama valutazione corta, o short circuit. Se testo != null è falso, in testo != null && !testo.isEmpty() Java non valuta la parte destra. Questo evita di chiamare isEmpty() attraverso un riferimento nullo. Il vantaggio non è soltanto la velocità: l'ordine delle condizioni esprime una dipendenza logica. Cambiarlo può far fallire il programma.

Questa regola vale anche per ||, ma con il motivo opposto: se la parte sinistra è già vera, non serve controllare la destra. In autorizzato || codiceValido(), il metodo codiceValido() non viene chiamato quando autorizzato è vero. Se il metodo ha effetti, per esempio registra un tentativo, quei due caratteri || cambiano anche il comportamento osservabile del programma. Gli operatori booleani & e | valutano invece entrambi i lati. Non sceglierli per obbligare una chiamata laterale a essere eseguita: separare la chiamata dalla decisione rende più chiara l'intenzione.

Gli operatori di assegnazione composta meritano una lettura propria. totale += voto aggiunge voto al totale e conserva il risultato nella stessa variabile; -=, *=, /= e %= seguono l'operazione indicata dal primo simbolo. Esistono anche &=, |=, ^=, <<=, >>= e >>>=, utili quando lo stato è davvero una maschera di bit. >>>= sposta i bit verso destra senza propagare il segno; un operatore <<< non esiste in Java. Scrivere x += y assomiglia a x = x + y, ma la specifica include una conversione implicita verso il tipo della variabile a sinistra. Per esempio, dopo byte piccolo = 1;, piccolo += 2; compila, mentre piccolo = piccolo + 2; richiede una conversione esplicita. La forma breve può quindi nascondere una perdita di informazione: se l'intervallo conta, controlla il risultato invece di fidarti della brevità.

L'operatore condizionale condizione ? valoreSeVero : valoreSeFalso sceglie una fra due espressioni. String esito = voto >= 18 ? "promosso" : "da recuperare"; è leggibile perché entrambi i rami producono un valore semplice. Quando ciascun ramo richiede più istruzioni o un commento per spiegare perché esiste, if e else del prossimo capitolo offrono più spazio al ragionamento. Non confondere una forma compatta con una scelta automaticamente migliore.

Con valori booleani ^ vale true quando esattamente uno dei due operandi è vero. Con interi, invece, &, |, ^ e ~ agiscono bit per bit. Per esempio, 100 & 125 vale 100, mentre 100 ^ 125 vale 25. ~100 vale -101 perché l'operatore inverte tutti i bit dell'intero con segno, non soltanto quelli che scegliamo di scrivere su carta. Occorre mantenere coerenti operando e risultato: ~125 varrebbe -126, non -101.

Gli spostamenti richiedono una distinzione simile. 100 << 1 vale 200; 100 >> 1 vale 50. Per numeri negativi e casi di overflow, trattare lo shift come una moltiplicazione o divisione ordinaria dà risultati sbagliati. >> propaga il bit del segno, mentre >>> inserisce zeri a sinistra. Usa questi operatori quando devi esprimere un'operazione sui bit; non sostituire automaticamente * 2 o / 2 nella speranza di rendere più veloce il programma. Il compilatore e la JVM possono già ottimizzare operazioni semplici, e la misura su un caso reale è l'unica prova di un vantaggio.

Consideriamo il programma ProdottoDivisione, perché osservare una sequenza insegna più di una singola uguaglianza. Partendo da 100, tre spostamenti a destra danno 50, 25 e 12; tornando a 100, tre spostamenti a sinistra danno 200, 400 e 800. Il passaggio da 25 a 12 mostra subito che non stiamo facendo una divisione fra numeri reali: l'intero scarta la parte frazionaria. In binario, sugli otto bit meno significativi, 100 si scrive 01100100; uno spostamento a destra lo porta a 00110010, cioè 50. La rappresentazione di un int Java ha però 32 bit: il gruppo di otto serve soltanto a vedere il passaggio, non è l'intero valore in memoria.

Il comportamento dei numeri negativi rende visibile la differenza fra i due spostamenti a destra. -8 >> 1 vale -4, mentre -8 >>> 1 vale 2_147_483_644 per un int: nel secondo caso gli zeri entrano da sinistra e il segno non viene conservato. >>> è uno spostamento a destra senza estensione del segno; chiamarlo «shift a sinistra senza segno» invertirebbe la direzione dell’operazione. Anche il numero di posizioni va interpretato secondo il tipo dell'operando sinistro: per un int Java usa soltanto i cinque bit meno significativi della distanza. Perciò 1 << 32 ha lo stesso risultato di 1 << 0, ossia 1; non produce zero e non sposta un bit oltre la parola. È un dettaglio da conoscere quando si leggono protocolli binari, non un trucco da inserire nei calcoli ordinari.

Per controllare &, | e ^ senza confondere le cifre decimali con i bit, allineiamo due valori su otto posizioni: 100 è 01100100 e 125 è 01111101. Dove entrambi hanno un 1, & conserva quel bit e otteniamo 01100100, cioè 100. | conserva ogni posizione in cui almeno un operando ha 1 e dà 01111101, cioè 125. ^ conserva le posizioni diverse e produce 00011001, cioè 25. Il complemento ~100 inverte invece tutti i 32 bit dell'int; leggere soltanto gli otto finali porterebbe all'errata conclusione che il risultato sia 155. Il valore completo è -101. Questi risultati sono raccolti nel programma riproducibile BitPassoPasso.java, che accompagna la spiegazione senza fare degli shift una raccomandazione di prestazioni.

Nota bene – Maiuscole e maschere di bit. Trasformare le lettere minuscole in maiuscole azzerando un bit è una scorciatoia con un limite preciso. Funziona soltanto per un sottoinsieme preciso di lettere latine e può cambiare in modo inatteso altri caratteri. Per il testo usa le API di Character o String con una scelta esplicita sulla lingua quando serve. Le maschere di bit restano un buon esempio per protocolli e formati binari, dove i bit hanno un significato dichiarato.

L'esempio storico Maiuscolo merita una prova, perché il suo risultato per 'a' sembra convincente. Il codice 97 & 223 produce 65, e questi due numeri corrispondono rispettivamente a 'a' e 'A' nel sottoinsieme latino di base. Ma la stessa maschera applicata a un carattere che non sia una lettera minuscola prevista può cambiarlo senza alcun motivo linguistico. Inoltre args[0].charAt(0) legge soltanto la prima unità UTF-16 del primo argomento; con un argomento assente fallisce prima ancora di mostrare un risultato. Per una singola lettera semplice Character.toUpperCase('a') rende chiara l'intenzione. Per una parola si può usare una trasformazione di String con un Locale scelto in base alla lingua e al contesto. Non esiste una sottrazione universale di 32 che trasformi correttamente tutto Unicode: questa è la correzione tecnica, mentre il ragionamento sui bit resta nel paragrafo precedente.

Prendi l'abitudine di separare tre livelli quando un'operazione sorprende: rappresentazione, tipo e significato. La rappresentazione binaria spiega perché 100 & 125 dà 100; il tipo int spiega perché ~100 coinvolge trentadue bit; il significato del dato decide se la maschera è una soluzione sensata. Applicare un operatore bit a bit a un codice di testo senza una regola sul repertorio dei caratteri salta l'ultimo livello. Lo stesso metodo di lettura sarà utile quando lavoreremo con gli array: un indice è un intero, ma il significato di quella posizione appartiene al problema che stiamo modellando.

Anche ++ e -- meritano attenzione. Se int i = 0;, l'espressione i++ produce il vecchio valore e poi incrementa i, mentre ++i incrementa prima e produce il nuovo valore. Quando l'espressione è usata da sola come istruzione, entrambe portano i a 1; la differenza conta se il valore dell'espressione viene assegnato altrove. Preferisci separare i passaggi se una riga come int j = i++ costringe a fermarsi per capire l'ordine.

Buona pratica – Parentesi quando chiariscono. La precedenza degli operatori è definita dal linguaggio, ma una persona non dovrebbe doverla ricostruire ogni volta. Scrivi (somma / numeroVoti) * 100 quando quella sequenza è importante per il ragionamento, e spiega il risultato se il tipo degli operandi cambia il calcolo.

Array: più valori dello stesso tipo

Tre variabili separate bastano per tre voti, ma diventano scomode quando i voti sono trenta. Un array raccoglie un numero fisso di elementi dello stesso tipo e permette di raggiungerli con un indice. L'indice del primo elemento è 0; l'ultimo indice valido è length - 1. In un array di lunghezza tre, quindi, gli indici sono 0, 1 e 2.

La forma int[] voti mette in evidenza che il tipo è «array di interi». Java ammette anche la forma storica int voti[], ereditata da altri linguaggi, ma negli esempi useremo la prima. Dichiarare int[] voti; crea una variabile di riferimento, non ancora l'array. voti = new int[3]; crea l'oggetto array e ne fissa la lunghezza. Dopo la creazione, la lunghezza non cambia; se ti serve una sequenza che cresce e si riduce, il capitolo sulle collezioni mostrerà altre strutture.

Array di tre voti
Figura 4.1 – Ogni posizione ha un indice, separato dal valore che contiene. length vale tre; provare a leggere voti[3] non significa chiedere il terzo elemento, ma una posizione inesistente.
int[] voti = {24, 27, 30};
int primo = voti[0];
int numeroVoti = voti.length;

int[] è il tipo dell'array; le parentesi graffe forniscono i valori iniziali. Possiamo anche creare new int[3] e assegnare poi gli elementi, ma in quel caso ciascuna posizione comincia con il valore predefinito 0. Se l'indice è fuori dall'intervallo, l'accesso fallisce durante l'esecuzione con ArrayIndexOutOfBoundsException. Il compilatore non può sempre conoscere il valore che un indice avrà quando il programma girerà.

Facciamo il percorso inverso rispetto alla dichiarazione compatta. Dopo int[] voti = new int[3];, il programma ha un array di lunghezza tre e i valori sono 0, 0, 0. L'istruzione voti[1] = 27; cambia la seconda posizione, non la ventisettesima e non il numero di elementi: l'array diventa {0, 27, 0}. voti.length continua a valere 3. Se scriviamo voti[3] = 30;, il codice compila perché l'indice è un intero, ma durante l'esecuzione fallisce: le sole posizioni valide sono 0, 1 e 2. Non si tratta di un errore della JVM da ignorare; ci sta dicendo che il programma ha fatto un'ipotesi sbagliata sul limite.

La lunghezza può anche essere zero. new int[0] crea un array valido che non contiene elementi; non esiste un voti[0] da leggere. Un algoritmo generale deve quindi controllare la lunghezza prima di accedere al primo elemento o dividere per il numero degli elementi. Questo spiega perché il programma MediaVoti qui sotto funziona con i tre valori stampati, ma non è ancora la versione definitiva: la somma scritta a mano presuppone proprio tre posizioni. Nel capitolo 5 useremo un ciclo e gestiremo esplicitamente il caso vuoto.

Concetto chiave – Lunghezza, capacità e numero di valori significativi. Per un array Java, length è il numero di posizioni create e rimane fisso. Non dice quanti elementi abbiamo già riempito con valori utili al problema. Un int[20] possiede venti posizioni fin dall'inizio, inizialmente tutte a zero; se abbiamo registrato tre voti, dobbiamo conservare separatamente il fatto che soltanto tre posizioni rappresentano voti reali. Più avanti vedremo strutture che gestiscono la crescita e il conteggio degli elementi per noi.

Ecco un programma completo che usa l'array per calcolare la media:

public class MediaVoti {
    public static void main(String[] args) {
        int[] voti = {24, 27, 30};
        int somma = voti[0] + voti[1] + voti[2];
        double media = (double) somma / voti.length;
        System.out.println("Media: " + media);
    }
}

La somma vale 81 e la lunghezza dell'array vale 3, quindi l'output è Media: 27.0. Il cast (double) converte il valore di somma prima della divisione; senza di esso, Java eseguirebbe una divisione fra interi e soltanto dopo convertirebbe il risultato in double. Nei capitoli sul controllo di flusso sostituiremo la somma scritta elemento per elemento con un ciclo, così il programma funzionerà per array di lunghezza diversa.

Array con più dimensioni

In Java un array bidimensionale è un array i cui elementi sono a loro volta array. int[][] caselle = new int[4][4]; crea quattro righe, ciascuna con quattro interi. Per leggere una posizione scriviamo prima l'indice della riga e poi quello della colonna, per esempio caselle[1][2].

Array bidimensionale
Figura 4.2 – La casella caselle[1][2] è nella seconda riga e nella terza colonna. La figura mostra una griglia regolare; Java permette anche righe di lunghezze diverse.

La tavola della dama rende concreta la griglia; la figura mette in evidenza gli indici, che sono ciò che dobbiamo imparare a usare. Un array di array può avere righe di lunghezza diversa: per questo, in un programma generale, controlliamo caselle[riga].length e non supponiamo che ogni riga abbia la lunghezza della prima.

Vediamo che cosa vuol dire «array di array» senza ricorrere subito alla tavola completa. int[][] righe = new int[2][]; crea un array esterno con due posizioni; ciascuna contiene inizialmente null, perché deve ancora riferirsi a un array interno. Se assegniamo righe[0] = new int[3]; e righe[1] = new int[1];, la prima riga ha tre numeri e la seconda uno soltanto. L'accesso righe[1][0] è valido, mentre righe[1][1] esce dalla seconda riga anche se righe[0][1] esiste. Una «matrice» rettangolare è dunque una scelta del nostro programma, non un vincolo del tipo int[][].

Gli array sono oggetti e le variabili che li nominano contengono riferimenti. Se dopo int[] primo = {24, 27}; scriviamo int[] secondo = primo;, non abbiamo copiato i due voti: primo e secondo indicano lo stesso array. secondo[0] = 30; farà leggere 30 anche attraverso primo[0]. Quando vogliamo due array indipendenti dobbiamo chiederne una copia, per esempio con primo.clone() per questo array semplice di primitivi, e dichiarare perché la copia serve. Questa distinzione prepara il terreno all'incapsulamento: una classe che consegna all'esterno il proprio array modificabile sta consegnando anche la possibilità di alterare il suo stato.

Un array può contenere riferimenti invece di numeri. In String[] nomi = new String[2]; entrambe le posizioni partono da null, non da "". Dopo nomi[0] = "Ada";, soltanto la prima posizione indica una stringa. Prima di chiamare nomi[1].length() bisogna assegnarle un testo oppure trattare esplicitamente l'assenza; altrimenti si incontra una NullPointerException. La regola dei valori iniziali che abbiamo visto per i campi e gli array si applica anche qui, ma il valore predefinito non rende l'elemento pronto per qualsiasi operazione.

Nota bene – Confrontare array. primo == secondo controlla se i due riferimenti indicano lo stesso array, non se gli elementi sono uguali. Per confrontare il contenuto di due array a una dimensione useremo java.util.Arrays.equals. La classe di utilità offre anche Arrays.toString, utile per stampare gli elementi durante un esperimento: la stampa diretta di un array con System.out.println(primo) non elenca i voti nel modo che molti principianti si aspettano.

Questo capitolo ha introdotto più di una sintassi. Per affrontare un esercizio sugli array, comincia da tre domande: quale dato rappresenta ogni elemento, quanti elementi esistono e quali indici sono validi? Solo dopo scegli se inizializzare tutti i valori subito o riempirli in passi successivi. Per un esercizio numerico aggiungi una quarta domanda: con quale tipo Java esegue il calcolo prima di assegnarne il risultato? Sono domande semplici da porre su carta, e risparmiano tentativi casuali davanti a un risultato inatteso. Nel prossimo capitolo useremo proprio questi dati per decidere quali istruzioni ripetere e quali eseguire soltanto in determinate condizioni.

Per collocare le pedine di una dama useremo una matrice di otto righe. La figura isola la regola degli indici prima dell'applicazione completa. Possiamo iniziare una tavola con String[][] dama = new String[8][8]; e collocare una pedina con dama[0][1] = "bianca";. Le caselle che non abbiamo assegnato contengono inizialmente null, non una stringa vuota. Per rappresentare tutta la disposizione iniziale useremo un ciclo nel prossimo capitolo, invece di copiare decine di assegnazioni; così potremo verificare che la stessa regola valga per ogni riga. Useremo una sola disposizione iniziale esplicitata e controllata, così figura, codice e conteggio delle pedine descriveranno la stessa tavola.

Per verificare

Prova a cambiare i voti in {20, 25, 28, 30}. Prima di avviare il programma, prevedi la media e indica quale riga dovrai modificare affinché la somma includa anche il nuovo elemento. Dopo averlo eseguito, spiega perché nel prossimo capitolo useremo un ciclo. Come seconda prova, scrivi su carta gli indici validi per un array di lunghezza zero e per uno di lunghezza cinque. L'attività è riuscita quando distingui valore, indice e lunghezza e sai motivare il tipo dell'espressione usata nella divisione.

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 ↑