mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 5/39

5. Controllare il flusso del programma

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 →

Una sequenza che può scegliere

Nel programma della media abbiamo sommato tre elementi scrivendo tre addizioni. Funziona, ma appena il numero dei voti cambia dobbiamo cambiare il codice. Inoltre, che cosa dovrebbe accadere se l'array fosse vuoto? Per rispondere ci servono istruzioni che scelgano un percorso e istruzioni che ripetano un lavoro. Questo è il controllo di flusso: decidere quale parte del programma eseguire, quante volte e quando interromperla.

Una condizione in Java deve produrre un valore boolean, cioè true o false. Non è sufficiente un numero diverso da zero, come in altri linguaggi. Le parentesi graffe racchiudono le istruzioni di un ramo. Anche quando un ramo contiene una sola istruzione le useremo: aiutano a vedere i confini e rendono più sicura una modifica successiva.

Il capitolo precedente ci ha dato gli operatori per costruire condizioni, ma un'espressione booleana da sola non cambia il percorso del programma. Se calcoliamo voti.length == 0, otteniamo true oppure false; è l'istruzione if a decidere se eseguire il blocco successivo in base a quel valore. Questo è un passaggio importante perché separa due domande che spesso vengono fuse: «la condizione è vera?» e «che cosa faccio se lo è?». Quando una regola diventa complessa, possiamo darle un nome significativo prima di usarla in un ramo; così il lettore controlla la condizione e l'azione in due passaggi.

Le istruzioni possono seguire un percorso sequenziale, una selezione o una ripetizione. La sequenza esegue una istruzione dopo l'altra. La selezione con if o switch sceglie fra alternative. La ripetizione con while, do–while o for torna su un blocco finché ha senso farlo. Infine break, continue e return modificano il percorso dall'interno di questi costrutti o di un metodo. Non sono categorie decorative: se ti chiedi «voglio scegliere un caso, ripetere un lavoro o terminare?», hai già ristretto molto la scelta della sintassi da usare.

if, else e decisioni annidate

L'istruzione if esegue un blocco soltanto se la condizione è vera:

if (voti.length == 0) {
    System.out.println("Nessun voto disponibile");
}

Se vogliamo un percorso alternativo aggiungiamo else. Per esempio, una classe può distinguere un array vuoto da uno che contiene valori. Una catena if–else if permette più scelte, valutate nell'ordine. Quando un ramo viene eseguito, i successivi non vengono considerati. Per questo l'ordine delle soglie conta:

if (media >= 27) {
    giudizio = "ottimo";
} else if (media >= 18) {
    giudizio = "sufficiente";
} else {
    giudizio = "da recuperare";
}

Il primo ramo include anche i valori maggiori di 30; se il dominio ammette soltanto voti da 0 a 30, quel vincolo va controllato nel punto in cui i dati entrano nel programma. Le condizioni non diventano corrette perché sembrano plausibili. In particolare, invertire i primi due rami farebbe classificare 29 come «sufficiente», perché la seconda soglia intercetterebbe il valore prima di arrivare alla prima.

Prova a seguire mentalmente l'esecuzione per media = 17, 18, 26 e 27. Con 17 i primi due confronti sono falsi e si arriva al ramo finale. Con 18 il primo è falso e il secondo è vero. Con 26 accade lo stesso, benché il numero sia più alto. Con 27 il primo confronto è vero e gli altri rami non vengono nemmeno esaminati. Questo piccolo esercizio è più istruttivo che leggere tre etichette di giudizio: mostra che una catena di condizioni ha un ordine e che i confini degli intervalli devono essere provati. Se cambi >= 27 in > 27, il valore 27 passa al ramo «sufficiente»; un solo carattere ha cambiato la regola.

Quando una decisione ne contiene un'altra, l'indentazione aiuta, ma non sostituisce le parentesi graffe. Se le condizioni annidate diventano molte, chiediti se stai cercando di rappresentare uno stato che merita un nome proprio o un metodo separato. Non c'è un numero magico di if consentiti: c'è un limite pratico alla quantità di casi che una persona riesce a tenere in mente mentre legge.

A quale if appartiene else?

