Programmation Java

Les bases de l'objet en Java : classes, objets, champs et méthodes

Débutant11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Ce guide explique le modèle objet sur lequel repose tout le reste de Java : ce que décrit une classe, ce qu'est réellement un objet en mémoire, comment fonctionnent constructeurs et méthodes, pourquoi l'encapsulation compte et quand un record remplace une classe écrite à la main. À la fin, vous saurez concevoir une petite classe correcte et expliquer ce qui se passe à chaque ligne d'un programme qui l'utilise.

Ce que vous allez apprendre

  • Distinguer une classe d'un objet, et une référence de l'objet qu'elle désigne
  • Écrire des constructeurs, des accesseurs et des méthodes qui maintiennent l'état d'un objet valide
  • Choisir en connaissance de cause entre membres d'instance et membres static
  • Implémenter correctement equals, hashCode et toString, ou laisser un record s'en charger
  • Expliquer comment les arguments sont transmis aux méthodes Java et pourquoi ce n'est pas un passage par référence

Java est un langage orienté objet dans un sens bien précis : presque chaque ligne de code que vous écrivez vit dans une classe, et presque chaque valeur que vous manipulez, en dehors des huit types primitifs, est un objet atteint via une référence. Maîtriser ce modèle tôt est payant pendant des années : la plupart des bugs déroutants que rencontrent les débutants — une NullPointerException, une méthode qui « ne modifie pas » la variable qu'on lui passe, deux objets « égaux » qu'un HashSet considère comme différents — viennent d'une image floue de ce que sont une classe, un objet et une référence. Ce guide construit cette image avec du code qui compile en Java 21.

Les classes décrivent, les objets existent

Une classe est un plan : elle déclare l'état (les champs) et le comportement (les méthodes) d'une catégorie de choses. Un objet est une instance concrète créée à partir de ce plan à l'exécution ; il vit dans le tas (heap) avec sa propre copie de chaque champ d'instance. On crée des objets avec new, et le résultat de new est une référence — une valeur qui pointe vers l'objet, pas l'objet lui-même.

public class BankAccount {
    private final String iban;   // set once, never changes
    private long balanceCents;   // mutable state, kept private

    public BankAccount(String iban, long openingBalanceCents) {
        if (openingBalanceCents < 0) {
            throw new IllegalArgumentException("Opening balance cannot be negative");
        }
        this.iban = iban;
        this.balanceCents = openingBalanceCents;
    }

    public void deposit(long cents) {
        if (cents <= 0) throw new IllegalArgumentException("Deposit must be positive");
        balanceCents += cents;
    }

    public long balanceCents() {
        return balanceCents;
    }
}
BankAccount a = new BankAccount("FR7630006000011234567890189", 10_000);
BankAccount b = a;          // b now refers to the SAME object as a
b.deposit(500);
System.out.println(a.balanceCents()); // 10500 — there is only one account

C'est dans le second extrait que le modèle des références prend tout son sens. b = a ne copie pas le compte ; il copie la flèche. Les deux variables pointent vers un seul et même objet, si bien qu'une modification faite via b est visible via a. Pour obtenir un second compte, indépendant, il faut en créer un avec new.

Dans une méthode, le mot-clé this désigne « l'objet sur lequel cette méthode a été appelée ». On en a surtout besoin pour distinguer un champ d'un paramètre portant le même nom, comme dans le constructeur ci-dessus.

Types primitifs et types référence

Java compte huit types primitifs (int, long, double, boolean, char, byte, short, float) dont les variables contiennent directement la valeur. Tous les autres types — String, les tableaux, vos propres classes — sont des types référence. Une variable de type référence peut aussi valoir null, c'est-à-dire « ne pointe vers rien », et déréférencer null (appeler une méthode ou lire un champ à travers elle) lève une NullPointerException. Depuis Java 15, le message indique par défaut quelle expression était nulle (because "order.customer" is null), ce qui rend ces erreurs bien plus rapides à diagnostiquer qu'autrefois.

