Java est régulièrement l’une des trois langues les plus populaires au monde. Son adoption dans des domaines tels que le développement de logiciels d’entreprise, les applications mobiles Android et les applications web à grande échelle est inégalée. Son système de types robuste, son écosystème étendu et sa capacité « write once, run anywhere » le rendent particulièrement attrayant pour la construction de systèmes robustes et évolutifs. Dans cet article, nous explorerons comment les fonctionnalités de programmation orientée objet de Java permettent aux développeurs de tirer parti de ces capacités de manière efficace, leur permettant de construire des applications maintenables et évolutives grâce à une organisation et une réutilisation appropriées du code.
Une Note sur l’Organisation et l’Exécution du Code Java
Avant de commencer à écrire du code, faisons quelques préparatifs.
Tout comme sa syntaxe, Java a des règles strictes concernant l’organisation du code.
Tout d’abord, chaque classe publique doit être dans son propre fichier, nommé exactement comme la classe mais avec une extension.java
. Ainsi, si je veux écrire une classe Laptop, le nom de fichier doit êtreLaptop.java
—sensible à la casse. Vous pouvez avoir des classes non publiques dans le même fichier, mais il est préférable de les séparer. Je sais que nous allons de l’avant—parler de l’organisation des classes même avant de les écrire—mais avoir une idée approximative d’où mettre les choses à l’avance est une bonne idée.
Tous les projets Java doivent avoir un Main.java
fichier avec la classe Main. C’est ici que vous testez vos classes en créant des objets à partir de celles-ci.
Pour exécuter du code Java, nous allons utiliser IntelliJ IDEA, un IDE Java populaire. Après avoir installé IntelliJ :
- Créez un nouveau projet Java (Fichier > Nouveau > Projet)
- Cliquez avec le bouton droit sur le dossier src pour créer le fichier
Main.java
et collez-y le contenu suivant:
public class Main { public static void main(String[] args) { // Créez et testez les objets ici } }
Chaque fois que nous parlons de classes, nous écrivons du code dans d’autres fichiers que le Main.java
fichier. Mais si nous parlons de création et de test de objets, nous revenons à Main.java
.
Pour exécuter le programme, vous pouvez cliquer sur le bouton de lecture vert à côté de la méthode principale :
La sortie sera affichée dans la fenêtre d’outils d’exécution en bas.
Si vous êtes complètement nouveau en Java, veuillez consulter notre Cours d’introduction à Java, qui couvre les fondamentaux des types de données Java et du flux de contrôle avant de continuer.
Sinon, plongeons directement dans le sujet.
Classes et objets Java
Alors, qu’est-ce que les classes, exactement ?
Les classes sont des constructions de programmation en Java pour représenter des concepts du monde réel. Par exemple, considérez cette classe MenuItem (créez un fichier pour écrire cette classe dans votre IDE) :
public class MenuItem { public String name; public double price; }
La classe nous donne un plan ou un modèle pour représenter différents éléments de menu dans un restaurant. En modifiant les deux attributs de la classe, nom
, et prix
, nous pouvons créer d’innombrables objets de menu tels qu’un burger ou une salade.
Donc, pour créer une classe en Java, vous commencez par une ligne qui décrit le niveau d’accès de la classe (private
, public
, ou protected
) suivi du nom de la classe. Immédiatement après les crochets, vous définissez les attributs de votre classe.
Mais comment créons-nous des objets qui appartiennent à cette classe? Java permet cela grâce aux méthodes de constructeur:
public class MenuItem { public String name; public double price; // Constructeur public MenuItem(String name, double price) { this.name = name; this.price = price; } }
Un constructeur est une méthode spéciale appelée lors de la création d’un nouvel objet à partir d’une classe. Il initialise les attributs de l’objet avec les valeurs que nous fournissons. Dans l’exemple ci-dessus, le constructeur prend un nom et un paramètre de prix et les assigne aux champs de l’objet en utilisant le mot-clé ‘this’ pour faire référence à une future instance d’objet.
La syntaxe du constructeur est différente des autres méthodes de classe car elle ne nécessite pas de spécifier un type de retour. De plus, le constructeur doit avoir le même nom que la classe et il doit avoir le même nombre d’attributs que vous avez déclarés après la définition de la classe. Ici, le constructeur crée deux attributs car nous en avons déclaré deux après la définition de la classe : name
et price
.
Après avoir écrit votre classe et son constructeur, vous pouvez créer des instances (objets) de celle-ci dans votre méthode principale :
public class Main { public static void main(String[] args) { // Créez des objets ici MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }
Sortie :
Burger, 3.5
En haut, nous créons deux MenuItem
objets dans les variables burger
et salad
. Comme requis en Java, le type de la variable doit être déclaré, qui est MenuItem
. Ensuite, pour créer une instance de notre classe, nous écrivons le mot-clé new suivi de l’invocation de la méthode du constructeur.
Outre le constructeur, vous pouvez créer des méthodes régulières qui donnent un comportement à votre classe. Par exemple, ci-dessous, nous ajoutons une méthode pour calculer le prix total après taxes:
public class MenuItem { public String name; public double price; // Constructeur public MenuItem(String name, double price) { this.name = name; this.price = price; } // Méthode pour calculer le prix après taxes public double getPriceAfterTax() { double taxRate = 0.08; // Taux de taxe de 8% return price + (price * taxRate); } }
Maintenant, nous pouvons calculer le prix total, taxes incluses:
public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }
Sortie:
Price after tax: $3.78
Encapsulation
L’objectif des classes est de fournir un modèle pour créer des objets. Ces objets seront ensuite utilisés par d’autres scripts ou programmes. Par exemple, nos objets MenuItem
peuvent être utilisés par une interface utilisateur qui affiche leur nom, prix et image sur un écran.
Pour cette raison, nous devons concevoir nos classes de manière à ce que leurs instances ne puissent être utilisées que de la manière dont nous l’avions prévu. En ce moment, notre MenuItem
classe est très basique et sujette aux erreurs. Une personne pourrait créer des objets avec des attributs ridicules, comme une tarte aux pommes à prix négatif ou un sandwich à un million de dollars :
// Dans Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Prix négatif ! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Déraisonnablement cher System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);
Donc, la première chose à faire après avoir écrit une classe est de protéger ses attributs en limitant la façon dont ils sont créés et accessibles. Pour commencer, nous voulons autoriser uniquement des valeurs positives pour prix et définir une valeur maximale pour éviter d’afficher accidentellement des articles ridiculement chers.
Java nous permet d’accomplir cela en utilisant des méthodes setter :
public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Examinons ce qui est nouveau dans le bloc de code ci-dessus :
1. Nous avons rendu les attributs privés en ajoutant le mot-clé private
. Cela signifie qu’ils ne peuvent être accédés que dans la classe MenuItem. L’encapsulation commence par cette étape cruciale.
2. Nous avons ajouté une nouvelle constante MAX_PRICE
qui est :
- privée (accessible uniquement dans la classe)
- statique (partagé entre toutes les instances)
- final (ne peut pas être modifié après l’initialisation)
- défini à $100,0 comme prix maximum raisonnable
3. Nous avons ajouté une setPrice()
méthode qui :
- prend un paramètre de prix
- Valide que le prix n’est pas négatif
- Valide que le prix ne dépasse pas MAX_PRICE
- Lance IllegalArgumentException avec des messages descriptifs en cas d’échec de la validation
- Ne définir le prix que si toutes les validations réussissent
4. Nous avons modifié le constructeur pour utiliser setPrice()
au lieu d’assigner directement le prix. Cela garantit que la validation du prix se produit lors de la création de l’objet.
Nous venons de mettre en œuvre l’un des piliers fondamentaux d’un bon design orienté objet — l’encapsulation. Ce paradigme impose le masquage des données et un accès contrôlé aux attributs des objets, garantissant que les détails d’implémentation internes sont protégés contre les interférences extérieures et ne peuvent être modifiés que par des interfaces bien définies.
Illustrons ce point en appliquant l’encapsulation à l’attribut nom. Imaginez que nous avons un café qui ne sert que des lattes, cappuccinos, espressos, americanos et mochas.
Donc, les noms de nos éléments de menu ne peuvent être que l’un des éléments de cette liste. Voici comment nous pouvons appliquer ceci dans le code :
// Reste de la classe ici ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }
Le code ci-dessus implémente la validation du nom des éléments de menu dans un café. Expliquons-le :
1. Tout d’abord, il définit un tableau privé statique final VALID_NAMES
qui contient les seuls noms de boissons autorisés : latte, cappuccino, espresso, americano et mocha. Ce tableau étant :
- privé : uniquement accessible à l’intérieur de la classe
- statique : partagé entre toutes les instances
- final : ne peut pas être modifié après l’initialisation
2. Il déclare un champ privé de type String nom pour stocker le nom de la boisson
3. La méthode setName()
implémente la logique de validation :
- Prend un paramètre de type String nom
- Le convertit en minuscules pour rendre la comparaison insensible à la casse
- Boucle à travers le tableau
VALID_NAMES
- S’il y a une correspondance, définit le nom et retourne
- Si aucune correspondance n’est trouvée, lance une IllegalArgumentException avec un message descriptif listant toutes les options valides
Voici la classe complète jusqu’à présent :
public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Après avoir protégé la manière dont les attributs sont créés, nous voulons également protéger la façon dont ils sont accédés. Cela se fait en utilisant des méthodes getter :
public class MenuItem { // Reste du code ici ... public String getName() { return name; } public double getPrice() { return price; } }
Les méthodes getter fournissent un accès contrôlé aux attributs privés d’une classe. Elles résolvent le problème d’accès direct aux attributs, ce qui peut entraîner des modifications indésirables et briser l’encapsulation.
Par exemple, sans accesseurs, nous pourrions accéder aux attributs directement :
MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Accès direct à l'attribut item.name = "INVALID"; // Peut être modifié directement, contournant la validation
Avec des accesseurs, nous imposons un accès approprié :
MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Accès contrôlé par l'accesseur // item.name = "INVALID"; // Non autorisé - doit utiliser setName() qui valide
Cette encapsulation :
- Protège l’intégrité des données en empêchant des modifications invalides
- Nous permet de changer l’implémentation interne sans affecter le code qui utilise la classe
- Fournit un point d’accès unique qui peut inclure une logique supplémentaire si nécessaire
- Rend le code plus maintenable et moins sujet aux bugs
Héritage
Notre classe commence à avoir fière allure mais présente de nombreux problèmes. Par exemple, pour un grand restaurant servant de nombreux types de plats et de boissons, la classe n’est pas assez flexible.
Si nous voulons ajouter différents types d’aliments, nous rencontrerons plusieurs défis. Certains plats peuvent être préparés pour emporter, tandis que d’autres nécessitent une consommation immédiate. Les éléments du menu peuvent avoir des prix et des remises variables. Les plats peuvent nécessiter un suivi de la température ou un stockage spécial. Les boissons peuvent être chaudes ou froides avec des ingrédients personnalisables. Les articles peuvent nécessiter des informations sur les allergènes et des options de portion. Le système actuel ne gère pas ces exigences variées.
L’héritage fournit une solution élégante à tous ces problèmes. Il nous permet de créer des versions spécialisées d’éléments de menu en définissant une classe de base MenuItem
avec des attributs communs, puis en créant des classes enfant qui héritent de ces bases tout en ajoutant des fonctionnalités uniques.
Par exemple, nous pourrions avoir une classe Drink
pour les boissons avec des options de température, une classe Food
pour les éléments nécessitant une consommation immédiate, et une classe Dessert
pour les éléments ayant des besoins spéciaux de stockage, tous héritant de la fonctionnalité de base des éléments de menu.
Extension des classes
Implémentons ces idées en commençant par Drink
:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
Pour définir une classe enfant qui hérite d’une classe parent, nous utilisons le mot-clé extends
après le nom de la classe enfant suivi de la classe parent. Après la définition de la classe, nous définissons les nouveaux attributs de cette classe enfant et implémentons son constructeur.
Mais remarquez comment nous devons répéter l’initialisation de name
et price
ainsi que isCold
. Ce n’est pas idéal car la classe parent peut avoir des centaines d’attributs. De plus, le code ci-dessus générera une erreur lors de la compilation car ce n’est pas la façon correcte d’initialiser les attributs de la classe parent. La bonne façon serait d’utiliser le mot-clé super
:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
Le super
mot-clé est utilisé pour appeler le constructeur de la classe parent. Dans ce cas, super(nom, prix)
appelle le constructeur de MenuItem
pour initialiser ces attributs, évitant ainsi la duplication de code. Nous devons uniquement initialiser le nouvel attribut isCold
spécifique à la classe Drink
.
Le mot-clé est très flexible car vous pouvez l’utiliser pour faire référence à la classe parent dans n’importe quelle partie de la classe enfant. Par exemple, pour appeler une méthode parent, vous utilisez super.nomMethode()
tandis que super.nomAttribut
est pour les attributs.
L’override de méthode
Maintenant, disons que nous voulons ajouter une nouvelle méthode à nos classes pour calculer le prix total après taxes. Comme différents articles de menu peuvent avoir des taux de taxe différents (par exemple, aliments préparés vs boissons emballées), nous pouvons utiliser l’override de méthode pour implémenter des calculs de taxe spécifiques dans chaque classe enfant tout en maintenant un nom de méthode commun dans la classe parent.
Voici à quoi cela ressemble :
public class MenuItem { // Reste de la classe MenuItem public double calculateTotalPrice() { // Taux de taxe par défaut de 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // Les aliments ont une taxe de 15% return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // Les boissons ont une taxe de 8% return super.getPrice() * 1.08; } }
Dans cet exemple, la substitution de méthode permet à chaque sous-classe de fournir sa propre implémentation de calculateTotalPrice()
:
La base MenuItem
classe définit un calcul de taxe par défaut de 10 %.
Quand Food
et Drink
étendent les classes MenuItem
, ils remplacent cette méthode pour implémenter leurs propres taux de taxe:
- Les articles alimentaires ont un taux de taxe plus élevé de 15%
- Les boissons ont un taux de taxe inférieur de 8%
Le @Override
annotation est utilisée pour indiquer explicitement que ces méthodes remplacent la méthode de la classe parent. Cela permet de détecter les erreurs si la signature de la méthode ne correspond pas à celle de la classe parent.
Chaque sous-classe peut toujours accéder au prix de la classe parente en utilisant super.getPrice()
, démontrant comment les méthodes remplacées peuvent utiliser la fonctionnalité de la classe parente tout en ajoutant leur propre comportement.
En résumé, la substitution de méthode est une partie intégrante de l’héritage qui permet aux sous-classes de fournir leur propre implémentation des méthodes définies dans la classe parente, permettant un comportement plus spécifique tout en maintenant la même signature de méthode.
Classes Abstraites
Notre MenuItem
hiérarchie de classes fonctionne, mais il y a un problème : est-ce que quelqu’un devrait pouvoir créer un objet MenuItem
ordinaire ? Après tout, dans notre restaurant, chaque élément de menu est soit un Aliment soit une Boisson – il n’existe pas d’« élément de menu générique ».
Nous pouvons prévenir cela en faisant MenuItem
une classe abstraite. Une classe abstraite fournit uniquement un modèle de base – elle ne peut être utilisée que comme classe parente pour l’héritage, et ne peut pas être instanciée directement.
Pour rendre MenuItem
abstrait, nous ajoutons le abstract
mot-clé après son modificateur d’accès :
public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Les getters/setters existants restent les mêmes // Rendre cette méthode abstraite - chaque sous-classe DOIT l'implémenter public abstract double calculateTotalPrice(); }
Les classes abstraites peuvent également avoir des méthodes abstraites comme calculateTotalPrice()
ci-dessus. Ces méthodes abstraites servent de contrats qui obligent les sous-classes à fournir leurs implémentations. En d’autres termes, toute méthode abstraite dans une classe abstraite doit être implémentée par les classes enfants.
Alors, réécrivons Nourriture
et Boisson
en tenant compte de ces changements :
public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% de taxe } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% de taxe } }
Grâce à cette implémentation du système de menu, nous avons vu comment l’abstraction et l’héritage travaillent ensemble pour créer un code flexible et maintenable qui peut facilement s’adapter à différents besoins commerciaux.
Conclusion
Aujourd’hui, nous avons jeté un coup d’œil sur ce que Java est capable de faire en tant que langage de programmation orienté objet. Nous avons couvert les bases des classes, des objets et quelques piliers clés de la POO : l’encapsulation, l’héritage et l’abstraction à travers un système de menu de restaurant.
Pour rendre ce système prêt pour la production, il vous reste encore beaucoup de choses à apprendre, comme les interfaces (partie de l’abstraction), le polymorphisme et les modèles de conception POO. Pour en savoir plus sur ces concepts, référez-vous à notre Introduction à la POO en Java cours.
Si vous souhaitez tester vos connaissances en Java, essayez de répondre à certaines des questions de notre article sur les questions d’entretien Java.