← Retour à la liste

Guide complet de Java 8 : Les nouveautés côté développement

Sommaire

Introduction

Java 8, publié en mars 2014, représente l'une des évolutions les plus significatives du langage Java depuis sa création. Cette version majeure introduit des concepts révolutionnaires issus de la programmation fonctionnelle, transformant radicalement la façon d'écrire du code Java.

Les développeurs Java ont longtemps été habitués à un paradigme impératif, où chaque action doit être explicitement décrite étape par étape. Java 8 bouleverse cette approche en introduisant les expressions lambda, l'API Stream, et de nombreuses autres fonctionnalités qui permettent d'écrire du code plus concis, plus lisible et souvent plus performant.

Cette révolution ne se contente pas d'ajouter de nouvelles fonctionnalités : elle repense fondamentalement la manière de traiter les collections, de gérer l'asynchrone, et d'organiser le code. Pour les développeurs et ingénieurs informatiques, maîtriser ces nouveautés est devenu essentiel pour produire du code moderne et efficace.

Cet article explore en profondeur les principales innovations de Java 8 côté développement, en se concentrant sur les aspects pratiques et les changements d'API. Nous examinerons comment ces nouveautés s'intègrent dans le développement quotidien et comment elles peuvent transformer vos pratiques de programmation.

Les expressions lambda : révolution de la programmation fonctionnelle

Syntaxe et concepts de base

Les expressions lambda constituent probablement la nouveauté la plus visible de Java 8. Elles permettent de traiter les fonctions comme des citoyens de première classe, c'est-à-dire comme des valeurs qu'on peut passer en paramètre, stocker dans des variables ou retourner depuis des méthodes.

La syntaxe générale d'une expression lambda est la suivante :

(paramètres) -> expression

ou

(paramètres) -> { instructions; }

Voici des exemples concrets illustrant cette syntaxe :

// Lambda sans paramètre
() -> System.out.println("Hello World")

// Lambda avec un paramètre (parenthèses optionnelles)
x -> x * 2
name -> name.toUpperCase()

// Lambda avec plusieurs paramètres
(a, b) -> a + b
(x, y) -> Math.max(x, y)

// Lambda avec corps de méthode
(list) -> {
    Collections.sort(list);
    return list.get(0);
}

Comparaison avec les classes anonymes

Avant Java 8, pour passer du comportement en paramètre, nous devions utiliser des classes anonymes, souvent verbeuses et difficiles à lire :

// Java 7 et antérieur - Classe anonyme
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.compareTo(b);
    }
});

// Java 8 - Expression lambda
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Collections.sort(names, (a, b) -> a.compareTo(b));

// Encore plus concis avec une référence de méthode
Collections.sort(names, String::compareTo);

L'amélioration en termes de lisibilité et de concision est flagrante. Le code Java 8 élimine le bruit syntaxique et se concentre sur l'essentiel : la logique de comparaison.

Autre exemple avec les listeners d'interfaces graphiques :

// Java 7 - Classe anonyme verbeuse
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("Button clicked!");
    }
});

// Java 8 - Lambda concise
button.addActionListener(e -> System.out.println("Button clicked!"));

Les interfaces fonctionnelles : fondation des lambdas

Définition et annotation @FunctionalInterface

Une interface fonctionnelle est une interface qui ne contient qu'une seule méthode abstraite. Cette contrainte permet au compilateur de faire le lien entre une expression lambda et la méthode à implémenter.

@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);
    
    // Les méthodes par défaut et statiques sont autorisées
    default void printResult(int result) {
        System.out.println("Result: " + result);
    }
    
    static Calculator getAddition() {
        return (a, b) -> a + b;
    }
}

// Utilisation
Calculator addition = (a, b) -> a + b;
Calculator multiplication = (a, b) -> a * b;

int sum = addition.calculate(5, 3); // 8
int product = multiplication.calculate(5, 3); // 15

L'annotation @FunctionalInterface n'est pas obligatoire mais fortement recommandée. Elle permet au compilateur de vérifier que l'interface respecte bien le contrat d'interface fonctionnelle et génère une erreur de compilation si ce n'est pas le cas.

Interfaces fonctionnelles prédéfinies

Java 8 introduit un ensemble d'interfaces fonctionnelles dans le package java.util.function pour couvrir les cas d'usage les plus courants :

