Comment concevoir des classes et des interfaces dans Java

1. Introduction

Quel que soit le langage de programmation que vous utilisez (et Java ne fait pas exception à la règle), le respect de principes de conception satisfaisants est un facteur clé pour rédiger un code propre, compréhensible et testable et proposer des solutions durables et faciles à maintenir. Dans cette partie du didacticiel, nous aborderons les éléments de base fournis par le langage Java et présenterons quelques principes de conception afin de vous aider à prendre de meilleures décisions de developpement.

Plus précisément, nous allons discuter des interfaces et des interfaces avec des méthodes par défaut, des classes abstraites et finales, des classes immuables, l’héritage, la composition .

2. Les interfaces

Dans la programmation orientée objet, le concept d’interface constitue la base du développement piloté par contrat (ou basé sur contrat). En un mot, les interfaces définissent l’ensemble de méthodes et chaque classe qui prétend prendre en charge cette interface particulière doit fournir l’implémentation de ces méthodes: une idée assez simple, mais puissante.

De nombreux langages de programmation ont des interfaces sous une forme ou une autre, mais Java fournit particulièrement un support linguistique pour cela. Jetons un coup d’oeil sur une définition d’interface simple en Java.

package com.javacodegeeks.advanced.design;
public interface SimpleInterface {
void performAction();
}

Dans l’extrait de code ci-dessus, l’interface que nous avons nommée SimpleInterface déclare une seule méthode avec nom performAction. Les principales différences entre les interfaces en ce qui concerne les classes sont que les interfaces décrivent le contact (méthodes de déclaration), mais ne fournissent pas leurs implémentations.

Cependant, les interfaces en Java peuvent être plus compliquées que cela: elles peuvent inclure des interfaces imbriquées, des classes, des énumérations, des annotations et les constantes . Par exemple:


package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefinitions {
String CONSTANT = "CONSTANT";

enum InnerEnum {
E1, E2;
}

class InnerClass {
}

interface InnerInterface {
void performInnerAction();
}

void performAction();
}

Avec cet exemple plus compliqué, les interfaces imposent implicitement deux contraintes vis-à-vis des constructions et déclarations de méthode imbriquées, et le compilateur Java l’applique. Tout d’abord, même si cela n’est pas dit explicitement, chaque déclaration de l’interface est publique. En tant que telles, les déclarations de méthode suivantes sont équivalentes:

public void performAction();
void performAction();

Il convient de mentionner que chaque méthode de l’interface est implicitement déclarée abstraite et que même ces déclarations de méthode sont équivalentes:

public abstract void performAction();
public void performAction();
void performAction();

En ce qui concerne les déclarations de champ constant, en plus d’être publique, elles sont implicitement déclarés comme static et final donc ils sont équivalents:

String CONSTANT = "CONSTANT";
public static final String CONSTANT = "CONSTANT";

Et enfin, les classes, interfaces ou énumérations imbriquées, en plus d’être public, sont implicitement déclarées comme static. Par exemple, ces déclarations de classe sont également équivalentes:


class InnerClass {
}

static class InnerClass {
}

Le style que vous allez choisir est une préférence personnelle. Toutefois, la connaissance de ces qualités simples d’interface peut vous éviter des saisies inutiles.

3. Les interfaces de marquage

Les interfaces de marquage sont un type d’interfaces particulier pour lequel aucune méthode ou autre construction imbriquée n’est définie. Voici comment cela est défini dans la bibliothèque Java:

public interface Cloneable {
}

Les interfaces de marquage ne sont pas des contrats en soi, mais une technique plutôt utile pour «attacher» un trait particulier à la classe. Par exemple, en ce qui concerne la classe Cloneablel, elle est marquée comme étant disponible pour le clonage. Toutefois, la manière dont elle devrait ou devrait être faite ne fait pas partie de l’interface. Un autre exemple très connu et largement utilisé d’interface marquage est le Serializable:

public interface Serializable {
}

Cette interface de marque la classe comme étant disponible pour la sérialisation et la désérialisation et, encore une fois, elle ne spécifie pas la manière dont cela pourrait ou devrait être fait.

Les interfaces de marquage ont leur place dans la conception orientée objet, bien qu’elles ne répondent pas à l’objectif principal de l’interface d’être un contrat.

