IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Cours complet pour apprendre à programmer en D


précédentsommairesuivant

37. Immutabilité

Nous avons vu que les variables représentent des idées dans les programmes. Les interactions de ces idées sont matérialisées par des expressions qui changent les valeurs de ces variables :

 
Sélectionnez
// Régler la note
prixTotal = calculerTotal(prixDesElements);
argentDansLePortefeuille -= prixTotal;
argentDuMarchand += prixTotal;

Quand on modifie une variable, on dit qu'on la mute. La mutabilité est essentielle pour la plupart des tâches. Cependant, dans certains cas, la mutabilité n'est pas adaptée :

  • Certaines idées sont immuables par définition. Par exemple, il y a toujours sept jours par semaine, pi (π) est une constante, un programme peut ne prendre en charge qu'un nombre limité de langages (par ex. seulement français et turc), etc.
  • Si toute variable était modifiable comme nous l'avons vu jusqu'à maintenant, alors tout morceau de code qui l'utiliserait pourrait potentiellement la modifier. Même quand il n'y a pas de raison de modifier une variable lors d'une opération quelconque, il n'y a également aucune garantie que ce n'est pas le cas. Les programmes sont difficiles à lire et à modifier quand il n'y a pas de garantie d'immuabilité.
    Par exemple, il peut être clair que l'appel de fonction retirer(bureau, employé) retire un employé d'un bureau. Si toutes les variables étaient mutables, on ne saurait déterminer avec certitude laquelle des deux variables serait modifiée par l'appel de fonction. On peut s'attendre à ce que le nombre d'employés de bureau actifs soit décrémenté, mais est-ce que l'appel de fonction modifie également employé d'une certaine manière ?

L'idée d'immuabilité aide à comprendre des bouts de programmes en garantissant que certaines opérations ne modifient pas certaines variables. Elle permet aussi de réduire le risque de faire certaines erreurs de programmation.

L'immuabilité en D est représentée par les mots-clés const et immutable. Même si les deux mots sont proches dans leur signification, leurs rôles sont différents et parfois incompatibles.

37-1. Variables immuables

Les expressions « variables immuables » ou « variables constantes » sont contradictoires quand le mot « variable » est pris au sens littéral pour désigner quelque chose qui change. Le mot « variable » désigne n'importe quel élément d'un programme qui peut être mutable ou immuable.

Il y a trois manières de définir des variables qui ne peuvent jamais être mutées :

37-1-1. Constantes enum

Nous avons vu plus tôt dans le chapitre sur les énumérationsLes énumérations (enum) qu'enum définit des valeurs constantes nommées :

 
Sélectionnez
enum nomFichier = "liste.txt";

Tant que leurs valeurs peuvent être déterminées lors de la compilation, les variables enum peuvent aussi être initialisées par des valeurs de retour de fonctions :

 
Sélectionnez
int nbLignesTotal()
{
   return 42;
}
 
int nbColonnesTotal()
{
   return 7;
}
 
string nom()
{
   return "liste";
}
 
void main()
{
   enum nomFichier = nom() ~ ".txt";
   enum nbCarrésTotal = nbLignesTotal() * nbColonnesTotal();
}

Comme on peut s'y attendre, les valeurs des constantes enum ne peuvent pas être modifiées :

 
Sélectionnez
++nbCarrésTotal;   // ← ERREUR de compilation

Même si c'est une manière très efficace de représenter des valeurs immuables, enum ne peut être utilisé que pour des valeurs connues lors de la compilation.

Une constante enum est une constante manifeste, ce qui signifie que le programme est compilé comme si la constante avait été remplacée par sa valeur partout dans le code. Par exemple, considérons la définition énumérée suivante et les deux expressions qui l'utilisent :

 
Sélectionnez
enum i = 42;
writeln(i);
foo(i);

Ce code serait le même en remplaçant i par sa valeur 42 :

 
Sélectionnez
writeln(42);
foo(42);

