Capsule de théorie Types enregistrement en Java

Introduction

Dans un langage de programmation, une variable représente une données. Le type de données de la variable représente la catégorie à laquelle appartient cette donnée. Examinons, par exemple, quelques-uns des types que nous connaissons. Le type int représente la catégorie des données dont la valeur est un nombre entier compris entre -231 - 1 et 231. Le type double, quant à lui, représente la catégorie des données dont la valeur est un nombre rationnel qui peut être codé selon la norme IEEE 754. Le type String, enfin, représente la catégorie des données dont la valeur est un texte dont la longueur n’excède pas 232 caractères. Ces types représentent des catégories de données dont la valeur est simple, un seul nombre entier, un seul nombre rationnel, un seul texte. Et il en va de même pour tous les types primitifs de Java, ce qui explique qu’ils sont parfois appelés type simple.

Un tableau est un type complexe qui représente une catégorie de données dont la valeur est une liste d’éléments indépendants et de même nature (p. ex. une liste de températures moyennes journalières). Mais comment représenter une donnée qui se compose d’un ensemble d’éléments appartenant à des catégories différentes, par exemple, un compte d’utilisateur qui se compose d’un identifiant (un entier), d’un nom d’utilisateur (un texte) et d’un nom complet ? La réponse est : avec un type enregistrement (record type).

Cette capsule à pour but de définir ce qu’est un type enregistrement et de voir comment nous pouvons définir et utiliser un type enregistrement en Java.

Qu’est-ce qu’un type enregistrement ?

Un type enregistrement est un type de données complexe (ou composé) représente une donnée, elle-même composée d’un ensemble de données. Chaque élément de cet ensemble possède un nom et un type de données spécifique, et il ne doit pas y avoir de doublons dans les noms des éléments.

Les éléments d’un enregistrement sont appelés champs (fields), attributs (attributes) ou encore propriétés (properties). Contrairement, aux valeurs individuelles d’un tableau, les valeurs des champs d’un enregistrement ne sont pas indépendantes les unes des autres. Ensemble, ces valeurs sont une description d’un objet concret ou abstrait du monde réel.

Par exemple, un compte d’utilisateur peut être décrit par :

  • un identifiant numérique (uid) de type entier
  • un nom d’utilisateur ()userName) de type texte,
  • un nom complet (displayName) de type texte.

Voyons maintenant comment nous pouvons définir ce type enregistrement en Java.

Définir un type enregistrement

En Java, un type enregistrement est défini à l’aide d’une classe, dans laquelle les variables membres représentent les champs de l’enregistrement. Les variables membres sont déclarées au début de la classe juste après l’accolade ouvrante. La déclaration d’une variable membre est similaire à celle d’une variable locale, mais elle est précédée du modificateur public. La figure 1 montre une définition possible du type UserAccount.

1
2
3
4
5
6
7
package ch.epai.ict.exemples.record_types;

public class UserAccount {
    public int uid;
    public String userName;
    public String displayName;
}
Fig. 1 – Définition du type UserAccount (UserAccount.java)

Voyons maintenant comment nous pouvons utiliser ce nouveau type de donnée.

Créer une instance d’un type enregistrement

Le type UserAccount que nous avons défini peut être utilisé comme n’importe quel autre type de données. Nous pouvons, par exempe, déclarer ou définir une variable de type UserAccount, déclarer paramètre formel du même type, ou encore définir une fonction qui renvoie un enregistrement de ce type. Déclarons une variable myAccount de type UserAccount.

1
2
3
4
5
6
7
8
9
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {

        UserAccount myAccount;
    }
}
Fig. 2 – Déclarer une variable de type UserAccount

La variable est déclarée, mais pas encore définie. S’il s’agissait d’un entier ou d’une chaîne de caractères, nous pourrions lui affecter une valeur littérale. Mais les valeurs de type enregistrement n’ont pas de représentation littérale en Java. Pour affecter une valeur à la variable myAccount, nous devons créer une nouvelle instance du type UserAccount avec l’opérateur new.