Gli if annidati meritano attenzione perché una riga apparentemente innocente può cambiare il risultato. Senza parentesi graffe, un else viene associato all'if compatibile più vicino. Considera il voto 20: vogliamo prima verificare che appartenga all'intervallo da 0 a 30, poi decidere se sia almeno 18. Se scriviamo i due controlli su righe diverse ma senza blocchi, l'indentazione può farci credere che l'else significhi «voto non valido» quando invece appartiene al controllo della promozione. Le graffe rendono visibile la struttura che il compilatore usa davvero.

Una catena if–else if non prova automaticamente che i casi siano completi e non sovrapposti. Nel nostro giudizio sui voti, media >= 27 assorbe anche 31, mentre il ramo finale assorbe anche -1. Se entrambi sono vietati, aggiungiamo un controllo di validità prima della classificazione. Questo separa due domande: «il dato ha senso?» e «in quale fascia cade?». La separazione aiuta a scrivere una prova per ogni confine: -1, 0, 17, 18, 26, 27, 30, 31.

Nota bene – Uguaglianza e assegnazione. In una condizione == confronta valori, mentre = assegna. Il compilatore intercetta molti scambi accidentali, ma non tutti i problemi logici: boolean pronto = true; if (pronto = false) { ... } è sintatticamente possibile e usa il valore assegnato come condizione. Se l'intenzione era confrontare, il programma è sbagliato.

switch: scegliere per casi

Quando più rami dipendono dallo stesso valore, switch può esprimere la scelta con maggiore chiarezza. La forma classica è un'istruzione: esegue azioni. Un esempio semplice usa un numero per rappresentare il giorno della settimana:

Nei frammenti che seguono assumiamo che giorno sia un int compreso normalmente fra 1 e 7; il caso fuori intervallo sarà gestito esplicitamente. I frammenti sono parti di un metodo, mentre i programmi completi sono mostrati per intero o disponibili nei materiali del capitolo.

switch (giorno) {
    case 6, 7 -> System.out.println("Fine settimana");
    default -> System.out.println("Giorno feriale");
}

Le etichette case con -> non cadono automaticamente nel caso successivo. La vecchia forma con i due punti esiste ancora e può essere necessaria per codice che condivide istruzioni fra casi, ma richiede attenzione al fall through: senza un'interruzione, l'esecuzione può proseguire nel ramo successivo. Nei nuovi esempi preferiremo le frecce quando esprimono meglio l'intenzione.

Nella forma classica case 6: segna il punto da cui cominciare, non un recinto chiuso. Se il codice stampa «sabato» e non incontra break, può proseguire nel blocco case 7: e stampare anche «domenica». A volte è voluto: più etichette possono condividere una stessa azione. Altre volte è un difetto difficile da vedere, soprattutto quando si aggiunge un nuovo caso fra due esistenti. La freccia case 6, 7 -> ... esprime direttamente che entrambi i numeri ricevono la stessa risposta e che quel ramo non prosegue nel successivo.

Per rendere osservabile il fall through, immaginiamo giorno = 6 e due rami classici. Se case 6: stampa sabato e non termina con break, l'esecuzione prosegue nelle istruzioni di case 7: e stampa anche domenica: il compilatore non rivaluta il selettore fra le due stampe. Inserendo break dopo la prima, la seconda non viene eseguita. Un default collocato in fondo può a sua volta essere attraversato da un caso precedente che non si interrompe; la sua posizione non crea da sola una barriera. Questa era la parte difficile del secondo programma storico sui numeri in parole: ogni ramo richiedeva un break corretto. Con case numero ->, ciascun ramo è separato per costruzione.

Le etichette costanti della forma classica devono essere valori accettati dalla grammatica e determinabili in compilazione. final String cane = "CANE"; può essere usata come etichetta di un switch su String; una variabile String gatto = "GATTO"; che può cambiare non è una costante di compilazione, anche se al momento contiene sempre lo stesso testo nei nostri esempi. Le variabili dichiarate dentro un ramo con case ... : richiedono attenzione all'ambito condiviso del blocco switch: graffe locali attorno al ramo rendono visibile dove nasce e termina il nome. Nella forma a freccia con blocco, quelle graffe sono già il luogo naturale per i dati temporanei del ramo.

switch non è sempre preferibile a if. Se le decisioni dipendono da condizioni diverse, come voto >= 18 e presenze >= 10, una catena di condizioni può essere più naturale. Se invece stai scegliendo fra valori distinti della stessa espressione, switch rende l'elenco dei casi più evidente. In entrambi i costrutti chiediti che cosa accade per un valore non previsto: un default che stampa «valore non valido» è una decisione esplicita, non una toppa da aggiungere meccanicamente.

Trasformiamo un numero nella sua forma scritta in due modi: prima con una catena di if ed else if, poi con uno switch. Considera il valore 3: nella catena si prova prima se è 0, poi se è 1, poi se è 2 e infine si arriva alla condizione per 3. Nello switch si dichiara invece un caso per ogni valore previsto. Il risultato visibile, «tre», può essere identico; cambia il modo in cui il sorgente esprime la relazione fra valore e risposta. Non dedurre dalle righe di un esempio didattico quale forma sia sempre più veloce: per scegliere qui conta soprattutto la leggibilità della regola e la necessità di gestire valori fuori dall'intervallo.

L’intervallo ammesso è una parte essenziale del contratto del programma. Se la conversione è definita soltanto da 0 a 10, che cosa deve accadere per -1 o 11? Lasciare una stringa non inizializzata o non stampare nulla renderebbe l'errore difficile da riconoscere. Possiamo scegliere un messaggio esplicito, rifiutare il dato prima della selezione oppure rappresentare l'assenza di una traduzione: l'importante è dichiarare la scelta. Anche i numeri scritti in lettere portano un piccolo problema di dominio: «uno» e «una» dipendono dal contesto. Un esempio chiamato InteroComeStringa deve dire se produce etichette convenzionali o parole adatte a una frase. Questo non cambia la sintassi di switch, ma ricorda che un programma corretto rispetto ai casi elencati può ancora essere incompleto rispetto al problema reale.

Le due versioni storiche meritano comunque di essere lette per intero, perché fanno sentire il peso di undici alternative meglio di un esempio con soli due giorni. Il programma seguente conserva gli stessi casi, da zero a dieci, e li mette in due metodi affiancati. conIf prova le condizioni dall'alto in basso e restituisce il testo appena ne trova una vera. conSwitch presenta gli stessi valori come etichette di un'unica espressione. In entrambi i casi il ramo finale tratta sia i numeri negativi sia quelli maggiori di dieci: l'antico messaggio che parlava solo dei numeri «maggiori» non descriveva il caso -1.

public class InteroComeStringaComparato {
    private static String conIf(int numero) {
        if (numero == 0) {
            return "zero";
        } else if (numero == 1) {
            return "uno";
        } else if (numero == 2) {
            return "due";
        } else if (numero == 3) {
            return "tre";
        } else if (numero == 4) {
            return "quattro";
        } else if (numero == 5) {
            return "cinque";
        } else if (numero == 6) {
            return "sei";
        } else if (numero == 7) {
            return "sette";
        } else if (numero == 8) {
            return "otto";
        } else if (numero == 9) {
            return "nove";
        } else if (numero == 10) {
            return "dieci";
        } else {
            return "fuori intervallo";
        }
    }

    private static String conSwitch(int numero) {
        return switch (numero) {
            case 0 -> "zero";
            case 1 -> "uno";
            case 2 -> "due";
            case 3 -> "tre";
            case 4 -> "quattro";
            case 5 -> "cinque";
            case 6 -> "sei";
            case 7 -> "sette";
            case 8 -> "otto";
            case 9 -> "nove";
            case 10 -> "dieci";
            default -> "fuori intervallo";
        };
    }

    public static void main(String[] args) {
        if (args.length != 1) {
            System.out.println("Uso: java InteroComeStringaComparato <intero>");
            return;
        }
        int numero = Integer.parseInt(args[0]);
        System.out.println("if: " + conIf(numero));
        System.out.println("switch: " + conSwitch(numero));
    }
}