// Predicate<T> - teste une condition
Predicate<String> isEmpty = String::isEmpty;
Predicate<Integer> isPositive = x -> x > 0;

List<Integer> numbers = Arrays.asList(-2, -1, 0, 1, 2);
List<Integer> positives = numbers.stream()
    .filter(isPositive)
    .collect(Collectors.toList()); // [1, 2]

// Function<T, R> - transforme un type T en type R
Function<String, Integer> stringLength = String::length;
Function<Integer, String> intToString = Object::toString;

List<String> words = Arrays.asList("hello", "world", "java");
List<Integer> lengths = words.stream()
    .map(stringLength)
    .collect(Collectors.toList()); // [5, 5, 4]

// Consumer<T> - consomme un élément sans rien retourner
Consumer<String> printer = System.out::println;
Consumer<List<String>> listProcessor = list -> list.sort(String::compareTo);

words.forEach(printer); // Affiche chaque mot

// Supplier<T> - fournit un élément
Supplier<String> randomUUID = () -> UUID.randomUUID().toString();
Supplier<List<String>> listSupplier = ArrayList::new;

String id = randomUUID.get();
List<String> newList = listSupplier.get();

Ces interfaces peuvent être combinées pour créer des comportements complexes :

// Composition de predicates
Predicate<String> isNotEmpty = isEmpty.negate();
Predicate<String> isLongWord = s -> s.length() > 5;
Predicate<String> isLongNonEmptyWord = isNotEmpty.and(isLongWord);

// Composition de functions
Function<String, String> trim = String::trim;
Function<String, String> uppercase = String::toUpperCase;
Function<String, String> trimAndUppercase = trim.andThen(uppercase);

String result = trimAndUppercase.apply("  hello  "); // "HELLO"

Les références de méthodes : syntaxe concise

Types de références de méthodes

Les références de méthodes offrent une syntaxe encore plus concise que les lambdas quand l'expression lambda ne fait qu'appeler une méthode existante. Java 8 propose quatre types de références de méthodes :

// 1. Référence à une méthode statique
// ClassName::staticMethodName
Function<String, Integer> parseInt = Integer::parseInt;
int number = parseInt.apply("123"); // 123

// Équivalent lambda : s -> Integer.parseInt(s)

// 2. Référence à une méthode d'instance d'un objet particulier
// instance::instanceMethodName
String prefix = "Hello ";
Function<String, String> addPrefix = prefix::concat;
String greeting = addPrefix.apply("World"); // "Hello World"

// Équivalent lambda : s -> prefix.concat(s)

// 3. Référence à une méthode d'instance d'un type arbitraire
// ClassName::instanceMethodName
Function<String, Integer> getLength = String::length;
int len = getLength.apply("Java"); // 4

// Équivalent lambda : s -> s.length()

// 4. Référence à un constructeur
// ClassName::new
Supplier<List<String>> listCreator = ArrayList::new;
Function<Integer, List<String>> sizedListCreator = ArrayList::new;

List<String> emptyList = listCreator.get();
List<String> sizedList = sizedListCreator.apply(10);

Exemples pratiques

Les références de méthodes brillent particulièrement dans le traitement de collections avec l'API Stream :

List<String> names = Arrays.asList("alice", "bob", "charlie", "david");

// Transformation avec référence de méthode
List<String> upperNames = names.stream()
    .map(String::toUpperCase)  // Référence de méthode
    .collect(Collectors.toList());

// Filtrage et tri
List<String> longNames = names.stream()
    .filter(name -> name.length() > 3)  // Lambda
    .sorted(String::compareTo)          // Référence de méthode
    .collect(Collectors.toList());

// Utilisation avec des constructeurs personnalisés
class Person {
    private String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public String getName() { return name; }
}

List<Person> persons = names.stream()
    .map(Person::new)  // Référence au constructeur
    .collect(Collectors.toList());

// Extraction de propriétés
List<String> personNames = persons.stream()
    .map(Person::getName)  // Référence de méthode d'instance
    .collect(Collectors.toList());

L'API Stream : traitement de données déclaratif

Concepts fondamentaux

L'API Stream représente une abstraction de haut niveau pour traiter des séquences d'éléments de manière déclarative. Contrairement aux collections traditionnelles où vous devez spécifier comment faire les choses (approche impérative), les streams vous permettent de décrire ce que vous voulez obtenir (approche déclarative).