Même si ce remplacement fait sens pour des types simples comme int et ne fait aucune différence dans le programme, les constantes enum apportent un coût caché quand elles sont utilisées pour des tableaux ou des tableaux associatifs :

 
Sélectionnez
enum a = [ 42, 100 ];
writeln(a);
foo(a);

En remplaçant a par sa valeur, le code équivalent que le compilateur compilerait est le suivant :

 
Sélectionnez
writeln([ 42, 100 ]);   // Un tableau est créé lors de l'exécution
foo([ 42, 100 ]);       // Un autre tableau est créé lors de l'exécution

Le coût caché ici est qu'il y aurait deux tableaux créés pour les deux expressions ci-dessus. Pour cette raison, il vaut mieux définir les tableaux et les tableaux associatifs en tant que variables immuables s'ils sont utilisés plusieurs fois dans le programme.

37-1-2. Variables immutable

Comme enum, ce mot-clé indique que la valeur d'une variable ne changera jamais. Sa différence par rapport à enum est que les valeurs des variables immutable peuvent être calculées lors de l'exécution du programme.

Le programme suivant compare les utilisations de enum et de immutable. Le programme attend que l'utilisateur devine un nombre qui a été choisi au hasard. Comme le nombre aléatoire ne peut pas être déterminé lors de la compilation, il ne peut pas être défini comme un enum. Cependant, comme la valeur choisie ne doit jamais être changée après avoir été choisie, il est correct de marquer cette variable comme immutable.

Le programme utilise la fonction lire_entier() définie dans le chapitre précédent :

 
Sélectionnez
import std.stdio;
import std.random;
 
int lire_entier(string message)
{
   int resultat;
   write(message, " ? ");
   readf(" %s", &resultat);
   return resultat;
}
 
void main()
{
   enum min = 1;
   enum max = 10;
 
   immutable nombre = uniform(min, max + 1);
 
   writefln("Je pense à un nombre entre %s et %s.",
          min, max);
 
   auto estCorrect = false;
   while (!estCorrect) {
      immutable tentative = lire_entier("Que proposez-vous");
      estCorrect = (tentative == nombre);
   }
 
   writeln("Correct !");
}

Observations :

min et max font partie intégrante du comportement du programme et leurs valeurs sont connues lors de la compilation. Pour cette raison, elles sont définies comme des constantes enum. nombre est défini comme immutable parce qu'il ne serait pas approprié de modifier cette variable lors de l'exécution du programme. Idem pour chaque tentative de l'utilisateur : une fois qu'elle est lue, la tentative ne devrait pas être modifiée.

Notez que les types de ces variables ne sont pas explicitement spécifiés. Comme avec auto, les types des variables enum et immutable peuvent être inférés depuis l'expression à droite de l'opérateur d'affectation.

Même s'il n'est pas nécessaire d'écrire le type complet comme dans l'exemple immutable(int), immutable prend normalement le type entre parenthèses. La sortie du programme suivant montre que le nom du type des trois variables est en fait le même :

 
Sélectionnez
import std.stdio;
 
void main()
{
   immutable      typeInféré    = 0;
   immutable int  typeExplicite = 1;
   immutable(int) typeComplet   = 2;
 
   writeln(typeof(typeInféré).stringof);
   writeln(typeof(typeExplicite).stringof);
   writeln(typeof(typeComplet).stringof);
}

Le nom du type inclut immutable :

 
Sélectionnez
immutable(int)
immutable(int)
immutable(int)

Le type qui est spécifié entre parenthèses a une signification. Nous verrons cela plus tard quand nous aborderons l'immuabilité d'une tranche entière comparativement à celle de ses éléments.

37-1-3. Variables const

Ce mot-clé a le même effet qu'immutable sur les variables. Les variables const ne peuvent pas être modifiées :

 
Sélectionnez
const moitié = total / 2;
moitié = 10;    // ← ERREUR de compilation

Je vous suggère d'utiliser immutable plutôt que const pour les variables. La raison à cela est que les variables immutable peuvent être passées aux fonctions en paramètre immutable. Nous allons voir cela tout de suite.