4. Interfaces fonctionnelles, méthodes par défaut et méthodes statiques

Avec la sortie de Java 8 , les interfaces ont obtenu de nouvelles fonctionnalités très intéressantes: méthodes statiques, méthodes par défaut et conversion automatique à partir de lambdas (interfaces fonctionnelles).

Dans la section Interfaces, nous avons mis l’accent sur le fait que les interfaces en Java peuvent uniquement déclarer des méthodes, mais ne sont pas autorisées à fournir leurs implémentations. Avec les méthodes par défaut, cela n’est plus vrai: une interface peut marquer une méthode avec le mot clé
default et en fournir la mise en oeuvre. Par exemple:


package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefaultMethods {
void performAction();

default void performDefaulAction() {
// Implementation ici
}
}

S’agissant d’un niveau d’instance, les méthodes par défaut peuvent être remplacées par chaque implémenteur d’interface, mais à partir de maintenant, les interfaces peuvent également inclure des méthodes statiques, par exemple:


package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefaultMethods {
static void createAction() {
// Implementation here
}
}

On peut dire que fournir une implémentation dans l’interface va à l’encontre de l’objectif même du développement basé sur les contrats, mais il y a de nombreuses raisons pour lesquelles ces fonctionnalités ont été introduites dans le langage Java.

Les interfaces fonctionnelles sont une autre histoire et il s’est avéré qu’elles complètent utilement le langage. Fondamentalement, l’interface fonctionnelle est l’interface avec une seule méthode abstraite déclarée. L’ interface Runnable de la bibliothèque standard Java est un bon exemple de ce concept:

@FunctionalInterface
public interface Runnable {
void run();
}

Le compilateur Java traite les interfaces fonctionnelles différemment et est capable de convertir la fonction lambda en une implémentation d’interface fonctionnelle appropriée. Regardons la définition de fonction suivante:

public void runMe( final Runnable r ) {
r.run();
}

Pour appeler cette fonction dans Java 7 et inférieur, l’implémentation de l’ interface Runnable doit être fournie (par exemple, à l’aide de classes anonymes ), mais sous Java 8, il suffit de passer à l’implémentation de méthode à l’aide de la syntaxe lambda:

runMe( () -> System.out.println( "Run!" ) );

En outre, l’ annotation @FunctionalInterface indique au compilateur de vérifier que l’interface ne contient qu’une seule méthode abstraite, de sorte que toute modification apportée à l’interface à l’avenir ne rompra pas cette hypothèse.

5. Classes abstraites

Un autre concept intéressant soutenu par le langage Java est la notion de classes abstraites. Les classes abstraites ressemblent quelque peu aux interfaces de Java 7 et sont très proches des interfaces avec les méthodes par défaut de Java 8. Contrairement aux classes ordinaires, les classes abstraites ne peuvent pas être instanciées, mais peuvent être sous-classées. Plus important encore, les classes abstraites peuvent contenir des méthodes abstraites: le type particulier de méthodes sans implémentations, un peu comme les interfaces. Par exemple:


package com.javacodegeeks.advanced.design;

public abstract class SimpleAbstractClass {
public void performAction() {
// Implementation here
}

public abstract void performAnotherAction();
}

Dans cet exemple, la classe SimpleAbstractClass est déclarée en tant que abstract et a également une déclaration abstraite de méthode. Les classes abstraites sont très utiles lorsque la plupart des sous-classes, voire une partie de celles-ci, peuvent être partagées. Cependant, ils laissent toujours la porte ouverte et permettent de personnaliser le comportement intrinsèque de chaque sous-classe au moyen de méthodes abstraites.

À lire aussi  Comment et quand utiliser Enums et Annotations dans Java

Une chose à mentionner, contrairement aux interfaces qui ne peuvent contenir que des déclarations publiques, les classes abstraites peuvent utiliser toute la puissance des règles d’accessibilité pour contrôler la visibilité des méthodes abstraites.

6. classes immuables

L’immuabilité devient de plus en plus importante dans le développement logiciel de nos jours. La montée en puissance des systèmes multicœurs a soulevé de nombreuses préoccupations liées au partage de données et à la simultanéité . Mais une chose est clairement apparue: moins (voire absence d’état) d’état mutable conduit à une meilleure évolutivité et à un raisonnement plus simple des systèmes.