// Approche impérative (Java 7 et antérieur)
List<String> words = Arrays.asList("apple", "banana", "cherry", "date", "elderberry");
List<String> result = new ArrayList<>();

for (String word : words) {
    if (word.length() > 5) {
        String upperWord = word.toUpperCase();
        result.add(upperWord);
    }
}
Collections.sort(result);

// Approche déclarative (Java 8 avec streams)
List<String> result = words.stream()
    .filter(word -> word.length() > 5)
    .map(String::toUpperCase)
    .sorted()
    .collect(Collectors.toList());

Les streams suivent un pipeline en trois étapes :

  1. Source : création du stream depuis une collection, un tableau, etc.
  2. Opérations intermédiaires : transformations lazy (filter, map, sorted, etc.)
  3. Opération terminale : déclenche le traitement et produit un résultat (collect, forEach, reduce, etc.)

Opérations intermédiaires

Les opérations intermédiaires sont lazy : elles ne sont exécutées que lorsqu'une opération terminale est appelée. Cela permet d'optimiser les performances en fusionnant les opérations.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// filter() - filtre les éléments selon un prédicat
Stream<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0);

// map() - transforme chaque élément
Stream<String> numberStrings = numbers.stream()
    .map(Object::toString);

// mapToInt/mapToLong/mapToDouble - transforme vers des types primitifs
IntStream squares = numbers.stream()
    .mapToInt(n -> n * n);

// flatMap() - aplat les structures imbriquées
List<List<Integer>> nestedNumbers = Arrays.asList(
    Arrays.asList(1, 2, 3),
    Arrays.asList(4, 5, 6),
    Arrays.asList(7, 8, 9)
);

List<Integer> flatNumbers = nestedNumbers.stream()
    .flatMap(List::stream)
    .collect(Collectors.toList()); // [1, 2, 3, 4, 5, 6, 7, 8, 9]

// distinct() - élimine les doublons
List<Integer> uniqueNumbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4)
    .stream()
    .distinct()
    .collect(Collectors.toList()); // [1, 2, 3, 4]

// sorted() - trie les éléments
List<String> sortedWords = Arrays.asList("banana", "apple", "cherry")
    .stream()
    .sorted()
    .collect(Collectors.toList()); // [apple, banana, cherry]

// limit() et skip() - limitent ou sautent des éléments
List<Integer> firstFive = numbers.stream()
    .limit(5)
    .collect(Collectors.toList()); // [1, 2, 3, 4, 5]

List<Integer> afterSkip = numbers.stream()
    .skip(5)
    .collect(Collectors.toList()); // [6, 7, 8, 9, 10]

// peek() - pour le debug (effet de bord sans modification)
List<Integer> debuggedNumbers = numbers.stream()
    .peek(n -> System.out.println("Processing: " + n))
    .filter(n -> n > 5)
    .collect(Collectors.toList());

Opérations terminales

Les opérations terminales déclenchent l'exécution du pipeline et produisent un résultat final :

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// collect() - collecte dans une structure de données
List<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());

Set<Integer> uniqueNumbers = numbers.stream()
    .collect(Collectors.toSet());

Map<Boolean, List<Integer>> partitioned = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

// reduce() - réduit à une valeur unique
Optional<Integer> sum = numbers.stream()
    .reduce((a, b) -> a + b);

int sumWithIdentity = numbers.stream()
    .reduce(0, (a, b) -> a + b); // 55

// forEach() - applique une action à chaque élément
numbers.stream()
    .filter(n -> n > 5)
    .forEach(System.out::println);

// count() - compte les éléments
long count = numbers.stream()
    .filter(n -> n > 5)
    .count(); // 5

// anyMatch(), allMatch(), noneMatch() - tests de condition
boolean hasEven = numbers.stream()
    .anyMatch(n -> n % 2 == 0); // true

boolean allPositive = numbers.stream()
    .allMatch(n -> n > 0); // true

boolean noneNegative = numbers.stream()
    .noneMatch(n -> n < 0); // true

// findFirst(), findAny() - trouve un élément
Optional<Integer> first = numbers.stream()
    .filter(n -> n > 5)
    .findFirst(); // Optional[6]