1
2
3
4
5
6
7
8
9
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {

        UserAccount myAccount = new UserAccount();
    }
}
Fig. 3 – Créer une nouvelle instance de UserAccount

Avec l’expression new UserAccount(), nous avons créé une instance de UserAccount dans la mémoire, mais les valeurs des champs n’ont pas été définies. Nous devons donc initialiser chaque champ en lui affectant une valeur. On référence un champ d’une variable par le nom de la variable suivi immédiatement d’un point et du nom du champ (<variable>.<champ>).

1
2
3
4
5
6
7
8
9
10
11
12
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {

        UserAccount myAccount = new UserAccount();
        myAccount.uid = 10000;
        myAccount.userName = "lovelacea";
        myAccount.displayName = "Ada Lovelace";
    }
}
Fig. 4 – Initialiser les variables membres

Maintenant que les champs ont une valeur, nous pouvons faire quelque chose de cette variable; par exemple, la passer en paramètre à une méthode qui écrit le displayName dans la sortie standard.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {

        UserAccount myAccount = new UserAccount();
        myAccount.uid = 10000;
        myAccount.userName = "lovelacea";
        myAccount.displayName = "Ada Lovelace";

        printDisplayName(myAccount);
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }
}
Fig. 5 – Utiliser la variable de type UserAccount

Ce code fonctionne, mais il a un gros défaut. Comme un enregistrement n’est pas cohérent tant que tous les champs ne sont pas initialisés, c’est une mauvaise idée de laisser à l’utilisateur du type le soin de le faire. Voyons comment nous pouvons traiter ce problème.

Encapsuler la création d’une instance

Une première possibilité est de créer une fonction qui prend en paramètre les données de l’enregistrement et qui renvoie une nouvelle instance de l’enregistrement initialisée.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {
         UserAccount myAccount = createUserAccount(1000, "lovelacea", "Ada Lovelace");
        printDisplayName(myAccount);
    }

    public static UserAccount createUserAccount(int uid, String userName, String displayName) {
        UserAccount a = new UserAccount();
        a.uid = uid;
        a.userName = userName;
        a.displayName = displayName;
        return a;
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }
}
Fig. 6 - Encapsuler la création de l'instance, 1er essai

C’est bien, mais la classe App n’est probablement pas le meilleur endroit pour implémenter la méthode createUserAccount. Puisqu’elle sert à instancier un enregistrement de type UserAccount et que ce type est défini avec une classe, pourquoi ne pas l’implémenter la méthode de création dans cette classe ? Faisons donc cela.

