C# – Réserves et bémols

Quelques raccourcis :

Ce texte est écrit en 2020, peu de temps après les premières annonces de ce que sera C# 9.

Je tiens à l'indiquer ici car les langages évoluent et il se peut que certaines des réserves ci-dessous deviennent un jour caduques avant que je ne puisse mettre l'article à jour. Par exemple, l'absence de types de retour covariants, qui empêchait d'implémenter le clonage sans imposer du transtypage inutile dans le code client, semble destiné à être réglé avec C# 9, et je ne doute pas que le langage s'améliorera encore dans le futur.

La vie et les choix faits autour de moi m'amènent à donner plus de formations en C# que par le passé. Certains aiment, d'autres moins.

Il y a des aspects du langage que j'apprécie, bien sûr. Par exemple, la forme courte de fonctions comme :

static int Carré(int n) => n * n;

... que je trouve fort agréable, entre autres car elle encourage le code simple et élégant, ou encore la réflexivité dynamique (que je voudrais bien avoir en C++, mais seulement dans la mesure où ce coûteux mécanisme demeurerait optionnel) pour donner deux exemples parmi plusieurs.

Il y a par contre beaucoup, beaucoup d'aspects qui sont décevants à mes yeux. Comme bien des langages à la mode, C# fait bien les choses simples, mais c'est à l'usage que les désagréments transparaissent.

Aucun langage n'est à l'abri de cette réalité : aborder la complexité a un coût. Ceux qui me connaissent savent que je préfère (de loin) C++, mais ce langage n'est vraiment pas exempt de critiques (parfois justifiées, parfois moins) : ..\Divers--cplusplus\pedagogie.html#critiques

J'ajoute que c'est le lot des langages populaires et utilisés d'avoir des critiques. C'est normal.

Puisqu'on me demande souvent pourquoi je fais des grimaces en utilisant C#, et puisque je ne souhaite pas lancer une polémique à chaque réponse, j'ai préféré colliger ici quelques-uns des aspects qui me déplaisent, code à l'appui (la liste est évidemment non-exhaustive). Il se peut que votre liste d'irritants diffère de la mienne, et il se peut que vous estimiez que certains des aspects qui m'irritent ne sont pas un problème pour le type de code que vous écrivez. C'est tout à fait recevable.

Problèmes propres au modèle en soi

Certains problèmes sont fondamentaux, au coeur du modèle qui sous-tend le langage C#.

Modèle « tout (ou presque) indirect »

C# distingue les types dits « types références » (les class) et les types dits « types valeurs » (les struct). Si les types valeurs sont accédés directement, il se trouve que dans le cas des types références, tout accès aux objets est indirect : les classes sont instanciées dynamiquement et ces instances sont accessibles à travers des indirections. Essentiellement, la sémantique d'accès aux instances en C# est celle des accès à travers des pointeurs en C++, mais avec quelques ajouts de sécurité (collecte automagique des ordures, impossibilité de faire de l'arithmétique sur les adresses, ce genre de truc). Utiliser des pointeurs est mal vu en C++ contemporain (la sémantique directe y est privilégiée), qui est un langage orienté valeurs, mais c'est le comportement normal en C# et en Java, qui sont des langages orientés références.

Ce choix de C# entraîne plusieurs conséquences néfastes dans le code contemporain :

using System;
public class Program
{
   class Entier
   {
      public int Valeur{ get; set; }
      public Entier(int valeur)
      {
         Valeur = valeur;
      }
      public override string ToString() => Valeur.ToString();
   }
   public static void Main()
   {
      Entier e0 = new Entier(3);
      Entier e1 = e0; // copie de référence, partage de référé
      e1.Valeur++;
      Console.WriteLine(e0); // 4
   }
}
using System;
public class Program
{
   class Entier
   {
      public int Valeur{ get; set; }
      public Entier(int valeur)
      {
         Valeur = valeur;
      }
      public override string ToString() => Valeur.ToString();
   }
   class EntierCarré : Entier
   {
      static bool EstCarré(int candidat) =>
         ((int) Math.Sqrt(candidat) * Math.Sqrt(candidat)) == candidat;
      static int Valider(int candidat) =>
         EstCarré(candidat) ? candidat : throw new Exception(); 
      public EntierCarré(int valeur) : base(Valider(valeur))
      {
      }
   }
   // ICI : e est au moins un Entier (donc : un Entier ou un de ses enfants)
   static void Afficher(Entier e)
   {
      Console.WriteLine(e);
   }
   public static void Main()
   {
      Afficher(new Entier(3));
      Afficher(new EntierCarré(9));
   }
}