Malheureusement, le langage Java ne fournit pas un support solide pour l’immuabilité des classes. Cependant, en utilisant une combinaison de techniques, il est possible de concevoir des classes immuables. Tout d’abord, tous les champs de la classe devraient être final. C’est un bon début mais ne garantit pas l’immuabilité tout seul.


package com.javacodegeeks.advanced.design;

import java.util.Collection;

public class ImmutableClass {
private final long id;
private final String[] arrayOfStrings;
private final Collection< String > collectionOfString;
}

Deuxièmement, suivez l’initialisation appropriée: si le champ est la référence à une collection ou à un tableau, n’affectez pas ces champs directement à partir des arguments du constructeur, effectuez plutôt des copies. Cela garantira que l’état de la collection ou du tableau ne sera pas modifié de l’extérieur.

public ImmutableClass( final long id, final String[] arrayOfStrings,
final Collection< String > collectionOfString) {
this.id = id;
this.arrayOfStrings = Arrays.copyOf( arrayOfStrings, arrayOfStrings.length );
this.collectionOfString = new ArrayList<>( collectionOfString );
}

Et enfin, fournissez les accesseurs appropriés (getters). Pour la collection, la vue immuable doit être exposée à l’aide de Collections.unmodifiableXxxwrappers.

public Collection<String> getCollectionOfString() {
return Collections.unmodifiableCollection( collectionOfString );
}

Avec les tableaux, le seul moyen de garantir la véritable immuabilité est de fournir une copie au lieu de renvoyer une référence au tableau. Cela pourrait ne pas être acceptable du point de vue pratique car cela dépend énormément de la taille de la matrice et peut mettre beaucoup de pression sur le garbage collector.

public String[] getArrayOfStrings() {
return Arrays.copyOf( arrayOfStrings, arrayOfStrings.length );
}

Même ce petit exemple donne à penser que l’immuabilité n’est pas encore un citoyen de première classe en Java. Les choses peuvent devenir vraiment compliquées si une classe immuable a des champs faisant référence à d’autres instances de classe. Ces classes devraient également être immuables, mais il n’existe pas de moyen simple de les appliquer.

Il existe quelques excellents analyseurs de code source Java tels que FindBugs et PMD qui peuvent aider beaucoup en inspectant votre code et en indiquant les failles de programmation Java les plus courantes. Ces outils sont des amis de tout développeur Java.

7. Classes anonymes

Avant Java 8, les classes anonymes étaient le seul moyen de fournir des définitions de classe sur place et des instanciations immédiates. Le but des classes anonymes était de réduire le passe-partout et de fournir un moyen simple et concis de représenter les classes sous forme d’expressions. Jetons un coup d’oeil sur la manière traditionnelle ancienne de générer un nouveau thread en Java:


package com.javacodegeeks.advanced.design;

public class AnonymousClass {
public static void main( String[] args ) {
new Thread(
// Exemple de création d'une classe anonyme implémentant
// interface exécutable
new Runnable() {
@Override
public void run() {
// Implementation ici
}
}
).start();
}
}

Dans cet exemple, l’implémentation d’une interface Runnable est fournie à la place en tant que classe anonyme. Bien qu’il existe certaines limitations associées aux classes anonymes, les inconvénients fondamentaux de leur utilisation sont des constructions de syntaxe très détaillées que Java impose en tant que langage. Même la classe anonyme la plus simple qui ne fait rien nécessite au moins 5 lignes de code à écrire à chaque fois.

new Runnable() {
@Override
public void run() {
}
}

Heureusement, avec Java 8, les lambdas et les interfaces fonctionnelles, tout ce passe-partout est sur le point de disparaître, ce qui donne finalement au code Java une apparence vraiment concise.


package com.javacodegeeks.advanced.design;

public class AnonymousClass {
public static void main( String[] args ) {
new Thread( () -> { /* Implementation here */ } ).start();
}
}

9. L’héritage

L’héritage est l’un des concepts clés de la programmation orientée objet et sert de base à l’établissement de relations de classe. Associé aux règles de visibilité et d’accessibilité, l’héritage permet de concevoir des hiérarchies de classes extensibles et maintenables.