37-2. Paramètres immuables

Il est possible pour les fonctions de promettre qu'elles ne vont pas modifier certains paramètres qu'elles prennent. Le compilateur garantit que cette promesse est tenue. Avant de voir comment ça fonctionne, voyons que les fonctions peuvent en effet modifier les éléments des tranches qui sont passées en arguments à ces fonctions.

D'après ce que nous avons vu dans le chapitre sur les tranches et autres fonctionnalités des tableauxTranches (slices) et autres fonctionnalités des tableaux, les tranches ne stockent pas leurs éléments mais y donnent accès. Il peut y avoir plus d'une tranche à un moment donné qui donne accès aux mêmes éléments.

Même si les exemples de cette section ne se concentrent que sur les tranches, cela s'applique aussi aux tableaux associatifs et aux classes parce que ce sont également des types référence.

Une tranche qui est passée en argument à une fonction n'est pas la tranche avec laquelle la fonction est appelée. L'argument est une copie de la tranche :

 
Sélectionnez
import std.stdio;
 
void main()
{
   int[] tranche = [ 10, 20, 30, 40 ];  // 1
   moitié(tranche);
   writeln(tranche);
}
 
void moitié(int[] nombres)              // 2
{
   foreach (ref nombre; nombres) {
      nombre /= 2;
   }
}

Quand l'exécution du programme entre dans la fonction moitié(), il y a deux tranches qui donnent accès aux mêmes éléments :

  • la tranche nommée tranche qui est définie dans main(), qui est passée en argument à moitié() ;
  • la tranche nommée nombres que moitié() reçoit en argument, qui donne accès aux mêmes éléments que tranche.

Aussi, à cause du mot-clé ref dans la boucle foreach, les valeurs des éléments d'origine (et seulement eux) sont divisées par deux :

 
Sélectionnez
[5, 10, 15, 20]

Il est utile pour les fonctions d'avoir la possibilité de modifier les éléments des tranches qui sont passées en arguments. Certaines fonctions n'existent que pour cela, comme dans cet exemple.

Le compilateur n'autorise pas à passer des variables immuables comme arguments à de telles fonctions, parce qu'il est impossible de modifier une variable immuable :

 
Sélectionnez
immutable int[] tranche = [ 10, 20, 30, 40 ];
moitié(tranche);   // ← ERREUR de compilation

L'erreur de compilation indique qu'une variable de type immutable(int[]) ne peut pas être utilisée comme un argument de type int[].

 
Sélectionnez
Error: function essai.moitié (int[] nombres) is not callable

37-2-1. Paramètres const

Il est important et naturel que le passage de variables immutable à des fonctions comme moitié(), qui modifient leurs arguments, soit interdit. Cependant, ne pas pouvoir les passer à des fonctions qui ne modifient pas leurs arguments serait une limitation :

 
Sélectionnez
import std.stdio;
 
void main()
{
   immutable int[] tranche = [ 10, 20, 30, 40 ];
   afficher(tranche);   // ← ERREUR de compilation
}
 
void afficher(int[] tranche)
{
   writefln("%s éléments: ", tranche.length);
 
   foreach (i, element; tranche) {
      writefln("%s: %s", i, element);
   }
}

Interdire à tranche d'être imprimée uniquement parce qu'elle est immuable n'a pas de sens. La bonne manière de gérer cette situation est d'utiliser les paramètres const.

Le mot-clé const indique qu'une variable n'est pas modifiée à travers cette référence particulière (par ex. une tranche) de la variable. Indiquer un paramètre comme const garantit que les éléments de la tranche ne seront pas modifiés à l'intérieur de la fonction. Une fois que afficher() donne cette garantie, le programme peut être compilé :

 
Sélectionnez
afficher(tranche);   // maintenant, compile
// ...
void afficher(const int[] tranche)

Cette garantie permet de passer aussi bien des variables mutables que des variables immuables en arguments :

 
Sélectionnez
immutable int[] tranche = [ 10, 20, 30, 40 ];
afficher(tranche);          // compile
 