Cela signifie qu'il est impossible en C# d'écrire une collection générique sécuritaire si le type T est un class mutable. En effet :

Prenons à titre d'exemple quelque chose de très simple, soit un Wrapper<T> qui se veut une enveloppe protectrice autour d'un:

class Wrapper<T>
{
   public T Valeur{ get; }
   public Wrapper(T valeur)
   {
      Valeur = valeur;
   }
}

On constate ici que la classe n'expose qu'un accesseur (un get) pour le T, présumément pour éviter que le Wrapper<T> ne soit corrompu. Il se trouve que cela empêchera de faire référer Valeur ailleurs qu'au T passé à la construction, mais si ce T est mutable, ce code n'offre vraiment aucune protection :

using System;
public class Program
{
   class Wrapper<T>
   {
      public T Valeur{ get; }
      public Wrapper(T valeur)
      {
         Valeur = valeur;
      }
   }
   class Entier
   {
      public int Valeur{ get; set; }
      public Entier(int valeur)
      {
         Valeur = valeur;
      }
      public override string ToString() => Valeur.ToString();
   }
   public static void Main()
   {
      var e = new Entier(3);
      var w0 = new Wrapper<Entier>(e);
      Console.WriteLine(w0.Valeur); // 3
      e.Valeur++;
      Console.WriteLine(w0.Valeur); // 4 (oups!)
      w0 = new Wrapper<Entier>(new Entier(12)); // ce sera sûrement mieux... n'est-ce pas?
      Console.WriteLine(w0.Valeur); // 12
      w0.Valeur.Valeur++; // oups!
      Console.WriteLine(w0.Valeur); // 13 (oups!)
   }
}

Comme le montre cet exemple, si T est mutable, Wrapper<T> n'est pas en mesure d'offrir quelque protection que ce soit sur sa propre intégrité. L'encapsulation en C# est fondamentalement brisée. Même les techniques de programmation défensive ne pourraient pas nous aider ici, comme l'illustrent les exemples qui suivent.

Il serait tentant de dupliquer le T passé en paramètre à la construction, pour éviter que le code client ne conserve malicieusement une référence sur ce qui sera entreposé dans le Wrapper<T>, mais comment? Tout T est manipulé indirectement s'il s'agit d'une instance d'une classe, alors est-ce un T ou un enfant de T? Pas de construction par copie fiable ici.

class Wrapper<T>
{
   public T Valeur{ get; }
   public Wrapper(T valeur)
   {
      // impossible de savoir si ceci est viable
      // (possibilité de Slicing si valeur est d'un
      // type dérivé de T)
      Valeur = new T(valeur);
   }
}

Le clonage pourrait être une option, mais la sémantique de ICloneable est confuse, ce qui rend impossible l'écriture de code comme celui à droite de manière à obtenir un comportement fiable.

class Wrapper<T> where T : ICloneable
{
   public T Valeur{ get; }
   public Wrapper(T valeur)
   {
      // quelle est la sémantique ici : copie de
      // surface ou en profondeur?
      Valeur = valeur.Clone();
   }
}

Le problème demeure entier si l'on souhaite se défendre contre les modifications à travers un partage du référé pointé par la référence retournée par l'accesseur. L'exemple à droite utilise le clonage en entrée comme en sortie.

Ces exemples ne se sont intéressés qu'à l'encapsulation et à la protection de Wrapper<T>, qui n'est même pas une collection, mais le problème s'étend à toutes les collections génériques... C'est un problème de fond. Notez que nous avons escamoté la question (non-négligeable!) des coûts dans notre survol, mais toutes ces allocations dynamiques coûtent cher, accroissent l'indéterminisme du temps d'exécution requis, et entraînent la production de déchets... donc potentiellement plus de collectes d'ordures.

