Cambiavalute#
La traccia#
Scopo della prova è progettare e implementare una gerarchia di oggetti utili a rappresentare il comportamento di un cambiavalute.
Valute e importi#
Una valuta è caratterizzata da un nome (non vuoto) e da un simbolo (carattere), per semplicità considereremo solo le seguenti valute:
Dollaro ($)
Euro (€)
Franco (₣)
Lira (₺)
Rupia (₹)
Sterlina (£)
Yen (¥)
Le valute sono definite nell’ordine in cui sono elencate qui; tale ordine sarà rilevante quando si tratterà di enumerare importi di valute differenti.
Un importo è caratterizzato da un valore (espresso in unità e centesimi) e da una valuta; sono ad esempio importi €-3, $123.32, ₹1000000. Due importi della stessa valuta possono essere sommati, sottratti o confrontati (per sapere chi è il maggiore, o minore, o se sono uguali) l’uno con l’altro; può essere comodo poter produrre (data una valuta) l’importo zero in tale valuta, così come determinare se un importo è pari a zero.
Si presti particolare attenzione alla rappresentazione del valore di un importo,
i tipi in virgola mobile (double e float) non sono adatti a causa della
loro incapacità a rappresentare in modo esatto le frazioni decimali. Ad esempio
0.10 + 0.20
in Java è uguale a 0.30000000000000004
il che è “sostanzialmente
corretto” dal punto di vista dei numeri reali, ma non è quello che ci si aspetta
da degli importi! Non sarà ritenuto accettabile risolvere questo problema
effettuando dei troncamenti (neppure nella conversione a stringa).
Cassa (multi-valuta)#
Una cassa è un “contenitore” di importi, in essa è possibile versare importi (in qualunque valuta) e prelevare importi (purché la cassa contenga un importo sufficiente nella valuta richiesta). La cassa deve consentire di iterare sui propri importi diversi da zero nell’ordine in cui sono state definite le valute; tale capacità è particolarmente utile nel fornire una rappresentazione testuale di una cassa che deve contenere solo gli importi diversi da zero in ordine di valuta; ad esempio:
Cassa:
$55.30
€87.79
₣89.50
₹11000.00
£200.00
¥24.95
che non elenca la Lira turca dal momento che non è presente in cassa alcun importo (diverso da zero) in tale valuta.
Tassi di cambio#
Un tasso di cambio è specificato da due importi (con valute diverse) da intendersi “equivalenti” nel senso che è possibile convertire qualunque multiplo del primo importo nello stesso multiplo del secondo.
Ad esempio, il cambio
$2 = €2.40
significa che 2 Dollari sono equivalenti a 2.40 Euro.
Usando le proporzioni imparate alla scuola elementare, un importo in una valuta è quindi in grado di determinare, dato un tasso di cambio tra la sua valuta e un’altra valuta, il suo equivalente nell’altra valuta (se diversa dalla prima).
Nel caso precedente, il tasso comporterà ad esempio che 3 Dollari siano equivalenti 3.60 Euro, o 1 Dollaro a 1.20 Euro.
Cambiavalute#
Un cambiavalute è un servizio dotato di una cassa che, presa conoscenza di una serie di tassi di cambio, può cambiare a richiesta un importo (in una data valuta) in una valuta differente.
In maggior dettaglio:
nel momento in cui inizia a operare, il cambiavalute riceve una serie di importi (di varie valute) che deposita in cassa; successivamente la cassa non può essere più modificata direttamente (ma solo tramite le operazioni di cambio);
quando sta operando può:
ricevere degli aggiornamenti sui tassi di cambio che memorizza; se riceve un tasso di cambio tra due valute di cui ne era già memorizzato uno, il nuovo tasso rimpiazza il precedente;
ricevere delle richieste di cambio di un dato importo in una nuova valuta; se ha memorizzato il tasso di cambio relativo e ha in cassa l’equivalente dell’importo nella nuova valuta procede a: (1) versare in cassa l’importo nella valuta originaria e (2) prelevare l’importo equivalente nella nuova valuta; viceversa segnala opportunamente gli errori relativi alla mancanza di conoscenza del tasso, o di disponibilità dei fondi.
In aggiunta, un cambiavalute deve consentire di iterare sui propri tassi di cambio nell’ordine in cui sono stati aggiornati (ossia inseriti e, nel caso, successivamente modificati); tale capacità è particolarmente utile nel fornire una rappresentazione testuale di un cambiavalute.
Cosa dovete implementare#
Dovete implementare una gerarchia di classi atta a rappresentare il cambiavalute, la cassa, i tassi, gli importi e le valute.
Prestate particolare attenzione a mutabilità e immutabilità, così come (se
necessaria) all’implementazione dei metodi equals
, hashCode
e compareTo
(dall’interfaccia Comparable
) e degli iteratori delle classi che
realizzerete; osservate che, in alcuni casi, record ed enum possono essere
molto comodi in questo lavoro.
La classe di test#
La classe di test deve istanziare un cambiavalute dopo aver letto una sequenza di importi (uno per linea) dal flusso di ingresso standard; quindi, proseguendo nella lettura del flusso di ingresso, deve interpretare i seguenti comandi:
A
seguito da due importi, che comporta l’aggiornamento del tasso di cambio definito da tali importi;C
seguito da un importo e una valuta, che comporta il cambio del primo importo nella seconda valuta e l’emissione del risultato nel flusso d’uscita;P
che comporta l’emissione nel flusso d’uscita dello stato del cambiavalute (dato dall’elenco dei tassi e dal contenuto della cassa).
l’esecuzione termina al termine del flusso d’ingresso.
Se l’esecuzione del comando comporta un errore (ad esempio perché le due valute
nel comando A
o C
sono identiche, oppure perché il cambio richiesto dal
comando C
non è possibile per mancanza di fondi nella cassa, o perché non è
noto il tasso) il programma deve emettere nel flusso d’uscita un opportuno
messaggio d’errore.
Ad esempio, eseguendo la classe di test e avendo nel flusso d’ingresso:
$100
€90.50
£200
¥100
₣80.50
₹10000
₺95000
A $1 = €1.07
C $3 = €
C €10 = ¥
P
A €0.10 = ¥15.01
C €0.50 = ¥
A ₣0.50 = ₺11.53
A ₺200 = $9.54
A ₹100 = ₣1.10
C ₹1000 = ₣
C €1 = €
C ₣10 = ₺
C €100 = ¥
C ₺1000 = $
P
A ₣1.50 = ₺34.80
P
A ₣1.50 = ₣2
C ₣10 = ₺
A €1 = ¥149.46
P
il programma emette
€3.21
ERRORE: Tasso non disponibile
[
Tassi:
$1.00 = €1.07
Cassa:
$103.00
€87.29
₣80.50
₺95000.00
₹10000.00
£200.00
¥100.00
]
¥75.05
₣11.00
ERRORE: Impossibile cambiare tra valute identiche
₺230.60
ERRORE: Fondi non sufficienti
$47.70
[
Tassi:
$1.00 = €1.07
€0.10 = ¥15.01
₣0.50 = ₺11.53
₺200.00 = $9.54
₹100.00 = ₣1.10
Cassa:
$55.30
€87.79
₣79.50
₺95769.40
₹11000.00
£200.00
¥24.95
]
[
Tassi:
$1.00 = €1.07
€0.10 = ¥15.01
₺200.00 = $9.54
₹100.00 = ₣1.10
₣1.50 = ₺34.80
Cassa:
$55.30
€87.79
₣79.50
₺95769.40
₹11000.00
£200.00
¥24.95
]
ERRORE: Impossibile definire un tasso di cambio tra valute identiche
₺208.80
[
Tassi:
$1.00 = €1.07
₺200.00 = $9.54
₹100.00 = ₣1.10
₣1.50 = ₺34.80
€1.00 = ¥149.46
Cassa:
$55.30
€87.79
₣89.50
₺95560.60
₹11000.00
£200.00
¥24.95
]
nel flusso d’uscita.
La soluzione#
Le valute#
Partiamo dalla classe più elementare, che come suggerito è opportunamente realizzata tramite un enum. La rappresentazione è ovvia, avendo due soli attributi (di cui solo il primo non è primitivo e quindi richiede il controllo di non nullità):
public final String nome; public final char simbolo;
Unica accortezza può essere quella di dotare la classe di una mappa che colleghi ciascun simbolo alla corrispondente valuta:
static final Map<Character, Valuta> ENUM_MAP; static { Map<Character, Valuta> map = new HashMap<>(); for (Valuta instance : Valuta.values()) { if (map.containsKey(instance.simbolo)) throw new IllegalArgumentException("Simbolo duplicato."); map.put(instance.simbolo, instance); } ENUM_MAP = Collections.unmodifiableMap(map); }
il popolamento della mappa consente anche di controllare l’unicità dei simboli.
Questo renderà particolarmente elementare ed efficiente implementare il metodo
valueOf
che consente di recuperare (se nota) la valuta corrispondente ad un
dato simbolo:
public static Valuta valueOf(char simbolo) { Valuta valuta = ENUM_MAP.get(simbolo); if (valuta == null) throw new IllegalArgumentException("Il simbolo non corrisponde ad una valuta nota."); return valuta; }
Nota
Una soluzione alternativa all’uso di una enum
(non solo più complessa da
realizzare, ma anche meno efficace) potrebbe essere basata su classe concreta;
in tal caso è però assolutamente necessario che:
la classe implementi in modo opportuno
equals
ehashCode
,il costruttore sia privato e venga usato per popolare una struttura dati interna utile a contenere tutte e soltanto le valute descritte dalla traccia;
ci sia un metodo di fabbricazione che consenta di ottenere una di tali istanze a partire dal simbolo e/o dal nome.
Una classe che consenta all’utente di definire qualunque valuta e non sia in grado di stabilire se due valute siano uguali è inutile ai fini della soluzione.
Gli importi#
La rappresentazione degli importi richiede un minimo di attenzione in più, dal momento che, come è messo in luce dalla traccia, l’uso dei numeri in virgola mobile non costituisce una rappresentazione adeguata.
La cosa più semplice da fare è quella di rappresentare gli importi in
centesimi, ad esempio $3.20
sarà rappresentato come 320
centesimi (di
Dollaro). Questo consente di svolgere tutte le operazioni aritmetiche in modo
elementare.
Oltre al valore così rappresentato, occorrerà memorizzare anche la valuta, con l’accortezza che non sia nulla:
private final int centesimi; public final Valuta valuta;
Piuttosto che rendere pubblico un costruttore che accetti un valore (in virgola mobile), è preferibile avere un costruttore privato che accetti il valore espresso in centesimi e rendere pubblico all’utente un metodo di fabbricazione che costruisca un importo a partire da una stringa:
public static Importo valueOf(String importo) { Objects.requireNonNull(importo, "L'importo non può essere null."); Valuta valuta = Valuta.valueOf(importo.charAt(0)); String[] parti = importo.substring(1).split("\\."); if (parti.length > 2) throw new IllegalArgumentException("L'importo contiene più di un punto decimale."); int centesimi = 0; try { centesimi = parti[0].isEmpty() ? 0 : Integer.parseInt(parti[0]) * 100; if (parti.length == 2) if (parti[1].length() == 2) centesimi += Integer.parseInt(parti[1]); else throw new IllegalArgumentException("La parte dei centesimi deve essere lunga due caratteri."); } catch (NumberFormatException e) { throw new IllegalArgumentException("L'importo contiene parti non convertibili ad un numero."); } return new Importo(centesimi, valuta); }
i controlli sono relativi al formato accettato, ad esempio sono validi:
$-3.20
, $3
e $.20
; nell’implementazione si nota l’utilità del metodo
valueOf
della classe Valuta
(che potrebbe sollevare una eccezione
documentata implicitamente assieme alle altre IllegalArgumentException
del
metodo).
Come atteso, la scelta della rappresentazione, rende elementare la scrittura dei metodi di somma e differenza:
public Importo somma(Importo altro) { if (Objects.requireNonNull(altro, "L'importo non può essere null.").valuta != valuta) throw new IllegalArgumentException("L'importo deve essere nella stessa valuta."); return new Importo(centesimi + altro.centesimi, valuta); } public Importo differenza(Importo altro) { if (Objects.requireNonNull(altro, "L'importo non può essere null.").valuta != valuta) throw new IllegalArgumentException("L'importo deve essere nella stessa valuta."); return new Importo(centesimi - altro.centesimi, valuta); }
unica cosa a cui prestare attenzione, a prescindere dall’ovvia richiesta che
l’altro importo non sia null
, è che i due importi siano espressi nella stessa
valuta.
Può essere comodo avere una istanza di importo con valore zero per ogni valuta, a tale fine si può preparare una mappa che colleghi ciascuna valuta con il relativo importo zero:
private static final Map<Valuta, Importo> ZERO = new EnumMap<>(Valuta.class); static { for (Valuta v : Valuta.values()) ZERO.put(v, new Importo(0, v)); }
che rende banale realizzare il metodo:
public static Importo zero(Valuta valuta) { return ZERO.get(Objects.requireNonNull(valuta, "La valuta non può essere null.")); }
Raccogliere gli importi (che sono immutabili) in una mappa consente di non avere istanze multiple di un valore costante comune come lo zero.
Altri metodi di utilità sono:
public boolean isZero() { return centesimi == 0; } public boolean isPositive() { return centesimi > 0; }
che torneranno comodi nell’implementazione della cassa.
Per finire aggiungiamo un po’ di sovrascritture di metodi che rendono confrontabili gli importi:
@Override public boolean equals(Object obj) { if (!(obj instanceof Importo)) return false; final Importo altro = (Importo) obj; return centesimi == altro.centesimi && valuta == altro.valuta; } @Override public int hashCode() { return Objects.hash(centesimi, valuta); } @Override public int compareTo(Importo o) { if (o.valuta != valuta) throw new ClassCastException("Non è possibile confrontare importi di valute diverse."); return Integer.compare(centesimi, o.centesimi); }
di nuovo, a prescindere dalla nullità, occorre controllare le valute e prestare
attenzione che equals
e compareTo
siano coerenti (ma questo è di nuovo
elementare data la rappresentazione scelta).
Nota
Si potrebbe pensare che la classe BigDecimal possa essere usata per rappresentare gli importi, ciò è vero a patto di occuparsi con estrema attenzione del fatto che il numero di cifre decimali sia sempre esattamente uguale a due; ciò è possibile, ma richiede una conoscenza abbastanza approfondita del funzionamento di tale classe e una notevole attenzione. Questo rende la soluzione proposta, basata sull’uso dei soli centesimi, notevolmente più elementare e pratica da implementare.
La cassa#
La cassa è una collezione di importi, che può essere realizzata tramite una mappa:
private final Map<Valuta, Importo> valuta2importo = new EnumMap<>(Valuta.class);
l’invariante di rappresentazione richiede, oltre alle banali condizioni di non nullità, che l’importo associato ad una valuta sia in tale valuta.
Dal punto di vista dell’implementazione, è possibile usare una EnumMap che offre diversi vantaggi:
non consente di inserire chiavi
null
(che non avrebbero senso);è iterabile nell’ordine in cui sono definiti gli elementi dell’enum (che torna utile per implementare l’iteratore richiesto dalla traccia);
per rendere elementare l’implementazione dell’iteratore, l’unica accortezza è aggiungere all’invariante di rappresentazione il vincolo che la mappa non contenga mai importi di valore zero.
Il primo metodo da implementare è quello che consente di conoscere il totale per una data valuta:
public Importo totale(Valuta valuta) { Importo totale = valuta2importo.get(Objects.requireNonNull(valuta, "La valuta non può essere null.")); return totale == null ? Importo.zero(valuta) : totale; }
poiché non è errato chiedere il totale per una valuta non presente in cassa, gestiremo questo caso restituendo un importo a valore zero (senza segnalare eccezione).
Aggiungere fondi è semplice:
public void versa(Importo importo) { if (!Objects.requireNonNull(importo, "L'importo non può essere null.").isZero()) return; if (!importo.isPositive()) throw new IllegalArgumentException("Non si possono depositare importi negativi."); valuta2importo.put(importo.valuta, totale(importo.valuta).somma(importo)); }
oltre alla nullità dell’importo o alla negatività del suo valore (che causeranno una eccezione), eviteremo di aggiungere importi di valore pari a zero (per non violare l’invariante di rappresentazione).
Il prelievo è similmente facile:
public void preleva(Importo importo) { if (!Objects.requireNonNull(importo, "L'importo non può essere null.").isZero()) return; if (!importo.isPositive()) throw new IllegalArgumentException("Non si possono depositare importi negativi."); final Importo resto = totale(importo.valuta).differenza(importo); if (resto.isZero()) valuta2importo.remove(resto.valuta); else if (!resto.isPositive()) throw new IllegalArgumentException("La cassa non contiene abbastanza fondi."); else valuta2importo.put(importo.valuta, resto); }
di nuovo, oltre alla nullità dell’importo o alla negatività del suo valore (che causeranno una eccezione), dovremo prestare attenzione al caso in cui il prelievo ecceda la disponibilità (fatto che segnaleremo con una eccezione), o annulli il totale in cassa: in tal caso dovremo eliminare il valore relativo alla valuta (sempre per non violare l’invariante di rappresentazione).
A questo punto l’iteratore è del tutto banale da scrivere:
@Override public Iterator<Importo> iterator() { return Collections.unmodifiableCollection(valuta2importo.values()).iterator(); }
I tassi di cambio#
I tassi sono dati da una coppia di importi che, come illustrato nella traccia, consente di calcolare per ciascun importo nella valuta del primo importo del tasso il valore dell’importo equivalente nella seconda valuta del tasso.
Per rappresentare un singolo tasso può bastare un record
, il costruttore:
public Tasso(Importo da, Importo a) { if (!Objects.requireNonNull(da, "Il primo importo non può essere null.").isPositive()) throw new IllegalArgumentException("Il primo importo deve essere positivo."); if (!Objects.requireNonNull(a, "Il secodo importo non può essere null.").isPositive()) throw new IllegalArgumentException("Il secondo importo deve essere positivo."); if (a.valuta.equals(da.valuta)) throw new IllegalArgumentException("Impossibile definire un tasso di cambio tra valute identiche"); this.da = da; this.a = a; }
deve occuparsi di assicurare che valga l’invariante di rappresentazione, ossia che i due importi non siano nulli, siano positivi e siano relativi a due valute diverse.
La competenza di ottenere l’importo equivalente dato un opportuno tasso può essere assegnata all’importo stesso:
public Importo equivalente(Cambi.Tasso tasso) { if (Objects.requireNonNull(tasso, "Il tasso non può essere null.").da().valuta != valuta) throw new IllegalArgumentException("Il tasso non parte dalla valuta di questo importo."); return new Importo( (int)((centesimi / tasso.da().centesimi) * tasso.a().centesimi), tasso.a().valuta ); }
A questo punto può risultare conveniente raccogliere i tassi noti in una classe
(che chiameremo Cambi
di cui i record Tasso
sia interno, per mere questioni
di naming).
La rappresentazione di tale classe è data da una lista:
private final List<Tasso> tassi = new LinkedList<>();
l’invariate, a parte le banali questioni di nullità, dovrà garantire che, per
una assegnata coppia di valute distinte, ci sia al più un tasso tra importi di
tali valute nella lista; dato che i tassi vanno riportati in ordine di aggiunta,
l’implementazione migliore sembra essere quella della LinkedList
che
consentirà di eliminare il vecchio tasso in modo efficiente.
La prima competenza della classe dei cambi è di trovare, se presente, il tasso tra due valute assegnate, tale competenza è una banale ricerca lineare:
public Tasso cerca(Valuta da, Valuta a) { for (Tasso t : tassi) if (t.da().valuta == da && t.a().valuta == a) return t; return null; }
questa competenza sarà utile non solo al cambiavalute, ma anche internamente, per aggiornare i tassi. Tale operazione, infatti, deve dapprima determinare l’evenutale presenza di un tasso tra le medesime valute di quello da aggiornare, che andrà nel caso eliminato, e quindi accodare il nuovo tasso all’elenco di quelli noti:
public boolean aggiorna(Tasso tasso) { Tasso precedente = cerca(tasso.da().valuta, tasso.a().valuta); if (precedente != null) tassi.remove(precedente); tassi.add(tasso); return precedente != null; }
La scelta della rappresentazione e del suo invariante, rendono anche in questo caso del tutto banale la scrittura dell’iteratore che realizzi il comportamento richiesto dalla traccia:
@Override public Iterator<Tasso> iterator() { return Collections.unmodifiableList(tassi).iterator(); }
Il cambiavalute#
Il cambiavalute è una entità molto semplice, ha una sola competenza, quella di
cambiare importi tra valute di cui conosce i tassi di cambio. Il suo stato sarà
data da una istanza di Cassa
e un di Cambi
a cui delegherà il compito di
gestire i suoi fondi e la sua conoscenza dei tassi di cambio:
private final Cassa cassa = new Cassa(); private final Cambi cambi = new Cambi();
Il costruttore riceve una lista di importi (non necessariamente di valute distinte) che verserà in cassa, delegando a quest’ultima il compito di accumulare gli importi per valuta e verificare che non ci siano importi di valore negativo:
public CambiaValute(List<Importo> importi) { for (Importo importo : importi) cassa.versa(importo); }
La competenza di cambiare importi tra valute è data dal metodo:
public Importo cambia(Importo da, Valuta aValuta) { if (da.valuta == aValuta) throw new IllegalArgumentException("Impossibile cambiare tra valute identiche."); Cambi.Tasso t = cambi.cerca(da.valuta, aValuta); if (t == null) throw new IllegalArgumentException("Tasso non disponibile."); Importo a = da.equivalente(t); if (cassa.totale(aValuta).compareTo(a) < 0) throw new IllegalArgumentException("Fondi non sufficienti."); cassa.versa(da); cassa.preleva(a); return a; }
a prescindere dai vari controlli, la logica è molto semplice: calcolato l’importo equivalente si versa in cassa quello da cambiare e si preleva quello equivalente.
Il cambiavalute può aggiornare i suoi tassi, delegando tale compito ai cambi:
public boolean aggiorna(Cambi.Tasso tasso) { return cambi.aggiorna(Objects.requireNonNull(tasso, "Il tasso non può essere null.")); }
Infine, al fine di consentire l’ispezione dello stato del cambiavalute, è sufficiente delegare alla cassa e ai cambi il compito di offrire il loro iteratore:
Iterator<Importo> importi() { return cassa.iterator(); } Iterator<Cambi.Tasso> tassi() { return cambi.iterator(); }
La classe di test#
Svolgere il test è piuttosto elementare, dapprima andranno letti gli importi
(ossia le righe che non iniziano con uno dei caratteri A
, C
o P
):
while (s.hasNextLine()) { line = s.nextLine(); if (line.charAt(0) == 'A' || line.charAt(0) == 'C' || line.charAt(0) == 'P') break; versamenti.add(Importo.valueOf(line)); } CambiaValute cv = new CambiaValute(versamenti);
Quindi si può passare ad elaborare i comandi, uno per riga, avendo l’accortezza
di avvolgere l’esecuzione dei metodi del cambiavalute in un try
/catch
per poter emettere l’eventuale messaggio d’errore contenuto nell’eccezione sollevata:
for (;;) { if (line.length() == 0) return; if (line.length() > 2) { parts = line.substring(2).split("="); parts[0] = parts[0].trim(); parts[1] = parts[1].trim(); } try { switch (line.charAt(0)) { case 'A': cv.aggiorna(new Cambi.Tasso(Importo.valueOf(parts[0]), Importo.valueOf(parts[1]))); break; case 'C': System.out.println( cv.cambia(Importo.valueOf(parts[0]), Valuta.valueOf(parts[1].charAt(0)))); break; case 'P': System.out.println("[\n" + cv + "\n]"); break; } } catch (IllegalArgumentException e) { System.out.println("ERRORE: " + e.getMessage()); } if (!s.hasNextLine()) break; line = s.nextLine(); }