Una scadenza e un pagamento non sono lo stesso genere di dato
Una biblioteca può dire che un prestito scade il 1° novembre. Un registro di pagamenti, invece, deve dire in quale istante è arrivata una conferma. Le due informazioni sembrano «date», ma rispondono a domande diverse. Se memorizziamo entrambe come una stringa, il programma non sa quali operazioni hanno senso. Se le memorizziamo entrambe come millisecondi dall'epoch, costringiamo una scadenza civile a fingere di essere un istante. La scelta del tipo è il primo mattone del capitolo: descrivere il significato dell'informazione prima di calcolarla.
L'epoch di Java è il punto di riferimento 1970-01-01T00:00:00Z sulla linea del tempo. Instant rappresenta un punto su quella linea; è adatto a registrare quando un evento è accaduto e a confrontare due eventi. LocalDate rappresenta una data di calendario senza ora e senza fuso. LocalTime rappresenta un'ora civile senza data; LocalDateTime unisce data e ora civili, ma ancora non individua necessariamente un solo istante. Nella figura 26.1 la domanda del dominio conduce al tipo, non a un elenco di classi da memorizzare.
Un ZoneOffset come +02:00 è una distanza da UTC in un momento. Un ZoneId come Europe/Rome identifica invece regole di un'area geografica, comprese transizioni che cambiano l'offset. OffsetDateTime lega una data e ora civile a un offset; ZonedDateTime le lega a una zona con regole. Se dobbiamo mostrare al lettore l'ora locale di un evento registrato come Instant, applichiamo una zona quando produciamo la vista: evento.atZone(zona). La zona scelta fa parte della richiesta di visualizzazione e non cambia l'istante registrato.
Concetto chiave – Un valore locale non è incompleto per definizione. «La biblioteca chiude alle 18:00» è naturalmente un orario civile; «il pagamento è arrivato alle 18:00» non basta a confrontarlo con un pagamento avvenuto altrove. In un caso il fuso può essere una proprietà del luogo, nell'altro dobbiamo registrare un istante o informazioni sufficienti per ricostruirlo. La completezza di un tipo dipende dalla domanda che l'applicazione deve risolvere.
Ore di calendario e durate trascorse
Duration misura una quantità di tempo sulla linea degli istanti, per esempio novanta minuti effettivamente trascorsi. Period esprime invece anni, mesi e giorni di calendario, per esempio «un mese dalla data di emissione». ChronoUnit consente di nominare unità in varie operazioni. La distinzione appare quando cambia l'ora legale: plusDays(1) su un valore con zona conserva l'ora civile e può attraversare ventitré o venticinque ore reali; plus(Duration.ofHours(24)) conserva ventiquattro ore trascorse e può cambiare l'ora civile mostrata. Nessuna delle due operazioni è universalmente corretta. Per una scadenza «domani alla stessa ora locale» serve la regola civile; per un timer di ventiquattro ore serve la durata.
Una data e ora locale può cadere in un gap, quando l'orologio salta avanti e quell'ora non esiste, oppure in una sovrapposizione, quando l'orologio torna indietro e la stessa ora locale compare due volte. ZoneRules.getValidOffsets(localDateTime) restituisce zero, uno o due offset validi. La figura 26.2 rappresenta i due casi. È un controllo da fare quando un utente sceglie una data e ora locale per un evento che deve avvenire in un preciso istante. Scegliere il primo offset disponibile senza mostrare la decisione all'utente può fissare l'appuntamento un'ora prima di quanto intendesse.
Nel programma di prova, 2026-10-25T02:30 a Roma è una sovrapposizione. La consultazione delle regole restituisce +02:00 e +01:00. Costruiamo due ZonedDateTime con la stessa ora civile ma con offset preferiti diversi; i loro Instant distano sessanta minuti. Il risultato non dipende dall'orologio corrente del computer. Le regole di fuso sono dati del runtime e possono essere aggiornate: per elaborazioni storiche o scadenze a lunga distanza si deve registrare la versione dei dati e la politica di risoluzione se la riproducibilità esatta è un requisito.
Un orologio controllabile
Instant.now() è comodo, ma se lo chiamiamo nel cuore della logica di una fattura rendiamo la prova dipendente dal momento di esecuzione. Clock separa la lettura dell'ora dal calcolo. Clock.fixed fornisce un istante costante, utile per un esempio e per un test; Clock.system(zone) legge l'orologio di sistema. Il programma usa un clock fissato al 2 ottobre 2026 alle 10:00 UTC con zona di Roma. LocalDate.now(prova) produce la data civile di emissione, mentre Instant.now(prova) registra l'evento. La scadenza è emissione.plusDays(30), che nell'esempio stampa 2026-11-01. Se il contratto dicesse «trenta periodi di 24 ore», useremmo un istante e una durata invece di LocalDate.
Formattare non deve cambiare il valore. DateTimeFormatter.ISO_LOCAL_DATE produce una rappresentazione stabile di una data; un pattern personalizzato richiede attenzione a simboli che hanno significati diversi, per esempio anno di calendario e anno di settimana. Il parsing di input esterni dovrebbe usare un formato dichiarato e gestire DateTimeParseException mostrando il campo non valido. Il lettore deve distinguere LocalDate.parse("2026-11-01"), che interpreta un formato ISO, dal tentativo di usare la lingua italiana come regola generale per una data ambigua quale 01/11/26.
Una data ricevuta come 31/02/2026 non è soltanto scritta in un formato insolito: non esiste nel calendario ISO. Il parser deve segnalarla. Per un input che dichiara giorno, mese e anno possiamo creare un DateTimeFormatter con il pattern previsto e una strategia di risoluzione rigorosa; non scegliamo silenziosamente il 3 marzo come «correzione» del 31 febbraio. Anche l'assenza del fuso va trattata come informazione mancante quando il risultato richiesto è un istante. In un'interfaccia la locale può determinare l'ordine dei campi mostrati, ma il contratto di scambio fra processi deve dichiarare un formato non ambiguo.
La prova di un calcolo temporale ha almeno due livelli. Un test unitario usa Clock.fixed per riprodurre la stessa data e ora. Un test di integrazione controlla che il fuso configurato per l'applicazione sia quello richiesto dal dominio, soprattutto quando il software gira su un server impostato in UTC ma serve utenti di città diverse. Se leggiamo implicitamente ZoneId.systemDefault() in un punto nascosto della logica, il test può passare sulla macchina dello sviluppatore e cambiare esito nel server. La zona va scelta o iniettata come dipendenza, non lasciata alla geografia del computer.
Perché un prezzo non è un double
Il tipo double rappresenta numeri in virgola mobile binaria. Molte frazioni decimali non hanno una rappresentazione binaria finita; questo rende double ottimo per numerosi calcoli scientifici ma inadatto come scelta predefinita per importi con regole decimali di arrotondamento. BigDecimal rappresenta un intero non scalato e una scala. In 19.90 l'intero non scalato è 1990 e la scala è 2: il valore è 1990 × 10⁻². Il simbolo × indica moltiplicazione e 10⁻² equivale a dividere per cento. Questo modello conserva esattamente il decimale scritto nella stringa.
La precisione decimale non risolve da sola la concorrenza. Se due richieste cambiano lo stesso saldo, una formula con BigDecimal può calcolare esattamente ciascun importo e perdere ugualmente un aggiornamento. Il capitolo 20A insegna a individuare la decisione atomica nella memoria della JVM; quando il saldo è conservato in un archivio, la coerenza dell'operazione deve essere garantita anche dalla transazione persistente. «Numero esatto» e «aggiornamento indivisibile» sono proprietà diverse, entrambe necessarie secondo il caso.
Costruiamo quindi new BigDecimal("19.90"). new BigDecimal(0.1) parte invece dall'approssimazione binaria già contenuta nel double: non recupera il decimale ideale 0.1. BigDecimal.valueOf(0.1) usa la rappresentazione testuale canonica del double e può essere utile a un confine, ma quando il dato nasce come testo decimale il costruttore da stringa conserva direttamente l'intenzione. Per quantità monetarie occorre anche un'unità di valuta: il numero 19.90 da solo non dice se siano euro o dollari.
La figura 26.3 collega prezzo, aliquota e arrotondamento. Nel caso di studio 19.90 × 0.22 produce 4.378: arrotondiamo l'imposta a due cifre decimali con RoundingMode.HALF_UP, ottenendo 4.38; il totale è 24.28. È una politica didattica dichiarata, non una regola fiscale universale. Se la legge o il contratto impongono arrotondamento per riga, per documento o per valuta, il punto in cui arrotondiamo cambia il risultato. MathContext limita invece la precisione complessiva di un'operazione: non è sinonimo di «due cifre dopo la virgola».
Una divisione decimale può avere un'espansione infinita, come 1 / 3. BigDecimal.ONE.divide(new BigDecimal("3")) senza una politica di arrotondamento lancia ArithmeticException; non restituisce silenziosamente un numero tagliato. La soluzione richiede una scala e un RoundingMode, oppure un MathContext appropriato al dominio. BigInteger risolve un problema diverso: interi che superano l'intervallo dei primitivi. Non gestisce da solo la virgola decimale; a parità di lavoro, oggetti e operazioni arbitrarie possono costare più dei primitivi.
Per vedere la differenza fra scala e precisione, consideriamo 123.456. La scala è tre perché ci sono tre cifre dopo il punto; la precisione è sei perché le cifre significative sono sei. setScale(2, HALF_UP) produce 123.46. Un MathContext con precisione quattro produrrebbe 123.5, cioè quattro cifre significative in totale. Dire «voglio due decimali» e usare una precisione di due è un errore concettuale: si ottiene un numero con due cifre significative, che può perdere anche la parte intera desiderata.
Anche BigInteger richiede una decisione motivata. Un contatore di prestiti che non supererà mai i limiti di long non trae vantaggio dal suo costo aggiuntivo; un identificatore numerico generato da una formula che cresce senza limite prefissato potrebbe richiederlo. BigInteger è immutabile: totale.add(unita) restituisce un nuovo oggetto e non modifica totale. La stessa regola vale per BigDecimal; dimenticare l'assegnazione del risultato lascia invariato il valore, proprio come accade con String.
Caso completo: data e importo della fattura
Il programma seguente riunisce i due confini. La data di emissione e la scadenza sono date civili. La registrazione è un istante. Il totale deriva da testo decimale e da una regola di arrotondamento esplicita. Compiliamo con javac --release 25 FatturaTempo.java ed eseguiamo con java FatturaTempo.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Locale;
public class FatturaTempo {
public static void main(String[] args) {
ZoneId zona = ZoneId.of("Europe/Rome");
Clock prova = Clock.fixed(Instant.parse("2026-10-02T10:00:00Z"), zona);
LocalDate emissione = LocalDate.now(prova);
LocalDate scadenza = emissione.plusDays(30);
System.out.println("Scadenza civile: " + scadenza);
System.out.println("Registrazione UTC: " + Instant.now(prova));
LocalDateTime sovrapposizione = LocalDateTime.of(2026, 10, 25, 2, 30);
System.out.println("Offset possibili: " + zona.getRules()
.getValidOffsets(sovrapposizione));
ZonedDateTime prima = ZonedDateTime.ofLocal(
sovrapposizione, zona, ZoneOffset.ofHours(2));
ZonedDateTime seconda = ZonedDateTime.ofLocal(
sovrapposizione, zona, ZoneOffset.ofHours(1));
System.out.println("Istanti distinti: " +
Duration.between(prima.toInstant(), seconda.toInstant()).toMinutes()
+ " minuti");
BigDecimal prezzo = new BigDecimal("19.90");
BigDecimal aliquota = new BigDecimal("0.22");
BigDecimal imposta = prezzo.multiply(aliquota)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal totale = prezzo.add(imposta);
System.out.println("Totale: " + totale.toPlainString());
System.out.println("Locale di stampa: " + Locale.ITALY);
System.out.println("Stesso numero, scale diverse: "
+ (new BigDecimal("2.0").compareTo(new BigDecimal("2.00")) == 0));
}
}
L'output atteso comprende Scadenza civile: 2026-11-01, Istanti distinti: 60 minuti e Totale: 24.28. L'esempio di sovrapposizione dimostra che la stessa data e ora locale non basta a identificare l'istante. L'ultima riga confronta numericamente 2.0 e 2.00: compareTo restituisce zero, mentre equals restituirebbe false perché considera anche la scala. In una HashSet e in una TreeSet questa differenza può produrre comportamenti diversi di unicità; il capitolo 17 sulle collezioni permette di spiegare perché. Prima di usare BigDecimal come chiave, fissiamo la semantica richiesta dal dominio e normalizziamo se necessario.
Presentare i valori a persone diverse
Locale governa convenzioni di presentazione, come separatore decimale, raggruppamento delle cifre e nomi di date; non cambia il numero o l'istante. NumberFormat può formattare un importo per una locale, mentre Currency identifica una valuta ISO e alcune sue proprietà. Formattare 24.28 per un lettore italiano può produrre 24,28; nel file di interscambio usiamo invece il formato concordato, non quello della macchina che esegue il programma. Per i testi traducibili occorrono risorse linguistiche separate; inserire le parole italiane dentro la logica di calcolo impedirebbe di cambiare lingua senza toccare il dominio.
Il simbolo mostrato non è un'identità sufficiente per la valuta: lo stesso simbolo può comparire in contesti diversi. Una fattura conserva un codice di valuta e un importo secondo le regole applicabili, poi decide come presentarli a un destinatario. NumberFormat.getCurrencyInstance(locale) fornisce una formattazione orientata alla locale; il programma deve anche impostare o conoscere la valuta effettiva del documento, invece di supporre che locale italiana significhi sempre euro. In un trasferimento dati non salviamo la stringa formattata come valore numerico da ricalcolare: essa incorpora separatori e simboli per persone, non un contratto aritmetico.
L'integrazione con sistemi esistenti può imporre Date, Calendar o TimeZone. Non sono il primo modello da insegnare a codice nuovo, ma bisogna conoscere il confine: Date.toInstant() recupera un istante; scegliere una ZoneId è un'altra decisione necessaria per mostrare la data civile. Migrare una data locale registrata come timestamp richiede capire con quale fuso fu creata, non soltanto cambiare il nome della classe.
Verifica e trasferimento
Immaginiamo ora una prenotazione alle 02:30 di una notte di cambio d'ora. Il programma deve chiedere se quell'ora esiste e, se corrisponde a due istanti, quale dei due desidera l'utente. Una semplice conversione che sceglie il comportamento predefinito dell'API può essere accettabile per una schermata informativa, ma non per una prenotazione da cui dipende un pagamento. Il problema indica il tipo e la verifica necessaria.
Supponiamo poi che tre righe di fattura abbiano imposte frazionarie. Calcolare l'imposta su ciascuna riga e sommare importi già arrotondati può dare un risultato diverso dal sommare basi imponibili e arrotondare una sola volta. Il lettore deve scrivere la regola del dominio, applicarla in un punto riconoscibile del codice e costruire un esempio in cui i due risultati divergano. Questa variante verifica che non abbia soltanto imparato a digitare setScale(2, ...).
Riferimenti essenziali. La documentazione Java 25 di
ZoneRulesdefinisce i casi con zero, uno e due offset; non decide per l'applicazione quale scegliere. La specifica diBigDecimaldefinisce rappresentazione, scala, divisione, confronto e arrotondamenti; la regola economica applicabile deve essere fornita dal dominio.