Esegui InteroComeStringaComparato.java con java InteroComeStringaComparato 3: le righe stampate sono if: tre e switch: tre. Ripeti con 0, 10, -1 e 11, poi controlla che le due righe coincidano ogni volta. Senza argomenti, il programma mostra l'uso previsto anziché tentare di leggere una posizione inesistente dell'array args. Una stringa non numerica, invece, provoca NumberFormatException: la gestione di un input davvero interattivo richiederebbe una decisione ulteriore, che affronteremo nel capitolo sulle eccezioni. Qui isoliamo il confronto fra le due selezioni.

Il confronto mostra anche che una forma più compatta non cancella il lavoro di definire il dominio. Gli undici valori restano undici; switch evita la ripetizione di numero ==, ma ogni etichetta richiede ancora la parola corretta. Se domani includiamo 11, dobbiamo aggiornare entrambe le versioni e il controllo dei confini. Scrivere prima una tabella di casi attesi e poi implementarla è una buona difesa contro omissioni e refusi, qualunque costrutto scegliamo.

Una espressione switch, invece, produce un valore. Questo evita di inizializzare una variabile in più punti:

String tipoGiorno = switch (giorno) {
    case 6, 7 -> "fine settimana";
    case 1, 2, 3, 4, 5 -> "feriale";
    default -> "valore non valido";
};

L'espressione deve coprire tutti i valori possibili del tipo selezionato, altrimenti il compilatore non potrebbe garantire un risultato. Qui default si occupa dei numeri fuori dall'intervallo previsto. Se un ramo richiede più istruzioni, si può usare un blocco e yield per consegnare il valore dell'espressione; return avrebbe un significato diverso, perché uscirebbe dal metodo. La specifica delle istruzioni e dei pattern di Java 25 descrive entrambe le forme.

Per vedere la differenza, immagina un caso che deve prima costruire una spiegazione e poi produrre la stringa finale. Il ramo può contenere un blocco:

String descrizione = switch (giorno) {
    case 6, 7 -> {
        String nome = "riposo";
        yield "giorno di " + nome;
    }
    default -> "giorno ordinario";
};

yield consegna il valore del ramo all'espressione switch; la variabile descrizione riceve quel valore. Non usarlo in un normale if per «uscire dal blocco»: il suo ruolo qui è specifico. Nell'esempio il blocco è più lungo di quanto servirebbe davvero; lo mostriamo soltanto per leggere la sintassi senza aggiungere altri problemi.

Concetto chiave – Un valore oppure un effetto. Usa switch come espressione quando devi calcolare un risultato da assegnare o restituire. Usalo come istruzione quando ciascun caso deve compiere un'azione. Scegli la forma che rende evidente lo scopo; la brevità da sola non basta.

Riconoscere il tipo con un pattern

Una forma moderna di switch può selezionare il ramo in base al tipo di un oggetto. Considera un valore che può essere testo, intero o qualcos'altro. Questo esempio è un approfondimento perché usa oggetti e tipi che studieremo con più calma, ma mostra perché un switch non è più limitato a una lista di numeri:

Il primo passo può essere un controllo con instanceof:

if (valore instanceof String testo) {
    System.out.println(testo.length());
}

Se valore è una String, la variabile testo contiene il riferimento già controllato nel ramo. Non serve ripetere un cast. Il nome non è disponibile ovunque: il compilatore ne determina l'ambito dai percorsi in cui il controllo può essere vero. Un switch con pattern estende la stessa idea a più casi:

String descrizione = switch (valore) {
    case null -> "assente";
    case String testo when testo.isBlank() -> "testo vuoto";
    case String testo -> "testo: " + testo;
    case Integer numero -> "intero: " + numero;
    default -> "altro valore";
};

Il pattern String testo controlla il tipo e, se il controllo riesce, rende disponibile il nome testo in quel ramo. La guardia when aggiunge una condizione. L'ordine è essenziale: il caso più specifico, quello della stringa vuota, viene prima del caso per qualunque stringa. Se li invertissimo, il primo caso assorbirebbe anche il secondo. Il caso null è esplicito, così il lettore sa come viene trattato il valore assente. Nel capitolo sui record vedremo anche i record pattern, che estraggono componenti da un record. Pattern matching per switch e record pattern sono funzionalità stabili nelle versioni di Java trattate qui; le eventuali estensioni preview di Java 25 restano fuori dal percorso principale, come indicato nel riepilogo ufficiale delle modifiche del linguaggio.