// min(), max() - trouve les extrêmes
Optional<Integer> max = numbers.stream()
    .max(Integer::compareTo); // Optional[10]

Optional<Integer> min = numbers.stream()
    .min(Integer::compareTo); // Optional[1]

Streams parallèles

Java 8 facilite le traitement parallèle grâce aux streams parallèles, qui utilisent le framework Fork/Join sous-jacent :

List<Integer> largeList = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .collect(Collectors.toList());

// Stream séquentiel
long sequentialSum = largeList.stream()
    .mapToLong(Integer::longValue)
    .sum();

// Stream parallèle - utilise plusieurs threads
long parallelSum = largeList.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();

// Conversion entre séquentiel et parallèle
long sum = largeList.stream()
    .parallel()          // Devient parallèle
    .filter(n -> n > 50000)
    .sequential()        // Redevient séquentiel
    .mapToLong(Integer::longValue)
    .sum();

// Exemple avec traitement complexe
List<String> words = Files.lines(Paths.get("large-file.txt"))
    .parallel()
    .filter(line -> line.length() > 10)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

Important : Les streams parallèles ne sont pas toujours plus rapides. Ils sont efficaces pour :

  • De gros volumes de données
  • Des opérations coûteuses en calcul
  • Des machines multi-cœurs

Ils peuvent être contre-productifs pour de petites collections ou des opérations simples à cause de l'overhead de la parallélisation.

Les méthodes par défaut dans les interfaces

Évolution des interfaces sans casser la compatibilité

Avant Java 8, ajouter une nouvelle méthode à une interface existante cassait toutes les implémentations existantes. Les méthodes par défaut résolvent ce problème en permettant d'ajouter des implémentations par défaut dans les interfaces.

// Interface avant Java 8
interface Vehicle {
    void start();
    void stop();
    // Impossible d'ajouter une méthode sans casser les implémentations existantes
}

// Interface Java 8 avec méthode par défaut
interface Vehicle {
    void start();
    void stop();
    
    // Méthode par défaut - n'oblige pas les implémentations existantes
    default void honk() {
        System.out.println("Beep beep!");
    }
    
    // Méthode statique également possible
    static void checkTrafficRules() {
        System.out.println("Follow traffic rules!");
    }
}

class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println("Car starting...");
    }
    
    @Override
    public void stop() {
        System.out.println("Car stopping...");
    }
    
    // honk() est hérité automatiquement
    // Peut être redéfini si nécessaire
}

// Utilisation
Car car = new Car();
car.start();   // "Car starting..."
car.honk();    // "Beep beep!" (méthode par défaut)
Vehicle.checkTrafficRules(); // "Follow traffic rules!" (méthode statique)

Cette fonctionnalité a été cruciale pour l'évolution de l'API Collections. Par exemple, la méthode forEach a été ajoutée à l'interface Iterable :

// Ajouté dans Iterable via méthode par défaut
default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

// Toutes les collections peuvent maintenant utiliser forEach
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(System.out::println); // Possible grâce à la méthode par défaut

Résolution de conflits

Quand une classe implémente plusieurs interfaces ayant des méthodes par défaut avec la même signature, Java suit des règles de résolution strictes :

interface A {
    default void hello() {
        System.out.println("Hello from A");
    }
}

interface B {
    default void hello() {
        System.out.println("Hello from B");
    }
}

// Erreur de compilation - conflit entre A.hello() et B.hello()
class C implements A, B {
    // Obligé de résoudre le conflit explicitement
    @Override
    public void hello() {
        // Option 1: choisir une implémentation
        A.super.hello();
        
        // Option 2: nouvelle implémentation
        // System.out.println("Hello from C");
        
        // Option 3: combiner les deux
        // A.super.hello();
        // B.super.hello();
    }
}

Les règles de résolution sont :

  1. Les méthodes de classe gagnent sur les méthodes par défaut
  2. Les méthodes par défaut les plus spécifiques gagnent
  3. En cas de conflit, la classe doit explicitement choisir

L'API Optional : gestion élégante des valeurs nulles

Problématique des NullPointerException

Les NullPointerException sont l'une des sources d'erreur les plus fréquentes en Java. Tony Hoare, inventeur de la référence null, l'a même appelée son "erreur à un milliard de dollars". Java 8 introduit Optional pour gérer explicitement l'absence de valeur.