En théorie, l’héritage en Java est implémenté à l’aide de mot clé extends, suivis de la classe parente. La sous-classe hérite de tous les membres publics et protégés de sa classe parente. En outre, une sous-classe hérite des membres privés de package de la classe parent si les deux résident dans le même package. Cela dit, il est très important, quel que soit ce que vous essayez de concevoir, de conserver l’ensemble minimal de méthodes que la classe expose publiquement ou à ses sous-classes. Par exemple, examinons une classe Parentet sa sous-classe pour illustrer les différents niveaux de visibilité et leurs effets:


package com.javacodegeeks.advanced.design;

public class Parent {
// Everyone can see it
public static final String CONSTANT = "Constant";

// No one can access it
private String privateField;
// Only subclasses can access it
protected String protectedField;

// No one can see it
private class PrivateClass {
}

// Only visible to subclasses
protected interface ProtectedInterface {
}

// Everyone can call it
public void publicAction() {
}

// Only subclass can call it
protected void protectedAction() {
}

// No one can call it
private void privateAction() {
}

// Only subclasses in the same package can call it
void packageAction() {
}
}

package com.javacodegeeks.advanced.design;

// Resides in the same package as parent class
public class Child extends Parent implements Parent.ProtectedInterface {
@Override
protected void protectedAction() {
// Calls parent's method implementation
super.protectedAction();
}

@Override
void packageAction() {
// Do nothing, no call to parent's method implementation
}

public void childAction() {
this.protectedField = "value";
}
}

L’héritage est un sujet très vaste en lui-même, avec beaucoup de détails subtils spécifiques à Java.

À lire aussi  Différences entre JDK, JRE et JVM

Cependant, il existe quelques règles faciles à suivre qui pourraient vous aider beaucoup à garder vos hiérarchies de classes concises. En Java, chaque sous-classe peut remplacer toute méthode héritée de son parent sauf si elle a été déclarée final.

Cependant, il n’y a pas de syntaxe spéciale ni de mot clé pour marquer la méthode comme étant surchargée, ce qui peut créer beaucoup de confusion. C’est la raison pour laquelle @Overrideannotation a été introduite: chaque fois que votre intention est de remplacer la méthode héritée, veuillez toujours utiliser @Overrideannotation pour l’indiquer.

Un autre dilemme auquel les développeurs Java sont souvent confrontés lors de la conception est la construction de hiérarchies de classes (avec des classes concrètes ou abstraites) et non la mise en œuvre d’interfaces. Il est vivement conseillé de préférer les interfaces aux classes ou aux classes abstraites autant que possible. Les interfaces sont beaucoup plus légères, plus faciles à tester et à maintenir, et minimisent les effets secondaires des modifications apportées à la mise en œuvre. De nombreuses techniques de programmation avancées, telles que la création de mandataires de classes dans une bibliothèque Java standard, dépendent fortement des interfaces.

10. L’héritage multiple

Contrairement à C ++ et d’autres langages, Java ne prend pas en charge l’ héritage multiple: en Java chaque classe a exactement un parent direct. Cependant, la classe peut implémenter plusieurs interfaces et, en tant que tel, empiler des interfaces est le seul moyen d’obtenir (ou d’imiter) un héritage multiple en Java.


package com.javacodegeeks.advanced.design;

public class MultipleInterfaces implements Runnable, AutoCloseable {
@Override
public void run() {
// quelque implementation ici
}

@Override
public void close() throws Exception {
// quelque implementation ici
}
}

L’implémentation d’interfaces multiples est en fait assez puissante, mais souvent le besoin de réutiliser une implémentation conduit à des hiérarchies de classes profondes comme moyen de surmonter l’absence de prise en charge de l’héritage multiple en Java.


public class A implements Runnable {
@Override
public void run() {
// quelque implementation ici
}
}

// La classe B veut hériter la méthode run () de la classe A.
public class B extends A implements AutoCloseable {
@Override
public void close() throws Exception {
// quelque implementation ici
}
}

// La classe C veut hériter la méthode run () de la classe A.
// et la méthode close() de la classe B.
public class C extends B implements Readable {
@Override
public int read(java.nio.CharBuffer cb) throws IOException {
// quelque implementation ici
}
}

