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.
Certains problèmes sont fondamentaux, au coeur du modèle qui sous-tend le langage C#.
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;
Entier e0 = new (3);
Entier e1 = e0; // copie de référence, partage de référé
e1.Valeur++;
Console.WriteLine(e0); // 4
class Entier
{
public int Valeur{ get; set; }
public Entier(int valeur)
{
Valeur = valeur;
}
public override string ToString() => Valeur.ToString();
}
using System;
Afficher(new Entier(3));
Afficher(new EntierCarré(9));
// ICI : e est au moins un Entier (donc : un Entier ou un de ses enfants)
static void Afficher(Entier e)
{
Console.WriteLine(e);
}
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))
{
}
}
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 T :
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;
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!)
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();
}
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. |
|
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. |
|
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. |
|
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.
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!) |
---|---|---|
|
|
|
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# où 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++ |
---|---|
|
|
... 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/uLdSWs 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. |
|
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 :
|
|
Cet état de fait est illustré par le programme à droite, qui affichera :
|
|
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.
Soit le code suivant :
using System;
Dessiner(new Carré()); // Ok, Carré est IDessinable
static void Dessiner(IDessinable d)
{
d.Dessiner(); // Ok, appel à partir de l'interface
}
interface IDessinable
{
void Dessiner();
}
sealed class Carré : IDessinable
{
public void Dessiner()
{
Console.WriteLine("####\n####\n####\n####\n");
}
}
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).
Notez, par-dessus le marché, que :
Imposer un public implicite dans une interface se défend peut-être (quoi que je puisse envisager des cas où protected serait raisonnable), mais imposer public aux dérivés... Passons.
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.
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.
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.
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.
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.
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...
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.
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.
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;
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;
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.
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;
Console.WriteLine(TitesMaths.Carré(3)); // 9
var x = new TitesMaths(); // compile mais essentiellement inutile
class TitesMaths
{
public static int Carré(int n) => n * n;
}
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;
Console.WriteLine(TitesMaths.Carré(3)); // 9
// var x = new TitesMaths(); // ne compilerait pas
static class TitesMaths
{
public static int Carré(int n) => n * n;
}
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;
WriteLine(Carré(3)); // 9
static class TitesMaths
{
public static int Carré(int n) => n * n;
}
... mais cela ne laisse-t-il pas en plan la question élémentaire : à ce stade, pourquoi ne pas simplement supporter les fonctions?
Les expressions λ de C# sont simples à exprimer. Par exemple :
using System;
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
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);
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;
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;
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
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# |
---|---|
; |
|
É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.
Le type List<T> de System.Collections.Generic est un type important de la plateforme .NET. Ce type (très) mal nommé modélise un tableau de capacité dynamique dont les éléments sont de type T. L'équivalent C++ le plus proche est std::vector<T> (un autre type important mais mal nommé). Dans un langage comme dans l'autre, ce type est probablement la structure de données la plus utilisée, étant avantageuse à plusieurs égards (en particulier, pour la Cache).
Dans un tableau dynamique qui se veut efficace, la capacité est habituellement plus grande que le nombre d'éléments, ce qui permet d'ajouter des éléments en temps constant, sans devoir redimensionner le substrat. En effet, ajouter à un tableau dynamique déjà plein demande d'allouer un nouveau tableau de plus grande capacité que le tableau existant, de copier tous les éléments de l'ancien substrat au nouveau substrat, puis de disposer de l'ancien substrat; c'est une opération très dispendieuse qu'il vaut en général mieux faire le moins souvent possible.
Il est raisonnable qu'un tableau dynamique offre des services pour redimensionner délibérément le substrat (donc pour changer la capacité du tableau). Ceci permet par exemple de ne pas payer le coût du redimensionnement à un moment inopportun, surtout pour les systèmes critiques (ce qui ne concerne pas vraiment C#, quoique même dams des systèmes non-critiques, contrôler la dimension d'un tableau dynamique peut permettre des optimisations appréciables). En C++ par exemple :
Notez que ces fonctions ont des noms explicites, ce qui fait que les utiliser implique un geste clair et volontaire. C'est une chose sage, car ces opérations sont dispendieuses.
En C#, la capacité d'une List<T> est une propriété, Capacity... dont le set est public! Cela signifie que le code suivant, une catastrophe sur le plan de l'efficacité, est légal et compile sans peine. Comparez le code raisonnable – à gauche – au code catastrophique – à droite, pour ce programme qui pige des int au hasard dans une List<int> et retire chaque int pigé de la collection :
Version raisonnable | Version catastrophique |
---|---|
|
|
Cet exemple est tiré de code écrit par des étudiant(e)s de bonne foi. Les deux fonctionnent, mais si celle de gauche est de complexité linéaire (), celle de droit est de complexité quadratique () car chaque changement de Capacity est susceptible de provoquer un redimensionnement du substrat.
Ce choix d'interface est épouvantablement mauvais. Je ne blâme pas les étudiant(e)s de se tromper, je blâme les conceptrices et les concepteurs de la classe List<T>.
Quelques liens pour enrichir le propos.