Les classes enveloppes (wrappers) Integer, Long, Double et consorts sont des types référence qui emballent un primitif pour qu'il puisse vivre dans une collection. L'auto-boxing masque la conversion, mais pas ses conséquences : une valeur Integer peut être null, et comparer deux objets Integer avec == compare des références, pas des nombres. Utilisez equals, ou déballez d'abord.

Constructeurs et initialisation des objets

Un constructeur s'exécute exactement une fois par objet, au moment où new est évalué, et son rôle est de laisser l'objet dans un état valide. Trois règles gouvernent les constructeurs :

  1. Si vous ne déclarez aucun constructeur, le compilateur en fournit un public, sans argument. Dès que vous en déclarez un, ce constructeur par défaut disparaît.
  2. Un constructeur peut appeler un autre constructeur de la même classe avec this(...) en première instruction, ce qui évite de dupliquer la logique de validation.
  3. Les champs que vous n'affectez pas reçoivent une valeur par défaut : 0, false ou null. Pour un champ final, le compilateur vous oblige à l'affecter dans chaque constructeur.
public class Temperature {
    private final double celsius;

    public Temperature(double celsius) {
        this.celsius = celsius;
    }

    public Temperature() {
        this(20.0);                  // delegate, do not repeat
    }

    public static Temperature ofFahrenheit(double f) {   // static factory
        return new Temperature((f - 32) * 5 / 9);
    }

    public double celsius() { return celsius; }
}

La méthode ofFahrenheit illustre l'idiome de la fabrique statique (static factory) : une méthode static qui renvoie une instance. Une fabrique peut porter un nom explicite, renvoyer une instance en cache plutôt qu'une nouvelle, ou encore renvoyer un sous-type — rien de tout cela n'est possible avec un constructeur. De nombreux types du JDK y ont recours : List.of, Integer.valueOf, LocalDate.of.

Validez dans le constructeur et levez une IllegalArgumentException plutôt que de tout accepter pour vérifier plus tard. Un objet qui ne peut exister que dans un état valide est bien plus facile à appréhender qu'un objet qu'il faut contrôler avant chaque utilisation.

Encapsulation : champs privés, intentions publiques

L'encapsulation signifie qu'un objet contrôle son propre état. Concrètement : déclarez les champs private, exposez le comportement par des méthodes et réduisez la surface publique au strict minimum. Il ne s'agit pas de cérémonial ; il s'agit que chaque règle portant sur l'état vive à un seul endroit. Si balanceCents était public, la règle « jamais négatif » devrait être revérifiée par chaque bout de code qui y touche.

Les getters et setters sont la façon mécanique d'exposer l'état, et les IDE les génèrent en un raccourci clavier — c'est précisément pour cela qu'on en abuse. Une classe dont l'API se résume à getX/setX pour chaque champ n'est qu'une simple structure de données, en plus verbeux. Demandez-vous plutôt quelles opérations l'objet prend en charge — deposit, withdraw, close — et exposez celles-là. Là où un simple accesseur est pertinent, le style Java moderne abandonne souvent le préfixe get, à l'image de ce que génèrent les records.

Les modificateurs d'accès, du plus restrictif au moins restrictif :

ModificateurVisible depuis
privateLa classe déclarante uniquement (y compris ses classes imbriquées)
(aucun, « package-private »)Toute classe du même package
protectedLe même package, plus les sous-classes de n'importe quel package
publicPartout

La visibilité package-private est sous-utilisée. Une classe utilitaire dont seul le package a besoin ne devrait porter aucun modificateur : l'API publique d'une bibliothèque n'expose ainsi que ce qu'elle entend vraiment exposer.

Immuabilité