1
2
3
4
5
6
7
8
9
10
11
12
13
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {
         UserAccount myAccount = UserAccount.createUserAccount(1000, "lovelacea", "Ada Lovelace");
        printDisplayName(myAccount);
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package ch.epai.ict.exemples.record_types;

public class UserAccount {
    public int uid;
    public String userName;
    public String displayName;

    public static UserAccount createUserAccount(int uid, String userName, String displayName) {
        UserAccount a = new UserAccount();
        a.uid = uid;
        a.userName = userName;
        a.displayName = displayName;
        return a;
    }
}
Fig. 7 - Encapsuler la création de l'instance, 2e essai

Voilà qui est beaucoup mieux. Comme une telle méthode est nécessaire pour tous les types enregistrement, on peut s’attendre à ce que cela soit prévu par le langage. C’est effectivement le cas : en Java, une classe peut avoir un ou plusieurs constructeurs. La figure 8 montre le même programme que le précédent, mais avec un constructeur à la place de la méthode de création.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {
        // Pour invoquer le constructeur, on utilise la même syntaxe que pour 
        // instancier un enregitrement vide, mais en passant les valeurs des 
        // champs en paramètres.
        UserAccount myAccount = new UserAccount(1000, "lovelacea", "Ada Lovelace");
        printDisplayName(myAccount);
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package ch.epai.ict.exemples.record_types;

public class UserAccount {
    public int uid;
    public String userName;
    public String displayName;

    // La définition du constructeur ressemble à la définition d'une méthode, 
    // mais il n'y a pas de modificateur static ni de spécification du type 
    // de retour. Le nom d'un constructeur est toujours identique à celui de
    // la classe. Dans le constructure, on référence le nouvel enregistrement 
    // avec le mot clé `this`. Enfin, il n'y a pas d'instruction return.
    public UserAccount(int uid, String userName, String displayName) {
        this.uid = uid;
        this.userName = userName;
        this.displayName = displayName;
    }
}
Fig. 8 - Utilisation d'un constructeur

En Java, une classe qui implémente un type enregistrement devrait toujours avoir au moins un constructeur. Le rôle d’un constructeur est d’initialiser la valeur de toutes les variables membres de la classe. Cela permet d’assurer la cohérence des enregistrements.

Type enregistrement immuable

Lorsqu’on initialise une variable de type int avec la valeur 1, par exemple, il n’est pas possible de modifier la valeur 1. On peut bien sûr donner une autre valeur à la variable, mais la valeur 1, elle reste inchangée. On dit que la valeur est immuable (immutable). En Java, c’est aussi le cas des chaînes de caractères. Lorsque l’on concatène deux chaînes de caractères, on crée une troisième qui contient les deux chaînes appondues, mais les deux chaînes, elles sont inchangées. En Java, les chaînes de caractères sont immuables.

En programmation, il est généralement souhaitable d’avoir des valeurs immuables, car elles permettent d’éviter de nombreuses erreurs. Pour rendre les valeurs de nos enregistrements immuables, nous devons demander au compilateur d’empêcher la modification des variables membres. En Java, nous pouvons faire cela avec le modificateur final. Lorsqu’une variable membre est déclarée avec le modificateur final, il n’est possible de lui affecter une valeur que dans un constructeur. Il n’est donc plus possible de la modifier après la création de l’instance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {
        UserAccount myAccount = new UserAccount(1000, "lovelacea", "Ada Lovelace");

        myAccount.uid = 2000; // Error !

        printDisplayName(myAccount);
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package ch.epai.ict.exemples.record_types;

public class UserAccount {
    public final int uid;
    public final String userName;
    public final String displayName;

    public UserAccount(int uid, String userName, String displayName) {
        this.uid = uid;
        this.userName = userName;
        this.displayName = displayName;
    }
}
Fig. 9 - Encapsuler la création de l'instance, 3e essai (constructeur)

L’utilisation d’enregistrements immuables n’empêche pas d’effectuer des opérations, mais ces opérations ne peuvent pas modifier les enregistrements auxquels elles sont appliquées. Au lieu de cela, elles doivent créer une nouvelle instance du type enregistrement avec de nouvelles valeurs. La figure 10 illustre la manière dont on peut réaliser une méthode qui modifie le displayName d’un compte d’utilisateur avec des enregistrements immuables.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package ch.epai.ict.exemples.record_types;

public class App {

    public static void main(String[] args) {
        UserAccount myAccount = new UserAccount(1000, "lovelacea", "Ada Lovelace");

        printDisplayName(myAccount);

        myAccount = changeDisplayName(myAccount, "Ada Byron, Comtesse de Lovelace");
        printDisplayName(myAccount);
    }

    public static void printDisplayName(UserAccount account) {
        System.out.println(account.displayName);
    }

    public static UserAccount changeDisplayName(UserAccount account, String newDisplayName) {
        return new UserAccount(account.uid, account.userName, newDisplayName);
    }
}
Fig. 10 - Implémenter une opération qui modifie le displayName.

Conclusion

Dans cette capsule, nous avons exploré la notion de type enregistrement et comment un tel type peut être défini en Java. Nous avons également discuté de l’importance de l’immuabilité des données et vu comment réaliser des types enregistrement immuables en utilisant des constructeurs et le modificateur final.