int[] trancheMutable = [ 7, 8 ];
afficher(trancheMutable);   // compile

Un paramètre qui n'est pas modifié dans une fonction, mais qui n'est pas indiqué comme const limite l'utilisabilité de cette fonction. De plus, les paramètres const donnent des informations utiles au programmeur. Savoir qu'une variable ne sera pas modifiée lorsqu'elle est passée à une fonction rend le code plus facile à comprendre. Cela évite également de faire des erreurs puisque le compilateur interdit les modifications sur les paramètres const :

 
Sélectionnez
void afficher(const int[] tranche)
{
   tranche[0] = 42;   // ← ERREUR de compilation
   // ...
}

Le programmeur prend alors conscience de l'erreur dans la fonction ou repense la conception et supprime éventuellement l'indicateur const.

Le fait que les paramètres const acceptent et les variables mutables et les variables immuables a une conséquence intéressante. Ceci est expliqué plus loin dans la section « Un paramètre devrait-il être const ou immutable ? »

37-2-2. Paramètres immutable

Comme nous l'avons vu à la section précédente, les variables immuables ou non peuvent toutes être passées aux fonctions en paramètre const. Dans un sens, les paramètres const sont accueillants.

En revanche, les paramètres immutable apportent un prérequis important : seules les variables immutable peuvent être passées aux fonctions en paramètres immutable :

 
Sélectionnez
void func(immutable int[] tranche)
{
   /* ... */
}
 
void main()
{
   immutable int[] immTranche = [ 1, 2 ];
             int[]    tranche = [ 8, 9 ];
 
   func(immTranche);        // compile
   func(tranche);         // ← ERREUR de compilation
}

Pour cette raison, l'indicateur immutable devrait être utilisé uniquement quand ce prérequis est nécessaire. Nous avons en fait déjà utilisé l'indicateur immutable indirectement à travers certains types de chaînes de caractères. Ceci sera couvert plus loin.

Nous avons vu que les paramètres qui sont indiqués comme const ou immutable promettent de ne pas modifier la variable qui est passée en argument. Ceci n'est pertinent que pour les types référence, le problème ne se pose pas pour les types valeur.

Les types référence et les types valeur seront couverts dans des chapitres ultérieurs. Parmi les types que nous avons vus jusqu'à maintenant, seuls les tranches et les tableaux associatifs sont des types référence ; les autres sont des types valeur.

37-3. Un paramètre devrait-il être const ou immutable ?

Les deux sections précédentes peuvent donner l'impression que comme ils sont plus flexibles, les paramètres const devraient être préférés aux paramètres immutable. Ceci n'est pas toujours vrai.

const efface l'information de l'immuabilité de la variable d'origine. Cette information est cachée même au compilateur.

Une conséquence de ceci est que les paramètres const ne peuvent pas être passés en arguments à des fonctions qui prennent des paramètres immutable. Par exemple, la fonction foo() qui suit ne peut pas passer son paramètre const à bar() :

 
Sélectionnez
void main()
{
   /* La variable d'origine est immuable */
   immutable int[] tranche = [ 10, 20, 30, 40 ];
   foo(tranche);
}
 
/* Une fonction qui prend un paramètre const, dans le
 * but d'être plus utile. */
void foo(const int[] tranche)
{
   bar(tranche);   // ← ERREUR de compilation
}
 
/* Une fonction qui prend un paramètre immuable pour une
 * raison valable. */
void bar(immutable int[] tranche)
{
   /* ... */
}

bar() nécessite un paramètre immutable. Cependant, on ne sait pas si la variable originale que le paramètre const de foo() référence est immuable ou non.

Il est clair dans le code qui précède que la variable d'origine dans main() est immuable. Cependant, le compilateur compile les fonctions individuellement sans regarder à tous les endroits depuis lesquels la fonction est appelée. Pour le compilateur, le paramètre tranche() peut se référer à une variable mutable ou à une variable immuable.