Nei due frammenti valore è dichiarato come Object; SwitchEPattern.java mostra il metodo completo e verifica quattro input diversi. Per i frammenti su yield, do–while e break c'è anche FrammentiControllo.java, così il codice può essere provato senza indovinare le dichiarazioni mancanti.

Approfondimento – Dominanza ed esaustività. Un pattern più generale può rendere irraggiungibile un caso successivo: case Object oggetto prima di case String testo intercetterebbe anche le stringhe. In uno switch che produce un valore, ogni possibilità deve portare a un risultato; se il compilatore non può dimostrarlo, serve un caso ulteriore. I record pattern seguono lo stesso principio, ma richiedono di conoscere prima che cosa sia un record.

Approfondimento Java 25 – Primitivi nei pattern, ancora preview. I casi precedenti lavorano con forme stabili. Se il valore da classificare è invece un long oppure occorre usare un tipo primitivo in un pattern, Java 25 offre un'estensione ancora preview, descritta nel riepilogo ufficiale del linguaggio. Il problema che affronta è evitare conversioni o rami separati solo perché certe forme di switch e instanceof non ammettevano quei primitivi. Non bisogna dedurne che ogni esempio con switch del capitolo richieda la preview: quelli già svolti compilano senza opzioni speciali. Per provare un programma che usa questa estensione servono javac --enable-preview --release 25 e java --enable-preview; la sintassi e il contratto possono ancora cambiare. Per un'applicazione che non vuole dipendere dalla preview si conserva una scelta stabile, per esempio una condizione if su un long. Il criterio di scelta resta il problema da risolvere, non il desiderio di usare l'ultima forma disponibile.

Ripetere con while e do–while

Un ciclo while controlla la condizione prima di eseguire il corpo. Se è falsa dall'inizio, il corpo non viene eseguito neppure una volta. La figura segue il percorso della condizione e il ritorno al controllo dopo ogni ripetizione.

Flusso del ciclo while
Figura 5.1 – Il corpo viene ripetuto finché la condizione è vera. La freccia di ritorno porta alla verifica, non direttamente al corpo.
int indice = 0;
while (indice < voti.length) {
    System.out.println(voti[indice]);
    indice++;
}

indice++ aumenta l'indice di uno. Se dimentichiamo di modificarlo, la condizione può restare vera per sempre; se lo aumentiamo prima della lettura, possiamo saltare il primo voto. Un ciclo va letto come tre domande: qual è lo stato iniziale, che cosa fa terminare la ripetizione e che cosa cambia a ogni passo?

La forma do–while controlla la condizione dopo il corpo, quindi lo esegue almeno una volta. È utile, per esempio, quando chiediamo un input e poi decidiamo se ripetere la richiesta. Non usarla soltanto per evitare una riga di inizializzazione: scegliendola stai promettendo che la prima esecuzione ha senso anche se la condizione finale è falsa.

Per leggere un ciclo while in modo affidabile, individua una proprietà che deve restare vera a ogni giro. Nel ciclo dei voti, prima di accedere a voti[indice], vogliamo 0 <= indice < voti.length. L'indice parte da zero, la condizione controlla il limite superiore e l'incremento avviene dopo l'accesso: insieme, questi tre pezzi spiegano perché non leggiamo fuori dall'array. Se scambi l'accesso e l'incremento, la prima posizione viene saltata e l'ultima iterazione può tentare un indice non valido. La prova con un array di un solo elemento rende subito visibile l'errore.

Un ciclo senza fine può essere intenzionale, per esempio un servizio che attende richieste, ma deve avere una strategia per interrompersi o essere arrestato. Nei programmi didattici il caso più frequente è molto più semplice: la variabile usata nella condizione non viene mai aggiornata. Quando un programma sembra bloccato, controlla prima se il corpo del ciclo modifica davvero ciò che potrebbe rendere falsa la condizione. Una stampa temporanea dell'indice può aiutare, purché poi venga tolta o sostituita con una verifica mirata.