Et ainsi de suite… La récente version de Java 8 traitait quelque peu du problème posé par l’introduction de méthodes par défaut. En raison des méthodes par défaut, les interfaces ont commencé à fournir non seulement un contrat, mais également une implémentation. Par conséquent, les classes qui implémentent ces interfaces héritent également automatiquement de ces méthodes implémentées. Par exemple:


package com.javacodegeeks.advanced.design;

public interface DefaultMethods extends Runnable, AutoCloseable {
@Override
default void run() {
// quelque implementation ici
}

@Override
default void close() throws Exception {
// quelque implementation ici
}
}

// La classe C hérite de l'implémentation des méthodes run () 
// et close () de l'interface DefaultMethods.
public class C implements DefaultMethods, Readable {
@Override
public int read(java.nio.CharBuffer cb) throws IOException {
// quelque implementation ici
}
}

Sachez que l’héritage multiple est à la fois un outil puissant, mais dangereux à utiliser. Le problème bien connu du «diamant de la mort» est souvent cité comme la faille fondamentale des implémentations à héritages multiples, aussi les développeurs sont-ils instamment priés de concevoir les hiérarchies de classes avec le plus grand soin. Malheureusement, les interfaces Java 8 avec des méthodes par défaut sont également victimes de ces failles.


interface A {
default void performAction() {
}
}

interface B extends A {
@Override
default void performAction() {
}
}

interface C extends A {
@Override
default void performAction() {
}
}

Par exemple, l’extrait de code suivant ne parvient pas à être compilé:


// E n'est compilable que s'il remplace également performAction ()
interface E extends B, C {
}

À ce stade, il est juste de dire que Java en tant que langage a toujours essayé d’échapper aux cas délicats de la programmation orientée objet, mais à mesure que le langage évolue, certains de ces cas commencent à apparaître.

11. Héritage et composition

Heureusement, l’héritage n’est pas le seul moyen de concevoir vos classes. Une autre alternative, que de nombreux développeurs considèrent comme meilleure que l’héritage, est la composition. L’idée est très simple: au lieu de construire des hiérarchies de classes, les classes devraient être composées à partir d’autres classes.

Regardons cet exemple:


public class Vehicle {
private Engine engine;
private Wheels[] wheels;
// ...
}

La classe Vehicle est composée de engineet wheels (et de nombreuses autres parties laissées de côté pour des raisons de simplicité). Cependant, on peut dire que la classe Vehicle est aussi un moteur et pourrait donc être conçue en utilisant l’héritage.


public class Vehicle extends Engine {
private Wheels[] wheels;
// ...
}

L’héritage a sa propre place, résout différemment les problèmes de conception réels et ne doit pas être négligé. N’oubliez pas ces deux alternatives lors de la conception de vos modèles orientés objet.

12. L’encapsulation

Le concept d’encapsulation dans la programmation orientée objet consiste à cacher les détails de la mise en œuvre (tels que l’état, les méthodes internes, etc.) au monde extérieur. Les avantages de l’encapsulation sont la facilité de maintenance et de modification. Moins les détails d’une classes sont exposées, plus les développeurs ont le contrôle sur la modification de leur implémentation interne, sans crainte de casser le code existant .

L’encapsulation en Java est réalisée à l’aide de règles de visibilité et d’accessibilité. En Java, il est généralement recommandé de ne jamais exposer les champs directement, uniquement à l’aide de getters et de setters (si le champ n’est pas déclaré comme final). Par exemple:


package com.javacodegeeks.advanced.design;

public class Encapsulation {
private final String email;
private String address;

public Encapsulation( final String email ) {
this.email = email;
}

public String getAddress() {
return address;
}

public void setAddress(String address) {
this.address = address;
}

public String getEmail() {
return email;
}
}

Cet exemple ressemble à ce qui est appelé JavaBeans en langage Java: les classes Java classiques écrites en respectant l’ensemble des conventions, l’une d’entre elles permettant l’accès aux champs à l’aide des méthodes getter et setter uniquement.

Comme nous l’avons déjà souligné dans la section Héritage , essayez de toujours minimiser le contrat public de classe, en respectant le principe d’encapsulation. Ce qui ne devrait pas être public devrait être private . À long terme, cela rapportera, en vous laissant la liberté de faire évoluer votre conception sans introduire de changements brusques (ou du moins les minimiser).


Articles similaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Bouton retour en haut de la page