// Problème classique avec null
public class PersonService {
    public String getPersonEmailDomain(long personId) {
        Person person = findPersonById(personId);
        if (person != null) {
            String email = person.getEmail();
            if (email != null) {
                int atIndex = email.indexOf('@');
                if (atIndex > 0 && atIndex < email.length() - 1) {
                    return email.substring(atIndex + 1);
                }
            }
        }
        return null; // Quelle est la signification de null ici ?
    }
}

Utilisation pratique d'Optional

Optional rend explicite le fait qu'une valeur peut être absente et force le développeur à traiter ce cas :

public class PersonService {
    // Signature explicite : peut retourner une personne ou rien
    public Optional<Person> findPersonById(long id) {
        Person person = database.findById(id);
        return Optional.ofNullable(person);
    }
    
    // Chaînage sûr avec Optional
    public Optional<String> getPersonEmailDomain(long personId) {
        return findPersonById(personId)
            .map(Person::getEmail)              // Optional<String>
            .filter(email -> email.contains("@")) // garde seulement les emails valides
            .map(email -> email.substring(email.indexOf('@') + 1))
            .filter(domain -> !domain.isEmpty()); // Optional<String>
    }
}

// Utilisation
PersonService service = new PersonService();

// Méthodes de consommation d'Optional
Optional<String> domain = service.getPersonEmailDomain(123L);

// ifPresent() - exécute une action si la valeur est présente
domain.ifPresent(d -> System.out.println("Domain: " + d));

// orElse() - valeur par défaut
String domainOrDefault = domain.orElse("unknown.com");

// orElseGet() - valeur par défaut via Supplier (lazy)
String domainOrGenerated = domain.orElseGet(() -> generateDefaultDomain());

// orElseThrow() - lance une exception si absent
String domainOrException = domain.orElseThrow(
    () -> new IllegalArgumentException("No domain found")
);

// map() et flatMap() pour les transformations
Optional<Integer> domainLength = domain.map(String::length);

Optional<Person> person = service.findPersonById(123L);
Optional<String> personName = person.map(Person::getName);

// flatMap() quand la fonction retourne déjà un Optional
public Optional<Address> getPersonAddress(long personId) {
    return findPersonById(personId)
        .flatMap(Person::getAddress); // Person::getAddress retourne Optional<Address>
}

Bonnes pratiques avec Optional :

// ✅ À faire
public Optional<String> findUserName(long id) {
    return Optional.ofNullable(users.get(id))
        .map(User::getName);
}

// ✅ À faire - méthodes fluides
user.getName()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .ifPresent(System.out::println);

// ❌ À éviter - Optional comme paramètre de méthode
public void processUser(Optional<User> user) { /* mauvaise pratique */ }

// ❌ À éviter - get() sans vérification
Optional<String> name = findUserName(123);
String result = name.get(); // Peut lever NoSuchElementException

// ❌ À éviter - isPresent() + get() (équivaut à un test null)
if (name.isPresent()) {
    System.out.println(name.get());
}

// ✅ Mieux - ifPresent()
name.ifPresent(System.out::println);

La nouvelle API Date/Time

Problèmes de l'ancienne API

L'API Date/Time de Java antérieure à Java 8 (java.util.Date, java.util.Calendar) avait de nombreux problèmes :

// Problèmes de l'ancienne API
Date date = new Date(2023, 11, 25); // Bug ! Année = 2023 + 1900, mois = 11 + 1
// Résultat : 25 décembre 3923 !

Date correctDate = new Date(2023 - 1900, 11 - 1, 25); // Correct mais confus

// Les dates sont mutables
Date birthday = new Date(123, 4, 15); // 15 mai 2023
Date modifiedDate = birthday;
modifiedDate.setTime(System.currentTimeMillis()); // Modifie aussi birthday !

// Formatage peu intuitif
SimpleDateFormat formatter = new SimpleDateFormat("dd/MM/yyyy");
String formatted = formatter.format(date);

// Calendar verbeux
Calendar cal = Calendar.getInstance();
cal.set(Calendar.YEAR, 2023);
cal.set(Calendar.MONTH, Calendar.MAY); // MAY = 4, pas 5 !
cal.set(Calendar.DAY_OF_MONTH, 15);
Date calendarDate = cal.getTime();