int tentativi = 0;
do {
    tentativi++;
    System.out.println("Tentativo " + tentativi);
} while (tentativi < 3);

Il programma stampa tre righe. Il corpo esegue il primo tentativo prima di valutare tentativi < 3; dopo il terzo, la condizione diventa falsa. Un while può produrre lo stesso risultato con una diversa disposizione del controllo, ma non offre la garanzia di una prima esecuzione quando la condizione iniziale è falsa.

for e for esteso

Quando conosciamo un indice che parte da un valore, prosegue finché una condizione è vera e cambia a ogni passo, un for riunisce i tre elementi:

for (int indice = 0; indice < voti.length; indice++) {
    System.out.println(voti[indice]);
}

La variabile indice nasce nell'intestazione del ciclo. La condizione indice < voti.length evita l'indice fuori intervallo. Se non ci serve conoscere la posizione e vogliamo soltanto ciascun valore, il for esteso è più diretto:

int somma = 0;
for (int voto : voti) {
    somma += voto;
}

La lettura è «per ogni voto in voti». Il ciclo non crea una copia dell'intero array: a ogni passaggio assegna l'elemento corrente alla variabile locale. Con valori primitivi, modificare voto non modifica l'elemento dell'array. Per cambiare una posizione precisa serve l'indice. Il for esteso si applica anche a tipi iterabili, che incontreremo parlando di collezioni.

I due programmi storici che stampavano i mesi mostravano la stessa sequenza con for ordinario e con for esteso. Su un array {"gennaio", "febbraio", "marzo"}, il primo usa gli indici 0, 1 e 2 per leggere i valori; il secondo consegna direttamente, un mese alla volta, la variabile mese. Se il compito è stampare tutti i nomi, la seconda forma evita un contatore che non aggiunge informazione. Se invece il compito è scrivere «mese 1: gennaio» o sostituire l'elemento in una posizione, l'indice torna utile. Questo confronto, e non una promessa generale di maggiore velocità, è il criterio per scegliere.

La variabile del for esteso va interpretata secondo il tipo dell'elemento. Con int[] voti, voto riceve una copia del valore primitivo: voto = 30; non cambia l'array. Con un array di riferimenti a oggetti modificabili, la variabile riceve una copia del riferimento: riassegnarla non sostituisce l'elemento dell'array, ma chiamare un metodo che modifica l'oggetto può cambiare lo stato osservato anche attraverso l'array. Non abbiamo ancora bisogno di usare questa seconda possibilità; serve però a non trasformare la frase «il for esteso non modifica gli elementi» in una regola falsa. Anche la rimozione durante l'attraversamento di una collezione dipende dal tipo di collezione e dal suo iteratore: quando arriveremo alle collezioni distingueremo la lettura, la modifica dell'oggetto e la modifica della struttura.

Il for ordinario può anche essere letto come un while con inizializzazione, controllo e aggiornamento raccolti in testa. Dopo aver inizializzato indice, valuta la condizione prima del corpo; dopo il corpo esegue l'aggiornamento e torna al controllo. Se la condizione è falsa dall'inizio, il corpo non parte. Questa corrispondenza aiuta a scegliere la forma senza attribuirle poteri diversi: entrambe possono attraversare un array, ma for rende più visibile il contatore quando il numero di passi è determinato dalla lunghezza.

Con un array di array, il ciclo esterno può attraversare le righe e quello interno le posizioni della riga corrente. Il limite corretto per il secondo è caselle[riga].length, non necessariamente caselle[0].length: Java permette righe di lunghezza diversa. È esattamente ciò che ci servirà nella tavola della dama. La figura del capitolo 4 era rettangolare per chiarezza, ma il codice generale non deve dipendere da quella figura.

Ricostruiamo la traccia di un while annidato con due contatori su un caso piccolo, per non perdere il significato in decine di righe stampate. Se riga parte da 0 e il ciclo esterno prosegue finché riga < 2, mentre a ogni giro colonna riparte da 0 e il ciclo interno prosegue finché colonna < 3, le coppie visitate sono (0,0), (0,1), (0,2), (1,0), (1,1) e (1,2). La riga non cambia durante i tre passi interni; la colonna viene azzerata quando si passa alla riga successiva. Dimenticare quell'azzeramento farebbe saltare tutta la seconda riga. Il programma della dama applica lo stesso ragionamento con due for e otto posizioni per riga.

