Java: caratteristiche moderne
Java è un linguaggio che ha saputo reinventarsi nel corso degli anni. A partire dalla versione 8, rilasciata nel 2014, il linguaggio ha introdotto cambiamenti radicali nella sintassi e nel modello di programmazione, avvicinandosi a paradigmi funzionali e dichiarativi senza abbandonare la propria natura orientata agli oggetti. Le versioni successive, fino alla 21 e oltre, hanno consolidato questa evoluzione con funzionalità come i record, le sealed class, il pattern matching, i virtual thread e i blocchi di testo. In questo articolo esploreremo nel dettaglio le caratteristiche moderne di Java, analizzando come ciascuna di esse contribuisca a rendere il codice più conciso, sicuro e performante.
Espressioni lambda e interfacce funzionali
Le espressioni lambda, introdotte in Java 8, rappresentano probabilmente il cambiamento più significativo nella storia recente del linguaggio. Una lambda è essenzialmente una funzione anonima che può essere passata come argomento a un metodo o assegnata a una variabile. Questa caratteristica ha reso possibile adottare uno stile di programmazione funzionale all'interno di un linguaggio tradizionalmente imperativo.
Una lambda si basa sul concetto di interfaccia funzionale, ossia un'interfaccia che dichiara un solo metodo astratto. L'annotazione @FunctionalInterface garantisce a livello di compilazione che l'interfaccia rispetti questo vincolo.
// Definizione di un'interfaccia funzionale personalizzata
@FunctionalInterface
public interface Transformer<T, R> {
R apply(T input);
}
L'utilizzo delle lambda elimina la necessità di creare classi anonime verbose. Il confronto tra la sintassi tradizionale e quella moderna è eloquente.
import java.util.Arrays;
import java.util.List;
import java.util.Comparator;
public class LambdaExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Marco", "Anna", "Luca", "Elena");
// Ordinamento con classe anonima (approccio tradizionale)
names.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareToIgnoreCase(b);
}
});
// Ordinamento con espressione lambda
names.sort((a, b) -> a.compareToIgnoreCase(b));
// Ordinamento con riferimento a metodo
names.sort(String::compareToIgnoreCase);
System.out.println(names);
}
}
Il pacchetto java.util.function fornisce un insieme di interfacce funzionali predefinite che coprono la maggior parte dei casi d'uso: Function<T, R>, Predicate<T>, Consumer<T>, Supplier<T>, UnaryOperator<T> e le loro varianti binarie.
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Consumer;
public class FunctionalInterfacesExample {
public static void main(String[] args) {
// Trasformazione di un valore
Function<String, Integer> length = String::length;
// Verifica di una condizione
Predicate<String> isNotEmpty = s -> !s.isEmpty();
// Esecuzione di un effetto collaterale
Consumer<String> printer = System.out::println;
// Composizione di funzioni
Function<String, String> trim = String::trim;
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> pipeline = trim.andThen(toUpper);
String result = pipeline.apply(" ciao mondo ");
printer.accept(result); // Stampa: CIAO MONDO
}
}
L'API Stream
L'API Stream, introdotta anch'essa in Java 8, consente di elaborare collezioni di dati in modo dichiarativo, componendo operazioni intermedie e terminali in una pipeline. Gli stream non modificano la collezione sorgente e supportano nativamente il parallelismo attraverso il metodo parallelStream().
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamExample {
// Classe di supporto per rappresentare un prodotto
record Product(String name, String category, double price) {}
public static void main(String[] args) {
List<Product> products = List.of(
new Product("Laptop", "Elettronica", 1200.0),
new Product("Mouse", "Elettronica", 25.0),
new Product("Scrivania", "Arredamento", 350.0),
new Product("Sedia", "Arredamento", 450.0),
new Product("Cuffie", "Elettronica", 80.0),
new Product("Lampada", "Arredamento", 60.0)
);
// Filtraggio, trasformazione e raccolta
List<String> expensiveElectronics = products.stream()
.filter(p -> p.category().equals("Elettronica"))
.filter(p -> p.price() > 50.0)
.map(Product::name)
.sorted()
.collect(Collectors.toList());
System.out.println(expensiveElectronics);
// Raggruppamento per categoria con calcolo della media
Map<String, Double> averagePriceByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.averagingDouble(Product::price)
));
averagePriceByCategory.forEach((category, avg) ->
System.out.printf("%s: %.2f€%n", category, avg)
);
// Riduzione personalizzata per calcolare il totale
double total = products.stream()
.mapToDouble(Product::price)
.reduce(0.0, Double::sum);
System.out.printf("Totale: %.2f€%n", total);
}
}
Le operazioni intermedie come filter(), map(), flatMap() e sorted() sono lazy: vengono eseguite solo quando una operazione terminale come collect(), forEach() o reduce() viene invocata. Questo meccanismo consente ottimizzazioni interne come il short-circuit e la fusione delle operazioni.
La classe Optional
La classe Optional<T>, introdotta in Java 8, è un contenitore che può o meno racchiudere un valore non nullo. Il suo scopo principale è rendere esplicita la possibilità che un valore sia assente, eliminando la necessità di controlli manuali su null e riducendo il rischio di NullPointerException.
import java.util.Optional;
import java.util.List;
public class OptionalExample {
record User(String name, String email) {}
public static Optional<User> findUserByEmail(List<User> users, String email) {
// Ricerca dell'utente tramite lo stream
return users.stream()
.filter(u -> u.email().equalsIgnoreCase(email))
.findFirst();
}
public static void main(String[] args) {
List<User> users = List.of(
new User("Marco", "marco@example.com"),
new User("Elena", "elena@example.com")
);
// Utilizzo con orElse per un valore predefinito
String name = findUserByEmail(users, "luca@example.com")
.map(User::name)
.orElse("Utente non trovato");
System.out.println(name);
// Utilizzo con ifPresentOrElse per gestire entrambi i casi
findUserByEmail(users, "marco@example.com")
.ifPresentOrElse(
user -> System.out.println("Trovato: " + user.name()),
() -> System.out.println("Nessun risultato")
);
// Concatenazione con or() per fornire un'alternativa
Optional<User> fallback = findUserByEmail(users, "admin@example.com")
.or(() -> findUserByEmail(users, "marco@example.com"));
fallback.ifPresent(u -> System.out.println("Fallback: " + u.name()));
}
}
I record
Introdotti come funzionalità stabile in Java 16, i record sono classi immutabili progettate per trasportare dati. Il compilatore genera automaticamente il costruttore canonico, i metodi di accesso, equals(), hashCode() e toString(). Questo elimina enormi quantità di codice boilerplate che caratterizzavano i tradizionali POJO.
// Dichiarazione compatta di un record
public record Coordinate(double latitude, double longitude) {
// Costruttore compatto con validazione
public Coordinate {
if (latitude < -90 || latitude > 90) {
throw new IllegalArgumentException("Latitudine non valida: " + latitude);
}
if (longitude < -180 || longitude > 180) {
throw new IllegalArgumentException("Longitudine non valida: " + longitude);
}
}
// Metodo personalizzato per il calcolo della distanza
public double distanceTo(Coordinate other) {
double earthRadius = 6371.0; // Raggio della Terra in chilometri
double dLat = Math.toRadians(other.latitude - this.latitude);
double dLon = Math.toRadians(other.longitude - this.longitude);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(this.latitude))
* Math.cos(Math.toRadians(other.latitude))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadius * c;
}
}
public class RecordExample {
public static void main(String[] args) {
// Creazione di istanze immutabili
var rome = new Coordinate(41.9028, 12.4964);
var milan = new Coordinate(45.4642, 9.1900);
// Accesso ai componenti tramite metodi generati
System.out.printf("Roma: %.4f, %.4f%n", rome.latitude(), rome.longitude());
// Calcolo della distanza
double distance = rome.distanceTo(milan);
System.out.printf("Distanza Roma-Milano: %.2f km%n", distance);
// equals() e toString() generati automaticamente
var romeCopy = new Coordinate(41.9028, 12.4964);
System.out.println(rome.equals(romeCopy)); // Stampa: true
System.out.println(rome); // Stampa: Coordinate[latitude=41.9028, longitude=12.4964]
}
}
I record possono implementare interfacce, essere generici e contenere metodi statici, ma non possono estendere altre classi né essere estesi. Questa limitazione è intenzionale: garantisce che la struttura dei dati sia completamente definita dalla dichiarazione del record.
Le sealed class
Le sealed class, stabilizzate in Java 17, permettono di controllare esplicitamente quali classi possono estendere una determinata classe o implementare una determinata interfaccia. Questo meccanismo crea gerarchie di tipi chiuse, fondamentali per modellare domini in cui l'insieme dei sottotipi è noto e finito.
// Definizione di una gerarchia sigillata per le forme geometriche
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
double perimeter();
}
// Ogni sottotipo deve essere final, sealed o non-sealed
public record Circle(double radius) implements Shape {
public double area() {
return Math.PI * radius * radius;
}
public double perimeter() {
return 2 * Math.PI * radius;
}
}
public record Rectangle(double width, double height) implements Shape {
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
}
public record Triangle(double a, double b, double c) implements Shape {
public double area() {
// Calcolo dell'area con la formula di Erone
double s = (a + b + c) / 2;
return Math.sqrt(s * (s - a) * (s - b) * (s - c));
}
public double perimeter() {
return a + b + c;
}
}
Il vantaggio principale delle sealed class emerge in combinazione con il pattern matching: il compilatore può verificare che tutti i casi siano coperti in un'espressione switch, garantendo l'esaustività senza bisogno di un ramo default.
Pattern matching
Il pattern matching è stato introdotto gradualmente a partire da Java 14 e ha raggiunto la sua forma più completa nelle versioni 17-21. Questa funzionalità permette di decomporre valori complessi in modo conciso ed espressivo, combinando il controllo del tipo con l'estrazione dei dati in un'unica operazione.
Pattern matching per instanceof
Il pattern matching per instanceof, stabilizzato in Java 16, elimina la necessità di eseguire un cast esplicito dopo il controllo del tipo.
public class InstanceofPatternExample {
public static String describe(Object obj) {
// Pattern matching con instanceof: il cast è implicito
if (obj instanceof String s && !s.isEmpty()) {
return "Stringa con %d caratteri".formatted(s.length());
} else if (obj instanceof Integer i && i > 0) {
return "Intero positivo: " + i;
} else if (obj instanceof double[] arr && arr.length > 0) {
return "Array di double con %d elementi".formatted(arr.length);
} else {
return "Tipo sconosciuto: " + obj.getClass().getSimpleName();
}
}
}
Pattern matching per switch
Il pattern matching per switch, stabilizzato in Java 21, consente di utilizzare pattern complessi all'interno delle espressioni switch, incluse guardie con la clausola when.
public class SwitchPatternExample {
// Utilizzo della gerarchia sigillata definita in precedenza
public static String describeShape(Shape shape) {
return switch (shape) {
// Pattern con decostruzione del record e guardia
case Circle c when c.radius() > 100 ->
"Cerchio grande con raggio %.2f".formatted(c.radius());
case Circle c ->
"Cerchio con raggio %.2f e area %.2f".formatted(c.radius(), c.area());
case Rectangle r when r.width() == r.height() ->
"Quadrato con lato %.2f".formatted(r.width());
case Rectangle r ->
"Rettangolo %.2f x %.2f".formatted(r.width(), r.height());
case Triangle t ->
"Triangolo con perimetro %.2f".formatted(t.perimeter());
// Nessun default necessario: la gerarchia è sigillata
};
}
public static void main(String[] args) {
Shape circle = new Circle(5.0);
Shape square = new Rectangle(4.0, 4.0);
Shape triangle = new Triangle(3.0, 4.0, 5.0);
System.out.println(describeShape(circle));
System.out.println(describeShape(square));
System.out.println(describeShape(triangle));
}
}
Record pattern
I record pattern, stabilizzati in Java 21, permettono di decomporre direttamente i componenti di un record all'interno di un pattern, anche in modo annidato.
public class RecordPatternExample {
record Address(String city, String zipCode) {}
record Person(String name, int age, Address address) {}
public static String greet(Object obj) {
return switch (obj) {
// Decostruzione annidata del record
case Person(var name, var age, Address(var city, _))
when age >= 18 ->
"Benvenuto %s da %s".formatted(name, city);
case Person(var name, var age, _) ->
"Ciao %s, hai %d anni".formatted(name, age);
default -> "Oggetto non riconosciuto";
};
}
public static void main(String[] args) {
var person = new Person("Marco", 30, new Address("Roma", "00100"));
System.out.println(greet(person));
}
}
Blocchi di testo
I blocchi di testo (text block), stabilizzati in Java 15, permettono di definire stringhe multilinea in modo leggibile, senza concatenazioni o caratteri di escape per le interruzioni di riga. Sono delimitati da triple virgolette e rispettano l'indentazione del codice sorgente, rimuovendo automaticamente gli spazi iniziali comuni.
public class TextBlockExample {
public static void main(String[] args) {
// Blocco di testo per un documento JSON
String json = """
{
"name": "Marco",
"age": 30,
"city": "Roma",
"skills": ["Java", "Kotlin", "Spring"]
}
""";
System.out.println(json);
// Blocco di testo con interpolazione tramite formatted()
String name = "Elena";
int score = 95;
String report = """
Risultato dell'esame:
=====================
Candidato: %s
Punteggio: %d/100
Esito: %s
""".formatted(name, score, score >= 60 ? "SUPERATO" : "NON SUPERATO");
System.out.println(report);
// Blocco di testo per query SQL
String query = """
SELECT u.name, u.email, COUNT(o.id) AS total_orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.active = true
GROUP BY u.name, u.email
HAVING COUNT(o.id) > 5
ORDER BY total_orders DESC
""";
System.out.println(query);
}
}
Virtual thread
I virtual thread, introdotti come funzionalità stabile in Java 21 all'interno del progetto Loom, rappresentano una rivoluzione nel modello di concorrenza di Java. A differenza dei thread tradizionali della piattaforma, che sono mappati uno a uno sui thread del sistema operativo, i virtual thread sono gestiti dalla JVM e hanno un costo di creazione e gestione drasticamente inferiore. È possibile creare milioni di virtual thread senza esaurire le risorse del sistema.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadExample {
public static void main(String[] args) throws Exception {
Instant start = Instant.now();
// Creazione di un executor con virtual thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Invio di 100.000 task concorrenti
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
// Simulazione di un'operazione di I/O bloccante
Thread.sleep(Duration.ofMillis(100));
return i;
})
);
} // L'executor attende il completamento di tutti i task
Instant end = Instant.now();
long elapsed = Duration.between(start, end).toMillis();
System.out.printf("100.000 task completati in %d ms%n", elapsed);
}
}
I virtual thread sono particolarmente vantaggiosi per applicazioni che gestiscono un elevato numero di operazioni di I/O concorrenti, come server web e microservizi. Il modello di programmazione resta quello tradizionale, basato su thread e blocchi sincronizzati, senza la complessità delle callback o della programmazione reattiva.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class StructuredConcurrencyExample {
record UserProfile(String name, String email) {}
record OrderHistory(int totalOrders) {}
record Dashboard(UserProfile profile, OrderHistory orders) {}
// Simulazione di chiamate a servizi remoti
static UserProfile fetchProfile(long userId) throws InterruptedException {
Thread.sleep(200); // Simulazione di latenza di rete
return new UserProfile("Marco", "marco@example.com");
}
static OrderHistory fetchOrders(long userId) throws InterruptedException {
Thread.sleep(300); // Simulazione di latenza di rete
return new OrderHistory(42);
}
public static Dashboard loadDashboard(long userId) throws Exception {
// Concorrenza strutturata: entrambe le operazioni vengono eseguite in parallelo
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<UserProfile> profileTask = scope.fork(() -> fetchProfile(userId));
Future<OrderHistory> ordersTask = scope.fork(() -> fetchOrders(userId));
// Attesa del completamento di entrambi i task
scope.join();
scope.throwIfFailed();
// Composizione del risultato
return new Dashboard(profileTask.resultNow(), ordersTask.resultNow());
}
}
public static void main(String[] args) throws Exception {
Dashboard dashboard = loadDashboard(1L);
System.out.println("Profilo: " + dashboard.profile().name());
System.out.println("Ordini totali: " + dashboard.orders().totalOrders());
}
}
Inferenza di tipo con var
A partire da Java 10, la parola chiave var consente di dichiarare variabili locali senza specificare esplicitamente il tipo, che viene inferito dal compilatore in base all'espressione di inizializzazione. Questa funzionalità riduce la verbosità senza sacrificare la sicurezza dei tipi, poiché il tipo viene comunque determinato a tempo di compilazione.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class VarExample {
public static void main(String[] args) {
// Il tipo viene inferito come HashMap<String, List<Integer>>
var scores = new HashMap<String, List<Integer>>();
scores.put("Marco", List.of(85, 92, 78));
scores.put("Elena", List.of(91, 88, 95));
// Iterazione con var nel ciclo for-each
for (var entry : scores.entrySet()) {
var name = entry.getKey();
var values = entry.getValue();
var average = values.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
System.out.printf("%s: media %.1f%n", name, average);
}
// var nelle espressioni try-with-resources
try (var reader = new java.io.BufferedReader(
new java.io.StringReader("Riga 1\nRiga 2\nRiga 3"))) {
reader.lines().forEach(System.out::println);
} catch (Exception e) {
System.err.println("Errore: " + e.getMessage());
}
}
}
L'uso di var è limitato alle variabili locali con inizializzazione, ai cicli for e ai blocchi try-with-resources. Non può essere utilizzato per i parametri dei metodi, i tipi di ritorno o i campi di classe.
Le nuove API per le collezioni
Le versioni moderne di Java hanno introdotto metodi factory statici per creare collezioni immutabili in modo conciso, nonché nuove interfacce come le sequenced collection introdotte in Java 21.
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SequencedCollection;
import java.util.ArrayList;
public class CollectionExample {
public static void main(String[] args) {
// Collezioni immutabili create con metodi factory (Java 9+)
var colors = List.of("Rosso", "Verde", "Blu");
var uniqueNumbers = Set.of(1, 2, 3, 4, 5);
var capitals = Map.of(
"Italia", "Roma",
"Francia", "Parigi",
"Spagna", "Madrid"
);
// Map.ofEntries per mappe con più di 10 elementi
var extendedMap = Map.ofEntries(
Map.entry("chiave1", "valore1"),
Map.entry("chiave2", "valore2"),
Map.entry("chiave3", "valore3")
);
// Copia immutabile di una collezione esistente (Java 10+)
var mutableList = new ArrayList<>(List.of("A", "B", "C"));
var immutableCopy = List.copyOf(mutableList);
// Sequenced collection (Java 21+)
SequencedCollection<String> sequenced = new ArrayList<>(colors);
String first = sequenced.getFirst();
String last = sequenced.getLast();
System.out.printf("Primo: %s, Ultimo: %s%n", first, last);
// Vista invertita della collezione
var reversed = sequenced.reversed();
System.out.println("Invertita: " + reversed);
}
}
L'API HttpClient
L'API HttpClient, introdotta in Java 11, sostituisce la vecchia e macchinosa HttpURLConnection con un'interfaccia moderna che supporta HTTP/2, richieste asincrone e WebSocket. Il design fluido dell'API si integra perfettamente con lo stile di programmazione funzionale introdotto nelle versioni precedenti.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public class HttpClientExample {
public static void main(String[] args) throws Exception {
// Creazione del client con configurazione personalizzata
var client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// Richiesta GET sincrona
var request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
System.out.printf("Stato: %d%n", response.statusCode());
System.out.println("Corpo: " + response.body());
// Richiesta POST asincrona
var postRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{"name": "Marco", "email": "marco@example.com"}
"""))
.build();
// Gestione asincrona con CompletableFuture
CompletableFuture<Void> future = client
.sendAsync(postRequest, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(body -> System.out.println("Risposta: " + body));
// Attesa del completamento dell'operazione asincrona
future.join();
}
}
Le espressioni switch migliorate
Le espressioni switch, stabilizzate in Java 14, trasformano il costrutto switch da istruzione a espressione, permettendo di restituire un valore direttamente. La nuova sintassi con la freccia elimina il fall-through implicito e rende il codice più sicuro e leggibile.
public class SwitchExpressionExample {
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
public static void main(String[] args) {
Season season = Season.AUTUMN;
// Switch come espressione con sintassi a freccia
String description = switch (season) {
case SPRING -> "Stagione della rinascita";
case SUMMER -> "Stagione del caldo";
case AUTUMN -> "Stagione dei colori";
case WINTER -> "Stagione del freddo";
};
System.out.println(description);
// Switch con blocchi di codice e yield
int monthNumber = 10;
String quarterReport = switch (monthNumber) {
case 1, 2, 3 -> {
// Primo trimestre: generazione del report iniziale
String quarter = "Q1";
yield quarter + " - Report iniziale";
}
case 4, 5, 6 -> {
// Secondo trimestre
yield "Q2 - Report intermedio";
}
case 7, 8, 9 -> {
// Terzo trimestre
yield "Q3 - Report avanzato";
}
case 10, 11, 12 -> {
// Quarto trimestre: chiusura dell'anno
yield "Q4 - Report finale";
}
default -> throw new IllegalArgumentException(
"Mese non valido: " + monthNumber
);
};
System.out.println(quarterReport);
}
}
String template e nuovi metodi per le stringhe
Le versioni moderne di Java hanno arricchito la classe String con numerosi metodi di utilità che semplificano operazioni comuni precedentemente gestite con librerie esterne o codice verboso.
public class StringMethodsExample {
public static void main(String[] args) {
// isBlank() verifica se la stringa è vuota o contiene solo spazi (Java 11)
String blank = " ";
System.out.println(blank.isBlank()); // Stampa: true
// strip(), stripLeading(), stripTrailing() gestiscono gli spazi Unicode (Java 11)
String padded = " \u2000 Ciao Mondo \u2000 ";
System.out.println("[" + padded.strip() + "]");
// lines() restituisce uno stream delle righe (Java 11)
String multiline = "Riga uno\nRiga due\nRiga tre";
long lineCount = multiline.lines().count();
System.out.println("Numero di righe: " + lineCount);
// repeat() ripete la stringa n volte (Java 11)
String separator = "=-".repeat(20);
System.out.println(separator);
// indent() aggiunge o rimuove indentazione (Java 12)
String indented = "Prima riga\nSeconda riga".indent(4);
System.out.println(indented);
// formatted() come alternativa a String.format() (Java 15)
String message = "Utente %s ha %d anni".formatted("Marco", 30);
System.out.println(message);
// stripIndent() rimuove l'indentazione comune (Java 15)
String code = """
public class Hello {
public static void main(String[] args) {
System.out.println("Hello");
}
}
""".stripIndent();
System.out.println(code);
}
}
Conclusioni
Java ha attraversato una trasformazione profonda negli ultimi anni, passando da un linguaggio percepito come verboso e rigido a una piattaforma moderna e versatile. Le espressioni lambda e l'API Stream hanno introdotto il paradigma funzionale, i record e le sealed class hanno semplificato la modellazione dei dati, il pattern matching ha reso la decomposizione dei tipi elegante e sicura, e i virtual thread hanno rivoluzionato il modello di concorrenza. Ciascuna di queste funzionalità non è un'aggiunta isolata, ma un tassello di un disegno coerente che mira a rendere Java un linguaggio espressivo, sicuro e adatto alle sfide dello sviluppo software contemporaneo. Il ciclo di rilascio semestrale adottato da Oracle garantisce che questa evoluzione continui a ritmo sostenuto, mantenendo Java competitivo nel panorama dei linguaggi di programmazione moderni.