Introduzione
Nel capitolo precedente abbiamo parlato di programmi come collaborazioni fra oggetti. È arrivato il momento di vedere che cosa serve per trasformare una descrizione scritta da noi in qualcosa che il computer possa eseguire. Qui incontreremo il linguaggio Java, il compilatore, la Java Virtual Machine e il JDK. Sono nomi vicini, ma indicano cose diverse. Se li distingui subito, molti messaggi del terminale diventeranno meno misteriosi.
Il linguaggio Java stabilisce come scrivere dichiarazioni ed espressioni e che cosa significano. Java SE indica una piattaforma standard con linguaggio, macchina virtuale e API. Il JDK, Java Development Kit, è una distribuzione degli strumenti necessari per sviluppare: contiene, fra l'altro, il compilatore javac e il comando java per avviare un programma. La JVM, Java Virtual Machine, è l'ambiente che esegue le istruzioni contenute nei file di classe. Puoi pensare al JDK come alla cassetta degli attrezzi; la JVM è una parte essenziale dell'ambiente di esecuzione, non il nome del linguaggio.
Potresti incontrare anche la sigla JRE, Java Runtime Environment. Indica un ambiente pensato per eseguire programmi Java senza gli strumenti completi di sviluppo. La distinzione concettuale resta utile: compilare richiede un compilatore, eseguire richiede un ambiente capace di avviare la JVM e di fornire le API necessarie. Non dedurne però che, oggi, ogni sistema debba installare prima un «JRE separato» e poi un JDK. Formati di distribuzione e pacchetti disponibili dipendono dal fornitore. Per seguire gli esempi del libro scegli un JDK completo e verifica la presenza sia di java sia di javac; quando distribuirai un'applicazione, deciderai quale runtime fornire in base al progetto.
Queste quattro parole rispondono dunque a domande diverse. «Quale sintassi posso scrivere?» riguarda il linguaggio e la versione selezionata. «Quali classi della libreria posso usare?» riguarda le API disponibili alla compilazione e all'esecuzione. «Con quale comando trasformo il sorgente?» riguarda gli strumenti del JDK. «Che cosa interpreta o compila il bytecode mentre il programma gira?» riguarda l'implementazione della JVM. Se un esempio non funziona, separare le domande ci impedisce di cercare la risposta nella scatola sbagliata.
javac compila, java avvia il programma nella JVM, javadoc crea documentazione e le API standard forniscono classi da usare nel codice.Definizione – API. Un'API è l'insieme di operazioni e contratti che un componente mette a disposizione di altri componenti. Le API standard di Java includono, per esempio, classi per stringhe, collezioni, file e comunicazione in rete. Impareremo a usarle quando avremo i concetti necessari per capirne i metodi.
Da Oak a Java 25
Il linguaggio nacque nel contesto di Sun Microsystems con il nome Oak e fu poi presentato come Java. In seguito il progetto e le sue implementazioni hanno avuto molti cambiamenti: nuove funzioni, nuove API, modifiche al modello di distribuzione e un ritmo di rilascio più regolare. Date, tabelle di supporto e licenze commerciali sono importanti quando devi scegliere un JDK per un'organizzazione, ma possono cambiare durante la vita del libro. Per una decisione di acquisto o distribuzione, consulta sempre le condizioni della distribuzione scelta e la relativa documentazione aggiornata.
Questa edizione usa Java 25 come riferimento. Non significa che il codice di Java 8 sia diventato improvvisamente illeggibile. Significa che, quando esistono più modi stabili di risolvere un problema, spiegheremo quello appropriato al linguaggio attuale e segnaleremo il requisito di versione se conta. La specifica della piattaforma Java 25 e il manuale di migrazione del JDK sono i riferimenti per distinguere comportamento corrente e compatibilità storica.
Nota bene – Distribuzione e linguaggio. Diversi fornitori possono distribuire un JDK conforme alla stessa versione di Java. Le licenze, la durata del supporto e i pacchetti disponibili sono questioni da verificare per ciascun prodotto e per la data in cui lo userai. Non si può dedurre una differenza di prestazioni fra due distribuzioni dal solo nome commerciale.
Perché la portabilità era un problema interessante
All'inizio degli anni Novanta il gruppo di Sun che sviluppava Oak pensava anche a dispositivi diversi dai normali computer. Un programma legato alle istruzioni di un solo processore avrebbe richiesto molto lavoro per essere trasferito su un altro apparecchio. Il progetto cambiò direzione mentre cresceva il Web, e Java fu presentato pubblicamente nel 1995. Se ti interessa la storia, vale la pena ricordare questa motivazione: la portabilità non nacque come uno slogan pubblicitario appeso a un linguaggio già finito, ma come risposta alla varietà dei dispositivi e degli ambienti di esecuzione.
Il motto «scrivi una volta, esegui ovunque» esprime bene l'obiettivo, purché lo leggiamo con attenzione. Compilare in bytecode evita di produrre ogni volta istruzioni native per un singolo processore. Occorre però un ambiente compatibile sulla macchina di destinazione, e l'applicazione deve usare soltanto risorse disponibili lì. Un percorso di file scritto per un sistema, una libreria nativa o una funzione non presente nel JDK di destinazione possono interrompere la promessa. Per questo nei prossimi esempi distingueremo sempre la versione del linguaggio, la versione delle API e le risorse esterne richieste dal programma.
Le distribuzioni del JDK possono avere licenze e periodi di supporto diversi. Una tabella di scadenze stampata in un libro può invecchiare rapidamente. Prima di scegliere, consulta il contratto e la pagina di supporto del fornitore: il nome della versione Java non basta a stabilire le condizioni d’uso. Per seguire gli esempi, verifica invece quale JDK è installato e quali caratteristiche stabili appartengono a Java 25. Il primo controllo riguarda il prodotto scelto; il secondo la piattaforma su cui compilare ed eseguire il codice.
Che cosa rende riconoscibile Java
Java usa classi e interfacce per descrivere tipi e comportamenti, ma non è corretto dire che «tutto è un oggetto». Un valore int, per esempio, appartiene a un tipo primitivo e non è un'istanza di una classe. La differenza diventerà concreta quando assegneremo valori a variabili e quando chiameremo metodi. Una variabile ha un tipo noto al compilatore: questo è il senso operativo di tipizzazione statica. Il compilatore può rifiutare alcune combinazioni incompatibili prima che il programma parta, mentre altri errori dipendono da valori conosciuti soltanto durante l'esecuzione.
Definizione – Tipo. Un tipo descrive quali valori può rappresentare una variabile o un'espressione e quali operazioni sono consentite. Java possiede tipi primitivi, tipi riferimento come classi, interfacce e array, e regole precise per conversioni e assegnazioni.
voidindica che un metodo non restituisce un valore; non è una variabile nella quale conservare un dato.
La gestione automatica della memoria libera il programmatore dal rilascio manuale della memoria degli oggetti, ma non chiude per lui ogni risorsa. Java dispone anche di eccezioni per rappresentare condizioni anomale e di strumenti per eseguire più attività in concorrenza. Sono caratteristiche importanti; non rendono però un programma sicuro, corretto o veloce per definizione. Un file lasciato aperto, un'eccezione ignorata o una modifica concorrente senza coordinamento restano problemi del programma, anche se il linguaggio offre strumenti per affrontarli.
Le librerie standard completano il linguaggio: stringhe, collezioni, file, rete e molte altre funzioni arrivano come API documentate. Strumenti esterni, come sistemi di build e librerie di terzi, appartengono all'ecosistema e seguono cicli di rilascio propri. Maven è utile nell'ecosistema Java, ma non è una parola chiave del linguaggio né un componente obbligatorio di ogni JDK. Lo incontreremo quando un programma avrà abbastanza file e dipendenze da giustificare uno strumento di progetto.
Esaminiamo le caratteristiche di Java a partire dalle domande a cui rispondono, perché ogni caratteristica risponde a una domanda che si presenterà durante il libro. La sintassi ci permette di esprimere istruzioni e dichiarazioni; le classi e le interfacce ci aiutano a definire responsabilità e contratti; il sistema dei tipi permette al compilatore di riconoscere molti usi incompatibili prima dell'avvio. Sono strumenti collegati, ma nessuno sostituisce gli altri. Una classe può essere sintatticamente valida e avere responsabilità confuse; una chiamata può rispettare i tipi ed essere logicamente sbagliata. Per questo non useremo «compila» come sinonimo di «funziona».
Le eccezioni danno una forma a condizioni che interrompono il percorso normale di un'operazione. Non significano che Java ripari l'errore da solo: chi progetta l'API deve decidere quali condizioni segnalare e chi la usa deve sapere come reagire. La gestione della memoria toglie al programmatore molte operazioni manuali legate agli oggetti, ma non garantisce che ogni risorsa venga chiusa o che il programma non conservi oggetti inutili. Le API standard offrono componenti già progettati e documentati; usarli bene richiede comunque di leggere i loro contratti, compresi i casi eccezionali. Sono tre esempi di una promessa concreta, accompagnata dal suo limite.
La concorrenza permette a più attività di procedere durante lo stesso intervallo di tempo. Se due attività toccano gli stessi dati, però, l'ordine in cui accadono le operazioni può contare. Non basterà creare due thread per ottenere un programma più rapido o corretto; dovremo distinguere cooperazione, visibilità dei dati e coordinamento. Allo stesso modo, la sicurezza non nasce automaticamente dal fatto che un programma gira nella JVM. La piattaforma offre controlli, API e confini, mentre l'applicazione deve gestire input, permessi e dipendenze secondo il proprio contesto. Presentare Java come «sicuro» senza spiegare rispetto a quale rischio preparerebbe il lettore a fidarsi della cosa sbagliata.
La portabilità, infine, riguarda il rapporto fra sorgente, bytecode e ambienti di esecuzione compatibili; non rende identici sistemi operativi, file e librerie native. La presenza di una comunità ampia, di documentazione e di strumenti di progetto aiuta a trovare soluzioni e a far crescere un'applicazione, ma appartiene all'ecosistema più che alla grammatica del linguaggio. Se confondiamo questi piani, finiamo per attribuire a una parola chiave ciò che dipende da un compilatore, a una JVM ciò che dipende dal sistema operativo o al JDK ciò che fa una libreria esterna. Nei capitoli successivi torneremo su ciascuno di questi temi con esempi, anziché chiederti di imparare un elenco di aggettivi.
Dalla sorgente al programma in esecuzione
Un file sorgente Java è testo, di solito con estensione .java. javac lo legge, controlla che le dichiarazioni rispettino il linguaggio e produce uno o più file .class. Questi file contengono bytecode, istruzioni per la JVM. Il comando java avvia una JVM che carica le classi necessarie ed esegue il programma. Compilare ed eseguire sono quindi due passaggi distinti: un programma può compilare correttamente e fallire durante l'esecuzione, oppure non arrivare neppure a essere eseguito perché il compilatore trova un errore.
Questa architettura aiuta la portabilità, ma non promette che ogni programma si comporti in modo identico ovunque. Un'applicazione può dipendere da file, permessi, codifica dei dati, librerie native o dettagli del sistema operativo. Se una dipendenza cambia, può cambiare anche il risultato. L'affermazione utile è più precisa: il bytecode conforme può essere eseguito da una JVM compatibile, a condizione che siano disponibili le API e le risorse di cui il programma ha bisogno.
Concetto chiave – Tre momenti diversi. Il compilatore controlla e traduce il sorgente; il caricamento rende disponibili le classi necessarie alla JVM; l'esecuzione produce gli effetti del programma. La compilazione non «carica le classi nella JVM». Torneremo sul caricamento delle classi in un capitolo dedicato.
Fermiamoci su un esempio che useremo fra poco. Scriveremo Saluto.java come testo leggibile. Il comando javac produrrà Saluto.class, che non è un documento da modificare con l'editor: contiene il risultato della compilazione. Il comando java cercherà la classe Saluto, avvierà il metodo d'ingresso e farà apparire una riga nel terminale. Se cambi soltanto il testo dentro Saluto.java, il file .class non cambia da solo. Dovrai ricompilare. Questo piccolo dettaglio è la causa di molti «ho corretto il programma, ma continua a stampare la vecchia frase».
Nota bene – Il bytecode non è codice macchina universale. È un formato di istruzioni per la macchina virtuale Java. Le diverse implementazioni della JVM eseguono quel formato sui sistemi che supportano. La JVM può interpretare istruzioni, compilare parti del programma durante l'esecuzione o combinare più strategie; il file
.classresta distinto dall'eseguibile nativo prodotto per una singola architettura.
Memoria e caricamento, senza scorciatoie
Java gestisce automaticamente la memoria degli oggetti mediante un garbage collector. In termini semplici, il runtime può recuperare memoria di oggetti che non sono più raggiungibili dal programma. Questo evita molte operazioni manuali, ma non elimina ogni problema di memoria: un programma può conservare riferimenti inutili, consumare troppe risorse o dover chiudere esplicitamente file e connessioni. Una coppia di oggetti che si riferiscono a vicenda pone una domanda utile: il ciclo impedisce al garbage collector di recuperarli? Vediamolo con precisione.
Un class loader contribuisce a trovare e caricare le definizioni delle classi richieste dal programma. Non significa semplicemente «cercare un .class nella cartella corrente»: la ricerca dipende da class path, moduli, loader e configurazione. Qui basta sapere che, dopo aver compilato, il comando di avvio deve poter trovare le classi necessarie. Il capitolo dedicato al caricamento approfondirà delega, collegamento e inizializzazione.
Due oggetti che si riferiscono l'uno all'altro non restano necessariamente in memoria: per i collector Java conta la raggiungibilità dal programma. Immagina A che contiene un riferimento a B e B che ne contiene uno ad A: se nessun riferimento raggiungibile dal programma conduce più a nessuno dei due, il ciclo non li rende automaticamente vivi. Viceversa, una lista conservata per errore in un campo ancora raggiungibile può trattenere migliaia di oggetti anche senza alcun ciclo. Non basta quindi contare le frecce fra gli oggetti; occorre chiedersi se esiste un percorso a partire da ciò che il programma può ancora usare.
| Situazione | Percorso dal programma | Conseguenza per la comprensione |
|---|---|---|
Una variabile ancora utilizzabile indica A; A indica B e B indica A. |
Esiste: programma → A → B. |
Il ciclo è raggiungibile; entrambi gli oggetti restano utilizzabili attraverso quel percorso. |
Nessuna radice raggiungibile conduce ad A o B, anche se si indicano a vicenda. |
Non esiste. | I due riferimenti interni non costituiscono da soli una ragione per conservarli. |
La tabella rende visibile la raggiungibilità degli oggetti senza far dipendere il ragionamento da un particolare algoritmo di raccolta. Una radice è, in questa spiegazione introduttiva, un punto da cui il runtime può ancora raggiungere oggetti usati dal programma, per esempio riferimenti disponibili durante l'esecuzione. Il momento esatto della raccolta non è promesso: «può essere recuperato» non significa «sarà recuperato subito». La documentazione Java 25 sulla raggiungibilità dà la definizione formale; quando studieremo la memoria in dettaglio distingueremo anche le diverse aree e le scelte delle implementazioni, ma la domanda decisiva per il ciclo è già risolta qui.
Un file aperto chiarisce un limite diverso. Se il programma smette di usare l'oggetto Java che rappresenta quel file, la memoria dell'oggetto potrà essere recuperata secondo le regole del runtime. La risorsa del sistema operativo, però, ha un proprio ciclo di vita: il programma deve chiuderla nel modo previsto dall'API. Più avanti useremo try con risorse proprio per rendere esplicita questa responsabilità. Memoria automatica e gestione delle risorse non sono la stessa promessa.
Anche il caricamento delle classi merita un'immagine corretta. Quando scriviamo java -cp build Saluto, indichiamo al comando dove cercare le classi dell'applicazione. Il runtime ha già modo di accedere alle proprie classi della piattaforma; non dobbiamo elencarle una per una in CLASSPATH. L'ambiente può caricare altre classi quando servono. Evitiamo perciò di memorizzare una sequenza fissa del tipo «prima tutte le classi di base, poi tutte le librerie, infine tutte le classi utente»: il comportamento reale dipende dalle richieste del programma e dalla configurazione.
Preparare il JDK
Installa una distribuzione di JDK 25 per il tuo sistema operativo seguendo le istruzioni del fornitore scelto. Dopo l'installazione apri un terminale nuovo ed esegui:
java --version
javac --version
Il primo comando controlla l'avvio dell'ambiente Java; il secondo controlla la presenza del compilatore. Se java funziona ma javac non viene trovato, potresti avere un ambiente di sola esecuzione oppure un percorso dei comandi incompleto. Se entrambi mancano, verifica l'installazione e la variabile d'ambiente usata dal sistema per cercare gli eseguibili. Il messaggio esatto dipende dal sistema operativo: per questo le vecchie schermate di un gestore di pacchetti non sono una guida affidabile per tutti i lettori.
Buona pratica – Registrare l'ambiente. Quando un esempio non dà il risultato previsto, annota la versione mostrata dai due comandi e il comando che hai eseguito. Sono i primi dati utili per riprodurre il problema. Una schermata senza il comando completo o senza la versione è molto meno utile.
Trovare il programma che il terminale sta usando
Se sul computer sono presenti più JDK, il comando java --version rivela quale eseguibile viene scelto in quel terminale, non necessariamente quale JDK hai installato per ultimo. La ricerca passa attraverso il percorso configurato nell'ambiente. Su macOS e Linux puoi usare command -v java e command -v javac per vedere i percorsi risolti; su Windows puoi usare where java e where javac. Se i due comandi puntano a installazioni diverse, la compilazione e l'esecuzione potrebbero usare versioni non allineate. Prima di modificare variabili d'ambiente alla cieca, annota entrambi i percorsi e confrontali con la directory del JDK che intendi utilizzare.
Installare tramite il gestore dei pacchetti del sistema può essere comodo, e un pacchetto JDK deve includere javac. I nomi dei pacchetti e le versioni disponibili cambiano fra distribuzioni e nel tempo. Per questo un lettore Fedora, Ubuntu, Windows o macOS deve partire dalla documentazione del proprio fornitore e poi fare lo stesso controllo verificabile: java --version, javac --version, compilazione di un file e avvio del risultato. La conferma non è «il programma di installazione ha finito», ma «riesco a compilare ed eseguire».
Il JDK comprende altri strumenti. jshell permette di provare piccoli frammenti senza preparare ogni volta un progetto; javadoc genera documentazione da commenti strutturati; jar raccoglie classi e risorse in un archivio; javap aiuta a osservare la struttura di un file di classe. Li useremo quando risponderanno a una domanda concreta. Per ora bastano javac e java.
Le opzioni di javac che useremo davvero
Il manuale ufficiale del compilatore distingue i file sorgente, la destinazione dei file compilati e i luoghi nei quali cercare classi già disponibili. Se non specifichi -d, il file .class viene normalmente scritto accanto al sorgente. Noi scegliamo -d build per tenere separati i file che modifichiamo da quelli generati. È una distinzione pratica: quando una prova non funziona, sappiamo che cosa cancellare e ricostruire senza toccare il sorgente.
Con -cp o --class-path indichiamo dove cercare classi e librerie necessarie; --source-path riguarda la ricerca di altri sorgenti. Nei nostri primi esempi tutto sta in una cartella e queste opzioni sono poco visibili. Quando organizzeremo il codice in package, il nome del package corrisponderà a una gerarchia di directory e la destinazione indicata da -d ne rispecchierà la struttura. Se il compilatore non trova una classe, la domanda giusta non è subito «Java è rotto?», ma «dove dovrebbe cercare quella classe, e con quale nome completo?».
Se dobbiamo compilare molti sorgenti, possiamo passarli tutti al comando oppure scrivere i nomi in un file di argomenti e anteporre @ al nome del file. Per esempio, un file sorgenti.txt che contiene Saluto.java e Argomenti.java, una riga per nome, può essere passato come javac --release 25 -d build @sorgenti.txt. Il carattere @ dice a javac di leggere gli argomenti da quel file; non fa parte del nome di una classe. Questo meccanismo evita righe di comando molto lunghe.
Leggiamo il comando dall'inizio, senza saltare un pezzo. javac è il programma che stiamo avviando. --release 25 chiede di compilare per la release indicata, controllando anche le API ammesse per quel livello; non installa Java 25 su un altro computer. -d build indica la radice sotto cui scrivere i file compilati: se il sorgente dichiara un package, il compilatore crea sotto quella radice la gerarchia corrispondente. @sorgenti.txt fornisce la lista dei file da trattare. Se sorgenti.txt non si trova nella cartella da cui esegui il comando, il problema non è nella sintassi di una classe Java: è nel percorso dell'argomento passato a javac.
L'opzione --source-path risponde a una domanda diversa da -d. La prima indica dove cercare ulteriori sorgenti; la seconda indica dove scrivere le classi prodotte. L'opzione -cp riguarda invece classi e archivi già disponibili come dipendenze. In un progetto con una sola classe non percepiamo ancora la differenza, ma quando un file usa un altro tipo vedremo perché il compilatore deve saperlo trovare. Il class path usato per compilare e quello usato per avviare un programma vanno entrambi considerati: riuscire a compilare contro una libreria non garantisce che quella libreria sia disponibile quando il programma parte.
Nota bene – Dove guardare dopo un errore. Se manca un sorgente, controlla la cartella di lavoro e il nome scritto nel comando. Se manca un tipo durante la compilazione, controlla package, visibilità, source path e class path. Se la compilazione riesce ma l'avvio non trova una classe, controlla il class path passato a
javae il nome qualificato richiesto. Scrivere il messaggio esatto e il comando eseguito accanto al problema è molto più efficace che cambiare più opzioni alla cieca.
La prima applicazione
Ho scritto il classico «Hello world» tante volte, in tanti linguaggi, che voglio spezzare il rito con una prima applicazione da osservare davvero. Il messaggio dice «Ciao, Java 25!», ma la domanda non è se abbiamo saputo copiare una frase. Vogliamo capire quali file cambiano quando compiliamo, quale classe viene avviata e come riconoscere un errore in ciascun passaggio.
Creiamo un file chiamato Saluto.java con il contenuto seguente. Non è necessario conoscere già ogni parola: subito dopo leggeremo il programma dall'esterno verso l'interno.
/** Mostra un saluto e permette di verificare compilazione ed esecuzione. */
public class Saluto {
public static void main(String[] args) {
System.out.println("Ciao, Java 25!");
}
}
Il nome della classe pubblica, Saluto, corrisponde al nome del file senza .java. Le parentesi graffe racchiudono prima la classe e poi il metodo main. Il metodo è il punto da cui questa applicazione comincia quando la avviamo. System.out.println scrive una riga nel terminale. Le parole public, static e void hanno significati precisi: per adesso trattale come parte della forma di questo punto di ingresso; le riprenderemo quando studieremo metodi e visibilità.
La lunghezza di un sorgente può aiutarci a riconoscere una responsabilità diventata troppo ampia, ma non costituisce una regola di correttezza. Non esiste una soglia del linguaggio, di 500 o 2.000 righe, oltre la quale una classe smette di essere corretta. Se un file cresce tanto da rendere difficili lettura e modifiche, chiediamoci quali responsabilità vi convivono e se alcune meritano un tipo separato. Il nome del file deve invece rispettare una regola concreta: se contiene una classe pubblica Saluto, il sorgente si chiama Saluto.java, con le stesse maiuscole. Il sistema operativo può trattare i nomi dei file in modi diversi, ma affidarsi a una differenza di maiuscole invisibile su una macchina crea problemi quando il progetto si sposta.
Nella cartella che contiene il file, esegui:
javac --release 25 -d build Saluto.java
java -cp build Saluto
Il primo comando chiede a javac di compilare per Java 25 e di mettere i file prodotti nella cartella build. L'opzione --release controlla versione del linguaggio, formato del bytecode e API disponibili per la release indicata; non installa quella versione su un'altra macchina. Il secondo comando indica con -cp dove cercare la classe e la avvia usando il nome Saluto, senza .class. L'output atteso è Ciao, Java 25! seguito da un a capo. Il manuale di javac documenta le opzioni usate qui.
javac da ciò che fa java.Se scrivi javac Saluto senza l'estensione del file, il compilatore non riceve il sorgente che intendevi indicare. Se scrivi java Saluto.class, il comando di avvio riceve un nome di classe non corretto per questa forma. Un errore di questo tipo è una buona occasione per osservare il messaggio, correggere un solo dettaglio e riprovare.
Anche il momento in cui compare l'errore aiuta a capire dove cercare. Se javac segnala una parentesi mancante, il programma non è stato ancora avviato: correggi il sorgente e ricompila. Se javac termina senza errori ma java non trova la classe principale, guarda il nome passato al comando e la radice del class path; cambiare il contenuto di println non risolve quel problema. Se invece il programma parte, stampa una riga e poi mostra un'eccezione, classi e punto di ingresso erano stati trovati: ora bisogna seguire la traccia dell'errore nell'esecuzione. Sono tre diagnosi diverse che nel terminale possono sembrare tutte «Java non funziona».
Facciamo una prova controllata. Compila Saluto.java e verifica che build/Saluto.class sia stato creato. Cambia il testo del saluto nel sorgente, ma avvia senza ricompilare: vedrai ancora il testo precedente. Ora ricompila e avvia di nuovo. Non è un comportamento capriccioso della JVM: stavi eseguendo il bytecode vecchio, mentre la modifica era rimasta soltanto nel file sorgente. Ripetere questo esperimento una volta è più utile che memorizzare la frase «bisogna compilare» senza aver visto quali file cambiano davvero.
Possiamo rompere la tradizione di iniziare sempre da «Hello world» e chiamare il programma LaMiaPrimaApplicazione. Il saluto resta corto per isolare la compilazione: scegli una frase tua e guarda quando cambia l'output. Il nome del file deve seguire esattamente quello della classe pubblica, comprese maiuscole e minuscole. Su alcuni filesystem un errore nel maiuscolo può essere meno evidente che su altri; la regola del linguaggio e degli strumenti, però, non va affidata alla tolleranza del disco.
Argomenti: dal caso minimo al caso completo
L'esempio con args[0] ci fa vedere un solo valore. Proviamo ora un programma che funziona con qualunque numero di argomenti, anche zero. Il file completo si chiama TuttiGliArgomenti.java.
/** Mostra nell'ordine tutti gli argomenti ricevuti. */
public class TuttiGliArgomenti {
public static void main(String[] args) {
for (int i = 0; i < args.length; i++) {
System.out.println("Argomento " + (i + 1) + " = " + args[i]);
}
}
}
Il ciclo for parte da i = 0, ripete il corpo finché i è minore di args.length e aumenta i dopo ogni passaggio. args.length è il numero di elementi presenti; l'ultimo indice valido è quindi args.length - 1. Nell'output usiamo i + 1 soltanto per numerare gli argomenti come farebbe una persona: internamente il primo elemento resta args[0]. Se l'array è vuoto, la condizione 0 < 0 è falsa subito e il programma termina senza stampare nulla, evitando l'errore dell'esempio minimo.
Compila con javac --release 25 -d build TuttiGliArgomenti.java, poi esegui java -cp build TuttiGliArgomenti primo secondo terzo quarto. Dovresti leggere quattro righe, dalla prima Argomento 1 = primo all'ultima Argomento 4 = quarto. Prova anche java -cp build TuttiGliArgomenti senza parole aggiuntive e spiega perché non compare alcuna riga. Quando studieremo i cicli, torneremo sulla stessa istruzione con maggiore dettaglio.
Il parametro String[] args contiene gli argomenti passati dopo il nome della classe sulla riga di comando. Nel nostro programma non li usiamo. Proviamo ora a leggerne uno con un esperimento minimo. Crea Argomenti.java:
/** Mostra il primo argomento ricevuto dalla riga di comando. */
public class Argomenti {
public static void main(String[] args) {
System.out.println("Primo argomento: " + args[0]);
}
}
Compilalo con javac --release 25 -d build Argomenti.java, poi esegui java -cp build Argomenti primo. Vedrai Primo argomento: primo. La parola primo dopo il nome della classe arriva al programma come testo. args è un array, una sequenza di elementi che approfondiremo nel capitolo 4; [0] indica il primo elemento, perché gli indici partono da zero. Se avvii questo esempio senza l'argomento, il programma fallisce tentando di leggere un elemento inesistente. Nel capitolo 5 impareremo a controllare args.length prima di accedere all'array. La documentazione del comando java conferma che gli argomenti scritti dopo il nome della classe sono passati al metodo main.
Commenti e documentazione
Un commento // occupa il resto della riga. Un commento /* ... */ può coprire più righe. Il compilatore non li esegue. Servono a spiegare un motivo o una regola che il codice, da solo, non rende evidente. Scrivere // stampa il saluto accanto a println("Ciao") aggiunge poco; spiegare perché un valore deve rispettare una condizione può evitare un errore futuro.
Prova a leggere questi tre casi come un futuro collega che deve modificare il programma. // aumenta i accanto a i++ ripete ciò che il codice mostra. // gli indici partono da zero vicino al primo accesso a un array può invece aiutare un lettore nuovo, ma diventa ridondante quando il concetto è stato imparato. Una nota come // manteniamo l'ordine originale perché la ricevuta segue l'ordine di inserimento conserva una ragione progettuale che l'istruzione da sola non esprime. I commenti migliori non compensano nomi confusi: prima rendiamo il codice leggibile, poi documentiamo il motivo delle scelte non ovvie.
I tre delimitatori non sono intercambiabili. // termina con la riga; /* ... */ delimita un blocco ordinario; /** ... */ introduce un commento che lo strumento javadoc può associare a una dichiarazione. Un blocco di commento non va usato per «spegnere» temporaneamente codice che contiene già altri commenti a blocco: i delimitatori non si annidano nel modo che potresti aspettarti. Per una prova breve è più chiaro usare il controllo di versione o intervenire direttamente sulle righe interessate.
Il commento che precede Saluto comincia con /**: è un documentation comment. Lo strumento javadoc può usarlo per produrre documentazione consultabile. In classi destinate ad altri programmatori, una buona documentazione dice che cosa promette un metodo, che cosa significano i parametri e quali casi eccezionali deve considerare chi lo usa. I tag come @param e @return aiutano a strutturare l'informazione, ma non devono ripetere il nome del parametro senza spiegare nulla. Il manuale di javadoc e la specifica dei commenti chiariscono la forma riconosciuta dal JDK.
Consideriamo un piccolo metodo che calcola il prezzo dopo uno sconto. Un commento utile direbbe se lo sconto è una percentuale fra 0 e 100, come viene arrotondato il risultato e che cosa accade per un valore fuori intervallo. La frase «calcola il prezzo scontato» è meno utile del nome stesso del metodo. Nel commento strutturato @param percentuale dovrebbe descrivere il significato e il dominio del parametro; @return dovrebbe descrivere il risultato, non limitarsi a scrivere «ritorna un numero»; @throws dovrebbe indicare in quale circostanza un'eccezione può essere lanciata. Sono promesse per chi userà il metodo, e devono restare allineate al codice.
Per leggere la documentazione quotidiana, conviene conoscere questi tag: @param descrive un parametro, @return il valore restituito, @throws una condizione eccezionale, @see un riferimento utile e @since l'introduzione di una parte dell'API. @deprecated accompagna un elemento che non si dovrebbe più scegliere per nuovo codice, ma l'annotazione Java @Deprecated ha un ruolo distinto nel segnalare la deprecazione agli strumenti. Non inventare @return void: un metodo void non ha un valore di ritorno da documentare. Il tag corretto è @version, non @versione, se decidiamo di usarlo.
Per leggere un commento di documentazione senza trattarlo come una raccolta di formule da copiare, tieni accanto questa tabella. Le descrizioni indicano il contenuto da spiegare: il solo tag non aggiunge conoscenza a chi userà il metodo. La specifica Javadoc del JDK 25 chiarisce anche in quali dichiarazioni ciascun tag è ammesso.
| Tag | Che cosa deve capire chi legge | Un errore da evitare |
|---|---|---|
@param nome |
Significato di un parametro, valori ammessi e unità se rilevanti. | Ripetere soltanto «il parametro nome». |
@return |
Significato del risultato e casi particolari. | Scrivere @return void per un metodo senza risultato. |
@throws Eccezione |
Condizione in cui il metodo segnala il problema. | Elencare un'eccezione senza dire quando si presenta. |
@see |
Un elemento correlato che aiuta a usare l'API. | Rimandare a una pagina senza spiegare la relazione. |
@since |
Versione in cui l'elemento documentato è stato introdotto. | Confonderla con la versione corrente del progetto. |
@version |
Versione del software documentato, quando ha senso esporla. | Scrivere @versione, che non è il tag standard. |
@deprecated |
Motivo per cui l'elemento è superato e alternativa, se esiste. | Limitarsi a «non usare» senza una via praticabile. |
La differenza fra @since e @version sembra piccola finché non aggiorni una libreria. Se una classe è comparsa nella versione 2 del tuo progetto ed è ancora presente nella versione 5, @since 2 continua a raccontare quando è arrivata; @version 5 può descrivere la versione del software documentato. Cambiare ogni @since a ogni rilascio cancellerebbe la storia utile a chi deve sapere da quando può usare quella classe. Il tag @version, invece, viene mostrato dal doclet standard quando si richiede la relativa opzione; non bisogna presumere che ogni informazione scritta nel sorgente compaia sempre nelle pagine generate.
Per un metodo che restituisce un int, il lettore ha bisogno di sapere se il valore rappresenta euro, centesimi, secondi o un conteggio. Per un metodo che riceve una percentuale, deve sapere se 20 significa venti per cento oppure se si attende 0,20. Queste sono informazioni che il tipo da solo non esprime. È qui che @param, @return e, quando una condizione può fallire, @throws diventano parte del contratto dell'API. L'esempio del prezzo scontato non è scelto per insegnare la sintassi dei tag: mostra perché documentare il significato di un numero può evitare errori anche quando il programma compila senza alcun avviso.
Puoi generare la documentazione di una classe pubblica con javadoc -d docs Saluto.java, dalla cartella che contiene il sorgente. L'opzione -d docs indica la destinazione delle pagine prodotte. Apri il file iniziale nella cartella docs e confronta il testo con il commento sorgente. Vedrai subito perché un commento incompleto non può diventare una documentazione completa soltanto perché è stato trasformato in HTML.
Il comando javadoc non è un correttore automatico delle spiegazioni. Se una frase promette che un metodo restituisce sempre un risultato e il codice può invece lanciare un'eccezione, la pagina HTML ripeterà una promessa sbagliata con una veste più elegante. Lo strumento può segnalare vari problemi strutturali e dispone di controlli come DocLint, ma la correttezza del contratto va letta confrontando commento, implementazione ed esempi. Per una libreria destinata ad altri programmatori questa lettura non è un lusso: la documentazione è spesso il primo punto di contatto con il comportamento del codice.
Versione Java – Commenti Javadoc in Markdown. Il JDK 25 riconosce anche commenti di documentazione composti da righe che iniziano con
///, oltre alla forma tradizionale/** ... */. La specifica del doclet standard ne descrive sintassi e limiti. Nei primi esempi manteniamo la forma tradizionale, già presente in molti sorgenti Java; conoscere l'altra forma aiuta a leggere sorgenti più recenti senza scambiare quelle righe per tre commenti ordinari scollegati.
Che cosa fa HotSpot?
HotSpot è una delle implementazioni della JVM. Durante l'esecuzione può osservare il programma e compilare in codice nativo parti che conviene ottimizzare; per questo si parla di compilazione just in time, o JIT. Non cambia il significato del sorgente Java e non implica che ogni programma diventi più veloce in ogni situazione. Prestazioni e consumi si misurano con un carico reale e un ambiente dichiarato. Per imparare a scrivere Java non devi regolare subito il compilatore JIT: è sufficiente distinguere ciò che fa javac prima dell'avvio da ciò che può fare una JVM mentre il programma gira.
Prova a immaginare un'applicazione che ripete milioni di volte la stessa elaborazione e un'altra che parte, stampa una riga e termina. Nel primo caso la JVM può avere tempo e dati per individuare parti eseguite spesso e ottimizzarle; nel secondo, il lavoro necessario per avviare e osservare il programma può pesare più del tempo risparmiato su quella singola riga. «HotSpot» richiama proprio l'attenzione sulle zone calde dell'esecuzione, ma la scelta delle ottimizzazioni è più complessa di un conteggio di chiamate. La JVM può anche rivedere una decisione quando cambiano le condizioni osservate.
La compilazione JIT non fa «uscire» il programma dalla JVM: il codice nativo prodotto durante l'esecuzione rimane gestito dal runtime, che continua a fornire servizi come gestione della memoria, collegamento delle classi ed eccezioni. Anche il confronto assoluto con C++ non regge senza un compito e una misura dichiarati. Per un programmatore che inizia, la regola pratica è semplice: prima costruisci un programma corretto e misurabile; poi, se la velocità è un requisito, osserva dove spende il tempo invece di attribuire ogni differenza alla presenza del bytecode.
Una verifica pratica
Modifica il testo del saluto, ricompila e riesegui. Poi cambia soltanto il sorgente senza ricompilare: che cosa compare? La prova è riuscita se sai spiegare perché il file .class conserva il comportamento precedente finché non viene prodotto di nuovo. Come autoverifica, indica quale comando useresti per conoscere la versione del compilatore, quale file è il sorgente e dove si trova il bytecode dopo l'opzione -d build.
Se la prova non restituisce ciò che ti aspettavi, non cambiare tre cose contemporaneamente. Comincia con javac --version: stai chiamando il compilatore che pensavi di aver installato? Poi ricompila Saluto.java con javac --release 25 -d build Saluto.java e controlla che build/Saluto.class sia stato aggiornato. Infine esegui java -cp build Saluto. Il nome dopo -cp è una directory da cui cercare la classe, mentre Saluto è il nome della classe da avviare; scrivere Saluto.class in quella posizione confonde i due ruoli. Se compare ancora il vecchio messaggio, verifica la directory da cui lanci i comandi e cerca eventuali altre copie di Saluto.class nel class path usato. La diagnosi segue la stessa catena del disegno: sorgente, compilazione, posizione del bytecode, avvio.
Prova anche a fare un errore intenzionale nel sorgente, per esempio togliendo la virgoletta finale da una stringa. javac deve rifiutare il file e segnalare una posizione vicina al problema. Non interpretare un eventuale vecchio .class ancora presente come prova che il nuovo sorgente sia corretto: quel file è il risultato dell'ultima compilazione riuscita, non di quella appena fallita. Dopo aver ripristinato la virgoletta, ricompila e confronta l'output. Questo esercizio distingue un errore di compilazione da un errore durante l'esecuzione meglio di una definizione imparata a memoria.