
Introduction
Bibliographie et sources
- Sources d'inspiration pour ce cours
- Quelques références utiles
Dualité de Java
Désigne à la fois :
- Un langage de programmation (orienté objet)
-
Une spécification de machine virtuelle (Java Virtual Machine)
- Existence de nombreux compilateurs permettant de compiler du code dans des langages divers (Python, Ruby...) en bytecode Java
-
Une bibliothèque standard (API) fournie par Java Standard Edition (JSE) permettant :
- de manipuler des fichiers et des flots d'octets et de caractères (java.io, java.nio),
- de gérer des collections d'éléments (java.util)
- de réaliser des communications réseau (java.net), de mener des opérations cryptographiques (javax.crypto)
- ... et bien plus en utilisant Java Enterprise Edition qui introduit des APIs pour créer des applications web
Évolution de Java
- Projet Java initié en 1991 par James Gosling, Mike Sheridan et Patrick Naughton
- Première version (1.0) de Java (langage et VM) apparue en 1996
-
Publication régulière de nouvelles versions avec des évolutions concernant :
- le langage (nouvelles constructions syntaxiques)
- les instructions de la machine virtuelle (JVM)
- et les APIs de Java Standard Edition
Évolutions notables du langage :
- 1 : support des classes internes (classe imbriquée dans une autre classe)
- 4 : support des assertions (mot-clé assert) pour vérifier des invariants à l'exécution (et détecter des bugs)
-
5 :
- support des generics pour limiter les casts (très utiles pour l'API collections) ; modification uniquement syntaxique sans impact sur la VM
- ajout des annotations pour rajouter des métadonnées sur les classes, méthodes et champs
- ajout des énumérations (enum)
- système de boxing/unboxing pour la conversion automatique entre types primitifs et types réifiés (int ←> Integer)
- nouvelle boucle for each pour itérer sur les éléments d'un tableau ou d'une collection
- support des varargs sur les méthodes (passage multiple d'arguments sous forme de tableau)
- imports statiques
-
7 :
- ajout de la syntaxe try-with-resource pour libérer automatiquement une ressource à la fin d'un bloc try
- support des strings dans le switch-case
-
8 :
- support des expressions lambdas
- support des mixins sur les interfaces (interfaces avec méthodes par défaut implantées)
-
10 :
- inférence automatique du type des variables locales avec déclaration var (e.g. var a = new ArrayList<String>())
-
14 :
- switch-case fléchés
-
15 :
- support des blocs de textes ("""bloc de texte""")
-
16 :
- support des classes record
- pattern-matching avec instanceof
-
17 :
- support des classes scellées
Évolutions notables de la Java Virtual Machine (JVM) :
- 2 : introduction de la compilation Just In Time (optimisation des portions de code exécutées souvent)
- 7 : ajout de l'instruction invokedynamic pour choisir les méthodes à appeler à l'exécution (utilité pour les langages dynamiques de script)
Evolutions notables de l'API :
-
2 :
- ajout de l'API collections (listes, ensembles, dictionnaires...)
- introduction de l'API Swing pour construire des interfaces graphiques
-
4 :
- ajout de classe Pattern pour gérer les expressions régulières
- support du protocole IPv6 par java.net
- ajout de l'API d'entrées/sorties non-bloquantes (java.nio)
- ajout de l'API cryptographique (javax.crypto)
-
7 :
- nouvelle API de manipulation de fichiers (java.nio.file)
-
8 :
- support des streams (sortes d'itérateurs d'éléments potentiellement infinis)
- nouvelle API pour les heures et dates
-
9 :
- modularisation de l'API (projet Jigsaw)
-
18 :
- API pour les calculs vectoriels
Évolutions notables des outils du JDK :
- 9 : ajout de jshell (outil REPL pour exécuter interactivement des instructions Java)
- 11 : exécution directe d'un fichier Java avec java --source (utilisation possible d'un shebang)
Processus d'évolution de Java :
- Java Enhancement Proposal (JEP) : document proposant des idées pour améliorer Java à l'avenir (liste ici]
- Java Specification Request (JSR) : proposition d'implantation d'une nouvelle fonctionnalité mature (liste ici)
L'inévitable Hello World en Java
// @run: HelloWorld UPEM public class HelloWorld { public static void main(String[] args) { System.out.println(String.format("Hello World %s", args[0])); } }
Quelques remarques :
- En Java, l'unité de compilatiom élémentaire est la classe
- La classe doit être écrite dans un fichier source éponyme d'extension .java (ici HelloWorld.java)
- Le point d'entrée d'un programme Java est la méthode statique main recevant les arguments de la ligne de commande dans le tableau String[] args
Génération du bytecode à partir de la classe Java
Obtention du fichier bytecode HelloWorld.class à partir du code source HelloWorld.java
Utilisation du compilateur Javac livré avec le Java Development Kit (JDK) :
- javac HelloWorld.java : génération de HelloWorld.class dans le répertoire courant
- javac -d bin src/HelloWorld.java : génération de HelloWorld.class dans le répertoire bin (bonne pratique de séparer répertoires de source et de bytecode)
Décompilation de HelloWorld.class avec javap -c HelloWorld.class afin de visualiser le bytecode :
Compiled from "HelloWorld.java" public class HelloWorld { public HelloWorld(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello World %s 5: iconst_1 6: anewarray #4 // class java/lang/Object 9: dup 10: iconst_0 11: aload_0 12: iconst_0 13: aaload 14: aastore 15: invokestatic #5 // Method java/lang/String.format:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String; 18: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 21: return
Remarque :
-
La compilation d'un fichier .java peut générer plusieurs fichiers de bytecode .class :
- Un fichier de bytecode .class pour la classe principale
- D'éventuels fichiers .class pour les classes internes et classes anonymes (MyClass$SubClass.class, MyClass$0.class...)
Exécution du bytecode
Exécution du bytecode avec la machine virtuelle Java en utilisant java :
- java HelloWorld arg0 arg1... si la classe HelloWorld est dans le répertoire courant
- java -cp bin HelloWorld arg0 arg1... si la classe HelloWorld est dans le répertoire bin/
Raccourci possible depuis Java 11 en une seule commande pour la compilation et l'exécution (ne fonctionne que pour les classes solitaires) : java HelloWorld.java arg0 arg1...
Possibilité de rendre une classe Java exécutable depuis Java 11 (systèmes Unix) :
- Ajout d'un shebang en tête du fichier (1ère ligne) : #!/path/to/java --source version
- Ajout de la permission d'exécution (x) sur le fichier : chmod u+x HelloWorld
- Exécution de la classe depuis un shell : ./HelloWorld
A propos du classpath
- classpath : ensemble de répertoires et fichiers JAR où le compilateur/la VM recherche les classes
- Possibilité de définir plusieurs lieux de recherche séparés par des :(e.g. .:/tmp/foo.jar:bin : recherche dans le répertoire courant, puis dans le jar /tmp/foo.jar, puis le répertoire bin)
- classpath spécifiable avec l'option de ligne de commande -cp rep0:rep1:rep2:...
Utilisation de Maven
A propos de Maven
- Outil de construction de projets Java (fonctionne aussi pour les projets utilisant d'autres langages)
- Permet de compiler, tester, exécuter... un projet en une ligne de commande (indépendamment d'un environnement de développement)
- Outil développé en langage Java
-
Promeut une convention pour l'organisation des fichiers en répertoires
- Sources contenues dans le répertoire src/main/java/nom/du/paquetage
- Répertoire src/test/java/nom/du/paquetage pour les tests unitaires
- Ressources (fichiers XML, images, sons... utiles pour l'application) contenues dans le répertoire src/main/resources/nom/du/paquetage
- Est supporté par la plupart des outils de développement (Eclipse, IntelliJ IDEA, NetBeans...)
-
Propose un ensemble de phases applicables pour construire, tester et exécuter le projet :
- mvn validate : vérifie que le projet est complet (présence des fichiers de configuration)
- mvn compile : compilation des sources
- mvn test : exécution des tests unitaires (notamment présents dans src/test/java)
- mvn package : création d'un fichier distribuable permettant d'exécuter le projet (fichier JAR contenant le bytecode du projet Java ainsi que des métadonnées)
- mvn verify : utilisé pour exécuter des tests d'intégration
- mvn install : installation du paquetage du projet dans le dépôt de bibliothèques local (dans $HOME/.m2/repository)
- mvn deploy : déploie le projet vers un dépôt distant
- mvn javadoc:javadoc : pour générer la Javadoc d'un projet (dans le répertoire apidocs/)
- mvn clean : efface tous les fichiers de compilation (répertoire build)
-
Lorsqu'une phase est lancée, des plugins liés à cette phase peuvent être exécutés
- Il existe des plugins pour différents langages de programmation (dont Java bien sûr)
-
Des tâches de plugins peuvent être lancées en les indiquant, par exemple pour le plugin Java :
- mvn javadoc:javadoc : pour générer la Javadoc d'un projet (dans le répertoire apidocs/)
- mvn exec:java -Dexec.mainClass="fr.uge.myapp.HelloWorld" -Dexec.args="myArg1 myArg2 : pour compiler et exécuter le main d'une classe Java avec les arguments indiqués
Création d'un projet Maven
-
En utilisant son environnement de développement favori (Eclipse, IntelliJ IDEA...)
- qui propose généralement un assistant de création de projet Maven,
- ainsi qu'une interface graphique pour lancer des phases et tâches Maven
-
Depuis la ligne de commande ; crééons par exemple une application helloworld-app avec l'archétype maven-archetype-quickstart :
- mvn archetype:generate -DgroupId=fr.uge.helloworld -DartifactId=helloworld-app -DarchetypeArtifactId=maven-archetype-quickstart
- Un répertoire helloworld-app est créé avec une structure de répertoires pour le projet et un fichier de configuration pom.xml
- Organisation obligatoire des classes en paquetages lors que l'on utilise Maven
Utilisation de bibliothèques
- Possibilité d'importer des bibliothèques d'un dépôt externe
- Principal dépôt externe : Maven Central Repository (plusieurs millions de projets)
Rajout d'une dépendance (bibliothèque téléchargée depuis Maven Central) dans le pom.xml (exemple avec jollyday) :
<dependency> <groupId>de.jollyday</groupId> <artifactId>jollyday</artifactId> <version>0.5.10</version> </dependency>
Génération de fat jar
- Fichier jar : archive respectant le format ZIP contenant toutes les classes compilées (fichiers .class) d'un projet
- Exécution des méthodes main des classes d'un projet possible grâce à son fichier jar...
- ...mais il faut également disposer dans son classpath de toutes les dépendances du projet (peu pratique)
- Solution : générer avec un fat jar comprenant toutes les dépendances...
- ...le fichier est plus lourd mais est suffisant pour exécuter l'application sur une machine avec une installation de Java
Génération d'un fat jar avec Maven :
- On modifie le fichier pom.xml pour ajouter maven-shade-plugin comme expliqué ici
- On génére le jar avec mvn package
- Le fichier jar peut être trouvé dans le répertoire target
Langages de programmation
Spécifité des langages
-
Pourquoi des langages de programmation ?
- Pour permettre à un humain de spécifier comment réaliser le traitement d'informations de toute nature...
- ...en essayant d'être plus proche de la spécification du problème que du fonctionnement interne du microprocesseur
- Utilisation d'un compilateur pour traduire le code du langage human-friendly vers un format compréhensible par le microprocesseur (ou une machine virtuelle)
-
Deux familles principales de langages
-
Langage généraliste : traitement de tout type de problème calculatoire
- Propriété de Turing-complétude
- Analyse statique des programmes limitée (par exemple impossible de garantir qu'un programme s'arrête un jour)
-
Langage dédié (Domain Specific Language) : langage centré sur un domaine d'application particulier (description de page web, de problème d'optimisation, fichier de configuration...)
- Syntaxe spécifique adaptée aux problèmes traités
- Facilitation de l'analyse statique
- Présence d'une API adaptée au domaine envisagé
-
Langage généraliste : traitement de tout type de problème calculatoire
Différents styles
-
Programmation impérative
- Séquences d'instructions avec structures de contrôle (conditions, boucles) et effet de bord sur la mémoire, regroupées logiquement en procédures
- Ex : Fortran, Pascal, Basic, C...
-
Programmation fonctionnelle
- Composition de fonctions : évaluation avec arguments sans effet de bord
- Ex : OCaml, Haskell, Erlang...
-
Programmation déclarative (généralement utilisée pour les DSL)
- Description du résultat souhaité pour des domaines spécifiques
- Turing-complétude non recherchée en général
- Ex : PDF, HTML, SVG, Prolog...
-
Programmation orientée-objet
- Données en mémoire organisées sous forme d'entités objet avec des méthodes réalisant des opérations sur ceux-ci
- Ex : Smalltalk, C++, Java, Python, Scala...
Quelques exemples de tâches de programmation classiques réalisées en utilisant différents langages sur Rosetta Code (site de chrestomathie de langages).
Quel langage choisir ?
Cela dépend des applications et contraintes :
- Programmation procédurale utile pour la réalisation rapide de scripts
- Programmation fonctionnelle prouvable et moins sujette à des bugs (pas d'effet de bord), utile pour manipuler des structures de données récursives (liste, arbres…)
- Programmation déclarative adaptée pour des applications spécifiques (DSL) : description de pages, fichiers de configuration, description de problèmes à résoudre…
- Programmation objet : permet de modéliser différentes entités interagissant entre-eux, utile pour des applications modulaires et évolutives
-
Approches hybrides possibles avec certains langages :
- Scala par exemple permet de combiner fonctionnel et objet
- Les annotations de certains langages (Java, Python...) permettent une programmation déclarative...
- ...
Mixer des langages dans un même projet ?
- Quelquefois intéressant...
- Par exemple de nombreux langages utilisent la JVM et peuvent cohabiter harmonieusement : Java, Kotlin, Scala, Jython, Groovy...
Un peu d'amusement : code golf
- Objectif : trouver le plus petit code source traitant un problème donné (avec un langage imposé ou libre)
- Quelques conseils pour golfeurs javanais ici
- Un petit exemple avec un Hello World en Java (76 caractères) :
- class H{public static void main(String[]a){System.out.print("Hello, World!");}}
-
Quel est le plus petit programme Java compilable ?
- Dans un shell : cd /tmp; touch MiniProg.java; javac MiniProg.java
- Soyons sérieux : mieux vaut du code verbeux compréhensible que du code verbeux ou court ésotérique
Problématique d'évolutivité de projet
Un projet naît, vit, évolue, grandit... mais on souhaiterait éviter qu'il meure. Comment faire ?
-
Réduire le coût de maintenance et améliorer la fiabilité
- Meilleure lisibilité et compréhension des programmes
- Réduction du nombre de bus et correction plus rapide des bugs résiduels
-
Promouvoir l'extensibilité de projets par la modularité
- Ajout aisé de nouvelles fonctionnalités
- Réutilisation possible de modules dans différents contextes
- Pas de perturbation sur les fonctionnalités antérieures (pas de régression)
-
Quelques pistes de réflexion :
- Isoler les différentes préoccupations d'un projet (découplage) en différents modules indépendants
-
Réduire le volume du code par la promotion du code générique (abstrait) qui peut ensuite être spécialisé
- Plusieurs versions d'un module assurant la même tâche mais de façon différente peuvent être implantés (exemple : jeu d'échecs avec différents moteurs d'IA)
- Réaliser du code documenté pour permettre, à soi-même ou à d'autres de recomprendre le code et faire des évolutions futures (cf approche de programmation lettrée promue par D. Knuth)
- Mettre en œuvre des tests afin de détecter au plus tôt d'éventuelles régressions lors d'implantation de nouvelles fonctionnalités
Programmation par classe
-
Éléments d'une classe :
- Données (état) sous la forme de champs (ou attributs)
- Méthodes agissant sur ces données
-
Plusieurs instances d'une même classe
- Par défaut la valeur des champs est propre à chaque instance...
- sauf pour les membres noté static (valeur de classe)
- Séparation des différents concepts et responsabilités
- Réduction de dépendances entre composants
- Protection des données par cloisonnement (politique de visibilité)
- Réutilisation des composants avec assemblage par composition et délégation
- Abstraction/spécialisation avec le concept d'héritage pour du code plus générique avec masquage des détails d'implantation
Types
Quelques généralités sur les types
-
Type
- Valeurs possibles d'une donnée en mémoire (entier, flottant, chaîne de caractères...)
- Opérateurs, fonctions, méthodes pouvant être appliqués sur cette donnée
-
Détermination des types des variables
-
Spécification explicite : les variables sont déclarées avec leur type
- Comportement standard en Java pour déclarer les champs et les variables locales
-
Inférence de types à la compilation : détermination en fonction des opérateurs
- Exemple en Java : var myIntSet = new HashSet<Integer>() (le compilateur "devine" le type de myIntSet grâce au new)
- Détermination dynamique à l'exécution
-
Spécification explicite : les variables sont déclarées avec leur type
-
Vérification des types nécessaire pour sélectionner les opérateurs, tester leur adéquation et cohérence
- Au moment de la compilation : typage statique (langages Pascal, C, C++ sans RTTI, Haskell, Caml…)
- Au moment de l'exécution : typage dynamique (généralement langages de script : Python, Ruby, PHP…)
- À la compilation et à l'exécution : approche hybride pour plus de flexibilité (langages C++ avec RTTI, Java, Scala, C#…)
-
Force du typage variable avec plus ou moins de conversions implicites d'opérande ; quelques exemples :
- En Java, conversion automatique de int en String : System.out.println("" + 1)
- En Python : print("" +1) → TypeError: Can't convert 'int' object to str implicitly
- En JavaScript : "1" + 1 → "11" ; "1" - 1 → 0
-
Pour ou contre le typage statique ?
- Repérage précoce de bugs dès la compilation : code généralement plus fiable (moins de surprises à l'exécution)
- Code compilé plus rapide : connaître les types permet de savoir à l'avance les fonctions à appeler, améliore les possibilités d'optimisation à la compilation
- Promeut une plus grande rigueur du développeur : effort de planification plus important
- Le code peut être plus difficile à adapter rapidement pour le rajout de fonctionnalités (limite le bidouillage rapide)
Typage statique en Java... mais la JVM supporte les langages à typage dynamique (notamment avec l'instruction de bytecode invokedynamic pour appeler une méthode dont on ne connaît pas à la compilation le type des arguments)
Typage en Java
-
Spécification explicite des types
- Déclaration des champs et variables locales préfixés par leur type
- Depuis Java 10, possibilité d'utiliser le mot-clé var à la place du type pour l'inférer depuis l'initialisateur (ne fonctionne que pour les variables locales)
-
Vérification du typage à la compilation
- Refus de compilation en cas d'erreur de typage
-
Pas de possibilité d'erreur de typage à l'exécution, sauf :
- En cas de coercition (cast) descendante
- En cas de déréférencement d'une référence nulle (fameuse exception NullPointerException)
-
Système de types paramétrés (generics) depuis Java 5 (ArrayList<String>, HashMap<Integer, String>...)
- generics = sucre syntaxique (i.e. impact uniquement syntaxique sans modification de la machine virtuelle)
- Utile pour la vérification statique du typage par le compilateur
- Aucun paramétrage de type présent dans le bytecode produit par le compilateur (type erasure)
- Projet d'amélioration des generics : Valhalla pour introduire des types valeurs (s'utilisant comme des types primitifs)
Déclarons des variables locales
// @run: VarTest // This class cannot be tested with Doppio JVM (using features from the JDK 10) public class VarTest { public static void main(String[] args) { int i = 42; // déclaration avec initialisation immédiate int j; // déclaration avec initialisation retardée (valeur par défaut: 0) System.out.println("i=" + i + ",j=" + j); ArrayList<Integer> l1 = new ArrayList<>(); // déclaration d'une liste (initialisée comme une ArrayList) List<Integer> l2 = l1; // possible car ArrayList hérite de l'interface List (coercition ascendante) // Inférence automatique du type depuis Java 10 en utilisant le type de l'initialisation var set = new TreeSet<String>(); // déclaration et initialisation d'un TreeSet vide de String var set2 = new TreeSet<>(); // impossible car le type des éléments dans le set n'est pas indiqué var k1 = 128; // déclaration et initialisation d'un int var k2 = 128L; // déclaration et initialisation d'un long var k3 = 128.0; // déclaration et initialisation d'un double var k4 = 128.0f; // déclaration et initialisation d'un float var s; // impossible car le type n'est pas inférable (pas d'initialisation) } }
Deux types de types en Java
Les types valeur (types primitifs)
- Stockage direct d'une variable ou champ de type valeur dans la mémoire
- Pas de possibilité de créer un type valeur personnalisé en Java (réflexions en cours pour un JDK futur)
-
Types valeurs proposés par le langage
-
Entiers signés : byte (8 bits), short (16 bits), int (32 bits), long (64 bits)
- Les entiers non-signés n'existent pas en Java (par exemple pour représenter 2^31, il faut utiliser un long)
- Et pour représenter 2^63 ? Un long ne suffit plus... il faut utiliser un BigInteger (entier de taille arbitraire) : ce n'est plus un type primitif !
-
Flottants : float (32 bits), double (64 bits)
- Représentation avec un bit de signe, une mantisse et un exposant en base 2 selon le standard IEEE 754
- Erreurs d'arrondis possibles... toujours privilégier les entiers aux flottants lorsque c'est possible (par exemple pour la manipulation de devises)
- BigDecimal permet de représenter des flottants décimaux avec une précision arbitraire (fraction numérateur/dénominateur ; le numérateur est stocké avec un BigInteger, le dénominateur est de la forme 10^(-x) avec x stocké par un int)
-
Caractère : char (représentation UTF-16 légèrement modifiée, codé sur 16 bits)
- Permet de représenter un caractère Unicode (certains caractères rares peuvent nécessiter 2 chars)
- Booléen : boolean (true, false)
-
Entiers signés : byte (8 bits), short (16 bits), int (32 bits), long (64 bits)
Les types référence (type objet)
- Référence vers un objet (classe instanciée)
-
Référence null
- Les explications de Tony Hoare en 2009
- Ne pas appeler de méthode sur une référence nulle : NullPointerException (exception la plus courante en Java) !
- Les tableaux de primitifs (int[], float[]...) ou d'objets (Object[], String[]...) sont des types référence
- Depuis Java 5, les types référence peuvent être paramétrés par des types référence (mais pas valeur). Quelques exemples : Map<String, Integer>, List<Object>, Set<int[]>...
-
Stockage indirect d'une variable ou champ de type objet
- Une référence est stockée et pointe vers l'endroit du tas où l'objet est présent
- Plusieurs variables de type référence peuvent pointer vers le même objet (mais l'objet est présent en un unique exemplaire)
- Lorsque l'objet n'est plus accessible par une variable (directement ou indirectement), il est détruit par le ramasse-miettes
Les types valeur réifiés
Présentation
-
Les types primitifs ont leur alter-ego réifié (dits boxed) :
- int → Integer, long → Long, short → Short, byte → Byte
- float → Float, double → Double
- char → Character
- boolean → Boolean
- Cette version réifiée s'utilise comme un type référence
- Les objets de types réifiés sont immuables : leur valeur ne peut pas changer
- Pour changer la valeur de l'objet lié à une variable, un nouvel objet doit être créé en mémoire
-
L'usage de type valeur n'est pas autorisé pour paramétrer des types : seuls les types objets peuvent être utilisés
- List<Integer> l = new ArrayList<>() est possible
- List<int> l = new ArrayList<>() est interdit
-
Depuis Java 5, la conversion d'un type valeur vers réifié (boxing) ou le contraire (unboxing) est gérée automatiquement par le compilateur
- Méfiance lors de l'unboxing : la variable peut être une coquille vide (référence nulle)
Consommation mémoire
Les types réifiés sont plus consommateurs de mémoire que les types valeur :
- La déclaration de variable locale de type valeur int x = 0 consomme 32 bits sur la pile d'appel
- La déclaration de variable locale de type réifié Integer y = 0 consomme 64 bits sur la pile d'appel pour la référence (taille d'un pointeur sur une architecture 64 bits) ainsi que l'espace occupé par l'objet Integer sur le tas (32 bits + l'en-tête)
- Bilan : 7 fois plus de mémoire occupée pour Integer que pour int
Petit exercice : comment stockeriez-vous en mémoire le génôme complet d'un être humain (génôme ~ 4 milliards de bases pouvant prendre 4 valeurs A, C, G ou T) ?
Un (malheureux) exemple d'unboxing
List<Integer> l = new ArrayList<>(); ... // code ajoutant des éléments dans la liste: l.add(null); l.add(1); l.add(new Integer(7)); ... Integer i = l.get(0); // pour obtenir le 1er élément de la liste int v = i * i; // on calcule le carré de l'élément (unboxing automatique) // l'unboxing échoue avec NullPointerException si i est une référence nulle
Les méthodes
Définition d'une méthode
Une méthode se définit par les informations suivantes :
-
Par sa signature : visibility modif1 modif2 ... retype name(T1 par1, T2 par2, ...) throws E1, E2,...
- Sa visibilité : par défaut si aucun modificateur de visibilité, private, protected ou public
-
Ses modificateurs (on peut en utiliser aucun ou plusieurs) :
- abstract : la méthode n'est pas implantée, on ne fait que la déclarer (la charge d'implantation repose sur les classes dérivées)
- final : la méthode n'est pas redéfinissable dans une classe dérivée
- static : la méthode agit sur la classe et pas sur un objet instantié (accès aux seuls champs statiques)
- synchronized : une seule thread peut exécuter la méthode à un instant donné
- Son type de retour (retype) : type primitif, type objet ou void si rien n'est retourné
- Son nom (name) : caractères alphanumériques et _ autorisés
-
Ses paramètres, pour chaque paramètre i, nous indiquons :
- Le type Ti du paramètre
- Le nom du paramètre
- Ses exceptions levables : E1, E2...
-
Par son implantation (corps de la méthode) entourée par des accolades : { ... }
- Dans le cas d'une méthode abstraite, aucune implantation n'est fournie
Code idiomatique (boilerplate)
Le code et méthodes présentés ci-après peuvent être (plus ou moins intelligemment) générées automatiquement par un environnement de développement tel qu'Eclipse ou IntelliJ IDEA.
En-tête de fichier
Déclaration de package
Une classe peut être :
- dans le paquetage par défaut (par d'organisation en paquetages)
- dans une hiérarchie de paquetages
Dans le second cas, la déclaration du paquetage en tête de fichier est obligatoire.
Par exemple si HelloWorld est dans le paquetage fr.upem.greeter, nous crééons le fichier HelloWorld.java dans le répertoire src/fr/upem/greeter.
Le fichier HelloWorld.java doit commencer par la ligne :
package fr.upem.greeter;
Importations
Les directives d'importation sont placées en tête du fichier après l'éventuelle déclaration de paquetage.
Importations de classes
Une classe manipulée doit être importée :
- si elle n'est pas présente dans le paquetage courant
- et si elle n'est pas présente dans le paquetage spécial java.lang de l'API (importation implicite dans ce cas)
- Seule une classe déclarée publique peut être importée d'un autre paquetage.
Deux types d'importations :
- Importation d'une classe particulière. Exemple : import java.util.Scanner
- Importation de toutes les classes d'un paquetage. Exemple : import java.util.*
Il faut éviter les importations wildcard car pouvant provoquer des conflits de noms potentiels.
☞ On peut laisser l'environnement de développement réaliser automatiquement les imports (avec l'autocomplétion)
Exemple de conflit : comment utiliser java.util.List et java.awt.List dans le même fichier ?
On peut importer une des deux classes et utiliser le nom complet de l'autre
import java.util.List; public class NameClash { public static void main(String[] args) { List<String> l = ...; java.awt.List l2 = ...; ... } }
⚠ Pour limiter les ambiguïtés, on évitera de nommer ses propres classes avec des noms déjà utilisés dans la bibliothèque standard.
Importation de membres statiques
On peut importer des membres statiques (champs, méthodes) avec import static.
Par exemple pour importer la constante Pi : import static java.lang.Math.PI
ou si l'on veut importer toutes les méthodes statiques et constantes de Math : import static java.lang.Math.*
ce qui est pratique pour faire des calculs mathématiques :
import static java.lang.Double.parseDouble; import static java.lang.Math.*; // @run: Haversine 36.12 -86.67 33.94 -118.40 /** Copied from https://rosettacode.org/wiki/Haversine_formula#Java */ public class Haversine { public static final double R = 6372.8; // In kilometers, radius of the Earth public static double haversine(double lat1, double lon1, double lat2, double lon2) { double dLat = toRadians(lat2 - lat1); double dLon = toRadians(lon2 - lon1); lat1 = toRadians(lat1); lat2 = toRadians(lat2); double a = pow(sin(dLat / 2),2) + pow(sin(dLon / 2),2) * cos(lat1) * cos(lat2); double c = 2 * asin(sqrt(a)); return R * c; } public static void main(String[] args) { System.out.println(haversine(parseDouble(args[0]), parseDouble(args[1]), parseDouble(args[2]), parseDouble(args[3]))); } }
Méthodes
Getter
Un getter permet de récupérer un attribut de la classe.
Stratégies d'implantation de getter :
- Retour direct de champ : public int getX() { return this.x; }
- Calcul d'après des champs : public int getMaxCoord() { return Math.max(this.x, this.y); }
- Calcul paresseux avec stockage dans un champ :
package fr.upem.jacosa.general; // @run: Point public class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } private int maxCoord = Integer.MIN_VALUE; public int getMaxCoord() { if (maxCoord == Integer.MIN_VALUE) // Integer.MIN_VALUE is a forbidden value maxCoord = Math.max(this.x, this.y); return maxCoord; } }
Passage possible d'une stratégie à une autre sans modifier le code externe (avantage par rapport à l'utilisation directe d'un champ).
Setter
Un setter permet de modifier un attribut de la classe pour lui attribuer une nouvelle valeur.
Créer un setter n'a de sens que pour un champ modifiable (pas de setter pour un champ final).
Remarque : certains langages permettent d'appeler des getters/setters avec une notation de champs (Kotlin, Python...) mais ce n'est pas (encore) le cas de Java.
equals et hashCode
equals
-
Méthode equals de signature suivante définie dans la classe Object :
- public boolean equals(Object other)
- Implantation par défaut : teste l'égalité par référence (est-ce que other == this ?)
-
Comportement recommandé : tester l'égalité par contenu (la valeur des champs est la même ?)
-
On teste si les classes de this et other sont les mêmes
- Il faut utiliser this.getClass().equals(other.getClass())
- other instanceof MyClass est à proscrire car brisant la symétrie de equals en cas d'héritage
- On teste l'égalité par valeur sur les champs de type primitif
- On teste l'égalité par contenu sur les champs objet en appelant this.fieldName.equals(other.fieldName)
-
On teste si les classes de this et other sont les mêmes
Contrat demandé pour la méthode equals() :
- Réflexivité : une instance est toujours égale à elle même ; ainsi a.equals(a) est toujours vrai
- Symétrie : a.equals(b) == b.equals(a)
- Transitivité : si a.equals(b) et b.equals(c) alors a.equals(c)
Pièges courants :
-
Ecrire une méthode equals ne testant préalablement pas la nullité de son paramètre
- Astuce : utiliser la méthode statique Objects.equals(Object a, Object b) qui teste la nullité
-
Tester l'égalité par référence et pas par contenu sur les champs
- En particulier pour String : toujours évaluer str1.equals(str2) (égalité par contenu) et pas str1 == str2 (égalité par référence)
- Ecrire une méthode equals non-symétrique : nous devons absolument avoir a.equals(b) == b.equals(a)
- Ecrire une méthode equals avec un paramètre d'un type différent de Object : une telle méthode ne redéfinit plus la méthode boolean equals(Object o) de l'ancêtre (introduction de polymorphisme) ; il s'agit alors d'une nouvelle méthode (inutile)
- Ne pas prendre en compte la possibilité de références circulaires : dans ce cas, une mauvaise implantation de equals peut provoquer des appels récursifs infinis
Comparaison avec d'autres langages :
- Les langages qui autorisent la surcharge d'opérateurs utilisent généralement == comme opérateur d'égalité de contenu
- Un autre opérateur est utilisé pour l'égalité par référence : === (Scala, Kotlin), is (Python)...
Exemple 1 : comparons deux personnes
package fr.upem.jacosa.general; import java.util.Objects; class GenericPerson { private final String name; private final int birthYear; public GenericPerson(String name, int birthYear) { this.name = name; this.birthYear = birthYear; } @Override public boolean equals(Object other) { if (this == other) return true; // pas la peine de se fatiguer plus longtemps si les références sont égales if (other == null) return false; // forcément this != null // le test qui suit garantit le respect de la symétrie en cas de descendance // if (! other instanceof Person) return false; // à éviter car cela brise la symétrie en cas d'héritage if (! getClass().equals(other.getClass())) return false; // les classes des deux objets doivent être identiques // si les classes des deux objets sont les mêmes, on est sûr de la symétrie : la méthode equals de this est la même que celle de other GenericPerson other2 = (GenericPerson)other; // cast nécessaire, aucun risque d'exception car on a préalablement testé l'égalité des classes return Objects.equals(name, other2.name) && birthYear == other2.birthYear; // on compare le contenu } } class Student extends GenericPerson { private final String curriculum; public Student(String name, int birthYear, String curriculum) { super(name, birthYear); this.curriculum = curriculum; } @Override public boolean equals(Object other) { // Exploitons l'implantation de la classe ancêtre... et ajoutons la comparaison du curriculum return super.equals(other) && Objects.equals(curriculum, ((Student)other).curriculum); } } public class StudentTest { public static void main(String[] args) { Person p = new Person("John Doe", 1995); Student s = new Student("John Doe", 1995, "DUT2info"); System.out.println("p.equals(s) ? " + p.equals(s)); System.out.println("s.equals(p) ? " + s.equals(p)); } }
Exemple 2 : comparons deux chaînes de caractères
package fr.upem.jacosa.general; public class StringEquality { public static final String S1 = "toto"; public static final String S2 = "toto"; public static final String S3 = "tototo"; public static void main(String[] args) { System.out.println(S1 == S2); // true car une seule chaîne conservée pour S1 et S2 (même référence) System.out.println(S1.equals(S2)); // true String s4 = S3.substring(2); // contenu de s4: toto System.out.println(S1 == s4); // false car la chaîne s4 a été créé dynamiquement System.out.println(S1.equals(s4)); // true car même contenu des chaînes } }
hashCode
Contrat de la méthode public int hashCode() :
- Retourne une valeur de hachage entière
- Implantation par défaut dans Object basée sur l'adresse mémoire de l'objet ; pas de garantie d'unicité de hashCode si beaucoup d'objets en mémoire
-
a.equals(b) implique a.hashCode() == b.hashCode()
- Si ce n'est pas respecté, des structures de données utilisant le hashCode poseront des problèmes (HashSet, HashMap...)
-
La réciproque est fausse : deux objets de même valeur de hachage peuvent être différents
- Il s'agit d'une collision qu'il est préférable d'éviter
Comment calculer la valeur de hachage d'un objet ?
- > En combinant les valeurs des champs (valeur, hashCode) pour créer une valeur de hachage peu soumise aux collisions
-
Si c est composé de a et b, comment combiner h(a) et h(b) pour calculer h(c) ?
- h(c) = h(a) + h(b)
- h(c) = h(a) ^ h(b)
- h(c) = h(a) * p + h(b) (avec p premier)
Exemple sur Person (auto-généré par le brave Eclipse) :
public class Person { ... @Override public int hashCode() { int v = birthYear; v = v * 65539; v += name.hashCode(); return v; } }
Méthode toString()
La méthode toString() permet d'avoir une représentation textuelle de l'objet.
Elle est surtout utile pour les sorties sur System.{out,err}, le débuggage.
Une méthode toString() par défaut est implantée dans Object et affiche le nom de la classe avec le code de hachage.
// @run: ToStringTest public class ToStringTest { public static void main(String[] args) { Object obj1 = new Object(); Object obj2 = new Object(); System.out.println("obj1=" + obj1); System.out.println("obj2=" + obj2); } }
On redéfinit la méthode toString() pour un affichage plus explicite. On peut inclure le nom de la classe ainsi que la valeur des champs.
package fr.upem.jacosa.general; import java.util.Objects; class VerbosePerson { private final String name; private final int birthYear; public VerbosePerson(String name, int birthYear) { this.name = name; this.birthYear = birthYear; } public String getFieldString() { return String.format("name=%s,birthYear=%d", name, birthYear); } @Override public final String toString() { return getClass().getSimpleName() + "[" + getFieldString() + "]"; } } class VerboseStudent extends VerbosePerson { private final String curriculum; public VerboseStudent(String name, int birthYear, String curriculum) { super(name, birthYear); this.curriculum = curriculum; } @Override public String getFieldString() { return String.format("%s,curriculum=%s", super.getFieldString(), curriculum); } } public class VerboseStudentTest { public static void main(String[] args) { VerbosePerson p = new VerbosePerson("Harry Cover", 2000); VerbosePerson p2 = new VerboseStudent("Jane Doe", 2001, "DUT2 info"); System.out.println("p=" + p); System.out.println("p2=" + p2); } }
☞ Pour un projet conséquent, il sera plus avantageux d'utiliser un Logger beaucoup plus puissant et paramétrable que System.err.println.
Vers moins de code idiomatique
- Malhereusement Java nécessite beaucoup de code idiomatique
- La conception de nouveaux langages permet de limiter ce type de code
Comment limiter le code idiomatique ?
- En supprimant la nécessité d'implanter des constructeurs purement affectatifs
- Le compilateur pourrait générer automatiquement des getters et setters pour les attributs
-
Le compilateur pourrait créer une méthode equals et hashCode par défaut qui tienne compte de tous les attributs
- Il serait toujours possible de redéfinir à la main equals et hashCode pour les cas spécifiques
-
Le compilateur pourrait générer une méthode toString() automatiquement avec le nom de la classe et la valeur des champs
- Attention toutefois aux chaînes longues (longues listes, gros graphes d'objets avec possibles références circulaires...)
- Le compilateur pourrait déduire le paquetage en fonction de l'emplacement de la classe
-
Le compilateur pourrait rechercher automatiquement les classes : pas d'import nécessaire
- Problématique des classes homonymes toutefois à résoudre
Classes Record
Introduction depuis Java 14 de la structure record :
-
Permet d'écrire une classe dont tous les membres sont immuables (private final) avec génération automatique du bytecode pour les éléments suivants :
- le constructeur avec les affectations (this.x = x)
- les getters pour les attributs; chaque getter porte le nom de l'attribut sans préfixe get (on accède à l'attribut x par la méthode x())
- la méthode hashCode() calculant la valeur de hachage de l'objet en considérant tous les attributs
- la méthode toString() retournant une chaîne de caractères avec le nom du record ainsi que les valeurs des attributs
-
Quelques remarques :
- Un record peut hériter d'une ou plusieurs interfaces mais pas d'une classe (en réalité tous les record héritent implicitement de la classe java.lang.Record)
- Un record peut utiliser peut être une type paramétré ; exemple record Pair<T>(T first, T second)
- Il est possible d'écrire à la main le constructeur d'un record (si celui-ci ne doit pas se contenter de faire des affectations pour les champs) ; on peut utiliser la syntaxe simplifiée public MyRecord { ... } sans avoir à indiquer en paramètres les attributs (déjà spécifiés dans la déclaration du record)
Exemple : un point avec un Record :
public record Point(int x, int y) { }
Passage de paramètres à une méthode
Différence entre types valeur et référence
-
Type valeur (primitif)
- Recopie de la valeur dans la pile d'appel
- Valeur originale (variable ou champ) non modifiée
-
Type référence (objet)
- Recopie de la référence (adresse mémoire) dans la pile d'appel
- Travail de la méthode appelée sur le même objet que la méthode appelante
-
Protection d'un objet contre des modifications de la méthode appelante ?
- Immutabilité de l'objet (champs non-visibles, aucune méthode pour modifier l'objet)
- Passage d'une copie de l'objet (par clonage)
- Note : final rend constante la référence... pas l'objet !
Passons des paramètres
Définissons une classe Box avec un champ entier :
package fr.upem.jacosa.general; // @run: IncTest public class IncTest { // Voici une classe pour conserver une valeur public static class Box implements Cloneable { public int field; // should implement getter/setter, just for testing /** Clone the current box */ public Box clone() { try { return (Box)super.clone(); } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } } // Écrivons différentes méthodes statiques d'incrémentation : /** Increment the given integer parameter * This method has no outside effect since the value of the parameter is copied on the stack * The new incremented value is then lost when we exit from the method */ public static void inc1(int i) { i++; } /** We increment the field inside the Box object given as parameter * We note that Box object reference is final (not its content) */ public static void inc2(final Box b) { b.field++; } /** We increment the first cell of the integer array passed as parameter */ public static void inc3(final int[] a) { assert(a.length >= 1) ; // Check that the array contains at least one element a[0]++; } // Écrivons une méthode main pour tester ces différentes méthodes d'incrémentation : public static void main(String[] args) { final Box box = new Box(); // box.field == 0 box.field = 1; inc1(box.field); System.out.println(box.field); // 1 inc2(box); System.out.println(box.field); // 2 final int[] array = new int[]{box.field}; inc3(array); System.out.println(array[0]); // 3 System.out.println(box.field); // 2 inc2(box.clone()); System.out.println(box.field); // 2 } }
Le sous-typage
- Pour des capacités d'abstraction/spécialisation du code, utilité de relations de sous-typage
-
Principe de substitution de Liskov
-
S est un sous-type de T → tout élément de type T peut être remplacé par un élément de type S sans modifier le reste du programme
- En effet S a au moins les champs et méthodes de T
-
S est un sous-type de T → tout élément de type T peut être remplacé par un élément de type S sans modifier le reste du programme
-
En Java
-
Hiérarchie de typage sur les types primitifs pour la conversion implicite : par exemple long > int > short > byte
- Conversion implicite impossible s'il y a perte d'information
-
Hiérarchie de typage sur les types objet définie par :
- Relation d'héritage entre classes
- Implantation d'interfaces
-
Hiérarchie de typage sur les types primitifs pour la conversion implicite : par exemple long > int > short > byte
Héritage de classe
Forçage ou interdiction de l'héritage en Java
-
Interdiction d'héritage : déclaration final de la classe
- Exemple : String est une classe finale qu'on ne peut dériver
-
Obligation d'héritage : déclaration abstract de la classe, certaines méthodes peuvent ne pas être implantées
-
Exemple :
- AbstractCollection avec iterator() et size() abstraits
- AbstractList (qui hérite d'AbstractCollection) avec get(int i) abstrait
-
Exemple :
- Par défaut : classe instanciable et également héritable par une autre classe
Classes scellées
- sealed class : concept introduit à partir de Java 15 (en preview) ; disponibilité finale à partir de Java 16
- Permet de restreindre la possibilité d'hériter d'une classe
- Déclaration de la classe ou de l'interface en sealed en spécifiant les classes qui ont le droit d'en hériter (les autres ne peuvent pas)
- Possibilité également d'utiliser des sealed interface
- Interêt d'une classe scellée : pattern matching pour traiter exhaustivement tous les cas de descendance d'une classe
public abstract sealed class Student permits ApprenticeStudent, InitialStudent { public Student(String name, int birthYear) { this.name = name; this.birthYear = birthYear; } public String getName() { return name; } public int getBirthYear() { return birthYear; } } // Inherited classes are forbidden public final class ApprenticeStudent extends Student { private String company; public ApprenticeStudent(String name, int birthYear, String company) { super(name, birthYear); this.company = company; } public String getCompany() { return this.company; } } // Inherited classes are authorized public non-sealed class InitialStudent extends Student { public InitialStudent(String name, int birthYear) { super(name, birthYear); } }
Une classe héritière d'une classe scellée peut être :
- scellée elle aussi (sealed class) pour n'autoriser qu'un héritage restreint
- non scellée (non-sealed class) pour permettre une descendance non-contrainte
- finale (final) pour interdir toute descendance
Pattern matching
- switch-case : structure conditionnelle de traitement de cas existant depuis Java 1
- Originellement switch-case ne supporte que le test sur des int
- A partir de la version 7, switch-case supporte les String
- La JEP 420 étend switch-case en supportant des tests sur les types de objets
-
La forme fléchée de switch-case doit être exhaustive
- Tous les cas doivent être traités
- Si des cas ne sont pas traités, il faut ajouter un cas default -> { ... } à la fin qui est exécuté pour les autres cas
- Si plusieurs cas valident un switch-case fléché, le premier est utilisé
- Le compilateur vérifie que des cas ne soient pas trivialement inaccessibles et peut refuser de compiler
Exemple : des étudiants doivent-il avoir des devoirs supplémentaires ?
public static boolean shouldHaveExtraWork(Student s) { switch (s) { case ApprenticeStudent as -> { return as.getName().length() % 2 == 0; /* because we don't like students whose name contains an even number of letters */ } case InitialStudent is -> { return true; } // no need to add a default -> { ... }, since all the cases are treated // Student is sealed with only two permitted children: ApprenticeStudent and InitialStudent } }
L'ajout d'un cas traitant une référence nulle peut être plus prudent. Si s est null, le premier cas est considéré ApprenticeStudent (avec un NullPointerException assuré) :
public static boolean shouldHaveExtraWork(Student s) { switch (s) { case null -> { return false; } case ApprenticeStudent as -> { return as.getName().length() % 2 == 0; /* because we don't like students whose name contains an even number of letters */ } case InitialStudent is -> { return true; } } }
La JEP 420 prévoit des guarded patterns (encore expérimentaux) permettant de faire accompagner le test de type avec un test booléen sur l'objet. On peut ainsi réécrire l'exemple précédent :
public static boolean shouldHaveExtraWork(Student s) { switch (s) { case null -> { return false; } case ApprenticeStudent as && (as.getName().length() % 2 == 0) -> { return true; } case ApprenticeStudent as -> { return false; } case InitialStudent is -> { return true; } } }
Utilité de l'héritage
-
Permettre le sous-typage et ainsi promouvoir du code générique
- Une méthode feed(Animal a) pourra être utilisée en passant en paramètre un Cat ou un Dog si Cat et Dog héritent de Animal
- Le sous-typage permet de limiter la duplication de code : on peut regrouper du code commun dans un type ancêtre en opération de refactoring
-
Hériter de tous les champs et méthodes implantés de l'ancêtre
- ☠ On ne peut pas supprimer un membre du type dérivé car cela violerait le principe de Liskov
- Pouvoir ajouter de nouvelles caractéristiques pour la classe : nouveaux champs, nouvelles méthodes
-
Pouvoir modifier l'implantation de certaines méthodes
-
La signature des méthodes ne peut pas être modifiée sauf si cela consiste à utiliser une signature compatible :
- Contravariance des paramètres de méthodes : les paramètres peuvent être remplacés par des types plus généraux
- Covariance du type de retour : le type de retour peut être remplacé par un type plus spécialisé
-
La signature des méthodes ne peut pas être modifiée sauf si cela consiste à utiliser une signature compatible :
-
Limitations de l'héritage :
- Une classe dérivée n'a pas accès aux champs et méthodes privés de son ancêtre (sauf par introspection)
- Les méthodes déclarées final ne peuvent être redéfinies
Ajout de champ et méthode
Redéfinition de méthode
Principe
- Permet de changer l'implantation d'une méthode
-
Signature de la méthode redéfinie :
- inchangée
-
ou plus générale pour respecter le principe de Liskov (et la loi de Postel) :
- avec des types de paramètres plus généraux (utilisation de sur-types) → contravariance
- avec un type de retour plus spécialisé (utilisation de sous-type) → covariance
- avec des types d'exceptions levées plus spécialisées → covariance
- avec une visibilité augmentée
- Possibilité d'appeler l'implantation du parent direct avec super.methode(p1, p2…) mais l'accès à un parent plus éloigné est impossible (sauf avec coopération de la classe parent)
- Généralement préférable de définir une méthode abstraite dans la classe ancêtre plutôt que de redéfinir une implantation ?
Exemple de redéfinition de la méthode protected clone() throws CloneNotSupportedException de la classe Object :
package fr.upem.jacosa.general; /** A simple box containing an integer * demonstrating the change of visibility of the method {@link Object#clone()}, * the change of the return type (covariance) * and the capture of the exception possibly thrown (by transforming it into a {@link RuntimeException}). */ public class CloneableBox implements Cloneable { private int value; public int getValue() { return value; } @Override public CloneableBox clone() { try { return (CloneableBox) super.clone(); } catch (CloneNotSupportedException e) { throw new RuntimeException(e); // never reached in fact } } }
Un exemple de redéfinition problématique
Objectif : dériver ArrayList pour empêcher la présence de références nulles
public class NotNullArrayList<T> extends ArrayList<T> { // ... @Override public boolean add(T object) { if (object == null) return false ; // do nothing if the argument is null else return super.add(object) ; } // ... }
Problème : cela ne suffit pas car de nombreuses autres méthodes peuvent ajouter des éléments qu'il faudrait redéfinir :
- add(int index, T object)
- addAll(Collection< ? extends T> c)
- Et peut-être d'autres méthodes dans les versions ultérieures de l'API ?
-
1ère solution
- Avoir une ArrayList avec une méthode abstraite boolean checkElementToAdd(T e) appelée systématiquement avant d'ajouter un élément (pour filtrer les éléments à ajouter)
- Implantation possible ensuite de classes dérivées avec tests personnalisés
- Malheureusement, on ne peut modifier ArrayList de l'API Java !
-
2ème solution
- Encapsuler une ArrayList dans une autre classe avec une méthode add déléguant son travail à l'ArrayList uniquement après filtrage
- On implante le minimum de méthodes dont a besoin en exposant un sous-ensemble des fonctionnalités d'ArrayList
- Malheureusement, on ne peut pas utiliser cette nouvelle classe là où des ArrayList étaient utilisées dans le code (car elle n'est pas un sous-type de ArrayList)
Masquage et résolution de champ
-
Masquage
- Déclaration possible par une classe dérivée de champs de même nom que des champs d'une classe ancêtre
- En pratique, à éviter
-
Résolution
- Désignation d'un champ par le type statique de la classe (déduit au moment de la compilation) et le nom du champ
Exemple :
package fr.upem.jacosa.general; // @run: MaskingField public class MaskingField { public static class Ancestor { public int x = 0 ; } public static class Child extends Ancestor { public double x = 1.0 ; } public static void main(String[] args) { Ancestor a = new Child(); System.out.println(a.x); // 0 System.out.println(((Child)a).x); // 1.0 } }
Interface
- Utilisé pour représenter des fonctionnalités transversales plutôt qu'une spécialisation
- Une interface peut ne pas avoir de méthodes déclarées → elle sert d'interface marqueur (ex : Serializable, RandomAccess...) pour indiquer une caractéristique de la classe
- Une interface peut hériter de zéro, une ou plusieurs interfaces (avec le mot-clé extends)
- Une classe peut implanter différentes interfaces avec le mot-clé implements (mais hériter d'une seule classe)
-
Une interface peut posséder :
- des méthodes abstraites (qu'il faudra définir dans les classes enfants)
- des constantes public static final
- depuis Java 8 des implantations de méthode par défaut (mixins)
- mais en aucun cas des champs (pour éviter les problèmes d'héritage multiple de champs : cas du diamant)
package fr.upem.jacosa.general; interface TaxableItem { /** Return the VAT rate of the item */ public float getVATRate(); /** Return the raw price (tax excluded) of the article */ public int getRawPrice(); /** Return the price with the tax included */ public default int getTaxIncludedPrice() { return (int)(getRawPrice() * (1.0f + getVATRate())); } } interface Nameable { public String getName(); } class Food implements TaxableItem, Nameable { public static final float FOOD_VAT_RATE = 0.055f; // rate in France private final String name; private final int rawPrice; public Food(String name, int rawPrice) { this.name = name; this.rawPrice = rawPrice; } @Override public String getName() { return this.name; } @Override public float getVATRate() { return FOOD_VAT_RATE; } @Override public int getRawPrice() { return rawPrice; } } // @run: FoodTester public class FoodTester { public static void main(String[] args) { Food apple = new Food("apple", 1000); System.out.println("Price of " + apple + " (with tax): " + apple.getTaxIncludedPrice()); } }
Membre statique
-
Membre lié à une classe et non à une instance de classe (objet)
- Pas d'instance de classe this disponible
- Un membre statique peut êtré accédé directement depuis la classe depuis un contexte statique ou non
- Il est impossible d'appeler une méthode non-statique depuis une méthode statique sans passer par une instance
- Depuis une classe extérieure, un membre statique est accédé en indiquant le nom de sa classe (NomClasse.maMethode())
- Constructeur statique pour une classe (appelé lors du chargement de la classe) : bloc static { ... }
-
Types de membre statique :
- Un champ : static int a ;
- Un champ final (donc constant) : final static int a ;
- Une méthode : public static void main(String[] args)
- Une classe interne : ne dispose pas de référence de la classe englobante et peut être utilisée hors de celle-ci
- Bonne pratique : limiter les membres statiques… tout comme il faut éviter les variables globales pour les langages procéduraux
Un exemple avec la création de plaques d'immatriculation française (avec le système SIV) :
package fr.upem.jacosa.general; // @run FrenchCarPlate 1500 /** A registration plate from a French car */ public final class FrenchCarPlate { private static FrenchCarPlate nextPlate = new FrenchCarPlate("AA", 1, "AA"); private static String LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; private final String prefix; private final int number; private final String suffix; private FrenchCarPlate(String prefix, int number, String suffix) { this.prefix = prefix; this.number = number; this.suffix = suffix; } public String toString() { return String.format("%s-%03d-%s", prefix, number, suffix); } private static String findNextLetters(String s) { char letter1 = s.charAt(0); char letter2 = s.charAt(1); int pos1 = LETTERS.indexOf(letter1); int pos2 = LETTERS.indexOf(letter2); if (pos2 < LETTERS.length()-1) pos2++; else if (pos1 < LETTERS.length()-1) { pos2 = 0; pos1++; } else return null; // cannot find a successor for ZZ return "" + LETTERS.charAt(pos1) + LETTERS.charAt(pos2); } /** Get the plate following a given plate */ private FrenchCarPlate getNextPlate() { String newPrefix = prefix; String newSuffix = suffix; int newNumber = number + 1; if (newNumber == 1000) { newNumber = 1; newSuffix = findNextLetters(suffix); if (newSuffix == null) { newSuffix = "AA"; newPrefix = findNextLetters(prefix); if (newPrefix == null) newPrefix = "AA"; // we cycle } } return new FrenchCarPlate(newPrefix, newNumber, newSuffix); } public static FrenchCarPlate createNewPlate() { try { return nextPlate; } finally { nextPlate = nextPlate.getNextPlate(); } } public static void main(String[] args) { int n = Integer.parseInt(args[0]); for (int k = 0; k < n; k++) System.out.println(createNewPlate()); } }
Visibilité en Java
private | défaut | protected | public | |
Classe | Intra-paquetage | Partout | ||
Classe interne | Classe englobante | Intra-paquetage | Classe et enfants, classe englobante et enfants, intra-paquetage | Identique à la classe englobante |
Membre de classe | Classe | Intra-paquetage | Classe et enfants, intra-paquetage | Identique à la classe |
Interface | partout |
Règles d'or de la visibilité
-
Utiliser la visibilité la plus réduite possible
- Principe de masquage par encapsulation (getter, setter)
- La visibilité est toujours facilement augmentable par la suite...
- ...par contre réduire la visibilité peut être plus difficile
- Visibilité d'un champ non-modifiable par héritage
- Le choix par défaut de visibilité d'un champ devrait être private (déroger à cette règle nécessite des motivations solides)
-
Visibilité d'une méthode :
- augmentable par redéfinition
- mais pas réductible (irait à l'encontre du principe de Liskov)
- Pour changer uniquement la visibilité, il suffit d'appeler super.methodName() comme seule instruction du corps rédéfini
-
La visibilité n'est pas un mécanisme de sécurité : outrepassable par introspection
- Doit être considéré uniquement comme un mécanisme pour imposer une discipline au programmeur
- L'utilisation d'exports par module peut limiter le risque d'accès non autorisé à des classes non publiques
Allocation mémoire
- Une variable ou champ non initialisé contient une référence nulle
-
Opérateur d'allocation : new
- Pixel p = new ColoredPixel(10, 20, Color.BLACK) ; // allocation d'un objet
- int[] tab = new int[10] ; // allocation d'un tableau d'entiers
- Pixel[] ps = new Pixel[10] ; // allocation d'un tableau de références vers des objets
-
Opérations réalisées :
- Allocation de la mémoire dans le tas pour stocker l'objet
-
Initialisation de l'objet
- Avec des valeurs par défaut (0 pour int, false pour boolean, null pour une référence d'objet…)
- Avec le constructeur sélectionné par les paramètres
-
new retourne une référence sauf :
-
si exception lors de l'allocation (OutOfMemoryError)
- java -Xms<size> pour demander un tas plus important
- si exception lors de l'initialisation
-
si exception lors de l'allocation (OutOfMemoryError)
Désallocation mémoire
- Pas de gestion manuelle possible (contrairement à C++)
-
Désallocation automatique par le ramasse-miettes (GC) :
- Recherche des objets non-accessibles dans le tas
-
Appel de la méthode void finalize() par le GC sur les objets
- peut être redéfinie dans des cas particuliers mais plutôt à éviter
- finalize() ne doit plus être utilisée à partir du JDK 18 (dépréciation par JEP 421)
- Marquage des zones mémoire comme libres
- Optimisation de la mémoire (différentes zones avec GC générationnel, défragmentation)
-
Perte d'accessibilité d'un objet :
- Si l'on sort du scope de la dernière variable qui le référence
- Si la dernière variable/champ qui le référence prend la valeur null
-
Le GC se déclenche automatiquement
- Possibilité de demander son passage : System.gc() (mais il passe quand il veut)
package fr.upem.jacosa.general; /** A class testing the finalize method */ // @run: Finalizer 3000 public class Finalizer { /** Keep the number of instances of the class */ private static int INSTANCES = 0; public Finalizer() { INSTANCES++; // we initialize a new instance } public static int getInstanceNumber() { return INSTANCES; } /** This method is called before the destruction of the object by the garbage collector */ protected void finalize() throws Throwable { super.finalize(); INSTANCES--; } /** Let's test the class */ public static void main(String[] args) throws Exception { int sleepTime = Integer.parseInt(args[0]); // sleep time is in args[0] in milliseconds Finalizer[] finalizers = new Finalizer[2]; System.out.println(getInstanceNumber()); // should print 0 finalizers[0] = new Finalizer(); System.out.println(getInstanceNumber()); // should print 1 finalizers[1] = new Finalizer(); System.out.println(getInstanceNumber()); // should print 2 finalizers = null; // the array is not reachable now (and transitively the finalizers) System.gc(); Thread.sleep(sleepTime); System.out.println(getInstanceNumber()); // has the garbage collector destroyed the finalizers? } }
Paquetages
- Permet d'organiser les classes par préoccupation (promeut la modularité de l'application)
-
Pour garantir l'unicité : préfixage par le nom de l'organisation
- Généralement le nom de domaine de l'organisation inversée : fr.upem., com.sun....
- Indispensable par exemple pour publier un projet dans un dépôt (de type Maven) ou sur un store d'applications (Google Play...)
- Nom du paquetage en minuscules
-
Déclaration obligatoire du paquetage d'une classe (1ère ligne) :
- package fr.upem.myproject.utils ;
-
Structure des paquetages et des répertoires source isomorphe :
- Classes dans le répertoire src/fr/upem/myproject/utils/
-
Documentation JavaDoc d'un paquetage :
- Fichier package-info.java à sa racine
-
Bonne pratique : limiter les dépendances entre les classes de différents paquetages
- En définissant des APIs publiques par des interfaces
- En limitant les classes publiques
-
Si utilisation d'une classe d'un paquetage externe :
-
Importation obligatoire en tête de fichier
- De toutes les classes du paquetage ; ex : import java.io.*
- Uniquement des la classe voulue ; ex : import java.io.InputStream
- Pour un membre statique d'une classe ; ex : import static java.lang.Math.PI
- Pour tous les membres statiques d'une classe (plutôt à éviter) ; ex : import static java.lang.Math.*
- … sauf pour le paquetage java.lang (import java.lang.* est implicitement réalisé)
-
Importation obligatoire en tête de fichier
-
La variable d'environnement CLASSPATH doit contenir les chemins vers les racines des paquetages séparés par des :
- Il est possible également d'utiliser java -cp chemin1:chemin2:...:cheminN
- Plutôt que d'être dans des répertoires, les paquetages compilés peuvent être regroupés dans des fichiers jar
Exemple de compilation de classe :
-
Hypothèses :
- compilation de la classe fr.upem.myproject.utils.MyClass
- classe importée : javolution.util.FastMap
- la bibliothèque Javolution est fournie par un jar : /opt/javajars/javolution.jar
- Localisation de la classe : dans le répertoire src/fr/upem/myproject/utils
-
Compilation de la classe :
- On se place à la racine de src/
- On exécute javac -cp .:/opt/javajars/javolution.jar fr.upem.myproject.utils.MyClass
- On obtient un fichier src/fr/upem/myproject/utils/MyClass.class
- Toujours dans src/, on peut exécuter la classe avec java fr.upem.myproject.utils.MyClass
-
Conseil : écrire les fichiers .class dans un répertoire distinct du répertoire source :
- javac -d bin/ -cp .:/opt/javajars/javolution.jar fr.upem.myproject.utils.MyClass
- La classe est maintenant dans le fichier bin/fr/upem/myproject/utils/MyClass.class
Modularisation (Jigsaw)
- Fonctionnalités de modularisation introduites depuis Java 9 (JSR 277, JSR 376)
- Paquetages regroupables en modules avec possibilité d'introduire des dépendances
- Chaque module est déclaré par un fichier module-info.java dans le paquetage concerné
Pour exporter toutes les classes (publiques) du paquetage fr.upem.zoo.api (dans un fichier module-info.java à la racine des sources) :
module fr.uge.zoo { exports fr.uge.zoo.api; }
Et si un module fr.uge.vivarium a besoin de notre paquetage api de zoo, il peut déclarer une dépendance :
module fr.uge.vivarium { requires fr.uge.zoo.api; }
Différentes variantes de l'instruction requires existent :
- requires chemin.du.paquetage : pour indiquer une dépendance nécessaire pour le module
- requires static chemin.du.paquetage : pour indiquer une dépendance optionnelle (utilisée à la compilation) pour le module ; il est possible qu'à l'exécution, l'utilisateur ne possède pas la dépendance (dans ce cas les fonctionnalités liées à cette dépendance doivent être désactivées)
- requires transitive chemin.du.paquetage : le module courant propagera aussi les exports de la dépendance ajoutée
- Compilation des modules possible avec javac (option --module-path pour indiquer le chemin des modules)
- Encapsulation forte par défaut des modules : seules les classes publiques d'un module exporté sont accessibles de l'extérieur ; les classes privées sont inaccessibles par introspection (sauf si on utilise une instruction opens chemin.du.paquetage)
- Empaquetage des modules en archives jar indépendantes avec l'outil jar
- Possibilité d'exposer une interface de service avec uses dans un module et de fournir une implantation dans un autre module avec provides
-
Génération d'images contenant tous les modules nécessaires pour une application avec jlink
- Le JDK étant modularisé, cela permet de limiter les classes fournies : intérêt lorsque les ressources sont restreintes
- Possibilité d'utiliser Maven en créeant un pom.xml global de configuration et un pom.xml pour chaque module dans des répertoires distincts (exemple ici)
Quelques bonnes conventions javanaises
- Bien organiser ses classes en paquetages
-
Respecter des conventions de nommage (la casse est importante) :
- Donner un nom pertinent à ses classes en utilisant la notation DromaDaire (pas de _) ; éviter (même si paquetages différents) de donner des noms de classes courantes de l'API (ex : String, InputStream, List...)
- Utiliser la notation dromaDaire pour les méthodes (commence par une minuscule)
- Opter pour des noms de constantes en MAJUSCULES
- Les constantes sont public static final
-
Bien documenter et commenter son code :
- JavaDoc d'en-tête pour les classes, méthodes
- commentaires locaux pour les zones de code de compréhension difficile
- Séparer les sources des classes compilées (répertoires différents)
Patterns créationnels
Construction d'une instance
- A partir d'une classe concrète (une classe abstract est inconstructible)
-
Si aucun constructeur pour initialiser la classe
- Un constructeur par défaut sans argument est automatiquement ajouté
-
Les constructeurs sont masqués par héritage
- Il faut si nécessaire les redéfinir pour les rendre appelables
- Un constructeur redéfini dans une classe dérivée doit toujours avoir pour première instruction super(...) pour appeler le constructeur de l'ancêtre
-
Un constructeur peut appeler un autre constructeur avec this(...)
- ...mais cet appel doit être la première instruction
- Utilité pour les paramètres par défaut
- Malheureusement pas de support pour les arguments nommés en Java (contrairement à d'autres langages comme Kotlin)
-
Un constructeur peut ne pas avoir de visibilité externe :
- pour une classe abstraite (typiquement visibilité protected)
- pour autoriser son appel uniquement depuis un autre constructeur de la même classe
- pour autoriser la construction uniquement depuis une méthode statique
- pour mettre en place une fabrique ou un singleton
Singleton
Principe du singleton
- Utile en cas de besoin de variables globales pour l'application (données de configuration par exemple)
- Regroupement des champs et membres utiles dans une classe
- Fourniture d'une méthode statique pour accéder à l'unique instance de la classe (avec pré-initialisation ou initialisation à la demande) : public T getInstance()
- Passage du constructeur en private pour empêcher la création externe par un constructeur : obligation d'utiliser la méthode statique d'accès au singleton
-
Exemple dans l'API Java :
- Runtime.getRuntime() : permet de connaître la mémoire disponible pour la JVM, de charger des bibliothèques, d'ajouter des crochets d’arrêt…
- Il existe des méthodes statiques de System qui sont des raccourcis vers le singleton Runtime : System.gc() appelle Runtime.getRuntime().gc()...
- Généralisation avec le multiton : plusieurs objets de la classe (mais créés et contrôlés par la classe elle-même avec des méthodes statiques)
Exemple de singleton pour calculer des nombres de Fibonacci
package fr.upem.jacosa.general; import java.util.ArrayList; import java.util.List; // @run: FiboSequence /** A Fibonacci sequence computer */ public class FiboSequence { private static FiboSequence defaultInstance; /** Return the default instance (and create it if required for the first time */ public static FiboSequence getDefaultInstance() { if (defaultInstance == null) defaultInstance = new FiboSequence(1, 1); return defaultInstance; } private final List<Long> fiboList = new ArrayList<Long>(); /** Build the FiboSequence object, * note that the constructor is private */ private FiboSequence(long first, long second) { fiboList.add(first); fiboList.add(second); } /** Compute the n-th number of Fibonacci */ public long getValue(int rank) { // first, compute the missing numbers while (rank >= fiboList.size()) { int n = fiboList.size(); fiboList.add(fiboList.get(n-1) + fiboList.get(n-2)); } return fiboList.get(rank); } /** Main method to compute the Fibonacci sequence term whose index is passed as args[0] */ public static void main(String[] args) { int rank = Integer.parseInt(args[0]); System.out.println(getDefaultInstance().getValue(rank)); } }
Fabrique (factory)
Principe de la fabrique
- Permet de consacrer une classe à la construction d'objets
- N'expose ni les classes concrètes ni les constructeurs
- Permet de changer les classes concrètes utilisées sans changer le code externe
-
Ajout de setters pour définir des paramètres complexes d'initialisation
- Astuce : retour de this par les setters pour pouvoir les chaîner
- Pattern builder
- Évolutivité possible (nouveaux paramètres) sans changer le code externe
- Possibilité d'hériter d'une fabrique pour avoir une fabrique plus spécialisée
Construction d'une Map avec une fabrique
package fr.upem.jacosa.general; import java.util.HashMap; import java.util.Map; import java.util.TreeMap; // @run: MapFactory public class MapFactory { private boolean sorted ; void setSorted(boolean sorted) { this.sorted = sorted ; } public <K, V> Map<K, V> create() { if (sorted) return new TreeMap<K, V>(); else return new HashMap<K, V>(); } /** We test the factory */ public static void main(String[] args) { MapFactory mf = new MapFactory(); Map<Integer, Integer> m1 = mf.create(); // Création d'une HashMap System.out.println(m1.getClass()); // should print HashMap mf.setSorted(true); Map<Integer, Integer> m2 = mf.create(); // Création d'une SortedMap System.out.println(m2.getClass()); // should print TreeMap } }
Construction par prototype
- On créé un objet modèle
- Lorsqu'un nouvel objet est nécessaire, on clone l'objet modèle, on peut modifier le nouvel objet sans impacter l'objet initial
-
Méthode Object Object.clone() par défaut : copie par valeur des types primitifs mais copie superficielle des champs objet (référence)
- Implantation de l'interface Cloneable (interface marqueur, la déclaration de la méthode clone est faite dans Object)
-
Redéfinition de protected Object clone() retournant une référence de la copie du type de l'objet avec élévation de la visibilité
- Copie profonde nécessaire en appelant clone sur les champs non-primitifs et mutables
Clonons des brebis
package fr.upem.jacosa.general; import java.util.Arrays; public class SheepCloning { public interface Animal { public char[] getDNA(); } public static class Sheep implements Animal { protected char[] dna; public char[] getDNA() { return dna; } public Sheep(String bases) { this.dna = bases.toCharArray(); } @Override public String toString() { return getClass().getSimpleName() + "[dna=" + Arrays.toString(dna) + "]"; } } public static class CloneableSheep extends Sheep implements Cloneable { public CloneableSheep(String bases) { super(bases); } public CloneableSheep shallowClone() { try { return (CloneableSheep)super.clone(); } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } public CloneableSheep clone() { CloneableSheep sheep = shallowClone(); sheep.dna = new char[this.dna.length]; System.arraycopy(this.dna, 0, sheep.dna, 0, this.dna.length); return sheep; } } public static class Virus { public void attack(Animal animal) { char[] dna = animal.getDNA(); for (int i = 0; i < dna.length; i+=2) dna[i] = 'A'; // replace all the pair bases with Adenosine } } public static void main(String[] args) { Sheep dolly = new CloneableSheep("ACGT"); Sheep dolly2 = ((CloneableSheep)dolly).shallowClone(); Sheep dolly3 = ((CloneableSheep)dolly).clone(); System.out.println("Dolly before virus attack: " + dolly); System.out.println("Dolly2 before virus attack: " + dolly2); System.out.println("Dolly3 before virus attack: " + dolly3); Virus virus = new Virus(); // We create a nasty virus virus.attack(dolly2); // The virus modifies the shared DNA of dolly2 and dolly System.out.println("Dolly after virus attack: " + dolly); System.out.println("Dolly2 after virus attack: " + dolly2); System.out.println("Dolly3 after virus attack: " + dolly3); } }
Objets immuables
Un objet immuable (immutable) ne peut pas changer d'état (champs figés).
On peut rendre un objet immuable en déclarant tous ses champs final.
Pour tout changement, il faut créer une nouvelle version de l'objet
Exemples de classes produisant des objets immuables dans l'API :
- String
- BigInteger
- tous les types réifiés (Integer, Long, Float, Double...)
- ...
Par exemple pour incrémenter un BigInteger, il faut créer un nouveau BigInteger :
BigInteger bi = new BigInteger("41"); BigInteger bi2 = bi.add(BigInteger.ONE);
- 👍 Les objets immuables facilitent le débuggage
- 👎 La destruction et création de nouvelles versions d'objets immuables sollicitent plus le ramasse-miettes
Injection de dépendances
Java sait déjà détruire automatiquement des objets (ramasse-miettes) mais peut-il automatiquement les instancier ?
-
C'est possible !
- ... avec une bibliothèque d'injection de dépendances
-
Principe :
- on déclare les champs avec leur type dans la classe, les paramètres des constructeurs...
- ... et le système instantie automatiquement les objets nécessaires et copie leur références dans les champs, les paramètres
-
Avantages :
- plus simple pour les projets volumineux avec beaucoup de classes à constructeurs à rallonge
-
configuration plus aisée :
- on peut utiliser des interfaces comme types de champ et remplacer le type d'objet à construire dans un fichier de configuration sans toucher au code
Quelques systèmes d'injection de dépendance :
- Spring : framework pionnier ayant popularisé le concept d'injection de dépendances
-
Context and Dependency Injection (CDI), une fonctionnalité de JEE (Enterprise Edition) normalisée postérieurement à Spring
- API très volumineuse surtout si l'on ne souhaite utiliser que CDI
- Guice : bibliothèque de Google plus récente spécialisée uniquement dans l'injection de dépendance
Injectons un calculateur de suite de Fibonacci :
public class FiboRatio { @Inject FiboSequence sequence; /** Get the Fibonacci ratio at a given rank (should converge to the golden number for high ranks) */ public double getRatio(int rank) { if (rank < 1) return Double.NaN; return (double)sequence.getValue(rank) / (double)sequence.getValue(rank-1); } }
Polymorphisme : surcharge
- En Java, méthodes homonymes autorisées mais avec types d'argument différents : mécanisme de surcharge
- Permet de personnaliser le comportement d'une méthode en fonction du type des arguments
-
Exemple avec StringBuilder.append :
- append(String s)
- append(int i)
- append(char c)
- Méthode non-statique : un argument caché, la référence de l'objet this est passé en premier paramètre
-
Comment sélectionner la méthode adéquate (résolution) ? Approche hybride en Java :
- Liaison tardive : considération du type réel à l'exécution pour le paramètre implicite this
-
Considération du type statique pour les autres paramètres
- La méthode avec les types les plus spécialisés est sélectionnée
- Quelquefois il peut y avoir des ambiguïtés : erreur à la compilation
Exemple de polymorphisme avec surcharge
package fr.upem.jacosa.general; // @run: PolymorphismTester public class PolymorphismTester { static abstract public class Animal { protected void speak(Animal other, String message) { other.receive(this, message) ; } public void speak(Animal other) { speak(other, "default speech"); } protected void receive(Animal sender, String message) { System.out.println(sender + " says to " + this + ": " + message) ; } public void run() { System.out.println("Running..."); } } static class Cat extends Animal { public void speak(Animal other) { speak(other, "miaou") ; } public void speak(Human other) { speak(other, "miiiiaou") ; } public void speak(Dog other) { speak(other, "") ; run() ; } } static class Dog extends Animal { public void speak(Animal other) { speak(other, "ouaf") ; } public void speak(Human other) { speak(other, "ouaf ouaf") ; } public void speak(Cat other) { speak(other, "OUAF") ; run() ; } } static class Human extends Animal {} public static void main(String[] args) { Animal cat = new Cat() ; Cat cat2 = (Cat)cat ; Animal dog = new Dog() ; Human human = new Human() ; // we consider cat as an animal (that has only a method speak(Animal) cat.speak(human) ; // miaou cat.speak(dog) ; // miaou cat.speak((Dog)dog) ; // miaou // we consider cat2 as a cat (that has methods to speak with an animal, human and dog) cat2.speak(human) ; // miiiiaou cat2.speak(dog) ; // miaou cat2.speak((Dog)dog) ; // stay mute and run cat2.speak((Human)dog) ; // ClassCatException! // cat2.speak((Cat)human) ; // do not compile (statically detected cast error) ! } }
Résolution ambigüe
package fr.upem.jacosa.general; // @run: AmbiguousResolution public class AmbiguousResolution { int x(int a, byte b) { return 0; } int x(byte a, int b) { return 1; } public static void main(String[] args) { // System.out.println(x(0, 0)) ; // System.out.println(x((byte)0, (byte)0)); } }
Coercition (cast)
-
Comportement différent entre types primitifs et types objets
- Conversion possible entre certains types primitifs (avec possible perte de précision, ex : (byte)2014)
- Coercition sur type objet changeant la manière dont est considérée statiquement la référence
long a = 1234567891234L; int b = (int)a; // cast avec perte de précision long c = b; // pas besoin de cast (conversion automatique) ... List<Integer> l1 = new ArrayList<>(); // cast automatique ascendant ArrayList<Integer> l2 = (ArrayList<Integer>)l1; // cast manuel descendant
Coercitions ascendantes
- Coercition ascendante implicite : mise en œuvre lors de l'affectation d'une référence d'un sous-type vers une variable/champ sur-typé ; utile pour la généralisation du code
-
Coercition ascendante explicite : utile sur des arguments de méthodes pour changer la résolution statique, n'agit pas sur le type réel de l'objet à l'exécution
- accès à une méthode masquée par redéfinition impossible
- accès à un champ masqué possible
❕ Coercition ascendante explicite généralement inutile :
- mauvaise pratique du masquage de champ
- utilité limitée de vouloir interférer avec la résolution sur des méthodes polymorphes
Coercitions descendantes
-
Coercition descendante (toujours explicite) :
- transformation de référence en sous-type
-
généralement précédée d'un test de type avec if (instance instanceof SousType)
- sinon risque de ClassCastException !
❕ Coercition descendante évitable :
- En exploitant la liaison tardive sur this (pattern Visiteur)
- En utilisant des generics pour éviter de transtyper depuis le type Object
En Java < 5 sans generic :
List l = new ArrayList(); l.add(new Integer(0)); l.add(new Integer(1)); ... Integer firstInt = (Integer)l.get(0);
En Java ≥ 5 avec generics (et auto-boxing et unboxing) :
List<Integer> l = new ArrayList<>(); l.add(0); l.add(1); ... int firstInt = l.get(0);
Conclusion : généralement la coercition peut être limitée (utile dans de rares cas)
☞ Toujours vérifier avant de faire une coercition descendante si l'objet est issu de la classe attendue avec instanceof.
Exemple : collecte de toutes les plaques d'immatriculation d'un tableau de Vehicle.
Définissons les classes (seule la classe Car a une méthode String getPlate()) :
abstract class Vehicle { ... } class Bicycle extends Vehicle { ... } class Car extends Vehicle { public String getPlate() }
Ecrivons la méthode de collecte de plaques :
List<String> collectPlates(Vehicle[] vehicles) { var list = new ArrayList<String>(); for (var v: vehicles) { // type of v: Vehicle // one cannot call v.getPlate() since Vehicle has not a method getPlate() // we must cast v to a car (only if it is possible) if (v instanceof Car c) list.add(c.getPlate()); } }
Avant Java 16, l'affectation doit être faite à la main avec le cast :
if (v instanceof Car) { Car c = (Car)v; // one can use also: var c = (Car)v; list.add(c.getPlate()); }
Depuis Java 18, un switch peut être utilisé pour faire des tests sur le type d'un objet :
public sealed interface Expr permits IntExpr, OpExpr {} public record IntExpr(int value) implements Expr {} public sealed interface OpExpr permits AddExpr, SubExpr {} public record AddExpr(int a, int b) implements OpExpr { } public record SubExpr(int a, int b) implements OpExpr { } .... public static int eval(Expr expr) { switch(expr) { case IntExpr ie -> return ie.value(); case AddExpr ae -> return ae.a() + ae.b(); case SubExpr se -> return se.a() + se.b(); } }
Styles de classes en Java
Classe fichier
- Un fichier .java == une classe Java
- Le fichier porte le même nom que la classe avec le suffixe .java
- Possibilité de mettre plusieurs classes top-level dans un même fichier mais à proscrire !
Classe et interface interne
- Utilité : éviter de créer de multiples fichiers pour de petites classes utilisées dans un certain contexte
-
Quelques exemples dans le JDK :
- Map.Entry<K,V> : interface publique représentant une association clé/valeur dans une Map
- ...
-
Trois types de classes/interface internes :
- Classe interne non-statique : instanciable que par une instance de la classe englobante, comprend une référence cachée au this de l'instance de la classe englobante
- Classe interne statique : instantiable par classe externe sans référence à l'instance de la classe englobante (si la visibilité le permet)
- Interface (toujours statique)
Classe anonyme
- Classe sans-nom dérivée d'une classe/interface définie et instanciée à la volée dans le corps d'une méthode
- Une classe anonyme n'est pas réutilisable (instanciation dans une autre méthode, héritage impossible)
-
Utilité : instanciation rapide de classe abstraite/interface avec peu de méthodes à redéfinir
- Intérêt pour certains frameworks pour passer des méthodes callback appelées par une boucle d'événements (ex : exploitation du MVC avec Swing)
-
Une classe anonyme peut utiliser :
- des variables locales de sa méthode englobante mais uniquement si elles sont déclarées final (non modifiables) ou effectivement final
- utiliser des champs de l'instance de classe englobante si existante (et obtenir ClasseEnglobante.this)
Classe anonyme avec Swing
package fr.upem.jacosa.general; import javax.swing.*; import java.awt.event.*; public class HelloSwing { private static void showHello() { final JFrame frame = new JFrame("HelloSwing"); final JButton button = new JButton("Hello Swing!"); button.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { frame.dispose(); } }); frame.getContentPane().add(button); frame.pack(); frame.setVisible(true); } public static void main(String[] args) { javax.swing.SwingUtilities.invokeLater(new Runnable() { public void run() { showHello(); } }); } }
Lambda
- Classe anonyme syntaxiquement trop lourde si l'on souhaite implanter une seule méthode avec peu de lignes de code
-
Syntaxe plus légère avec les expressions lambda (possible depuis Java 8) :
- () -> expression
- x -> expression
- (x, y, ...) -> expression
- (x, y, …) -> {bloc de code ; return result ; }
-
Une expression lambda peut être utilisée en paramètre d'une méthode, là où une instance d'une interface fonctionnelle est attendue
- Une interface fonctionnelle est une interface (ou une classe abstraite) ne comportant qu'une seule méthode abstraite
-
Permet un style de programmation plus fonctionnel en considérant du code comme un objet
- Certaines méthodes (comme List.sort(Comparator<T> cmp)) peuvent prendre en paramètre un objet de type fonctionel
- On peut ainsi développer des algorithmes génériques paramétrés par des fonctions
- Les lambdas ont permis une refonte des Collections Java avec l'introduction de Stream (sorte d'itérateurs sur les collections connectables à une chaîne de fonctions de traitement)
-
Manipulation possible de références de méthodes d'objet ou de classe avec la syntaxe Classe::methode
-
Classe::methode (en supposant que methode prend un seul argument) est équivalent à :
- l'expression lambda (Classe x,y) -> x.methode(y) si la méthode est non-statique
- l'expression lambda y -> Classe.methode(y) si la méthode est statique
-
Classe::methode (en supposant que methode prend un seul argument) est équivalent à :
Liste filtrante avec lambda
package fr.upem.jacosa.general; import java.util.AbstractCollection; import java.util.ArrayList; import java.util.Iterator; // @run: FilteringArrayList /** A list that does not add null values (we override the add method) */ public class FilteringArrayList<T> extends AbstractCollection<T> { public interface Filter<T> { public boolean accept(T e) ; } private final ArrayList<T> list ; private final Filter<T> filter ; public FilteringArrayList(Filter<T> filter) { list = new ArrayList<T>() ; this.filter = filter ; } public boolean add(T e) { if (filter.accept(e)) return list.add(e); else return false; } @Override public int size() { return list.size(); } @Override public Iterator<T> iterator() { return list.iterator(); } public static void main(String[] args) { // Instantiation of a list // not containing null references // Without lambda FilteringArrayList<Integer> l1 = new FilteringArrayList<Integer>( new Filter<Integer>() { public boolean accept(Integer e) { return e != null ; } }) ; l1.add(1); l1.add(2); l1.add(null); System.out.println("Size of l1: " + l1.size()); // With lambda FilteringArrayList<Integer> l2 = new FilteringArrayList<Integer>(e -> e != null) ; l2.add(1); l2.add(2); l2.add(null); System.out.println("Size of l2: " + l1.size()); } }
Interfaces fonctionnelles de l'API
-
L'API fournit des interfaces fonctionnelles pour des besoins standard que l'on peut utiliser comme type de paramètre de méthode
- Inutile alors de crééer ses propres interfaces
-
Quelques interfaces du paquetage java.util.function :
-
Méthodes sans retour (void)
- void Consumer<T>.consume(T object)
- void BiConsumer<T,U>.consume(T object1, U object2)
-
Méthodes retournant un objet
- T Supplier<T>.get()
- R Function<T,R>.apply(T object)
- R BiFunction<T,U,R>.apply(T object1, U object2)
- T UnaryOperator<T>.apply(T object)
- T BinaryOperator<T>.apply(T object1, T object2)
-
Méthode retournant un booléen
- boolean Predicate<T>.test(T object)
-
Il existe des variantes de ces méthodes prenant pour paramètre des types primitifs et/ou retournant un type primitif (int, long, double)
- DoubleConsumer, IntSupplier, IntToDoubleFunction, ToLongBiFunction...
-
Méthodes sans retour (void)
Un peu de programmation fonctionnelle...
Philosophie de la programmation fonctionnelle : les fonctions sont des valeurs comme les autres. On peut passer une fonction en paramètre d'une fonction, une fonction peut retourner une fonction...
A partir de Java 8, l'approche fonctionnelle a été rendue syntaxiquement plus simple.
package fr.upem.jacosa.general; import java.util.function.*; // @run: FuncTest public class FuncTest { public static long mult(int x, int y) { return (long)x * (long)y; } public static <U, T, R> Function<U, R> compose(Function<U, T> f1, Function<T, R> f2) { return (x) -> f2.apply(f1.apply(x)); } public static <U, V, R> Function<U,R> partialApply(BiFunction<U, V, R> bifunc, V arg2) { return arg -> bifunc.apply(arg, arg2); } public static Function<Long, Integer> logFunction(int base) { return compose(x -> (x > 0)?Long.toString(x, base):"", x -> x.length() - 1); } public static void main(String[] args) { Function<Integer, Long> mult2 = partialApply(FuncTest::mult, 2); System.out.println("Let's multiply 7 by 2: " + mult2.apply(7)); Function<Long, Integer> log2 = logFunction(2); Function<Long, Integer> log10 = logFunction(10); long n = 1037L; System.out.println(String.format("Logs for %d: base2=%d, base10=%d", n, log2.apply(n), log10.apply(n))); } }
Exceptions
Principe
-
Sortie d'une méthode
- soit par retour d'une valeur (ou de void) si tout se passe bien (normalité)
- ou alors par levée d'une exception si un problème survient (exceptionnel)
-
Comment lever une exception ?
- Avec l'instruction throw new NomException(param1, param2)
- Généralement le premier paramètre du constructeur est une chaîne décrivant l'exception, le second est une autre exception ayant conduit à l'obtention de cette exception (exception cause)
-
Que faire d'une exception lancée ?
- la capturer et la gérer par un try-catch
- ou la laisser se propager dans la pile d'appel
-
Toutes les exceptions ont pour ancêtre Throwable
- elles doivent toutes être gérées explicitement (exceptions vérifiées)
- sauf les exceptions dérivées de RuntimeException ou Error (exceptions non-vérifiées)
-
Si une méthode appelle une méthode ou constructeur levant une exception à gestion explicite ou lève elle-même une telle exception
- elle doit la capturer par un try-catch
- ou la laisser se propager en indiquant cette possibilité dans la signature de la méthode : throws Exception1, Exception2...
Try-catch-finally
try { /* Code susceptible de lever une exception */ } catch (ExceptionSpecifique1 e1) { /* Code de traitement de l'exception */ /* Peut éventuellement lever une nouvelle exception avec e pour cause */ } catch (ExceptionSpecifique2|ExceptionSpecifique3 e2) { /* On peut capturer des exceptions de plusieurs types à la fois En les séparant par des | depuis Java 7 */ } catch (ExceptionGenerale e3) { /* Code de traitement pour un type d'exception plus général */ } finally { /* Le block finally est systématiquement exécuté, qu'il y ait ou non une exception. En cas d'exception, le catch adéquat s'il existe est préalablement exécuté Un seul catch (le premier pertinent) peut être exécuté pour une exception */ }
try ( InputStream is = new FileInputStream(« monFichier ») ; BufferedInputStream bis = new BufferedInputStream(is)) { // Lecture du fichier }
Depuis Java 7, le try-with-resources permet de libérer des ressources déclarées en appelant automatiquement leur méthode close() (classe implantant l'interface AutoCloseable).
Pour en savoir plus sur les exceptions...
Lien vers un cours plus détaillé sur les exceptions
Assertions
-
Programmation par contrat
- On documente le contrat dans la Javadoc
- Java intègre déjà des éléments contractuels pour les types des arguments, le type de retour, les exceptions
-
Éléments contractuels supplémentaires avec assert :
-
Pré-conditions sur les arguments (plages de validité, absence de référence nulle...)
- Quelquefois lever une exception est plus adapté
- Invariants dans les algorithmes (conditions restant vraies à tout moment)
- Les post-conditions sur la valeur de retour, les effets de bord sur les arguments (dont this)
-
Pré-conditions sur les arguments (plages de validité, absence de référence nulle...)
-
Utilisation de assert
- assert (expression retournant un booléen) : "message si assert non respecté"
- Si l'expression de l'assert est évaluée à faux, une AssertionError avec le message est levée
-
Possibilité d'activer les assertions au moment de l'exécution avec des options de la JVM :
- -ea pour les assertions utilisateur
- -esa pour les assertions des classes système
JShell
- JShell introduit par Java 9 (REPL)
- Propose un langage de script utilisable interactivement
- Permet de réaliser d'évaluer des expressions, d'implanter des méthodes directement en dehors de classes
-
Utilité :
- Réalisation de scripts
- Expérimentation interactive et test de code
-
Utilisation de JShell
- Lancement dans un terminal avec jshell
- Evaluation d'expressions, implantation de méthodes
- Importation de scripts depuis un fichier externe avec /open filepath
- Visualisation de toutes les variables déclarées avec /vars
- Visualisation des méthodes déclarées avec /methods
- Sauvegarde des commandes de la session courante dans un fichier avec /save filepath
Exemple pratique : testons si des chaînes sont des palindromes
jshell> boolean isPalindrome(String x) { for (int i = 0; i < x.length() / 2; i++) if (x.charAt(i) != x.charAt(x.length() - 1 - i)) return false; return true; } | created method isPalindrome(String) jshell> isPalindrome("radar") $2 ==> true jshell> isPalindrome("sonar") $3 ==> false
java.util.Collection<T>
- Implante méthodes d'ajout (add(), addAll()...) et suppression d'élement (remove(), removeAll()...)
- Implante Iterable<T> : retourne un itérateur pour parcourir tous les éléments
- Implante size() pour connaître le nombre d'éléments
Iterable<T> et Iterator<T>
- L'interface Iterable<T> requiert l'implantation de Iterator<T> iterator()
-
L'interface Iterator<T> définit :
- boolean hasNext() : condition à vérifier avant de tenter d'obtenir l'élément suivant
- T next() : retourne l'élément suivant de l'itération (NoSuchElementException levée si plus d'élément)
- void remove() throws UnsupportedOperationException (le support de la suppression est facultatif)
Comparaison d'éléments
- Set est un ensemble (sans doublon)
- Lors de l'ajout d'un élément, il faut vérifier si un autre élément égal existe
-
Vérification d'égalité :
-
Soit par l'égalité des références : ==
- IdentityHashSet : n'existe pas dans le JDK
-
Soit en utilisant la méthode Object.equals(Object) : a.equals(b)
- HashSet
- Attention à bien redéfinir la méthode hashCode() avec la redéfinition de equals()
-
Soit en utilisant un ordre avec a.compareTo(b)
- SortedSet : TreeSet
-
Soit par l'égalité des références : ==
Table de hachage HashSet
-
Indexe chacun de ses éléments par une valeur de hachage calculée sur son contenu : méthode int hashCode() définie dans Object
- Deux éléments égaux ont le même hashCode
- Mais deux éléments de même hashCode ne sont pas toujours égaux
-
Pour les éléments de même hashCode, on vérifie s'il y a réellement égalité en utilisant la méthode equals(Object o)
- Redéfinition nécessaire de hashCode() et equals(Object) car l'implantation par défaut de Object teste l'égalité par référence et non sur le contenu
- L'itérateur d'un HashSet retourne les éléments selon un ordre non-défini (en fait dépendant du hashCode)
Ensemble ordonné SortedSet
-
Les éléments doivent implanter l'interface Comparable et proposer une méthode int compareTo(T element)
- Retourne une valeur < 0 si this < element
- Retourne 0 si this = element
- Retourne une valeur > 0 si this > element
- L'itérateur d'un SortedSet retourne les éléments triés
- Un élément peut avoir un ordre naturel en implantant Comparable ; des autres ordres peuvent être définis avec Comparator (peut être passé en construction d'un TreeSet)
D'un Set à une Map
- Map : définissable comme un Set auquel on aurait associé des valeurs pour chaque élément (clé)
- HashSet et TreeSet sont déclinés en HashMap et TreeMap
-
Conversion d'une Map<T, Boolean> en un Set<Boolean> :
- Collections.newSetFromMap(map) (utili
- Possibilité d'obtenir l'ensemble des clés d'une Map (keySet()) et une collection des valeurs (values()) qui peuvent être en doublon
- entrySet().iterator() retourne un Iterator<Map.Entry<K,V>> permettant d'itérer sur les couples clé-valeur (utilisation possible avec boucle for-each)
Exemple : ensemble des fichiers d'un répertoire
package fr.upem.jacosa.general; import java.io.File; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; // @run: FileLister /** List all the files of a directory (sorted by name and modification date) */ public class FileLister { public static void main(String[] args) { final String dir = args[0]; // we retrieve the directory listing into an array of files File[] files = new File(dir).listFiles(); // we add the files into a SortedSet (the natural ordering on files is based on the lexicographic order of their names) SortedSet<File> sortedFiles = new TreeSet<>() ; for (File f: files) sortedFiles.add(f) ; // we use an other SortedSet with an explicit order based on the modification date of the file SortedSet<File> sortedFiles2 = new TreeSet<>( (f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified()) ) ; for (File f : files) sortedFiles2.add(f) ; System.out.println("Files sorted by name: " + sortedFiles); System.out.println("Files sorted by modification date: " + sortedFiles2); } }