class Wrapper<T> where T : ICloneable
{
   T valeur;
   public T Valeur
   {
      get => valeur.Clone(); // sémantique?
      // mieux que set : init avec C# 9
      private set { valeur = value.Clone(); // sémantique? }
   }
   public Wrapper(T valeur)
   {
      Valeur = valeur;
   }
}

La plupart des gens se ferment les yeux devant ce problème et font comme s'il n'existait pas. Cela peut même fonctionner, particulièrement avec des outils internes, des situations sans hostilité, des types principalement immuables... Notez tout de même que si vous souhaitez exposer une API en C# qui soit destinée à être consommée par des tiers, vous avez ici un réel problème entre les mains.

Modèle de construction

Construire un objet en C# peut se faire d'une multitude de manières, et je ne ferai pas de liste ici; c'est une caractéristique de plusieurs langages OO, et chaque langage aborde cette question à sa manière. Par contre, le modèle de C# coûte parfois inutilement cher.

En particulier, les constructeurs de C# n'ont pas la cohérence à laquelle sont habitué(e)s celles et ceux qui ont une familiarité avec C++. À titre de rappel, comparons :

À la C# À la C++ (correct) À la C++ (inefficace; ne faites pas ça!)
// ...
class Personne
{
   public string Nom { get; }
   public Personne(string nom)
   {
      // copie de référence, pas de référé
      Nom = nom;
   }
}
// ...
class Personne {
   string nom_;
public:
   // appel du constructeur de copie de string
   Personne(const string &nom) : nom_{ nom } {
   }
   auto nom() const {
      return nom_;
   }
};
// ...
class Personne {
   string nom_;
public:
   // appel implicite (inutile) du constructeur
   // par défaut de string (pour nom_)
   Personne(const string &nom) {
      nom_ = nom; // affectation
   }
   auto nom() const {
      return nom_;
   }
};

Ici, dans les deux cas, le constructeur de Personne accepte en paramètre une référence vers une chaîne de caractères (le sens du mot « référence » est un peu différent dans les deux langages, mais la clé est que c'est dans chaque cas une indirection). En C#, la propriété Nom réfère ensuite à cette chaîne de caractères, ce qui est convenable car le type string y est immuable (partager un objet immuable est sans risque). En C++, l'attribut nom_ est construit à partir de la valeur du paramètre nom, ce qui est convenable car nom_ y est un objet, pas une référence sur un objet, et parce que string est mutable dans ce langage.

Notez une distinction de fond ici. Si le code C++ utilisait l'affectation (exemple tout a droite, non-recommandable) comme le fait le code C#, ce serait inefficace :

Ces considérations sont utiles pour comprendre le problème de fond avec le modèle de C#. En effet, dans les deux langages, il est possible d'initialiser un attribut (ou une propriété, dans le cas de C#) dès sa déclaration. Par exemple (notez que contrairement à C# struct et class sont des choses différentes, en C++ struct et class ne diffèrent que dans leur qualification par défaut, qui est private pour class et public pour struct) :

À la C# À la C++
// ...
class Point2D
{
   public int X { get; set; } = 0;
   public int Y { get; set; } = 0;
   public Point2D()
   {
      // X == 0 et Y == 0
   }
   public Point2D(int x, int y)
   {
      X = x;
      Y = y;
   }
}
// ...
struct Point2D {
   int x = 0, 
       y = 0;
   Point2D() = default; // x == 0, y == 0
   Point2D(int x, int y) : x{ x }, y { y } {
   }
};

... ici, dans les deux langages, un Point2D par défaut modélisera la position 0,0 et un Point2D paramétrique modélisera une position au choix du code client. Il y a toutefois une différence de fond entre les deux : en C++, dans le cas du constructeur paramétrique, chaque int (x et y) n'est construit qu'une fois, alors qu'en C#, chaque propriété (X et Y) est initialisée deux fois.

Ceci se démontre d'ailleurs aisément (voir https://dotnetfiddle.net/F7SgLf pour une démonstration).

Soit le type Val proposé à droite, utilisé ici en remplacement d'un simple int pour que nous ayons une trace à la console de chacune de ses constructions.

using System;
public class Program
{
   struct Val
   {
      public int Valeur { get; }
      public Val(int val)
      {
         Console.WriteLine($"Val({val})");
         Valeur = val; 
      }
   }

Ensuite, soit la classe X à droite, qui initialise la propriété Valeur par défaut avec la valeur de retour d'une méthode F – vous pouvez remplacer F par une instanciation de votre cru, par exemple new Val(42), si vous pensez que cela aura un impact).

Deux constructeurs de X sont offerts :

  • Le constructeur par défaut, qui bénéficie de l'initialisation par défaut de Valeur, et
  • Le constructeur paramétrique. C'est ici que le bât blesse : contrairement à C++, où les attributs ne sont construits qu'une fois (avec la valeur par défaut si on ne fait rien de particulier, et avec une valeur spécifiée à l'appel du constructeur si cela s'avère), C# initialisera deux fois Valeur, soit une fois par défaut et une autre fois à la demande du code client (le constructeur)
   class X
   {
      static Val F() => new Val(3);
      public Val Valeur { get; } = F();
      public X(int valeur)
      {
         Valeur = new Val(valeur);
      }
      public X()
      {
      }
   }

Cet état de fait est illustré par le programme à droite, qui affichera :

Val(3)
Val(3)
Val(4)
   public static void Main()
   {
      X x0 = new X();
      X x1 = new X(4);
   }
}

Ce constat un peu déplaisant découle du modèle objet de C# : Valeur est une référence, pas un objet, et cette référence est initialisée avant l'accolade ouvrante du constructeur; le constructeur paramétrique ne reconstruit pas cette référence, il ne fait que la faire pointer ailleurs.

Pour les programmeuses et les programmeurs, toutefois, cela implique qu'il est inefficace de réaliser par défaut une initialisation non-triviale d'un attribut d'une propriété si un constructeur, quel qu'il soit, est susceptible de préférer un état différent de cet état par défaut.

Cas de public imposés

Soit le code suivant :

class Program
{
   interface IDessinable
   {
      void Dessiner();
   }
   sealed class Carré : IDessinable
   {
      public void Dessiner()
      {
         Console.WriteLine("####\n####\n####\n####\n");
      }
   }
   static void Dessiner(IDessinable d)
   {
      d.Dessiner(); // Ok, appel à partir de l'interface
   }
   static void Main()
   {
      Dessiner(new Carré()); // Ok, Carré est IDessinable
   }
}

Il se peut que ce code ne vous offusque pas, mais il m'irrite profondément. La raison de cette irritation, pour moi, est que Carré est dans l'obligation d'exposer publiquement la méthode Dessiner. Il ne lui est pas possible de l'exposer avec une autre qualification que public.

Si cette restriction ne vous irrite pas, c'est peut-être parce que cette exposition est dans vos habitudes. Avec un modèle limité à l'héritage public comme celui de C#, cette restriction est un moindre mal (on ne peut pas vraiment articuler de hiérarchies fines où Carré seul saurait qu'il est dessinable, par exemple, l'héritage étant trop limité), mais si nous voulions rendre privé le service Dessiner de Carré et de faire passer les appels à Dessiner explicitement par son parent IDessinable, par exemple, il faudrait utiliser un autre langage (ou une organisation de classes plus complexe).

Cas d'encapsulation incomplète

Une classe écrite en C# ne peut se porter garante de la gestion des ressources sous sa gouverne, du moins pas sans l'aide du code client. Exprimé plus directement : une classe C# ne peut assurer pleinement sa propre encapsulation. Il lui faut l'aide du code client.

Le langage offre (heureusement!) des mécanismes pour que le code client puisse prêter secours aux classes. Qu'il s'agisse de blocs lock pour gérer la libération de mutex, de blocs using pour automatiser les appels à Dispose sur les instances de classes implémentant IDisposable, ou de blocs finally pour la finalisation plus manuelle de ressources atypiques, il est possible d'écrire un programme gérant correctement des ressources en C#. Il est par contre impossible pour un objet de gérer lui-même, sans aide, ses propres ressources. C'est ce qui explique que l'encapsulation y soit incomplète.

Il existe plusieurs raisons (compréhensibles!) pour cet état de fait; la principale est que la finalisation des objets est indéterministe, les objets étant alloués dynamiquement et étant soumis à une collecte d'ordures. La collecte d'ordures simplifie la gestion de la mémoire allouée dynamiquement, mais complique la gestion de toutes les autres ressources.

Cas d'interfaces à statut spécial

Dans ce cas, l'irritant à mes yeux tient à un biais esthétique : je préfère les langages où la bibliothèque standard pourrait être implémentée dans le langage, et où le couplage entre les outils de la bibliothèque et le langage en soi reste minimal. Cet idéal est difficile à atteindre, et ne doit pas être un dogme; ce couplage est très présent en C#, mais il existe aussi en C++ par exemple (les variables atomiques sont à cheval entre la bibliothèque et le coeur du langage; implémenter pleinement std::vector en C++ 17 demande certaines tricheries que la bibliothèque standard peut se permettre mais qui seraient techniquement incorrectes pour le code client; certains mécanismes supposent l'existence d'une fonction globale get<int>(T); etc.)

En C#, les interfaces à statut spécial pullulent :

L'ennui avec ce couplage est que les interfaces sont un outil intrusif; elles doivent faire partie des types, être pensées a priori, et sont exposées publiquement. Le lien entre ces interfaces et le langage impactent négativement (à mes yeux) l'élégance du modèle. Cela dit, c'est, je le répète, un jugement esthétique de ma part, pas une position formelle. En C#, écrire un type aussi simple que Pair<T,U> supportant operator+ oblige à être conscient d'un certain nombre d'interfaces.

Obligations asymétriques

Pour des raisons qui m'échappent, C# impose quelques obligations asymétriques au code que nous rédigeons; par exemple, surcharger operator== pour un type donné implique surcharger aussi operator!= pour ce type. L'idée est intéressante : imposer une forme de cohérence au code écrit dans ce langage. Notez que la cohérence visée est syntaxique ici; la sémantique associée aux opérateurs repose sur les épaules des programmeuses et des programmeurs, qui devraient (on peut le souhaiter) faire en sorte que a != b soit équivalent à !(a == b).

Tristement, les moyens choisis ne permettent pas toujours d'atteindre cet objectif.

Cas des opérateurs d'inégalité

Il est bien connu qu'il soit possible d'exprimer le quatuor d'opérateurs d'inégalité que sont <, <=, > et >= sur la base de l'un d'eux (typiquement operator<) et de la négation logique. Ainsi, supposant que operator<(T a, T b) existe, nous avons , et . Dans certains langages, il est possible de mettre en place des mécanismes pour automatiser cette représentation et réduire la quantité de code à écrire.

Dans le cas de C#, quiconque implémente operator< doit aussi implémenter... operator>, mais ces deux opérateurs ne sont pas l'inverse l'un de l'autre (l'inverse logique de operator< est operator>= après tout). De même, quiconque implémente operator<= doit aussi implémenter... operator>=, deux opérateurs qui ne sont pas non plus l'inverse l'un de l'autre.

Qu'on ait obligé la rédaction des quatre aurait, à la limite, été plus raisonnable. Voir Operateurs.html#inegalite pour plus de détails.

Cas de GetHashCode

En C#, implémenter les opérateurs d'égalité implique (si vous souhaitez éviter les avertissements à la compilation) de spécialiser à la fois les méthodes Equals(object) et GetHashCode de System.Object.

Si le cas de Equals(object) se défend dans le contexte d'un langage où tout est référence, le cas de GetHashCode est beaucoup plus discutable.

Quelques étrangetés diverses

Certains irritants sont plus disparates, et plus difficiles à catégoriser. En voici quelques-uns. Notez d'office que C++ (que je préfère) regorge d'étrangetés, surtout dans les coins obscurs du langage; ce qui m'irrite avec les étrangetés de C# est qu'elles ne sont pas dans les coins obscurs, bien au contraire...

Cas des opérateurs ++ et --

Il est possible en C# de surcharger les opérateurs ++ et --, mais seulement dans leur déclinaison suffixe, soit celle qui crée des variables temporaires. La version préfixe, qui pourrait éviter une instanciation (donc, en C#, une allocation!) est synthétisée à partir de la version suffixe (donc ++a est équivalente à a = a++, ce qui en C# a un sens déterminé, soit temp = a++ d'abord et a = temp ensuite pour une variable temp inventée). Voir Operateurs.html#restriction_autoincrementation pour plus de détails.

Conséquemment, il est impossible en C# de profiter de ces occasions pour écrire du meilleur code. Seul le code inefficace est possible.

Cas des opérateurs @=

Il est impossible en C# de surcharger les opérateurs +=, -=, *=, etc. Une expression comme a += b est réécrite comme a = a + b par le compilateur, ce qui génère une temporaire (donc, en C#, une allocation!)  à chaque fois. Voir Operateurs.html#restriction_affectation pour plus de détails.

Conséquemment, il est impossible en C# de profiter de ces occasions pour écrire du meilleur code. Seul le code inefficace est possible.

Effets pervers du modèle d'opérateurs

En plus des problèmes susmentionnés, le modèle d'opérateurs de C# entraîne des conséquences déplaisantes (à mes yeux) sur la logique du code. Examinez par exemple ce qui suit :

using System;
public class Program
{
   public static void Main()
   {
      string s = null;
      Console.WriteLine(s.Length); // boum!
   }
}

... sans surprises, ce code lève une NullReferenceException lors de l'accès à la propriété Length de s puisque s réfère à null. Toutefois, regardez maintenant ceci :

using System;
public class Program
{
   public static void Main()
   {
      string s = null;
      s += "Yo";
      Console.WriteLine(s.Length); // 2
   }
}

... ce programme fonctionne, et affiche 2. Pourtant, nous appelons += sur s qui est null. Certain(e)s diront qu'il s'agit d'une bonne nouvelle (ça ne plante pas, après tout), mais d'autres signaleront que le code comporte probablement un bogue qui a échappé à la programmeuse ou au programmeur, et qui est désormais masqué. En général, mieux vaut repérer ces bogues latents tôt dans le processus de développement que d'attendre qu'ils ne reviennent nous mordre une fois le code livré chez les client(e)s.

Pourquoi cela fonctionne-t-il? Le problème est que le langage transforme :

string s  = null;
s+= "Yo";

... en ceci :

string s = null;
s = s + "Yo";

... et détermine que, dans operator+(string s0, string s1), que s0 ou s1 soient null ou soient des chaînes vides est équivalent. D'ailleurs, ceci :

string s = null;
s = s + null;
Console.Write(s.Length);

... compilera sans problème, affichera 0, et ne plantera pas. Je pense que ça se passe de commentaires.

Tout ça pour ne pas avoir de fonctions

Le langage C# ne supporte pas les fonctions globales. Il y est impossible d'écrire un truc simple comme une fonction Carré(n) retournant le carré de n.

Il est possible d'écrire une méthode de classe (static) faisant ce travail, bien entendu. Par exemple :

using System;
class TitesMaths
{
   public static int Carré(int n) => n * n;
}
class Program
{
   static void Main()
   {
      Console.WriteLine(TitesMaths.Carré(3)); // 9
      var x = new TitesMaths(); // compile mais essentiellement inutile
   }
}

Dans cet exemple, il serait possible (mais inutile) d'instancier la classe TitesMaths. Quand une classe ne sert qu'à titre de collection de membres de classe (méthodes static, attributs static, propriétés static, etc.), il est possible en C# de faire de cette classe une classe static, au sens où elle ne peut être instanciée et ne peut donc avoir que des membres de classe :

using System;
static class TitesMaths
{
   public static int Carré(int n) => n * n;
}
class Program
{
   static void Main()
   {
      Console.WriteLine(TitesMaths.Carré(3)); // 9
      // var x = new TitesMaths(); // ne compilerait pas
   }
}

Pour alléger l'écriture, C# permet désormais d'utiliser using static pour une classe static, de manière à ce que ses services deviennent implicitement appelables comme s'ils étaient... des fonctions. Ainsi, on peut maintenant écrire :

using static System.Console;
using static TitesMaths;
static class TitesMaths
{
   public static int Carré(int n) => n * n;
}
class Program
{
   static void Main()
   {
      WriteLine(Carré(3)); // 9
   }
}

... mais cela ne laisse-t-il pas en plan la question élémentaire : à ce stade, pourquoi ne pas simplement supporter les fonctions?

λ simples... parfois trop simples

Les expressions λ de C# sont simples à exprimer. Par exemple :

using System;
class Program
{
   static T Appliquer(Func<T,T,T> f, T x, T y) => f(x,y);
   static T Appliquer(Func<T,T> f, T x) => f(x);
   static void Main()
   {
      Console.WriteLine(Appliquer((x,y) => x + y, 2, 3)); // 5
      Console.WriteLine(Appliquer((x,y) => x * y, 2, 3)); // 6
      Console.WriteLine(Appliquer(x => x * x, 3)); // 9
      Console.WriteLine(Appliquer(x => -x, 3)); // -3
   }
}

Tant que les λ se limitent à des fonctions qui ne dépendent que de leurs paramètres, ce modèle fonctionne bien. Par contre, quand elles définissent une fermeture autour d'états capturés dans la portée de la λ, les choses sont moins jolies. Par exemple le programme suivant :

using System;
using System.Threading;
using System.Collections.Generic;
public class Program
{
   public static void Main()
   {
      const int N = 10;
      var th = new Thread[N];
      for(int i = 0; i != th.Length; ++i)
         th[i] = new Thread(() =>
         {
            Console.WriteLine($"Je suis le fil {i}");
         });
      foreach(var thr in th) thr.Start();
      foreach(var thr in th) thr.Join();
   }
}

... affichera ce qui suit :

Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10
Je suis le fil 10

La raison pour cette situation est que les captures dans une fermeture générée par une expression λ se font par référence, donc toutes les λ ici réfèrent au même i qui, au moment où les fils d'exécution seront démarrés, vaudra 10.

En C#, plutôt que de faire en sorte que la λ détermine les règles de capture qui lui conviennent, c'est au code client de faire un choix. Dans un cas comme celui-ci, le code client devra introduire un bloc (une portée) à l'intérieur de la boucle qui instanciera les λ et y faire une copie locale de i, puis utiliser cette copie locale dans la λ par la suite. Cela fonctionne, mais on peut se questionner sur l'élégance de la solution. Ainsi, le programme suivant :

using System;
using System.Threading;
using System.Collections.Generic;
public class Program
{
   public static void Main()
   {
      const int N = 10;
      var th = new Thread[N];
      for(int i = 0; i != th.Length; ++i)
      {
         int indice = i;
         th[i] = new Thread(() =>
         {
            Console.WriteLine($"Je suis le fil {indice}");
         });
      }
      foreach(var thr in th) thr.Start();
      foreach(var thr in th) thr.Join();
   }
}

... affichera N messages avec des valeurs distinctes pour indice.

La question, à mon sens, est : la complexité est-elle au bon endroit? Comme c'est souvent le cas en C#, le code client doit pallier manuellement les manques dans les mécanismes dont il se sert. D'autres détails dans Lambdas.html#fermeture

Coût des accès aux propriétés

Un des aspects qui distingue C# de Java (particulièrement) et de C++ (dans une moindre mesure toutefois, les différences entre les deux langages étant plus importantes) est les propriétés. Cette couche de sucre syntaxique formalise les accesseurs (get) et les mutateurs (set) en permettant de les exprimer sous forme relativement naturelle d'affectation. C'est facile à utiliser, un peu plus compliqué à écrire pour des débutant(e)s (rien d'insurmontable, quoique certaines et certains butent longtemps sur la syntaxe), et ça peut participer de manière pertinente à l'encapsulation :

Version Java Version C#
import java.io.*;
public class X {
   int x;
   int y;
   public int getX() {
      return x;
   }
   public int getY() {
      return y;
   }
   // on pourrait ajouter de la validation ici
   private void setX(int x) {
      this.x = x;
   }
   private void setY(int y) {
      this.y = y;
   }
   public Point() {
      setX(0);
      setY(0);
   }
   public Point(int x, int y) {
      setX(x);
      setY(y);
   }
   public static void main() {
      Point pt = new Point(2,3);
      System.out.println(pt.getX() + "," pt.getY());
   }
}
;
using System;
class Program
{
   class Point
   {
      // on pourrait ajouter de la validation ici
      public int X { get; private set; }
      public int Y { get; private set; }
      public Point()
      {
         X = 0;
         Y = 0;
      }
      public Point(int x, int y)
      {
         X = x;
         Y = y;
      }
   }
   static void Main()
   {
      var pt = new Point(2,3);
      Console.WriteLine($"{pt.X},{pt.Y}");
   }
}

Étant donné que les propriétés, bien qu'étant des méthodes (p. ex. : get_X, set_X quand on regarde le code généré) sous la couverture, apparaissent comme des variables simplifiant l'écriture du code client, il est tentant de les utiliser comme s'il s'agissait d'attributs.

Malheureusement, cette abstraction est coûteuse. Voir Proprietes-Methodes.html#cout_propriete pour plus de détails.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !