Ca y est, Java 17 est là !

Cette nouvelle version apporte quelques nouveautés que nous avons suivi de près chez Liksi. Elle est d’autant plus importante qu’elle a le statut LTS, ce qui veut dire qu’elle servira de version de base pour les projets à venir. Nous avons souhaité vous partager dans cet article une des nouvelles features qu’elle embarque : la notion de sealed classes, qui étaient en preview depuis Java 15.

Note : cette feature est très fortement liée à la notion de pattern matching dans les switchs. Pour plus d’informations, vous pouvez retrouver notre article sur le sujet ici.

[JEP-409] Les Sealed classes

Cette section se base sur un superbe article de Raphael Ndonga, que vous pouvez retrouver ici : https://www.section.io/engineering-education/sealed-classes-in-kotlin/. L’objectif ici est de simplifier et de transcrire les exemples de code en Java puisque cette feature vient d’être introduite (contrairement à Kotlin). Quant à l’intérêt d’utiliser les sealed classes, vous retrouverez toutes ces informations dans son article.

La feature des sealed classes est en preview depuis le JDK15, et est enfin officiellement disponible dans cette version 17. Elle permet de définir une liste finie d’implémentations pour une interface ou une classe donnée. Son objectif est de compléter les possibilités actuelles de hiérarchisation de classes. En effet, actuellement, les deux seules solutions permettant de limiter les implémentations d’une classe/interface sont les suivantes : 

  • Déclarer la classe comme étant finale : il n’est alors plus possible de l’étendre, on limite donc son nombre d’implémentations à 0.

public final class Shape { }
public class Circle extends Shape { } // ERROR
  • Limiter la classe à son package, et donc limiter les seules implémentations à celles définies dans ce même package. Ainsi, l’auteur d’une librairie s’assure qu’aucun utilisateur ne pourra implémenter cette classe.
package org.example.java17.package1;
class Shape { }

package org.example.java17.package1;
class Rectangle extends Shape { } // OK

package org.example.java17.package2;
class Triangle extends Shape { } // KO

Note : En théorie, rien n’empêcherait un petit malin de déclarer un package qui ait exactement le même nom et qui lui permettrait de définir une nouvelle implémentation de votre classe abstraite. Cette technique permet malgré tout de rendre moins facile d’accès la dérivation de cette classe.

Désormais, on a donc une nouvelle possibilité puisque l’on va pouvoir lister les différentes implémentations autorisées pour notre classe/interface. Syntaxiquement, c’est très simple à mettre en place : 

public abstract sealed class MyAbstractClass permits MyCustomImpl, MyCustomImpl2 { }
public final class MyCustomImpl extends MyAbstractClass { }
public final class MyCustomImpl2 extends MyAbstractClass { }

Notre classe Shape est déclarée avec le mot-clé sealed, et on peut lister ses implémentations après le mot-clé permits. A noter que si les implémentations de cette classe sont définies dans le même fichier source, le mot-clé permits peut être omis : 

public abstract sealed class MyAbstractClass { }
public final class MyCustomImpl { }
public final class MyCustomImpl2 { }

Afin que cette hiérarchie soit bien respectée, il est nécessaire que les implémentations de notre super classe Shape soient finales (sinon, on perd tout l’intérêt de cette feature).

Mais justement, quel est l’intérêt de cette feature ?

L’avantage des sealed classes est de conserver la liberté de représentation qu’offrent les classes abstraites tout en ayant la possibilité de définir un ensemble bien fini de types comme pour une énumération. Pour bien comprendre son utilité, nous allons reprendre l’exemple vu dans l’article sur les pattern matching dans les instructions switch : le statut de retour d’une requête HTTP. Cette fois-ci, nous allons décrire ces données de trois manières différentes : avec une énumération, des classes et enfin des sealed classes.

  • Avec une énumération : 
public enum StatusEnum {
    SUCCESS,
    ERROR;
}

@Service
public class Caller {
    public String fetch() throws Exception {
        return "data fetched";
    }
}

@Service
public class MyService {

    private final Caller caller;
    private StatusEnum status;

    public MyService(final Caller caller) {
        this.caller = caller;
    }

    public Optional<String> fetchData() {
        Optional<String> result = Optional.empty();
        try {
            result = Optional.of(caller.fetch());
            status = StatusEnum.SUCCESS;
        } catch (Exception e) {
            status = StatusEnum.ERROR;
        } finally {
            printStatus();
        }
        return result;
    }

    private void printStatus() {
        switch (status) {
            case SUCCESS -> System.out.println("Successfully fetched data");
            case ERROR -> System.out.println("An error occured while fetching data");
        }
    }
}

Rajoutons un petit test là-dessus pour vérifier tout ça : 

@Test
void should_fetch() throws Exception {
	when(caller.fetch()).thenReturn("fetched data");
	assertThat(service.fetchData()).isEqualTo(Optional.of("fetched data"));
	when(caller.fetch()).thenThrow(new Exception("An error occured"));
	assertThat(service.fetchData()).isEqualTo(Optional.empty());
}

// Successfully fetched data
// An error occured while fetching data