Les nouvelles classes principales

Java 8 introduit une nouvelle API dans le package java.time, inspirée de la bibliothèque Joda-Time :

// LocalDate - date sans heure ni fuseau horaire
LocalDate today = LocalDate.now();
LocalDate specificDate = LocalDate.of(2023, 5, 15);
LocalDate parsedDate = LocalDate.parse("2023-05-15");

// Opérations fluides et immutables
LocalDate nextWeek = today.plusWeeks(1);
LocalDate lastMonth = today.minusMonths(1);
LocalDate firstDayOfMonth = today.withDayOfMonth(1);

System.out.println("Today: " + today);           // 2025-08-25
System.out.println("Next week: " + nextWeek);     // 2025-09-01

// LocalTime - heure sans date
LocalTime now = LocalTime.now();
LocalTime specificTime = LocalTime.of(14, 30, 45); // 14:30:45
LocalTime parsedTime = LocalTime.parse("14:30:45");

LocalTime inTwoHours = now.plusHours(2);
LocalTime rounded = now.withMinute(0).withSecond(0); // Arrondi à l'heure

// LocalDateTime - date et heure sans fuseau horaire
LocalDateTime dateTime = LocalDateTime.now();
LocalDateTime specific = LocalDateTime.of(2023, 5, 15, 14, 30, 45);
LocalDateTime combined = LocalDate.of(2023, 5, 15).atTime(14, 30);

// ZonedDateTime - date, heure et fuseau horaire
ZonedDateTime zonedNow = ZonedDateTime.now();
ZonedDateTime paris = ZonedDateTime.now(ZoneId.of("Europe/Paris"));
ZonedDateTime utc = ZonedDateTime.now(ZoneId.of("UTC"));

// Conversion entre fuseaux horaires
ZonedDateTime tokyoTime = paris.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

// Instant - timestamp UTC
Instant instant = Instant.now();
Instant fromEpoch = Instant.ofEpochSecond(1609459200); // 1er janvier 2021 UTC

// Duration et Period - durées et périodes
LocalDateTime start = LocalDateTime.of(2023, 1, 1, 10, 0);
LocalDateTime end = LocalDateTime.of(2023, 1, 1, 15, 30);
Duration duration = Duration.between(start, end);

System.out.println("Duration: " + duration.toHours() + " hours"); // 5 hours

LocalDate startDate = LocalDate.of(2023, 1, 1);
LocalDate endDate = LocalDate.of(2023, 12, 31);
Period period = Period.between(startDate, endDate);

System.out.println("Period: " + period.getMonths() + " months"); // 11 months

// Formatage avec DateTimeFormatter
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
String formatted = dateTime.format(formatter);

DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
String isoFormatted = dateTime.format(isoFormatter);

// Parsing avec gestion d'erreur
String dateString = "15/05/2023 14:30";
try {
    LocalDateTime parsed = LocalDateTime.parse(dateString, formatter);
    System.out.println("Parsed: " + parsed);
} catch (DateTimeParseException e) {
    System.out.println("Invalid date format");
}

// Comparaisons et tests
LocalDate date1 = LocalDate.of(2023, 5, 15);
LocalDate date2 = LocalDate.of(2023, 5, 20);

boolean isBefore = date1.isBefore(date2);    // true
boolean isAfter = date1.isAfter(date2);      // false
boolean isEqual = date1.isEqual(date2);      // false

// Tests sur les propriétés temporelles
boolean isLeapYear = date1.isLeapYear();
int dayOfYear = date1.getDayOfYear();
DayOfWeek dayOfWeek = date1.getDayOfWeek();
Month month = date1.getMonth();

Avantages de la nouvelle API :

  • Immutabilité : toutes les classes sont immutables et thread-safe
  • Clarté : API fluide et intuitive
  • Séparation des responsabilités : classes spécialisées pour chaque cas d'usage
  • Gestion des fuseaux horaires : support natif et robuste
  • Interopérabilité : conversion facile avec l'ancienne API
// Conversion avec l'ancienne API
Date oldDate = Date.from(instant);
Instant newInstant = oldDate.toInstant();

LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());

Améliorations des collections

Nouvelles méthodes utilitaires

Java 8 enrichit les interfaces de collections avec de nombreuses méthodes utilitaires qui s'intègrent parfaitement avec l'API Stream :

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