Un for può avere anche due contatori nella sua intestazione, come for (int sinistra = 0, destra = 4; sinistra < destra; sinistra++, destra--). Su un array di cinque elementi visita le coppie di indici (0,4) e (1,3) e poi si ferma quando entrambi arrivano a 2. Le virgole separano le due dichiarazioni dello stesso tipo e le due espressioni di aggiornamento; la condizione centrale resta una sola espressione booleana. È utile quando un algoritmo guarda contemporaneamente le due estremità di una sequenza, per esempio per controllare se una parola si legge allo stesso modo in entrambi i versi. Se due contatori non condividono una relazione così chiara, spostarli in istruzioni separate evita di trasformare l'intestazione del ciclo in un rompicapo.

La forma for (;;) omette inizializzazione, condizione e aggiornamento; non essendoci una condizione falsa che la termini, il ciclo continua finché un break, un return o un'interruzione eccezionale non cambia il flusso. È lecita, come while (true), ma richiede che il percorso di uscita sia evidente. Omettere soltanto l'aggiornamento e poi dimenticare di modificare il contatore nel corpo è invece un errore comune. La flessibilità di for non la rende automaticamente la scelta migliore in ogni problema: per «ripeti finché arriva un evento» un while può raccontare più direttamente l'intenzione.

Ecco il programma della media dopo la revisione. La verifica dell'array vuoto avviene prima della divisione; la somma funziona con qualunque lunghezza positiva:

public class MediaVotiConCiclo {
    public static void main(String[] args) {
        int[] voti = {24, 27, 30};
        if (voti.length == 0) {
            System.out.println("Nessun voto disponibile");
            return;
        }

        int somma = 0;
        for (int voto : voti) {
            somma += voto;
        }
        double media = (double) somma / voti.length;
        System.out.println("Media: " + media);
    }
}

return termina qui il metodo main quando non ci sono voti. Il caso normale prosegue fino alla stampa. L'output per i tre voti dell'esempio è ancora Media: 27.0; se aggiungi un quarto voto, non devi riscrivere la somma.

Tornare alla tavola della dama

Nel capitolo sugli array avevamo lasciato in sospeso una tavola di otto righe e otto colonne. Le caselle utilizzabili sono alternate; nelle prime tre righe mettiamo le pedine bianche, nelle ultime tre le nere. Con due cicli for annidati possiamo applicare la stessa regola a ogni posizione, invece di copiare molte assegnazioni simili. L'operatore % ci dice se la somma degli indici è pari o dispari.

public class DamaIniziale {
    public static void main(String[] args) {
        String[][] caselle = new String[8][8];
        int bianche = 0;
        int nere = 0;

        for (int riga = 0; riga < caselle.length; riga++) {
            for (int colonna = 0; colonna < caselle[riga].length; colonna++) {
                boolean giocabile = (riga + colonna) % 2 == 1;
                if (giocabile && riga < 3) {
                    caselle[riga][colonna] = "bianca";
                    bianche++;
                } else if (giocabile && riga >= 5) {
                    caselle[riga][colonna] = "nera";
                    nere++;
                }
            }
        }

        System.out.println("Bianche: " + bianche + ", nere: " + nere);
    }
}

Le due righe centrali restano senza pedine; anche le caselle non utilizzabili rimangono null. La condizione giocabile evita di collocare una pedina in ogni casella della riga. Il risultato atteso è Bianche: 12, nere: 12: tre righe per colore, quattro caselle utilizzabili in ogni riga. Questo controllo numerico è piccolo, ma scopre subito un errore nella parità delle caselle o nel confine fra le righe.

break, continue e return

break interrompe il ciclo o lo switch che lo contiene nella forma in cui è previsto. continue salta il resto dell'iterazione corrente e torna al controllo o all'aggiornamento del ciclo. return esce dal metodo e, se il metodo produce un risultato, restituisce quel valore. Sono tre trasferimenti di controllo diversi. Prima di usarli, chiediti che cosa deve accadere dopo: ignorare un solo elemento, terminare una ricerca o terminare l'intera operazione?