Un objet immuable ne change plus jamais après sa construction : tous les champs sont final, il n'y a pas de setters, et tout composant mutable (une List, une Date, un tableau) fait l'objet d'une copie défensive à l'entrée comme à la sortie. Les objets immuables peuvent être partagés sans risque entre threads, servir de clés de map et être mis en cache. String, LocalDate, BigDecimal et toutes les classes enveloppes sont immuables, et les collections renvoyées par List.of et Map.of ne sont pas modifiables. Optez pour l'immuabilité par défaut et n'introduisez de la mutabilité que là où le domaine l'exige.

Méthodes, surcharge et transmission des arguments

Une méthode a un nom, un type de retour (ou void), des paramètres et un corps. Deux méthodes d'une même classe peuvent partager un nom si leurs listes de paramètres diffèrent ; c'est la surcharge (overloading), et le compilateur choisit la version d'après les types statiques des arguments au point d'appel. Surcharger sur des types qui s'auto-boxent ou s'élargissent l'un vers l'autre (int, long, Integer) conduit à des choix surprenants : gardez des surcharges clairement distinctes.

Java transmet chaque argument par valeur. Pour un primitif, la valeur est le nombre. Pour un objet, la valeur est la référence. La méthode reçoit sa propre copie de la référence, qui pointe vers le même objet :

static void rename(Person p) {
    p.setName("Grace");       // mutates the shared object — caller sees this
    p = new Person("Ada");    // rebinds the local copy — caller does NOT see this
}

Après l'exécution de rename(person), person.getName() vaut "Grace". La réaffectation de la deuxième ligne n'a modifié que la variable propre à la méthode. Ce seul fait explique la plupart des débats du type « Java passe par référence — non, pas du tout ».

Les paramètres d'arité variable (String... parts) permettent à une méthode d'accepter zéro ou plusieurs arguments d'un même type ; dans la méthode, ils forment un tableau. Placez le paramètre varargs en dernier et évitez de surcharger une méthode varargs, car le choix du compilateur devient difficile à prévoir.

Membres static : l'espace de noms de la classe

Un champ ou une méthode static appartient à la classe plutôt qu'à une instance. Il n'en existe qu'une copie, initialisée au premier chargement de la classe, et on l'appelle sous la forme Math.max(a, b) ou Temperature.ofFahrenheit(98.6), sans objet. Réservez static :

  • aux constantes : public static final int MAX_RETRIES = 3;
  • aux fonctions utilitaires pures qui n'opèrent que sur leurs arguments (Math, Collections, Objects) ;
  • aux méthodes de fabrique, comme vu plus haut.

N'utilisez pas static pour un état qui appartient logiquement à un objet, et méfiez-vous en général de l'état static mutable : il est global, partagé par tous les threads et invisible pour les tests. Une méthode static ne peut pas utiliser this et ne peut pas être redéfinie ; en abuser bloque donc aussi le polymorphisme, sujet du guide suivant.

Sur une classe imbriquée, le mot-clé static signifie qu'elle ne capture pas de référence vers une instance englobante. Presque toutes les classes imbriquées devraient être static ; une classe interne (non static) maintient silencieusement en vie son objet englobant, source classique de fuite mémoire.

equals, hashCode et toString

Toute classe hérite de java.lang.Object trois méthodes que vous redéfinirez souvent.

toString() renvoie une description lisible. Celle par défaut (BankAccount@1b6d3586) est inutile dans les logs : redéfinissez-la tôt.

equals(Object) définit quand deux objets représentent la même valeur. L'implémentation par défaut compare les références : deux objets Money distincts valant 10 € sont donc différents tant que vous n'en décidez pas autrement. Une redéfinition correcte est réflexive, symétrique, transitive, cohérente et renvoie false pour null :

public final class Money {
    private final long cents;
    private final String currency;

    public Money(long cents, String currency) {
        this.cents = cents;
        this.currency = Objects.requireNonNull(currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money other)) return false;   // pattern matching, Java 16+
        return cents == other.cents && currency.equals(other.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(cents, currency);
    }

