Quelques raccourcis :
Lors d'un premier contact avec la programmation orientée objet (POO) en C#, l'opportunité d'offrir des services sous forme de propriétés ou sous forme de méthodes confond parfois les gens. Ce qui suit offre un bref survol syntaxique des deux approches, et propose des cas pour lesquels il serait préférable d'avoir recours à l'une ou à l'autre.
Prenons tout d'abord un cas très simple, soit celui d'un point sur le plan cartésien :
class Point
{
const float X_DÉFAUT = 0.0f,
Y_DÉFAUT = 0.0f;
//
// Attributs d'instance
//
float x, y;
//
// Méthodes (accesseurs pour x et y)
//
public float GetX() => x;
public float GetY() => y;
//
// Constructeur par défaut
//
public Point()
{
x = X_DÉFAUT;
y = Y_DÉFAUT;
}
//
// Constructeur paramétrique
//
public Point(float valX, float valY)
{
x = valX;
y = valY;
}
}
Telle que proposée, cette classe décrit des objets immuables, donc qui ne changent plus une fois qu'ils ont été construits. Nous nous en tiendrons à de tels objets pour le moment, dans l'optique de simplifier le propos. Nous constatons ceci à partir des signes suivants :
À l'utilisation, un tel Point pourrait se manipuler comme suit :
// ...
Point pt = new ();
Console.WriteLine("$Point à l'origine: ({pt.GetX()} , {pt.GetY()})");
//
// Glisser pt de (1.5,-1.5)
//
pt = Déplacer(1.5f, -1.5f);
Console.WriteLine($"Point après déplacement: ({pt.GetX()} , {pt.GetY()})");
static Point Déplacer(Point pt, float dx, float dy) =>
new Point(pt.GetX() + dx, pt.GetY() + dy);
// ...
Remarquez l'utilisation des services de pt : pour accéder à une copie de pt.x (attribut privé, donc inaccessible de prime abord sans passer par les services d'un Point), nous appelons pt.GetX(). On dit que GetX() dans ce cas-ci qu'il s'agit d'une méthode de pt. Puisque pt est une instance de Point, et puisque GetX() n'est pas qualifiée static, GetX() est une méthode d'instance de pt.
Les méthodes constituent la voie habituelle pour qu'un objet offre des services; tous les langages orientés objets commerciaux (C#, Java, C++, etc.) offrent ce mécanisme.
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, ce qui explique que nous les présentions ici.
Une classe Point essentiellement identique à celle vue précédemment, du moins pour ce qui est de sa structure, mais exposant des propriétés plutôt que des méthodes, pourrait s'écrire comme suit :
class Point
{
const float X_DÉFAUT = 0.0f,
Y_DÉFAUT = 0.0f;
//
// Attributs d'instance
//
float x, y;
//
// Propriétés (accesseurs pour x et y)
//
public float X
{
get => x;
}
public float Y
{
get => y;
}
//
// Constructeur par défaut
//
public Point()
{
x = X_DÉFAUT;
y = Y_DÉFAUT;
}
//
// Constructeur paramétrique
//
public Point(float valX, float valY)
{
x = valX;
y = valY;
}
}
À l'utilisation, nous aurions alors ceci :
// ...
Point pt = new ();
Console.WriteLine($"Point à l'origine: ({pt.X} , {pt.Y})");
//
// Glisser pt de (1.5,-1.5)
//
pt = Déplacer(1.5f, -1.5f);
Console.WriteLine($"Point après déplacement: ({pt.X} , {pt.Y})");
static Point Déplacer(Point pt, float dx, float dy) =>
new Point(pt.X + dx, pt.Y + dy);
// ...
Comme vous pouvez le constater, rien de fondamental n'a changé, mais l'écriture pt.X est plus légère que l'écriture pt.GetX().
Il est important à ce stade d'indiquer qu'une même classe peut avoir à la fois des méthodes et des propriétés. En C#, il est d'usage d'utiliser une propriété lorsqu'un service ressemble, à l'usage, à un état. Ainsi, une instance d'une hypothétique classe Rectangle aurait typiquement des propriétés Largeur, Hauteur, Surface et Périmètre plutôt que des méthodes GetLargeur(), GetHauteur(), GetSurface() et GetPérimètre().
En retour, pour certains services plus complexes ou qui ne correspondent pas en surface à un état, par exemple Déplacer(dx,dy), l'usage est d'avoir recours à une méthode. C'est d'abord et avant tout une question d'esthétique.
Dans le cas décrit jusqu'ici, il n'est pas possible de modifier les valeurs des attributs x et y d'un Point, et toutes les valeurs possibles sont acceptables pour ces deux attributs. C# permet dans un tel cas d'automatiser la génération des états et leur correspondance avec des propriétés.
Le code de la classe Point profitant de ce mécanisme irait comme suit :
class Point
{
const float X_DÉFAUT = 0.0f,
Y_DÉFAUT = 0.0f;
//
// Propriétés automatiques (modifiables à l'interne seulement)
//
public float X
{
get; private set;
}
public float Y
{
get; private set;
}
//
// Constructeur par défaut
//
public Point()
{
X = X_DÉFAUT;
Y = Y_DÉFAUT;
}
//
// Constructeur paramétrique
//
public Point(float x, float y)
{
X = x;
Y = y;
}
}
Remarquez la disparition des attributs explicites x et y, même dans les constructeurs. Le code client, lui, ne changerait en rien.
Pour les propriétés de second ordre qui n'ont qu'un get, pas de set, une écriture plus concise est possible. Par exemple, prenez la classe Carré suivante
using System;
var c = new Carré(3);
Console.WriteLine(c.Surface); // 9
class Carré
{
public int Côté { get; private set; }
public int Surface { get => Côté * Côté; }
public Carré(int côté)
{
Côté = côté;
}
}
Ici, la propriété Surface ne fait que retourner le fruit d'un calcul, et se limite à un get. Dans un tel cas, il est possible d'abréger l'écriture comme suit :
using System;
var c = new Carré(3);
Console.WriteLine(c.Surface); // 9
class Carré
{
public int Côté { get; private set; }
public int Surface => Côté * Côté;
public Carré(int côté)
{
Côté = côté;
}
}
... ce qui a une certaine élégance.
Avant C# 9, une propriété n'exposant qu'un get, pas de set, pouvait être initialisée à la construction. Par exemple :
Avec un set | Sans set (pré-C# 9 seulement) |
---|---|
|
|
Depuis C# 9, pour être initialisée à la construction, une propriété automatique exposant un get mais pas de set, doit désormais exposer un init. Dans une propriété, un init est un set qui n'est accessible que pendant un constructeur. Par exemple :
Sans set (depuis C# 9) |
---|
|
Cela fonctionne aussi pour des propriétés automatiques :
Avec un set | Sans set (pré-C# 9 seulement) | Sans set (depuis C# 9) |
---|---|---|
|
|
|
Les exemples examinés jusqu'ici ont tous en commun de n'avoir que des accesseurs banals, qui se limitent à retourner un état connu a priori. Il est bien sûr possible d'implémenter des propriétés et des méthodes plus riches que cela. Par exemple, examinez la classe Rectangle ci-dessous, et portez attention aux méthodes GetSurface() et GetPérimètre() tout comme aux propriétés Surface et Périmètre.
Version avec méthodes | Version avec propriétés |
---|---|
|
|
Mentionnons pour conclure que, bien que j'aie placé la validation des modifications des attributs dans les mutateurs (méthodes Set... et propriétés set), suivant ainsi le modèle classique, il aurait été plus efficace ici de valider les tentatives de mutation des attributs à un niveau plus élevé : en effet, les mutateurs de cette classe sont privés, donc ne sont accessibles qu'à travers des appels générés par Rectangle, qui est un « client » pour le moins digne de confiance.
De plus, cette classe Rectangle est immuable (les états de ses instances ne changent pas une fois la construction complétée), donc le seul point de validation requis serait à la construction. Cela dit, ce code est correct et présente les similitudes et différences syntaxiques entre méthodes et propriétés, incluant des propriétés dont la partie Get n'est pas banale.
Les exemples présentés ci-dessus utilisent pour la plupart des objets immuables. Qu'en est-il d'objets modifiables, pour lesquels les états peuvent changer suite à la construction?
Avec une méthode, les paramètres utilisés comme valeurs sources pour l'attribut à modifier sont explicitement nommés dans la méthode, et sont utilisés comme dans n'importe quelle autre méthode. Pour les propriétés, la supposition est que le code client modifiera la valeur représentée par une affectation, et donc que la mutation de l'état de l'attribut reposera sur un seul paramètre, identifié par le nom implicite value du type de la propriété. Chose amusante toutefois : le compilateur génèrera automatiquement l'ensemble des opérations permettant de modifier les états de la valeur représentée par la propriété, incluant les opérateurs =, ++, +=, etc.
Les exemples ci-dessous montrent une classe Point, dont les instances sont modifiables cette fois, implémentée à partir de méthodes (à gauche) et à partir de propriétés (à droite), pour fins de comparaison. Les deux versions sont correctes et opérationnelles; on choisira celle qui nous sied le mieux en fonction des critères de notre entreprise. Nous la déclinerons de plusieurs manières :
Dans la version avec validation, le recours aux attributs est nécessaire même pour les propriétés.
Version avec méthodes | Version avec propriétés | Version avec propriétés automatiques |
---|---|---|
|
|
|
La validation que nous appliquerons ici sera d'obliger le point à se situer dans le premier quadrant du plan cartésien. Pour simplifier la chose, nous accepterons une abcisse ou une ordonnée nulle.
Version avec méthodes | Version avec propriétés |
---|---|
|
|
Malheureusement, accéder à une propriétés en C# est un choix très coûteux, et qui impose malheureusement d'écrire du code d'un autre époque si on souhaite maintenir des temps d'exécution raisonnables malgré tout. Par exemple, examinez ce comparatif directement inspiré de tests faits par mon ami et collègue Kyle Ross :
Notre programme de test crée un tableau d'entiers, puis fait nTests fois une série de tests :
Sur la base de ces deux tests, on peut constater la différence entre lire plusieurs fois la valeur d'une variable locale, et consulter plusieurs fois la valeur d'une propriété.
Ceci montre à la fois le coût d'un appel polymorphique (passage par une interface) et d'une propriété. Enfin :
Ces deux tests permettent de constater la différence entre lire plusieurs fois la valeur d'une variable locale, et consulter plusieurs fois la valeur d'une propriété, et ce pour un type générique réifié. |
|
La méthode BatchTest ne fait que réaliser le même test plusieurs fois et cumuler les résultats. |
|
Comme indiqué plus haut, les méthodes TestHoisted<T> et TestUnhoisted<T> acceptant en paramètre un ICollection<T> font le même travail, banal : ici, on parle de créer un T par défaut; un compilateur de meilleur qualité transformerait la fonction en no-op, mais on pourrait évidemment régler ce problène en cumulant puis en retournant la somme des valeurs. Ceci compare le coût de l'appel répétitif à la propriété polymorphique Count d'un ICollection<T> avec celui de l'utilisation d'une variable locale contenant le résultat du premier appel. |
|
Les méthodes TestHoisted et TestUnhoisted acceptant en paramètre un int[] comparent quant à elles le coût relatif d'un accès répétitif à la propriété Length d'un tableau avec celui de l'accès répétitif d'une variable locale ayant la même valeur. Notez que ces deux tests utilisent int[] et non pas T[]; si nous avions une signature générique acceptant un T[], alors l'appel à TestHoisted<int> ou à TestUnhoisted<int> aurait préféré cette version à celle acceptant en paramètre un ICollection<int>, ce qui aurait faussé les résultats. |
|
Enfin, les méthodes TestHoisted et TestUnhoisted acceptant en paramètre un List<T> comparent le coût relatif d'un accès répétitif à la propriété Count de la collection avec celui de l'accès répétitif d'une variable locale ayant la même valeur. Notez qu'il y a une différence fondamentale entre cette version et celle acceptant un ICollection<T> : il se trouve que ICollection<T> est une interface, et ses méthodes sont implicitement virtuelles, alors que List<T> est une classe réifiée pour T, et il est plus probable que le compilateur parvienne à éviter l'appel virtuel ici.
|
|
Les résultats à l'exécution sur mon poste de travail sont :
Warming up the cache
Testing without virtual calls
With optimisation : 3645,3395 ms
With no optimisation : 7097,5251 ms
Testing with virtual calls
With optimisation : 3535,7427 ms
With no optimisation : 32452,6463 ms
Testing with reified collection
With optimisation : 3679,7294 ms
With no optimisation : 7009,1295 ms
Que pouvons-nous en déduire?
Tristement, pour écrire du code efficace en C#, il faut revenir une vingtaine d'années dans le passé et réaliser manuellement des optimisations qu'un compilateur contemporain devrait pouvoir faire de manière routinière.
Une propriété ressemble à un attribut, mais n'en est pas un. En fait, une propriété est un mensonge, ce qu'on nomme souvent du « sucre syntaxique » pour faire apparaître des appels de méthodes comme s'il s'agissait de variables.
À titre d'exemple, prenez le programme suivant :
using System;
X x = new (2, 3);
Console.WriteLine(x); // 2,3
Permuter(ref x.a, ref x.b);
Console.WriteLine(x); // 3,2
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
class X
{
public int a;
public int b;
public X(int a, int b)
{
this.a = a;
this.b = b;
}
public override string ToString() => $"{a},{b}";
}
Ce programme affichera, sans surprises, ceci :
2,3
3,2
... car les attributs x.a et x.b auront été permutés. Toutefois, si nous remplaçons les attributs par des propriétés :
using System;
X x = new (2, 3);
Console.WriteLine(x); // 2,3
Permuter(ref x.B, ref x.B); // ne compile pas
Console.WriteLine(x);
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
class X
{
public int A { get; set; }
public int B { get; set; }
public X(int a, int b)
{
A = a;
B = b;
}
public override string ToString() => $"{A},{B}";
}
... le code ne compilera pas, et un message d'erreur comme « impossible de passer une propriété ou un indexeur en tant que paramètre de sortie (out) ne de référence (ref) » sera généré.
Pourquoi donc? Simplement parce que dans ce cas, x.A et x.B ne sont pas des variables mais bien des paires de méthodes, dans ce cas nommées (dans le code généré) get_A ou get_B pour les get, et set_A et set_B pour les set. D'ailleurs, si nous faisons la permutation « manuellement », sans écrire de méthode, nous pouvons voir la différence dans le code généré.
Avec attributs | Avec propriétés | |
---|---|---|
Code C# |
|
|
Code IL |
|
|
Il ne faut donc pas confondre propriété et attribut. La ressemblance entre les deux n'est que superficielle.
Quelques liens pour en savoir plus.