// forEach() - itération avec Consumer
names.forEach(name -> System.out.println("Hello " + name));
names.forEach(System.out::println);

// removeIf() - suppression conditionnelle
List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
numbers.removeIf(n -> n % 2 == 0); // Supprime les nombres pairs
System.out.println(numbers); // [1, 3, 5, 7, 9]

// replaceAll() - transformation sur place
List<String> words = new ArrayList<>(Arrays.asList("hello", "world", "java"));
words.replaceAll(String::toUpperCase);
System.out.println(words); // [HELLO, WORLD, JAVA]

// sort() - tri avec Comparator
List<Person> people = Arrays.asList(
    new Person("Alice", 30),
    new Person("Bob", 25),
    new Person("Charlie", 35)
);

people.sort(Comparator.comparing(Person::getAge));
people.sort(Comparator.comparing(Person::getName).reversed());

// Map - nouvelles méthodes utiles
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 85);
scores.put("Bob", 90);
scores.put("Charlie", 78);

// forEach() sur les Map
scores.forEach((name, score) -> System.out.println(name + ": " + score));

// getOrDefault() - valeur par défaut si clé absente
int aliceScore = scores.getOrDefault("Alice", 0);    // 85
int unknownScore = scores.getOrDefault("Unknown", 0); // 0

// putIfAbsent() - ajoute seulement si absent
scores.putIfAbsent("David", 80); // Ajoute David
scores.putIfAbsent("Alice", 95); // N'ajoute pas car Alice existe déjà

// computeIfAbsent() - calcule et ajoute si absent
Map<String, List<String>> groupedNames = new HashMap<>();
groupedNames.computeIfAbsent("A", k -> new ArrayList<>()).add("Alice");
groupedNames.computeIfAbsent("A", k -> new ArrayList<>()).add("Andrew");
groupedNames.computeIfAbsent("B", k -> new ArrayList<>()).add("Bob");

// computeIfPresent() - modifie si présent
scores.computeIfPresent("Alice", (name, score) -> score + 10); // Alice: 95

// compute() - calcule toujours
scores.compute("Eve", (name, score) -> score == null ? 70 : score + 5); // Ajoute Eve: 70

// merge() - fusionne les valeurs
Map<String, Integer> bonuses = Map.of("Alice", 5, "Bob", 3, "Eve", 8);
bonuses.forEach((name, bonus) -> 
    scores.merge(name, bonus, (oldScore, bonusPoints) -> oldScore + bonusPoints)
);

// replaceAll() sur les Map
scores.replaceAll((name, score) -> score > 80 ? score + 2 : score);

Intégration avec les streams

L'intégration entre les collections et les streams est transparente :

List<String> words = Arrays.asList("apple", "banana", "cherry", "date", "elderberry");

// Du collection vers stream
Map<Integer, List<String>> wordsByLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

// Collecteurs avancés
String concatenated = words.stream()
    .collect(Collectors.joining(", ", "[", "]")); // [apple, banana, cherry, date, elderberry]

Map<Boolean, List<String>> partitioned = words.stream()
    .collect(Collectors.partitioningBy(word -> word.length() > 5));

// Collecteurs statistiques
List<Integer> lengths = words.stream()
    .map(String::length)
    .collect(Collectors.toList());

IntSummaryStatistics stats = words.stream()
    .collect(Collectors.summarizingInt(String::length));

System.out.println("Average length: " + stats.getAverage());
System.out.println("Max length: " + stats.getMax());
System.out.println("Total chars: " + stats.getSum());

// Collecteurs personnalisés
Collector<String, ?, String> customCollector = Collector.of(
    StringBuilder::new,              // Supplier
    StringBuilder::append,           // Accumulator
    StringBuilder::append,           // Combiner
    StringBuilder::toString          // Finisher
);

String result = words.stream()
    .collect(customCollector);

// Streams infinis avec limite
Stream.generate(Math::random)
    .limit(10)
    .forEach(System.out::println);

Stream.iterate(0, n -> n + 2)
    .limit(10)
    .collect(Collectors.toList()); // [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

// Création de streams depuis diverses sources
Stream<String> fileLines = Files.lines(Paths.get("data.txt"));
IntStream range = IntStream.range(1, 10);         // [1, 2, ..., 9]
IntStream rangeClosed = IntStream.rangeClosed(1, 10); // [1, 2, ..., 10]

