C# – Introduction aux opérateurs

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.

Idée générale

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.

Opérateurs relationnels

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.

Opérateurs d'inégalité

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;
}

Comment réussir ses opérateurs d'inégalité

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.

Opérateurs d'égalité

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)
}

Spécialisation de Equals(object)

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.

Spécialisation de GetHashCode()

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

Opérateurs arithmétiques

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);
}

Opérateurs d'auto-incrémentation et d'auto-décrémentation

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);
}

Opérateurs !, true et false

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");

Opérateurs de conversion

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...)

Restrictions

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.

Opérateurs logiques binaires (à deux opérandes)

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.

Opérateur []

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.

Opérateurs d'affectation et d'affectation composite

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...

Opérateurs d'auto-incrémentation / décrémentation

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...

Exemple concret – un type Rationnel

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 :

  • L'attribut dénominateur est pour usage interne
  • Les propriétés Numérateur et Dénominateur constituent la couche d'abstraction primitive privilégiée en C#. La clause get, équivalent d'un accesseur, représente le code à exécuter lorsqu'une propriété est consultée en lecture, et la clause set (avec un paramètre implicite nommé value), équivalent d'un mutateur, représente le code à exécuter lorsque la propriété est utilisée à gauche d'une affectation
  • Les exceptions sont levées comme en Java, et les classes d'exceptions dérivent typiquement de la classe Exception (utilisée ici, à tort, par souci de simplicité; une classe plus spécialisée, dont le type décrit mieux la réalité du problème rencontré, serait nettement préférable). Notez que les méthodes C# n'annoncent pas les exceptions qu'elles sont susceptibles de lever
  • Les opérateurs sont des méthodes de classe (qualifiées static). Le langage C# est un peu... oppressant, du moins à mes yeux, en ce que pour implémenter ==, il faut aussi implémenter != (ce qui est raisonnable), Equals(object) (ce qui est acceptable) et GetHashCode() (ce qui, sans être inacceptable, est un peu abusif)
  • Les méthodes polymorphiques (qui, comme en C++, doivent être explicitement qualifiées virtual, même si notre exemple ne le montre pas ici), lorsque surchargées, doivent l'être de manière explicite, avec le mot clé override (que vous voyez ici apposé à GetHashCode() et à Equals())

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.

using System;

Rationnel r0 = new (2, 3),
          r1 = new (5, 2);
Console.WriteLine($"{r0}, {r1}"); // utilise Rationnel.ToString()
Console.WriteLine($"{new Rationnel(3, 6).Simplifier()}");
r0 = new (3, 6);
r1 = new (1, 2);
Console.WriteLine("{0} {1} {2}", r0, r0 == r1 ? "==" : "!=", r1);
r0 = new (1, 4);
r1 = new (1, 2);
Console.WriteLine("{0} {1} {2}", r0, r0 == r1 ? "==" : "!=", r1);

sealed class Rationnel : IEquatable<Rationnel>
{
   // pour faciliter la simplification
   private static int Pgcd(int a, int b) =>
      b != 0 ? Pgcd(a, a % b) : a;
   private int dénominateur;
   public int Numérateur { get; set; }
   public int Dénominateur
   {
      get => dénominateur;
      set
      {
         if (value == 0)
            throw new Exception("Zut!");
         dénominateur = value;
      }
   }
   public Rationnel(int num, int dénom)
   {
      Numérateur = num;
      Dénominateur = dénom;
   }
   public Rationnel Simplifier()
   {
      int pgcd = Pgcd(Numérateur, Dénominateur);
      return new (Numérateur / pgcd, Dénominateur / pgcd);
   }
   public static bool operator ==(Rationnel r0, Rationnel r1) =>
      r0.Equals(r1);
   // écrire == oblige d'écrire !=
   public static bool operator !=(Rationnel r0, Rationnel r1) =>
      !(r0 == r1);
   // écrire == oblige d'écrire Equals(object)
   // Notez que la surcharge polymorphique doit être explicite
   public override bool Equals(object autre) =>
      autre is Rationnel r && Equals(r);
   public bool Equals(Rationnel autre)
   {
      Rationnel r0 = Simplifier(),
                r1 = autre.Simplifier();
      return r0.Numérateur == r1.Numérateur &&
             r0.Dénominateur == r1.Dénominateur;
   }
   // écrire Equals(object) oblige d'écrire GetHashCode()... >soupir!<
   // Notez que la surcharge polymorphique doit être explicite
   public override int GetHashCode() =>
      Numérateur.GetHashCode() ^ Dénominateur.GetHashCode(); // disons
   public override string ToString() =>
      Numérateur + "/" + Dénominateur;
}

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !