Quelques raccourcis
Il est possible en C# de surcharger certains opérateurs. Cette surcharge est toutefois soumise à un certain nombre de restrictions, plusieurs d'entre elles discutables.
Notez que ce qui suit n'est en rien exhaustif.
En C#, les opérateurs sont surchargés sur la base de méthodes de classe (static). Ces méthodes doivent être publiques (on ne peut donc pas faire le choix de surcharger ces opérateurs pour usage interne seulement).
Pour prendre un exemple simple avec un type Entier, qui modélisera de manière banale... un entier, l'opérateur + pourrait s'implémenter comme suit :
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// petit exemple pour montrer la syntaxe
public static Entier operator+(Entier e0, Entier e1) =>
new Entier(e0.Valeur + e1.Valeur);
}
J'utiliserai Entier pour le reste de cet article, par souci de simplicité. Un exemple plus riche (un type Rationnel) apparaît plus bas.
C# permet de surcharger les opérateurs relationnels, mais offre un traitement différent pour les opérateurs d'inégalité et pour les opérateurs d'égalité. Dans les deux cas, les choix faits sont critiquables (rien de dramatique, mais rien d'édifiant), mais pour des raisons différentes.
La surcharge d'opérateurs d'inégalité de C# doit absolument se faire par paire... mais les paires ne sont pas faites d'opérateurs qui sont l'inverse l'un de l'autre. Ainsi, surcharger < implique surcharger aussi > même si l'inverse logique de < est >= et non pas > (exprimé autrement, , mais C# approche les paires d'opérateurs d'inégalité sous un angle... particulier).
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
public static bool operator<(Entier e0, Entier e1) => e0.Valeur < e1.Valeur;
// si vous codez l'opérateur < ci-dessus, alors il est nécessaire de coder
// l'opérateur > ci-dessous
public static bool operator>(Entier e0, Entier e1) => e0.Valeur > e1.Valeur;
}
Si l'exemple de la classe Entier ci-dessus est quelque peu trivial, il est généralement plus difficile de définir un bon opérateur < pour un type donné. Si on suppose par exemple un type Mot maison, représentant une chaîne de caractères dénuée d'espaces et qui utiliserait un char[] comme substrat, il faudrait s'assurer de définir un ordre lexicographique correct, ce qui est subtil :
Supposons que m0 et m1 soient deux instances de Mot, on voudrait que :
m0 | m1 | m0 < m1 |
---|---|---|
"allo" |
"all" |
false |
"all" |
"allo" |
true |
"allo" |
"allo" |
false |
"" |
"allo" |
true |
"allo" |
"" |
false |
"" |
"" |
false |
Ceci peut impliquer une répétitive tenant compte des tailles relatives des deux tableaux de même que des valeurs des éléments aux positions correspondantes dans ceux-ci, ce qui demande une certaine prudence. Il est bien sûr possible d'implémenter séparément les quatre opérateurs d'inégalité, mais il est plus simple d'implémenter operator< puis ensuite d'appliquer les relations suivantes :
Ainsi, pour cette hypothétique classe Mot, on aurait :
class Mot
{
// ...
public static bool operator<(Mot m0, Mot m1)
{
// ... votre implémentation ...
}
public static bool operator>(Mot m0, Mot m1) => m1 < m0;
public static bool operator<=(Mot m0, Mot m1) => !(m1 < m0);
public static bool operator>=(Mot m0, Mot m1) => !(m0 < m1);
}
... ce qui simplifie l'entretien du code et réduit (beaucoup!) les risques d'erreurs.
Puisque C# ne donne pas directement accès aux objets, les opérateurs == et != définissent normalement une relation d'identité : p == q signifie alors « est-ce que p et q réfèrent ou non au même endroit? »
Surcharger ces opérateurs permet de définir une relation d'équivalence : p == q signifie alors « est-ce que les contenus référés par p et q sont équivalents? »
Si vous écrivez du code générique en C# mais souhaitez comparer des références pour fins d'identité, vous pouvez transtyper vos références en object au préalable, ce type ne surchargeant pas == ou !=.
La surcharge d'opérateurs d'égalité de C# doit aussi absolument se faire par paire... mais il y a plus. Ainsi, surcharger == implique surcharger aussi !=, mais si vous vous limitez à cela, votre code compilera avec avertissements.
Les avertissements porteront sur deux aspects. L'un est raisonnable, l'autre est une erreur de catégorie au coeur du modèle .NET, mais il est trop tard pour y faire quoi que ce soit.
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
public static bool operator==(Entier e0, Entier e1) => e0.Valeur == e1.Valeur;
// si vous codez l'opérateur == ci-dessus, alors il est nécessaire de coder
// l'opérateur != ci-dessous
public static bool operator!=(Entier e0, Entier e1) => !(e0.Valeur == e1.Valeur);
// notez toutefois que si vous vous arrêtez ici, votre code compilera, mais avec
// avertissements (voir ci-dessous)
}
Le premier avertissement est que vous devriez aussi spécialiser la méthode booléenne Equals(object) définie à même object.
Cet avertissement est raisonnable : dans le modèle .NET, plusieurs langages partagent un système de types commun, incluant une racine commune (System.Object, que C# nomme object), or si votre programme C# interagit avec des programmes écrits dans un autre langage de la même plateforme, tous auront accès aux méthodes de System.Object comme Equals(object), mais il est moins clair que les opérateurs == et != de votre programme C# leur seront aussi accessibles.
Pour cette raison, si vous définissez une relation d'équivalence avec == et !=, il est à votre avantage de spécialiser Equals(object) pour offrir cette relation de manière plus large.
Tristement, définir l'équivalence dans une hiérarchie polymorphique à travers des méthodes virtuelles est boîteux et demande du transtypage; c'est l'une des nombreuses faiblesses des langages sans réels objets. Un bon truc pour se simplifier l'existence est de définir une méthode Equals non-polymorphique réalisant la comparaison pour fins d'équivalence, et de l'appeler à la fois de l'opérateur == et de Equals(object) une fois seulement que les validations générales imposées par les faiblesses du modèle auront été faites. Enfin, != peut passer par la négation logique de == pour réduire le coût d'entretien du code.
Une implémentation complète serait donc :
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// spécialisation de la méthode de object
public override bool Equals(object obj) =>
obj is Entier e && Equals(e);
// version locale (non-polymorphique)
public bool Equals(Entier e) => Valeur == e.Valeur;
public static bool operator==(Entier e0, Entier e1) => e0.Equals(e1);
// si vous codez l'opérateur == ci-dessus, alors il est nécessaire de coder
// l'opérateur != ci-dessous
public static bool operator!=(Entier e0, Entier e1) => !(e0.Valeur == e1.Valeur);
// notez toutefois que si vous vous arrêtez ici, votre code compilera, mais
// toujours avec avertissements (voir ci-dessous)
}
Une avertissement demeure toutefois, et celui-là est nettement moins joli.
La plateforme .NET demande à qui spécialise Equals(object) d'implémenter GetHashCode. Il y a une sorte de logique à l'oeuvre ici : un code de hachage est un entier susceptible de servir de clé dans une collection (typiquement une sorte de dictionnaire) et est calculable à partir de la représentation d'un objet. La logique, donc, est que deux objets équivalents devraient avoir le meme code de hachage.
Un avertissement n'est pas une erreur; cela dit, nombreuses sont les entreprises pour lesquelles un avertissement est considéré une erreur, et où tous les avertissements doivent être corrigés avant de publier un logiciel.
Le problème avec cette logique est à deux volets :
Pour un type simpliste comme Entier (dans nos exemples ici), on peut considérer que le code de hachage d'un Entier est le même que celui de sa Valeur. Ainsi, pour nous, ceci suffirait :
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// spécialisation de méthodes de object
public override bool Equals(object obj) =>
obj is Entier e && Equals(e);
public override int GetHashCode() => Valeur.GetHashCode();
// version locale (non-polymorphique)
public bool Equals(Entier e) => Valeur == e.Valeur;
public static bool operator==(Entier e0, Entier e1) => e0.Equals(e1);
// si vous codez l'opérateur == ci-dessus, alors il est nécessaire de coder
// l'opérateur != ci-dessous
public static bool operator!=(Entier e0, Entier e1) => !(e0.Valeur == e1.Valeur);
}
C# offre un mécanisme pour implémenter GetHashCode raisonnablement, soit HashCode.Combine : https://learn.microsoft.com/en-us/visualstudio/ide/reference/generate-equals-gethashcode-methods?view=vs-2022
Nous avons mis de l'avant un opérateur d'addition (operator+) en tout début d'article. La syntaxe est la même pour les autres opérateurs arithmétiques binaires (à deux opérandes) comme -, *, /, %, etc.
Il est aussi possible en C# de surcharger les opérateurs arithmétiques unaires (un seul opérande) comme le + et le - unaire.
Voici un exemple simple pour illustrer ces diverses syntaxes. Notez qu'il n'y a heureusement pas de restrictions ici imposant, par exemple, d'implémenter - si vous avez implémenté + :
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// opérateurs +, -, *, / et % binaires
public static Entier operator+(Entier e0, Entier e1) =>
new Entier(e0.Valeur + e1.Valeur);
public static Entier operator-(Entier e0, Entier e1) =>
new Entier(e0.Valeur - e1.Valeur);
public static Entier operator*(Entier e0, Entier e1) =>
new Entier(e0.Valeur * e1.Valeur);
public static Entier operator/(Entier e0, Entier e1) =>
new Entier(e0.Valeur / e1.Valeur);
public static Entier operator%(Entier e0, Entier e1) =>
new Entier(e0.Valeur % e1.Valeur);
// opérateurs + et -unaires
public static Entier operator+(Entier e) => new Entier(e.Valeur);
public static Entier operator-(Entier e) => new Entier(-e.Valeur);
}
Rappel : ++a modifie a et retourne une référence sur a suivant la modification, alors que a++ modifie a mais retourne une copie de l'ancienne valeur de a. Conséquemment, a++ est comme ++a, mais avec création d'une temporaire supplémentaire.
Il est possible en C# de surcharger les opérateurs ++ et --, mais seulement dans leur déclinaison suffixe inefficace; c'est un enjeu philosophique : C# ne donne pas accès direct aux objets, et impose au code client une distance et des coûts pour chaque opération.
Le compilateur synthétisera pour vous les versions préfixes à partir des versions suffixes... Ainsi, en C#, les expressions a++ et ++a ne sont pas plus efficaces que a = a + 1. C'est une occasion manquée.
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// opérateurs ++ et -- suffixes
public static Entier operator++(Entier e) =>
new Entier(e.Valeur + 1);
public static Entier operator--(Entier e) =>
new Entier(e.Valeur - 1);
}
Si vous souhaitez utiliser votre type dans des expressions logiques, C# permet de surcharger les opérateurs unaires de négation logique (opérateur !) de même que les « opérateurs » true et false. Dans ces derniers cas, on ne parle pas vraiment d'opérateurs, mais bien d'une syntaxe inspirée de celle des opérateurs et qui intervient quand le compilateur tente de considérer un objet comme un booléen.
Les opérateurs true et false viennent par paire; implémenter l'un oblige à implémenter l'autre.
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
// opérateurs true, false et !
public static bool operator true(Entier e) => e.Valeur != 0;
public static bool operator false(Entier e) => e.Valeur == 0;
public static bool operator!(Entier e) => e.Valeur == 0;
}
Les opérateurs true et false servent principalement aux opérateurs || et && respectivement (voir plus bas).
Notez les cas d'utilisation suivants :
Entier e0 = new Entier(2);
if(e0) // appelle operator true
Console.WriteLine($"{e0} est true");
if(!e0) // appelle operator!
Console.WriteLine($"{e0} est false");
Il est possible de contrôler l'effet d'un transtypage d'un type de notre cru vers certains types choisis. Par exemple, prenons la classe Entier ci-dessous :
using System;
var e = new Entier(3);
int n = e.Valeur;
// ...
class Entier
{
public int Valeur { get; } = 0;
public Entier()
{
}
public Entier(int valeur)
{
Valeur = valeur;
}
// ...
}
L'écriture n = e.Valeur; est bien sûr légale, mais peut sembler inélégante. Si notre souhait est plutôt d'écrire n = (int)e; alors il faut exprimer au compilateur ce que signifie convertir un Entier en un int.
L'écriture pour ce faire est :
using System;
var e = new Entier(3);
int n = (int) e;
// ...
class Entier
{
public int Valeur { get; } = 0;
public Entier()
{
}
public Entier(int valeur)
{
Valeur = valeur;
}
// ...
public static explicit operator int(Entier e) => e.Valeur;
}
L'opérateur Entier.operator int() explique comment la conversion d'un Entier en int doit se faire (son type de retour, implicitement, est int). Le mot explicit est exigé et signifie que la conversion requiert un transtypage (un cast) pour prendre effet. Il aurait aussi été possible d'utiliser implicit pour permettre la conversion implicite (p. ex. : int n = e;), mais n'utilisez ce mécanisme qu'après mûre réflexion (les conversions implicites peuvent surprendre...)
Certains opérateurs ne peuvent être surchargés en C#. La liste complète est dans https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading#overloadable-operators mais quelques cas particuliers méritent notre attention.
Les opérateurs logiques binaires (à deux opérandes; à ne pas confondre avec les opérateurs bit à bit) que sont && et || par exemple.
Dans ce cas, C# fait un choix raisonnable; en effet, certains langages permettent de surcharger ces opérateurs, mais ce n'est pas nécessairement une bonne idée : les opérateurs surchargés sont des fonctions après tout, alors a + b demande d'évaluer a et b pour appeler operator+(a,b), or quand on écrit a && b, b ne sera évalué que si a est évalué à true. Ce glissement sémantique peut jouer des tours.
Notez que C# oblige à surcharger les opérateurs... & et | (oui, sans blagues) de même que true et false pour que a && b et a || b fonctionnent pour un type, car contrairement à plusieurs autres langages qui font de & le « et » bit à bit et de | le « ou » bit à bit, C# fait de & un « et » « logique » qui évalue ses deux opérandes, et de | un « ou » « logique » qui évalue ses deux opérandes. Il faut comprendre que, pour un type T donné, C# oblige que operator& et operator| prennent tous deux en paramètre deux références sur des T et retournent un T, et il faut aussi que ce T supplée les « opérateurs » true et false. Les sémantiques de & et | ne sont pas tout à fait... limpides (voir https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/expressions#user-defined-conditional-logical-operators pour plus de détails).
Ainsi, ceci ne compile pas :
using System;
Entier e0 = new (2);
if(e0) // operator true
Console.WriteLine($"{e0} est true");
if(!e0) // operator!
Console.WriteLine($"{e0} est false");
var e1 = new Entier(3);
if(e0 && e1) // illégal
Console.WriteLine($"{e0} && {e1}");
if(e0 || e1) // illégal
Console.WriteLine($"{e0} || {e1}");
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
public override string ToString() => Valeur.ToString();
public static bool operator true(Entier e) => e.Valeur != 0;
public static bool operator false(Entier e) => e.Valeur == 0;
public static bool operator!(Entier e) => e.Valeur == 0;
}
... alors que ceci compile :
using System;
Entier e0 = new (2);
if(e0) // operator true
Console.WriteLine($"{e0} est true");
if(!e0) // operator!
Console.WriteLine($"{e0} est false");
var e1 = new Entier(3);
if(e0 && e1) // Ok : operator false et operator true
Console.WriteLine($"{e0} && {e1}");
if(e0 || e1) // Ok : operator true et operator true
Console.WriteLine($"{e0} || {e1}");
class Entier
{
public int Valeur { get; }
public Entier(int valeur)
{
Valeur = valeur;
}
public override string ToString() => Valeur.ToString();
public static bool operator true(Entier e) => e.Valeur != 0;
public static bool operator false(Entier e) => e.Valeur == 0;
public static bool operator!(Entier e) => e.Valeur == 0;
public static Entier operator&(Entier e0, Entier e1) => new Entier((e0.Valeur != 0 && e1.Valeur != 0)? 1 : 0);
public static Entier operator|(Entier e0, Entier e1) => new Entier((e0.Valeur != 0 || e1.Valeur != 0) ? 1 : 0);
}
Soyez averti(e)s.
Il n'est pas possible de surcharger l'opérateur [] en tant que tel, mais C# offre une syntaxe particulière pour arriver à un effet connexe, soit celle des indexeurs.
Il n'est pas possible de surcharger les opérateurs qui touchent directement aux objets; C# impose une distance entre le code que nous écrivons est les objets que nous manipulons, et l'opérateur = ne s'applique qu'aux références, pas aux référés.
Ceci exclut donc la surcharge de =, +=, -=, *=, etc. Si vous écrivez a += b; pour a et b d'un type de votre cru, par exemple, le compilateur réécrira le code sous la forme a = a + b; et créera une temporaire silencieusement (vous ne pouvez pas optimiser votre propre code en ce sens).
Ainsi, ce programme :
using System;
X x0 = new (2),
x1 = new (3);
Console.WriteLine(x0 + x1);
x0 += x1;
Console.WriteLine(x0);
class X
{
public int Valeur { get; private set; } = 0;
public X()
{
}
public X(int valeur)
{
Valeur = valeur;
}
public static X operator+(X a, X b)
{
Console.WriteLine($"Dans operator+({a},{b})");
return new (a.Valeur + b.Valeur);
}
public override string ToString() => $"{Valeur}";
}
... affichera :
Dans operator+(2,3)
5
Dans operator+(2,3)
5
Si vous souhaitez du code plus efficace, il existe d'autres langages...
Entre autres, les versions préfixes de ++ et de -- ne peuvent être surchargées; C# les synthétise à partir des versions suffixées, qui génèrent typiquement des objets temporaires, ce qui rend impossible l'écriture de versions efficaces de ces fonctions.
Si vous souhaitez du code plus efficace, il existe d'autres langages...
Ceci est un exemple illustrant les propriétés et certains opérateurs en C#. Vous pourrez comparer par vous-mêmes avec des programmes équivalents dans les langages que vous connaissez (ou avec cet exemple en C++).
Cet exemple présente d'abord une ébauche de classe Rationnel, incomplète (amusez-vous à en compléter l'arithmétique en ajoutant addition, multiplication et autres trucs amusants) et un peu incorrecte (la gestion du signe est à revoir, pour ne pas dire à implanter). Une version plus contemporaine est offerte ici. À remarquer les usages suivants :
Dans l'implémentation d'Equals(object o), remarquez le test pour valider que le paramètre o soit non nul, et (puisque Equals() prend un object en paramètre) la validation (downcast) que o mène au moins vers un Rationnel, à l'aide de l'opérateur is. Lorsqu'un objet est utilisé là où une string pourrait être utilisée, C# peut en tirer profit et utiliser, si cela s'avère opportun, la méthode ToString() de l'objet en question. Cette technique est mise en application à quelques reprises dans le programme principal. Détail très technique : le transtypage se fait comme en C ou en Java, au sens où (T)expr demande de traiter l'expression expr comme étant de type T. |
|
Quelques liens pour enrichir le propos.