    @Override
    public String toString() {
        return cents / 100 + "." + String.format("%02d", cents % 100) + " " + currency;
    }
}

hashCode() doit être cohérent avec equals : des objets égaux doivent produire le même hash. HashMap et HashSet se servent du hash pour choisir un bucket et n'appellent equals qu'ensuite ; une paire incohérente fait donc échouer les recherches de manière apparemment aléatoire. Ne redéfinissez jamais l'une sans l'autre, et ne fondez jamais ni l'une ni l'autre sur un champ mutable d'un objet destiné à une collection basée sur le hachage : modifier ce champ après l'insertion rend l'élément inaccessible.

Records : le compilateur l'écrit pour vous

Depuis Java 16, un record déclare en une ligne un porteur de données immuable et génère le constructeur, les accesseurs, equals, hashCode et toString :

public record Money(long cents, String currency) {
    public Money {                                   // compact constructor: validation only
        Objects.requireNonNull(currency);
        if (cents < 0) throw new IllegalArgumentException("negative amount");
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(cents + other.cents, currency);
    }
}

Le constructeur compact n'a pas de liste de paramètres et s'exécute avant l'affectation des champs : c'est l'endroit idéal pour la validation et la normalisation. Un record peut avoir des fabriques statiques et des méthodes d'instance, et implémenter des interfaces, mais il ne peut ni déclarer de champs d'instance supplémentaires ni étendre une classe. Quand votre classe n'est « que des données », un record est plus court, correct par construction, et signale clairement l'intention au lecteur.

Un exemple complet

Assemblons les pièces : un petit modèle de commande composé d'une classe entité et de deux records.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

record Product(String sku, String name, long priceCents) {}

record OrderLine(Product product, int quantity) {
    OrderLine {
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
    }
    long totalCents() { return product.priceCents() * quantity; }
}

public class Order {
    private static long nextId = 1;            // shared counter — fine for a single thread

    private final long id;
    private final List<OrderLine> lines = new ArrayList<>();

    public Order() { this.id = nextId++; }

    public void add(Product product, int quantity) {
        lines.add(new OrderLine(product, quantity));
    }

    public long totalCents() {
        long sum = 0;
        for (OrderLine line : lines) sum += line.totalCents();
        return sum;
    }

    public List<OrderLine> lines() {
        return Collections.unmodifiableList(lines);   // callers can read, not mutate
    }

    public long id() { return id; }
}

Order a une identité (son id) et évolue dans le temps : c'est donc une classe. Product et OrderLine sont des valeurs : ce sont donc des records. L'accesseur lines() renvoie une vue non modifiable plutôt que la liste interne — sans cela, un appelant pourrait faire order.lines().clear() et contourner toutes les règles de la classe. Le compteur static fonctionne ici mais n'est pas thread-safe ; le guide sur la concurrence montre comment utiliser AtomicLong dans un vrai programme.

Erreurs fréquentes

Comparer des objets avec ==. Sur des références, == demande « même objet ? », pas « même valeur ? ». Utilisez equals, et pour les chaînes en particulier ne vous fiez jamais à ==, même si les littéraux internés donnent parfois l'illusion que cela fonctionne.

Exposer les collections internes. Renvoyer le champ lui-même depuis un getter revient à offrir un accès direct à votre état privé. Renvoyez une vue non modifiable ou une copie.

Redéfinir equals sans hashCode. Expliqué plus haut ; le symptôme est un HashSet qui contient des « doublons » ou une HashMap incapable de retrouver une clé.

L'état static mutable. Un static List<Order> allOrders n'est qu'une variable globale avec l'accent Java. Il provoque des fuites mémoire, casse les tests qui s'exécutent dans une même JVM et n'est pas sûr en contexte concurrent.