Per esempio, durante una ricerca in un array un break può fermare il ciclo appena troviamo l'elemento richiesto. Se invece vogliamo ignorare i valori negativi e continuare la somma dei successivi, continue può esprimere la scelta, anche se talvolta un if rende il codice più semplice. Etichette e interruzioni di cicli annidati esistono, ma in un programma molto annidato è spesso più chiaro estrarre un metodo con un nome significativo.

Nella forma classica di switch, break interrompe lo switch; in un ciclo interrompe il ciclo più interno che lo contiene. continue non serve a «passare al prossimo case» di uno switch: riguarda un ciclo e ne avvia la successiva iterazione secondo le sue regole. return termina il metodo corrente, anche se compare dentro un ciclo. Prima di usare uno dei tre, nomina mentalmente il costrutto da cui vuoi uscire. Se la risposta è vaga, la riga sarà difficile da mantenere.

Consideriamo un esempio con continue che conta i numeri pari da 0 a 20. Vale la pena verificare i confini, perché un contatore inizializzato a -1 e incrementato all'inizio del corpo visita prima 0 e poi, all'ultimo giro, anche 20: i pari contati sono undici, non dieci. Se volevamo l'intervallo da 1 a 20, era meglio cominciare da 1 in un for con condizione numero <= 20; i pari diventano dieci. Una descrizione come «tra 1 e 20» deve quindi specificare se gli estremi sono inclusi e trovare riscontro nel codice. continue può saltare l'incremento del contatore se lo collochiamo nel punto sbagliato di un while, creando un ciclo senza fine; nel for l'aggiornamento dell'intestazione viene eseguito prima del controllo successivo.

Quando un metodo è dichiarato void, può terminare con return; senza valore, come main nel caso dell'array vuoto. Se il metodo promette un risultato, come int trovaPrimoNegativo(int[] valori), i percorsi che terminano normalmente devono restituire un valore compatibile: return indice; quando trovano una posizione e return -1; al termine della ricerca. Un break al posto di quel primo return uscirebbe soltanto dal ciclo e richiederebbe ancora un'istruzione per consegnare il risultato al chiamante. Distinguere questi due livelli di uscita rende più semplice anche la gestione degli errori: un'eccezione non è un sostituto generico di break quando vogliamo soltanto smettere di cercare.

int sommaNonNegativi = 0;
for (int numero : new int[] {5, -2, 3}) {
    if (numero < 0) {
        continue;
    }
    sommaNonNegativi += numero;
}
System.out.println("Somma non negativi: " + sommaNonNegativi);

Il risultato è 8. Quando numero vale -2, continue salta soltanto l'addizione di quell'iterazione: il ciclo passa poi a 3. Non termina il ciclo e non esce dal metodo. Il frammento è eseguibile nel file di supporto FrammentiControllo.java.

int primoNegativo = -1;
for (int indice = 0; indice < valori.length; indice++) {
    if (valori[indice] < 0) {
        primoNegativo = indice;
        break;
    }
}

Qui -1 significa «nessuna posizione trovata», perché gli indici validi partono da zero. Appena il ciclo incontra un valore negativo conserva l'indice e si ferma; se non ne incontra, il valore iniziale resta invariato. Se il ciclo fosse annidato in un altro, questo break fermerebbe soltanto il ciclo che lo contiene direttamente. Per uscire da tutto il metodo potremmo progettare un metodo di ricerca che usa return, invece di dipendere da un'etichetta difficile da seguire.

Prova tu

Parti da MediaVotiConCiclo e prova tre casi: tre voti, quattro voti e un array vuoto. Prima di eseguire il programma scrivi che cosa ti aspetti. Il lavoro è completato quando il caso vuoto non provoca una divisione per zero, gli altri due calcolano la media corretta e sai spiegare quale istruzione impedisce di accedere a una posizione fuori intervallo. Come autoverifica, confronta while e do–while: quale dei due può non eseguire mai il corpo, e perché?

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 ↑