C# – Propriétés

Quelques raccourcis :

Le langage C# propose un mécanisme de contrôle d'accès de bas niveau aux états d'un objet : les propriétés. Sans être nécessaires, elles permettent de déterminer qui peut accéder à un état en lecture, et qui peut accéder à un état en écriture.

En fait, les propriétés jouent aussi un rôle structurel mineur, mais ce n'est pas le coeur de notre propos ici.

Les propriétés sont pour l'essentiel ce qu'on nomme du « sucre syntaxique », soit une facilité du langage pour que certaines opérations communes de programmation soient plus agréables ou plus conviviales. Il est possible de programmer sans elles, comme en fait foi leur absence dans beaucoup de langages connus. Cependant, dans les langages où elles existent, par exemple C# ou Delphi, les gens en tirent profit, et elles deviennent vite idiomatiques.

Pourquoi les propriétés?

Pour comprendre le rôle d'une propriété, examinons la classe Point ci-dessous, modélisant un point 2D sur un plan cartésien.

Point v0 (attributs seulement)
class Point
{
   public float x, y; // note : publics
   public Point() : this(0, 0)
   {
   }
   public Point(float x, float y)
   {
      this.x = x;
      this.y = y;
   }
   public override string ToString() => $"{x},{y}";
}

À l'utilisation, nous aurions alors ceci :

// ...
Point pt = new ();
Console.WriteLine($"Point à l'origine: ({pt})");
//
// Translater pt de (1.5,-1.5)
//
pt = Déplacer(1.5f, -1.5f);
Console.WriteLine($"Point après déplacement: ({pt})");

static Point Déplacer(Point pt, float dx, float dy) =>
   new (pt.x + dx, pt.y + dy);
// ...

Le tout fonctionne, mais rien n'empêche qui que ce soit d'accéder en lecture ou en écriture aux attributs d'instance x et y d'un Point. Ceci peut être adéquat si Point n'a aucun invariant à garantir (si toutes les valeurs de x et de y sont acceptables), et s'il est raisonnable qu'un Point soit mutable (modifiable une fois construit).

Dans la plupart des cas, cependant, l'une ou l'autre de ces conditions ne sera pas présente :

Si on souhaite avoir une classe pour laquelle un contrôle de l'accès aux états est offert, les propriétés peuvent être utiles.

Syntaxe générale

Reprenons l'exemple du Point plus haut sans changer la manière dont les états sont accédés, soit en permettant à n'importe qui de consulter un état et en permettant à n'importe quoi de modifier un état, mais avec des propriétés :

Point v1 (propriétés, libre accès en lecture et en écriture)
class Point
{
   private float x, y; // note : privés
   public float X
   {
      get => x;
      set { x = value; }
   }
   public float Y
   {
      get => y;
      set { y = value; }
   }
   public Point() : this(0, 0)
   {
   }
   public Point(float x, float y)
   {
      X = x; // note : on passe par les propriétés
      Y = y;
   }
   public override string ToString() => $"{X},{Y}"; // note : on passe par les propriétés
}

On remarque :

Dans cet exemple, nous avons des propriétés publiques pour le volet accesseur comme pour le volet mutateur, ce qui signifie que n'importe qui peut consulter le X d'un Point et n'importe qui peut modifier le X d'un Point. L'une des utilités des propriétés est de permettre de contrôler finement les accès à un état. Comparez :

Il est possible de permettre l'accès public en lecture et en écriture

class X
{
   int val;
   public Val
   {
      get => val;
      set { val = value; }
   }
   // ...
}

Il est possible de permettre l'accès public en lecture mais privé (ou protégé) en écriture

class X
{
   int val;
   public Val
   {
      get => val;
      private set { val = value; }
   }
   // ...
}

Il est possible de permettre l'accès privé (ou protégé) en lecture mais public en écriture. Cela dit, c'est une combinaison plutôt rare

class X
{
   int val;
   public Val
   {
      private get => val;
      set { val = value; }
   }
   // ...
}

Il est possible de permettre l'accès privé en lecture comme en écriture

class X
{
   int val;
   private Val
   {
      get => val;
      set { val = value; }
   }
   // ...
}

... et ainsi de suite. La règle est que la propriété donne la qualification d'accès la moins restrictive, et que les volet de la propriété peuvent restreindre l'accès (pas l'élargir!) par la suite.

Accesseurs (get)

Le volet get d'une propriété contrôle l'accès en lecture sur cette propriété. C'est souvent le volet qui nous intéresse le plus (les états consultables d'un objet sont en général plus nombreux que les états modifiables d'un objet). Les classes dont la majorité des propriétés se limitent au volet get sont nombreuses en pratique. Par exemple :

class X
{
   int [] vals;
   public X(int n)
   {
      vals = new int[n];
   }
   // ...
   public int NombrePairs
   {
      get
      {
         int n = 0;
         for(int i = 0; i != tab.Length; ++i)
            if(tab[i] % 2 == 0)
               ++n;
         return n;
      }
   }
   public int NombreImpairs => vals.Length - NombrePairs;
}

Remarquez au passage NombreImpairs. Une propriété se limitant à retourner une valeur est une propriété de second ordre (une propriété calculée), qui se limite à un get. Les écritures suivantes sont équivalentes :

// ...
      public int NombreImpairs
      {
         get { return vals.Length - NombrePairs; }
      }
// ...
// ...
      public int NombreImpairs
      {
         get => vals.Length - NombrePairs;
      }
// ...
// ...
      public int NombreImpairs => vals.Length - NombrePairs;
// ...

Évidemment, NombrePairs est aussi une propriété calculée.

Mutateurs (set, init)

Le volet set d'une propriété contrôle l'accès en lecture sur cette propriété. Il en va aussi du volet init.

Une propriété qui n'a ni volet set, ni volet init n'admet pas de mutation d'état à travers cette propriété.

Un mutateur est une fonction acceptant en paramètre la valeur destinée à modifier un état. Dans un set ou dans un init, le mot clé value est une « variable magique » modélisant le paramètre. Ainsi :

Ceci... ... équivaut précisément à cela
using System;
X x = new();
x.Valeur = 3; // mutation : set
Console.Write(x.Valeur); // consultation : get
class X
{
   int val;
   public int Valeur
   {
      get => val;
      set { val = value; }
   }
}
using System;
X x = new();
x.set_Valeur(3);
Console.Write(x.get_Valeur());
class X
{
   int val;
   public int get_Valeur() => val;
   public void set_Valeur(int value) { val = value; }
}

On voit tout de suite ici qu'une propriété n'est que du « sucre syntaxique », une chose techniquement superflue mais qui peut, à l'occasion, rendre le code un peu plus agréable à l'oeil.

La différence entre set et init est que set fonctionne en tout temps, alors que init ne fonctionne que dans un constructeur. Quand on a le choix entre les deux, on tend à préférer init (moins une propriété est modifiable et moins complexe est l'entretien du code).

Déplacer un Point mutable Déplacer un Point immuable
using System;
Point pt = new();
Console.WriteLine($"Avant : {pt}");
Déplacer(pt, 1, 2.5f); // <--
Console.WriteLine($"Après : {pt}");
static void Déplacer(Point pt, float dx, float dy) // <--
{
   pt.X += dx;
   pt.Y += dy;
}
class Point
{
   float x, y;
   public float X { get => x; set { x = value; } } // note : mutable
   public float Y { get => y; set { y = value; } }
   public Point() : this(0,0) {}
   public Point(float x, float y)
   {
      X = x;
      Y = y;
   }
   public override string ToString() => $"{X},{Y}";
}
using System;
Point pt = new();
Console.WriteLine($"Avant : {pt}");
pt = Déplacer(pt, 1, 2.5f); // <--
Console.WriteLine($"Après : {pt}");
static Point Déplacer(Point pt, float dx, float dy) // <--
   => new(pt.X + dx, pt.Y + dy);
class Point
{
   float x, y;
   public float X { get => x; private init { x = value; } } // note : immuable
   public float Y { get => y; private init { y = value; } }
   public Point() : this(0,0) {}
   public Point(float x, float y)
   {
      X = x; // Ok, init et privé
      Y = y;
   }
   public override string ToString() => $"{X},{Y}";
}

Propriétés automatiques

Si une propriété doit garantir le respect d'un invariant, alors une propriété servira de rempart pour protéger l'intégrité d'un attribut. Par exemple, remplaçons un Point quelconque par un PointQuadrant1 modélisant un point du premier quadrant. On aura alors :

using System;
Point pt = new();
Console.WriteLine($"Avant : {pt}");
pt = Déplacer(pt, 1, 2.5f);
Console.WriteLine($"Après : {pt}");
static Point Déplacer(Point pt, float dx, float dy) // <--
   => new(pt.X + dx, pt.Y + dy);
class PointInvalideException : Exception {}
class PointQuadrant1
{
   float x, y;
   static bool EstXValide(float candidat) => candidat >= 0;
   static bool EstYValide(float candidat) => candidat >= 0;
   static float ValiderX(float candidat) => EstXValide(candidat) ? candidat : throw new PointInvalideException();
   static float ValiderY(float candidat) => EstYValide(candidat) ? candidat : throw new PointInvalideException();
   public float X { get => x; private init { x = ValiderX(value); } }
   public float Y { get => y; private init { y = ValiderY(value); } }
   public PointQuadrant1() : this(0,0) {}
   public PointQuadrant1(float x, float y)
   {
      X = x; // Ok, init et privé
      Y = y;
   }
   public override string ToString() => $"{X},{Y}";
}

Si toutefois on se trouve face à une classe sans invariants à garantir, alors on peut simplifier le code et enlever l'attribut, laissant le compilateur générer ce dernier de manière implicite et innomable. On a alors :

using System;
Point pt = new();
Console.WriteLine($"Avant : {pt}");
pt = Déplacer(pt, 1, 2.5f);
Console.WriteLine($"Après : {pt}");
static Point Déplacer(Point pt, float dx, float dy)
   => new(pt.X + dx, pt.Y + dy);
class Point
{
   public float X { get; private init; } // x implicite
   public float Y { get; private init; } // y implicite
   public Point() : this(0,0) {}
   public Point(float x, float y)
   {
      X = x; // Ok, init et privé
      Y = y;
   }
   public override string ToString() => $"{X},{Y}";
}

Cas d'utilisation des propriétés

En résumé, les principaux cas d'utilisation des propriétés sont probablement les suivants.

Contrôle des accès sans invariants – Propriétés automatiques

Si un état n'est pas associé à un invariant et peut être modifié de manière indépendante des autres états d'un même objet, une propriété a un avantage sur un attribut au sens où elle permet tout de même de contrôler les accès à cet état.

Attribut (accès libre) Propriété avec mutateur set privé Propriété avec mutateur init privé
class X
{
   public int valeur;
   // ...
}
class X
{
   public int Valeur { get; private set; }
   // ...
}
class X
{
   public int Valeur { get; private init; }
   // ...
}

Protéger un attribut – Propriétés pour fins de validation

Si un état est associé à un invariant, alors une propriété peut être placée « devant » un attribut pour empêcher de corrompre ce dernier. Par exemple :

class NASInvalideException : Exception;
class NuméroAssuranceSociale
{
   string valeur; // note : privé
   // règle d'affaire : un NAS doit être composé de neuf chiffres, chacun dans 0-9
   static bool EstNASValide(string s) =>
      s != null && s.Length == 9 && ContientSeulementChiffres(s);
   static bool ContientSeulementChiffres(string s)
   {
      foreach(char c in s)
         if(!EstChiffre(c))
            return false;
      return true;
   }
   static bool EstChiffre(char c) => c >= '0' && c <= '9';
   //
   // la propriété Nom garantit les invariants de l'attribut nom
   //
   public string Nom
   {
      get => nom;
      private init
      {
         if(!EstNASValide(value))
            throw new NASInvalideException();
         nom = value;
      }
   }
   public NuméroAssuranceSociale(string s)
   {
      Nom = s; // note : utilise le mutateur init de Nom
   }
}

Synthétiser un état – Propriétés calculées (propriétés de second ordre)

Si un état peut être déduit d'autres états, il est souvent préférable de le calculer sur demande plutôt que de tenter de le tenir à jour séparément. Par exemple :

class CôtéInvalideException : Exception;
class Carré
{
   int côté; // note : privé
   static bool EstCôtéValide(int candidat) => candidat > 0;
   // propriété de premier ordre : englobe directement un attribut
   public int Côté
   {
      get => côté;
      private init
      {
         côté = EstCôtéValide(value)? value : throw new CôtéInvalideException();
      }
   }
   public Carré(int côté)
   {
      Côté = côté;
   }
   // propriétés de seconde ordre : synthétisée à partir d'autre états
   public int Périmètre => 4 * Côté;
   public int Aire => Côté * Côté;
   /*
   Alternativement :
   public int Périmètre
   {
      get => 4 * Côté;
   }
   public int Aire
   {
      get => Côté * Côté;
   }
   */
}

L'exemple ci-dessus utilise la syntaxe abrégée (p. ex. : public int Aire => Côté * Côté;) qui signifie la même chose qu'une propriété se limitant à un accesseur (un get). Ne confondez pas => (calcule puis retourne) avec = (affecte une valeur à) car les deux notations ont un sens (et un effet!) totalement différent l'une de l'autre.

Lectures complémentaires

Quelques liens pour en savoir plus.


Valid XHTML 1.0 Transitional

CSS Valide !