Des constructeurs qui font du vrai travail. Ouvrir des fichiers, se connecter à une base de données ou démarrer des threads dans un constructeur rend la classe impossible à tester isolément et laisse s'échapper un objet à moitié construit si ce travail lève une exception. Construisez à moindre coût ; faites le travail dans une méthode explicite.

Des classes imbriquées non static par inadvertance. Une classe interne conserve une référence cachée vers son instance englobante. Déclarez vos classes utilitaires imbriquées static, sauf si vous avez réellement besoin de cette référence.

Oublier que les champs ont des valeurs par défaut. Un int non affecté vaut 0 et une référence non affectée vaut null, sans le moindre avertissement. Affectez-les dans le constructeur et déclarez les champs final autant que possible, pour que le compilateur détecte les oublis.

Bonnes pratiques

  • Rendez chaque champ private et chaque classe final, sauf si elle est conçue pour être étendue.
  • Privilégiez les types valeur immuables ; depuis Java 16, les records sont le choix par défaut pour les représenter.
  • Validez dans les constructeurs et les constructeurs compacts pour qu'aucun objet invalide ne puisse exister.
  • Implémentez toString sur tout ce qui apparaîtra dans une ligne de log.
  • Fondez equals/hashCode sur des champs immuables, et générez-les (record, IDE ou Objects.hash) plutôt que de les écrire à la main.
  • Réservez static aux constantes, aux utilitaires purs et aux fabriques.
  • Nommez les classes avec des noms et les méthodes avec des verbes, et laissez le guide des conventions de code trancher le reste.

Points clés à retenir

  • Une classe est un plan ; un objet est une instance sur le tas ; une variable contient une référence vers lui. L'affectation copie la référence, pas l'objet.
  • Les constructeurs établissent un état valide ; les fabriques statiques sont une alternative plus souple, dotée de noms parlants.
  • L'encapsulation regroupe à un seul endroit toutes les règles qui portent sur un élément d'état : champs privés, méthodes publiques choisies délibérément, vues non modifiables des collections internes.
  • Java passe toujours par valeur ; la valeur d'un argument objet est une référence, donc une méthode peut modifier l'objet de l'appelant mais pas réaffecter sa variable.
  • equals et hashCode se redéfinissent ensemble et reposent sur des champs stables ; les records génèrent correctement les deux.
  • Les membres static appartiennent à la classe : utilisez-les pour les constantes et les utilitaires, pas pour l'état des objets.

Sources et lectures complémentaires

Questions fréquentes

Java passe-t-il les arguments par référence ou par valeur ?
Strictement par valeur. Pour un argument objet, la valeur copiée est la référence : la méthode peut donc modifier les champs de l'objet, mais pas faire pointer la variable de l'appelant vers un autre objet. Réaffecter le paramètre dans la méthode n'a aucun effet à l'extérieur.
Quand utiliser un record plutôt qu'une classe ?
Utilisez un record quand le type est un simple porteur de données, transparent et immuable : un point, un montant, un DTO, une clé de map. Préférez une classe quand l'objet a un état mutable, doit masquer sa représentation interne ou possède un cycle de vie et un comportement qui vont au-delà d'une série d'accesseurs.
Pourquoi redéfinir hashCode dès que l'on redéfinit equals ?
HashMap et HashSet localisent d'abord un élément par son hashCode, et n'appellent equals qu'ensuite. Si deux objets égaux renvoient des hash codes différents, un set acceptera sans broncher les deux, et une map ne retrouvera pas une clé que vous venez d'insérer. Le contrat de java.lang.Object impose que des objets égaux aient le même hash code.
Quelle différence entre un champ static et un champ d'instance ?
Un champ d'instance a une valeur par objet ; un champ static a une valeur par classe, partagée par toutes les instances et accessible sans aucune instance. Le static sert aux constantes, aux compteurs partagés par tous les objets et aux méthodes utilitaires, pas à un état qui appartient à un objet précis.