Une solution serait d'appeler bar() avec une copie immuable du paramètre :

 
Sélectionnez
void foo(const int[] tranche)
{
   bar(tranche.idup);
}

Même s'il s'agit d'une solution raisonnable, elle a le coût d'une copie, qui serait un gâchis dans le cas où la variable d'origine était déjà immutable.

Après cette analyse, il devrait être clair qu'utiliser systématiquement des paramètres const ne semble pas la meilleure approche dans toutes les situations. Après tout, si le paramètre foo() a été défini comme immutable, il ne devrait pas être nécessaire de le copier avant d'appeler bar() :

 
Sélectionnez
void foo(immutable int[] tranche) // Cette fois-ci, immutable
{
   bar(tranche);   // Copier n'est plus nécessaire
}

Même si le code compile, définir le paramètre en tant qu'immutable a un coût similaire : cette fois, une copie immuable de la variable originale est nécessaire lors de l'appel à foo() si cette variable n'était pas immutable à l'origine :

 
Sélectionnez
foo(trancheMutable.idup);

Les modèles peuvent aider. (Nous verrons les modèles dans des chapitres ultérieurs). Même si je ne m'attends pas à ce que vous compreniez totalement la fonction suivante à cet endroit du livre, je vous la présente comme une solution à ce problème. La fonction modèle foo() suivante peut être appelée aussi bien avec des arguments mutables qu'immuables. Le paramètre ne sera copié que si la variable d'origine était mutable ; aucune copie ne sera faite si elle était immuable :

 
Sélectionnez
import std.conv;
// ...
 
/* Parce qu'elle est un modèle, foo() peut être appelée avec
 * des variables mutable et immuable. */
void foo(T)(T[] tranche)
{
   /* 'to()' ne fait pas de copie si la variable d'origine est
    * déjà immuable. */
   bar(to!(immutable T[])(tranche));
}

37-4. Immuabilité de la tranche versus immuabilité des éléments

Nous avons vu plus haut le type d'une tranche immuable affiché comme immutable(int). Comme les parenthèses après immutable l'indiquent, c'est la tranche entière qui est immuable. Une telle tranche ne peut être modifiée d'aucune manière : les éléments ne peuvent pas être ajoutés ou supprimés, leurs valeurs ne peuvent pas être modifiées et la tranche ne peut pas se mettre à donner accès à un autre ensemble d'éléments :

 
Sélectionnez
immutable int[] immTranche = [ 1, 2 ];
immTranche ~= 3;                // ← ERREUR de compilation
immTranche[0] = 3;              // ← ERREUR de compilation
immTranche.length = 1;          // ← ERREUR de compilation
 
immutable int[] immAutreTranche = [ 10, 11 ];
immTranche = immAutreTranche;   // ← ERREUR de compilation

Pousser cette immuabilité à un tel extrême peut ne pas être adapté dans tous les cas. Dans la plupart des cas, ce qui est important est seulement l'immuabilité des éléments. Comme une tranche est seulement un outil d'accès aux éléments, effectuer des changements sur la tranche elle-même ne devrait pas poser de problème tant que les éléments ne sont pas modifiés.

Pour indiquer que seuls les éléments sont immuables, le type des éléments (seul) est écrit entre parenthèses. Quand le code est modifié de cette manière, seuls les éléments sont immuables, pas la tranche elle-même :

 
Sélectionnez
immutable(int)[] immTranche = [ 1, 2 ];
immTranche ~= 3;                // peut ajouter des éléments
immTranche[0] = 3;              // ← ERREUR de compilation
immTranche.length = 1;          // peut perdre des éléments
 
immutable int[] immAutreTranche = [ 10, 11 ];
immTranche = immAutreTranche;   // peut donner accès à d'autres éléments

Même si les deux syntaxes sont très similaires, elles ont des significations différentes. En résumé :

 
Sélectionnez
immutable int[]  a = [1]; /* Ni les éléments, ni la
                           * tranche ne peuvent être modifiés */
 
immutable(int[]) b = [1]; /* identique à la précédente */
 