Imaginons que l’on souhaite reprendre le modèle de données présenté dans la section sur le pattern matching : en cas de succès, la chaîne de caractères représentée, et en cas d’échec l’exception qui a été levée. Le problème, c’est qu’on ne peut pas stocker deux informations de type différent dans une énumération : on a bien un ensemble fini de statuts possibles, mais on est bloqué par rapport à la représentation que l’on veut donner à notre statut.

  • Avec une classe abstraite
public abstract class AbstractStatus { }

public final class ErrorStatus extends AbstractStatus {

    private final Exception ex;

    private ErrorStatus(final Exception ex) {
        this.ex = ex;
    }

    public static ErrorStatus of(Exception ex) {
        return new ErrorStatus(ex);
    }

    public Exception getEx() {
        return ex;
    }
}

public final class SuccessStatus extends AbstractStatus {

    private final String fetchedData;

    private SuccessStatus(final String fetchedData) {
        this.fetchedData = fetchedData;
    }

    public static SuccessStatus of(String fetchedData) {
        return new SuccessStatus(fetchedData);
    }

    public String getFetchedData() {
        return fetchedData;
    }
}

On retrouve ici le même modèle de données que celui présenté dans l’article précédent sur le pattern matchingComme souhaité, on a bien formalisé nos données avec en cas de succès la rétention des données récupérées et dans le cas d’une erreur le stockage de l’exception retournée par notre Caller. Du côté de notre service, quasiment rien ne bouge mise à part la méthode printStatus() et la mise à jour de l’état de notre service : 

public Optional<String> fetchData() {
	Optional<String> result = Optional.empty();
	try {
		final var fetched = caller.fetch();
		result = Optional.of(fetched);
		status = SuccessStatus.of(fetched);
	} catch (Exception e) {
		status = ErrorStatus.of(e);
	} finally {
		printStatus();
	}
	return result;
}

private void printStatus() {
	switch (status) {
		case SuccessStatus s -> System.out.println("Successfully fetched data : " + s.getFetchedData());
		case ErrorStatus e -> System.out.println("An error occured while fetching data : " + e.getEx().getMessage());
		default -> System.out.println("invalid");
	}
}

Ce qui pose problème avec cette solution, à l’inverse de la première, c’est que l’utilisateur est obligé de gérer le cas où notre instance de AbstractStatus n’est ni un SuccessStatus, ni un ErrorStatus. Or dans la pratique, ce cas n’arrivera jamais, mais le compilateur est incapable de déduire que seules ces deux implémentations seront utilisées dans notre classe. Ce serait quand même sympa de pouvoir profiter du meilleur des deux mondes, n’est-ce pas ? Eh bien c’est tout l’intérêt de cette nouvelle feature : on va dire au compilateur que les seules instances de notre classe abstraite sont ErrorStatus et SuccessStatus, et le cas nominal disparaît ! On conserve notre liberté de représentation, couplée à la rigidité de typage offerte par les enums !

  • Avec les sealed classes :
public abstract sealed class AbstractStatus permits ErrorStatus, SuccessStatus { }

Seule notre classe parente change en déclarant les différentes implémentations autorisées. La seule différence côté service va être cette branche default qui va disparaître de notre instruction switch : 

private void printStatus() {
	switch (status) {
		case SuccessStatus s -> System.out.println("Successfully fetched data : " + s.getFetchedData());
		case ErrorStatus e -> System.out.println("An error occured while fetching data : " + e.getEx().getMessage());
	}
}

Il existe une autre raison pour laquelle cette feature est très intéressante (qui est liée à l’instruction switch ci-dessus). En effet, dans le cas où un utilisateur final souhaiterait ajouter une nouvelle implémentation, le compilateur renverrait une erreur pour chacun des cas d’utilisation qui ne seraient pas couverts pour ce nouveau type. Et les erreurs à la compilation, on en est très friands puisqu’ils nous évitent de mauvaises surprises une fois l’application lancée. Dans les faits, cela nous évitera d’oublier une instruction switch pour notre nouveau type, qui passera donc dans la branche default et qui ne correspondra, dans la majeure partie des cas, pas du tout au comportement souhaité. Un autre petit point bonus à cela est qu’on gagne l’autocomplétion dans notre IDE, c’est le petit bonbon supplémentaire si jamais vous n’étiez pas encore convaincus !

Pour des informations plus détaillées sur cette feature, vous pouvez vous référer à la description officielle de la JEP ici.

Conclusion

Ces deux features couplées sont très intéressantes et elles se rapprochent d’ailleurs beaucoup de la façon de faire en Kotlin. Cependant, un point reste vraiment problématique dans cette version 17 : le fait que le pattern matching dans les instructions switch ne soit qu’en preview rend presque nul l’intérêt des sealed classes. En effet, comme nous l’avons vu par l’exemple, la combinaison de ces deux features est très agréable et simple à utiliser. En revanche, tant que ces pattern matching sont en preview, il faudra continuer à utiliser la syntaxe if/elseif/else avec des instructions instanceof, ce qui rend les sealed classes bien moins intéressantes…

Il faudra donc suivre à la loupe les prochaines versions… En tout cas, chez Liksi, nous sommes prêts !