// Streams depuis arrays
String[] array = {"a", "b", "c"};
Stream<String> arrayStream = Arrays.stream(array);

// Streams vides et singletons
Stream<String> empty = Stream.empty();
Stream<String> single = Stream.of("single");
Stream<String> multiple = Stream.of("a", "b", "c");

Collecteurs utiles :

List<Person> people = Arrays.asList(
    new Person("Alice", 30, "Engineering"),
    new Person("Bob", 25, "Marketing"),
    new Person("Charlie", 35, "Engineering"),
    new Person("David", 28, "Marketing")
);

// Grouping
Map<String, List<Person>> byDepartment = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment));

// Grouping avec transformation
Map<String, List<String>> namesByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.mapping(Person::getName, Collectors.toList())
    ));

// Grouping avec comptage
Map<String, Long> countByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.counting()
    ));

// Grouping avec statistiques
Map<String, IntSummaryStatistics> ageStatsByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.summarizingInt(Person::getAge)
    ));

// Partitioning (cas spécial de grouping avec boolean)
Map<Boolean, List<Person>> youngAndOld = people.stream()
    .collect(Collectors.partitioningBy(p -> p.getAge() < 30));

// toMap avec gestion des doublons
Map<String, Integer> nameToAge = people.stream()
    .collect(Collectors.toMap(
        Person::getName,
        Person::getAge,
        (existing, replacement) -> existing // En cas de doublon, garde l'existant
    ));

Conclusion

Java 8 représente un tournant majeur dans l'évolution du langage Java, introduisant des paradigmes de programmation fonctionnelle qui transforment fondamentalement la façon d'écrire du code. Cette version ne se contente pas d'ajouter de nouvelles fonctionnalités : elle repense l'approche du développement Java en privilégiant la concision, la lisibilité et l'expressivité.

Les apports révolutionnaires de Java 8 se résument en plusieurs axes majeurs :

Les expressions lambda ont libéré Java de la verbosité des classes anonymes, permettant d'écrire du code plus concis et plus expressif. Cette fonctionnalité, combinée aux interfaces fonctionnelles, ouvre la voie à un style de programmation plus déclaratif où l'on décrit ce que l'on veut obtenir plutôt que comment l'obtenir.

L'API Stream constitue probablement la nouveauté la plus impactante pour le développement quotidien. Elle transforme le traitement des collections en remplaçant les boucles imperatives par des pipelines de transformation fluides et composables. Cette approche améliore non seulement la lisibilité du code mais ouvre aussi des opportunités d'optimisation, notamment avec les streams parallèles.

Les méthodes par défaut dans les interfaces ont résolu un problème architectural majeur en permettant l'évolution des APIs sans casser la compatibilité. Cette innovation a été cruciale pour moderniser l'écosystème Java existant.

L'API Optional apporte une solution élégante à la gestion des valeurs potentiellement nulles, réduisant drastiquement les risques de NullPointerException tout en rendant explicite l'absence possible de valeur.

La nouvelle API Date/Time remplace enfin les classes problématiques Date et Calendar par un ensemble cohérent de classes immutables, thread-safe et intuitives.

Impact sur les pratiques de développement : Java 8 encourage un style de code plus fonctionnel, privilégiant l'immutabilité, les transformations déclaratives et la composition de fonctions. Cette approche produit généralement du code plus robuste, plus testable et plus maintenable.

Recommandations pour l'adoption : Pour les développeurs et équipes souhaitant adopter Java 8, il est recommandé de commencer par maîtriser les expressions lambda et l'API Stream, qui constituent le cœur des nouveautés. L'intégration progressive de ces concepts dans le code existant permettra de mesurer immédiatement les bénéfices en termes de lisibilité et de maintenabilité.

Java 8 n'est pas seulement une mise à jour technique : c'est une évolution conceptuelle qui prépare les développeurs Java aux paradigmes modernes de programmation, tout en conservant la robustesse et la performance qui font la réputation de la plateforme Java.

L'adoption de ces fonctionnalités représente un investissement stratégique pour tout développeur Java, car elles constituent désormais les fondations sur lesquelles s'appuient toutes les versions ultérieures du langage.