immutable(int)[] c = [1]; /* Les éléments ne peuvent pas être
                           * modifiés mais la tranche peut l'être */

Cette distinction se retrouve dans certains programmes que nous avons rencontrés. Comme vous devez vous en souvenir, les trois alias de chaînes de caractères impliquent l'immuabilité :

  • string est un alias de immutable(char)[] ;
  • wstring est un alias de immutable(wchar)[] ;
  • dstring est un alias de immutable(dchar)[].

De même, les littéraux de chaînes sont également immuables :

  • Le type du littéral "hello"c est string ;
  • Le type du littéral "hello"w est wstring ;
  • Le type du littéral "hello"d est dstring.

Selon ces définitions, les chaînes D sont normalement des tableaux de caractères immuables.

37-5. .dup et .idup

Il peut y avoir des problèmes d'immuabilité quand les chaînes sont passées aux fonctions en paramètres. Les propriétés .dup et .idup font des copies des tableaux avec l'immuabilité désirée :

  • .dup fait une copie mutable du tableau (son nom vient de "dupliquer") ;
  • .idup fait une copie immuable du tableau.

Par exemple, une fonction qui insiste sur l'immuabilité d'un paramètre peut devoir être appelée avec une copie immuable d'une chaîne mutable :

 
Sélectionnez
void foo(string s)
{
   // ...
}
 
void main()
{
   char[] salutation;
   foo(salutation);            // ← ERREUR de compilation
   foo(salutation.idup);       // ← ceci compile
}

37-6. Utilisation

  • En règle générale, préférez les variables immuables aux variables mutables.
  • Définissez les valeurs constantes comme enum si elles peuvent être calculées lors de la compilation. Par exemple, la valeur constante représentant le nombre de secondes par minute peut être une enum :

     
    Sélectionnez
    enum int secondesParMinute = 60;
  • Il n'y a pas besoin de spécifier le type de façon explicite s'il peut être inféré depuis le côté droit de l'affectation :

     
    Sélectionnez
    enum secondesParMinute = 60;
  • Considérez le coût de copie des tableaux (associatifs) enum. Définissez-les comme des variables immutable si les tableaux sont larges et qu'ils sont utilisés plus d'une fois dans le programme.

  • Indiquez les variables comme immutable si leur valeur ne change pas, mais qu'elle ne peut pas être connue lors de la compilation. De même, le type peut être inféré :

     
    Sélectionnez
    immutable tentative = lire_entier("Que proposez-vous");
  • Si une fonction ne modifie pas un paramètre, indiquez ce paramètre comme const. Ceci permettra de passer en argument aussi bien des variables mutables qu'immuables :

     
    Sélectionnez
    import std.stdio;
     
    void inverser(dchar[] s)
    {
       foreach (i; 0 .. s.length / 2) {
          immutable temp = s[i];
          s[i] = s[$ - 1 - i];
          s[$ - 1 - i] = temp;
       }
    }
     
    void main()
    {
       dchar[] salutation = "salut"d.dup;
       inverser(salutation);
       writeln(salutation);
    }

  • La sortie :
 
Sélectionnez
tulas

37-7. Résumé

  • Les variables enum représentent des idées immuables connues lors de la compilation.
  • Les variables immutable représentent des idées immuables qui doivent être calculées lors de l'exécution.
  • Les paramètres const sont ceux que les fonctions ne modifient pas. Les variables mutables et immuables peuvent toutes être passées comme arguments de paramètres const.
  • Un paramètre ne doit être déclaré immutable que si cela répond à un impératif spécifique à l'intérieur de la fonction. Seules les variables immutable peuvent être passées comme arguments de paramètres immutable.
  • immutable(int[]) : indique que ni la tranche ni ses éléments ne peuvent être modifiés.
  • immutable(int)[] : indique que seuls les éléments ne peuvent être modifiés.
  • L'immuabilité est un outil très puissant en programmation. Il est utile de savoir que les variables et les paramètres ne sont pas modifiés dans des contextes spécifiques ou lors d'opérations spécifiques.

précédentsommairesuivant