Limitations de l'instruction de commutateur C # - pourquoi?

141

Lors de l'écriture d'une instruction switch, il semble y avoir deux limitations sur ce que vous pouvez activer dans les instructions case.

Par exemple (et oui, je sais, si vous faites ce genre de chose, cela signifie probablement que votre architecture orientée objet (OO) est incertaine - ce n'est qu'un exemple artificiel!),

  Type t = typeof(int);

  switch (t) {

    case typeof(int):
      Console.WriteLine("int!");
      break;

    case typeof(string):
      Console.WriteLine("string!");
      break;

    default:
      Console.WriteLine("unknown!");
      break;
  }

Ici, l'instruction switch () échoue avec «Une valeur d'un type intégral attendu» et les instructions case échouent avec «Une valeur constante est attendue».

Pourquoi ces restrictions sont-elles en place et quelle est la justification sous-jacente? Je ne vois aucune raison pour laquelle l'instruction switch doit succomber à l'analyse statique uniquement, et pourquoi la valeur activée doit être intégrale (c'est-à-dire primitive). Quelle est la justification?

ljs
la source
1
Voir la solution de contournement possible Existe-t-il une meilleure alternative que celle-ci pour «activer le type»?
Michael Freidgeim
Une autre option pour activer les types intégrés consiste à utiliser TypeCode Enum .
Erik Philips
Créez simplement un ENUM et utilisez NameOf dans le cas de Switch. Cela fonctionnera comme une constante sur une variable dynamique.
Vaibhav

Réponses:

99

C'est mon article original, qui a suscité un débat ... car il est faux :

L'instruction switch n'est pas la même chose qu'une grosse instruction if-else. Chaque cas doit être unique et évalué statiquement. L'instruction switch effectue une branche à temps constant, quel que soit le nombre de cas. L'instruction if-else évalue chaque condition jusqu'à ce qu'elle en trouve une vraie.


En fait, l'instruction switch C # n'est pas toujours une branche à temps constant.

Dans certains cas, le compilateur utilisera une instruction de commutateur CIL qui est en fait une branche à temps constant utilisant une table de saut. Cependant, dans des cas rares, comme l'a souligné Ivan Hamilton, le compilateur peut générer tout autre chose.

Ceci est en fait assez facile à vérifier en écrivant diverses instructions de commutateur C #, certaines éparses, d'autres denses, et en regardant le CIL résultant avec l'outil ildasm.exe.

Brian Ensink
la source
4
Comme indiqué dans d'autres réponses (y compris la mienne), les affirmations formulées dans cette réponse ne sont pas correctes. Je recommanderais la suppression (ne serait-ce que pour éviter d'appliquer cette idée fausse (probablement courante)).
mweerden
S'il vous plaît voir mon post ci-dessous où je montre, à mon avis de manière concluante, que l'instruction switch fait une branche à temps constant.
Brian Ensink
Merci beaucoup pour votre réponse, Brian. Veuillez consulter la réponse d'Ivan Hamilton ((48259) [ beta.stackoverflow.com/questions/44905/#48259] ). En bref: vous parlez de l' switch instruction (du CIL) qui n'est pas la même que l' switchinstruction de C #.
mweerden
Je ne crois pas non plus que le compilateur génère des branchements en temps constant lors de l'activation des chaînes.
Drew Noakes
Est-ce toujours applicable avec la correspondance de modèle dans les instructions de cas de commutateur dans C # 7.0?
B.Darren Olson
114

Il est important de ne pas confondre l'instruction de commutateur C # avec l'instruction de commutateur CIL.

Le commutateur CIL est une table de sauts, qui nécessite un index dans un ensemble d'adresses de saut.

Ceci n'est utile que si les cas du commutateur C # sont adjacents:

case 3: blah; break;
case 4: blah; break;
case 5: blah; break;

Mais peu utiles s'ils ne le sont pas:

case 10: blah; break;
case 200: blah; break;
case 3000: blah; break;

(Vous auriez besoin d'une table d'environ 3000 entrées en taille, avec seulement 3 emplacements utilisés)

Avec des expressions non adjacentes, le compilateur peut commencer à effectuer des vérifications linéaires if-else-if-else.

Avec des ensembles d'expressions non adjacents plus grands, le compilateur peut commencer par une recherche d'arborescence binaire, et finalement if-else-if-else les derniers éléments.

Avec des ensembles d'expressions contenant des groupes d'éléments adjacents, le compilateur peut rechercher dans l'arborescence binaire, et enfin un commutateur CIL.

C'est plein de "mays" et "mights", et cela dépend du compilateur (peut différer avec Mono ou Rotor).

J'ai répliqué vos résultats sur ma machine en utilisant des cas adjacents:

temps total pour exécuter un commutateur 10 voies, 10000 itérations (ms): 25,1383
temps approximatif par commutateur 10 voies (ms): 0,00251383

temps total pour exécuter un commutateur à 50 voies, 10000 itérations (ms): 26,593
temps approximatif par commutateur à 50 voies (ms): 0,0026593

temps total pour exécuter un commutateur à 5000 voies, 10000 itérations (ms): 23,7094
temps approximatif par commutateur à 5000 voies (ms): 0,00237094

temps total pour exécuter un commutateur de 50000 voies, 10000 itérations (ms): 20,0933
temps approximatif par commutateur de 50000 voies (ms): 0,00200933

Ensuite, j'ai également utilisé des expressions de cas non adjacentes:

temps total pour exécuter un commutateur 10 voies, 10000 itérations (ms): 19,6189
temps approximatif par commutateur 10 voies (ms): 0,00196189

temps total pour exécuter un commutateur à 500 voies, 10000 itérations (ms): 19,1664
temps approximatif par commutateur à 500 voies (ms): 0,00191664

temps total pour exécuter un commutateur à 5000 voies, 10000 itérations (ms): 19,5871
temps approximatif par commutateur à 5000 voies (ms): 0,00195871

Une instruction switch de 50 000 cas non adjacente ne serait pas compilée.
"Une expression est trop longue ou trop complexe pour être compilée près de 'ConsoleApplication1.Program.Main (string [])'

Ce qui est drôle ici, c'est que la recherche dans l'arbre binaire apparaît un peu (probablement pas statistiquement) plus rapidement que l'instruction de commutation CIL.

Brian, vous avez utilisé le mot « constante », qui a une signification très précise du point de vue de la théorie de la complexité computationnelle. Alors que l'exemple d'entier adjacent simpliste peut produire un CIL considéré comme O (1) (constant), un exemple fragmenté est O (log n) (logarithmique), les exemples groupés se trouvent quelque part entre les deux et les petits exemples sont O (n) (linéaire ).

Cela ne résout même pas la situation de String, dans laquelle une statique Generic.Dictionary<string,int32>peut être créée, et subira une surcharge définie lors de la première utilisation. La performance dépendra ici de la performance de Generic.Dictionary.

Si vous vérifiez la spécification du langage C # (pas la spécification CIL), vous trouverez "15.7.2 L'instruction switch" ne fait aucune mention du "temps constant" ou que l'implémentation sous-jacente utilise même l'instruction switch CIL (soyez très prudent de supposer de telles choses).

À la fin de la journée, un commutateur C # contre une expression entière sur un système moderne est une opération inférieure à la microseconde et ne vaut normalement pas la peine de s'inquiéter.


Bien entendu, ces délais dépendront des machines et des conditions. Je ne ferais pas attention à ces tests de synchronisation, les durées en microsecondes dont nous parlons sont éclipsées par tout code «réel» exécuté (et vous devez inclure du «code réel» sinon le compilateur optimisera la branche), ou gigue dans le système. Mes réponses sont basées sur l'utilisation d' IL DASM pour examiner le CIL créé par le compilateur C #. Bien sûr, ce n'est pas définitif, car les instructions réelles exécutées par le processeur sont ensuite créées par le JIT.

J'ai vérifié les instructions du processeur finales réellement exécutées sur ma machine x86 et je peux confirmer un simple commutateur de jeu adjacent faisant quelque chose comme:

  jmp     ds:300025F0[eax*4]

Où une recherche d'arbre binaire est pleine de:

  cmp     ebx, 79Eh
  jg      3000352B
  cmp     ebx, 654h
  jg      300032BB
  
  cmp     ebx, 0F82h
  jz      30005EEE
Ivan Hamilton
la source
Les résultats de vos expériences me surprennent un peu. Avez-vous échangé le vôtre avec celui de Brian? Ses résultats montrent une augmentation avec la taille, contrairement aux vôtres. Je manque quelque chose? Dans tous les cas, merci pour la réponse claire.
mweerden
Il est difficile de calculer avec précision la synchronisation avec une si petite opération. Nous n'avons pas partagé de code ni de procédures de test. Je ne vois pas pourquoi son temps devrait augmenter pour les cas adjacents. Les miens étaient 10 fois plus rapides, donc les environnements et le code de test peuvent varier considérablement.
Ivan Hamilton
23

La première raison qui me vient à l'esprit est historique :

Comme la plupart des programmeurs C, C ++ et Java ne sont pas habitués à avoir de telles libertés, ils ne les exigent pas.

Une autre raison, plus valable, est que la complexité de la langue augmenterait :

Tout d'abord, faut-il comparer les objets avec .Equals()ou avec l' ==opérateur? Les deux sont valables dans certains cas. Devrions-nous introduire une nouvelle syntaxe pour ce faire? Doit-on permettre au programmeur d'introduire sa propre méthode de comparaison?

De plus, permettre d'activer des objets romprait les hypothèses sous-jacentes concernant l'instruction switch . Il existe deux règles régissant l'instruction switch que le compilateur ne pourrait pas appliquer si les objets étaient autorisés à être activés (voir la spécification du langage C # version 3.0 , §8.7.2):

  • Que les valeurs des étiquettes de commutateur sont constantes
  • Que les valeurs des étiquettes de commutateur sont distinctes (de sorte qu'un seul bloc de commutateur peut être sélectionné pour une expression de commutateur donnée)

Considérez cet exemple de code dans le cas hypothétique où les valeurs de cas non constantes étaient autorisées:

void DoIt()
{
    String foo = "bar";
    Switch(foo, foo);
}

void Switch(String val1, String val2)
{
    switch ("bar")
    {
        // The compiler will not know that val1 and val2 are not distinct
        case val1:
            // Is this case block selected?
            break;
        case val2:
            // Or this one?
            break;
        case "bar":
            // Or perhaps this one?
            break;
    }
}

Que fera le code? Que faire si les instructions de cas sont réorganisées? En effet, l'une des raisons pour lesquelles C # a rendu le basculement de commutateur illégal est que les instructions de commutateur pourraient être arbitrairement réorganisées.

Ces règles sont en place pour une raison - de sorte que le programmeur peut, en regardant un bloc de cas, connaître avec certitude la condition précise dans laquelle le bloc est entré. Lorsque l'instruction switch susmentionnée atteint 100 lignes ou plus (et ce sera le cas), cette connaissance est inestimable.

Antti Kissaniemi
la source
2
À noter sur la réorganisation des commutateurs. La chute est légale si le cas ne contient pas de code. Tels que, Cas 1: Cas 2: Console.WriteLine ("Salut"); Pause;
Joel McBeth
10

À propos, VB, ayant la même architecture sous-jacente, permet des Select Caseinstructions beaucoup plus flexibles (le code ci-dessus fonctionnerait en VB) et produit toujours un code efficace là où cela est possible, donc l'argument par contrainte technique doit être examiné attentivement.

Konrad Rudolph
la source
1
Le Select Caseen VB est très flexible et permet de gagner du temps. Ça me manque beaucoup.
Eduardo Molteni
@EduardoMolteni Passez alors à F #. En comparaison, les commutateurs de Pascal et de VB semblent être des enfants idiots.
Luaan
10

La plupart du temps, ces restrictions sont en place à cause des concepteurs de langage. La justification sous-jacente peut être la compatibilité avec l'historique de la langue, les idéaux ou la simplification de la conception du compilateur.

Le compilateur peut (et fait) choisir de:

  • créer une grande instruction if-else
  • utiliser une instruction de commutateur MSIL (table de saut)
  • construire un Generic.Dictionary <string, int32>, le remplir à la première utilisation et appeler Generic.Dictionary <> :: TryGetValue () pour un index à passer à une instruction de commutateur MSIL (table de saut)
  • utiliser une combinaison de sauts if-elses et MSIL "switch"

L'instruction switch N'EST PAS une branche à temps constant. Le compilateur peut trouver des raccourcis (en utilisant des seaux de hachage, etc.), mais des cas plus compliqués généreront du code MSIL plus compliqué, certains cas se ramifiant plus tôt que d'autres.

Pour gérer le cas String, le compilateur finira (à un moment donné) en utilisant a.Equals (b) (et éventuellement a.GetHashCode ()). Je pense qu'il serait trival pour le compilateur d'utiliser n'importe quel objet qui satisfait ces contraintes.

Quant au besoin d'expressions de cas statiques ... certaines de ces optimisations (hachage, mise en cache, etc.) ne seraient pas disponibles si les expressions de cas n'étaient pas déterministes. Mais nous avons déjà vu que parfois le compilateur choisit de toute façon la route simpliste if-else-if-else ...

Edit: lomaxx - Votre compréhension de l'opérateur "typeof" n'est pas correcte. L'opérateur "typeof" permet d'obtenir l'objet System.Type pour un type (rien à voir avec ses supertypes ou interfaces). La vérification de la compatibilité d'exécution d'un objet avec un type donné est le travail de l'opérateur «est». L'utilisation de "typeof" ici pour exprimer un objet n'est pas pertinente.

Ivan Hamilton
la source
6

Alors que sur le sujet, selon Jeff Atwood, l'instruction switch est une atrocité de programmation . Utilisez-les avec parcimonie.

Vous pouvez souvent accomplir la même tâche en utilisant une table. Par exemple:

var table = new Dictionary<Type, string>()
{
   { typeof(int), "it's an int!" }
   { typeof(string), "it's a string!" }
};

Type someType = typeof(int);
Console.WriteLine(table[someType]);
Judah Gabriel Himango
la source
7
Vous citez sérieusement le post Twitter de quelqu'un sans aucune preuve? Au moins un lien vers une source fiable.
Ivan Hamilton
4
Il provient d'une source fiable; le message Twitter en question est de Jeff Atwood, auteur du site que vous consultez. :-) Jeff a une poignée d'articles de blogs sur ce sujet si vous êtes curieux.
Judah Gabriel Himango
Je crois que c'est un BS total - que Jeff Atwood l'ait écrit ou non. C'est drôle à quel point l'instruction switch se prête bien à la gestion des machines à états et à d'autres exemples de modification du flux de code en fonction de la valeur d'un enumtype. Ce n'est pas non plus un hasard si intellisense remplit automatiquement une instruction switch lorsque vous activez une variable d'un enumtype.
Jonathon Reinhart
@JonathonReinhart Oui, je pense que c'est le point - il y a de meilleures façons de gérer le code polymorphe que d'utiliser l' switchinstruction. Il ne dit pas que vous ne devriez pas écrire de machines à états, mais simplement que vous pouvez faire la même chose en utilisant de jolis types spécifiques. Bien sûr, c'est beaucoup plus facile dans des langages comme F # qui ont des types qui peuvent facilement couvrir des états assez complexes. Pour votre exemple, vous pouvez utiliser des unions discriminées où l'état devient une partie du type et remplacer la switchpar correspondance de modèle. Ou utilisez des interfaces, par exemple.
Luaan
Ancienne réponse / question, mais j'aurais pensé que (corrigez-moi si je me trompe) Dictionaryaurait été considérablement plus lent qu'une switchdéclaration optimisée ...?
Paul
6

Je ne vois aucune raison pour laquelle l'instruction switch doit succomber à l'analyse statique uniquement

Certes, ce n'est pas obligatoire , et de nombreux langages utilisent en fait des instructions de commutation dynamiques. Cela signifie cependant que réorganiser les clauses "case" peut changer le comportement du code.

Il y a des informations intéressantes derrière les décisions de conception qui sont entrées dans "switch" ici: Pourquoi l'instruction de commutateur C # est-elle conçue pour ne pas autoriser les interruptions, mais nécessite toujours une pause?

Autoriser les expressions de cas dynamiques peut conduire à des monstruosités telles que ce code PHP:

switch (true) {
    case a == 5:
        ...
        break;
    case b == 10:
        ...
        break;
}

qui devrait franchement simplement utiliser la if-elsedéclaration.

Roman Starkov
la source
1
C'est ce que j'aime dans PHP (maintenant que je passe à C #), c'est la liberté. Avec cela vient la liberté d'écrire du mauvais code, mais c'est quelque chose qui me manque vraiment en C #
silkfire
5

Microsoft vous a enfin entendu!

Désormais, avec C # 7, vous pouvez:

switch(shape)
{
case Circle c:
    WriteLine($"circle with radius {c.Radius}");
    break;
case Rectangle s when (s.Length == s.Height):
    WriteLine($"{s.Length} x {s.Height} square");
    break;
case Rectangle r:
    WriteLine($"{r.Length} x {r.Height} rectangle");
    break;
default:
    WriteLine("<unknown shape>");
    break;
case null:
    throw new ArgumentNullException(nameof(shape));
}
dimaaan
la source
3

Ce n'est pas une raison, mais la section 8.7.2 de la spécification C # indique ce qui suit:

Le type directeur d'une instruction switch est établi par l'expression switch. Si le type de l'expression de commutateur est sbyte, byte, short, ushort, int, uint, long, ulong, char, string ou un type enum, alors c'est le type dominant de l'instruction switch. Sinon, exactement une conversion implicite définie par l'utilisateur (§6.4) doit exister à partir du type de l'expression de commutation vers l'un des types gouvernants possibles suivants: sbyte, byte, short, ushort, int, uint, long, ulong, char, string . Si aucune conversion implicite de ce type n'existe, ou si plusieurs conversions implicites existent, une erreur de compilation se produit.

La spécification C # 3.0 se trouve à l' adresse : http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/CSharp%20Language%20Specification.doc

Markus
la source
3

La réponse de Juda ci-dessus m'a donné une idée. Vous pouvez "simuler" le comportement du commutateur de l'OP ci-dessus en utilisant un Dictionary<Type, Func<T>:

Dictionary<Type, Func<object, string,  string>> typeTable = new Dictionary<Type, Func<object, string, string>>();
typeTable.Add(typeof(int), (o, s) =>
                    {
                        return string.Format("{0}: {1}", s, o.ToString());
                    });

Cela vous permet d'associer un comportement à un type dans le même style que l'instruction switch. Je crois qu'il a l'avantage supplémentaire d'être détruit au lieu d'une table de saut de style commutateur lorsqu'il est compilé en IL.

Dave Swersky
la source
0

Je suppose qu'il n'y a aucune raison fondamentale pour laquelle le compilateur n'a pas pu traduire automatiquement votre instruction switch en:

if (t == typeof(int))
{
...
}
elseif (t == typeof(string))
{
...
}
...

Mais il n'y a pas grand-chose à gagner.

Une instruction case sur les types intégraux permet au compilateur de faire un certain nombre d'optimisations:

  1. Il n'y a pas de duplication (sauf si vous dupliquez les étiquettes de cas, que le compilateur détecte). Dans votre exemple, t pourrait correspondre à plusieurs types en raison de l'héritage. Le premier match doit-il être exécuté? Tous?

  2. Le compilateur peut choisir d'implémenter une instruction switch sur un type intégral par une table de saut pour éviter toutes les comparaisons. Si vous activez une énumération qui a des valeurs entières de 0 à 100, il crée un tableau avec 100 pointeurs, un pour chaque instruction switch. Au moment de l'exécution, il recherche simplement l'adresse du tableau en fonction de la valeur entière activée. Cela permet de bien meilleures performances d'exécution que d'effectuer 100 comparaisons.

Rob Walker
la source
1
Une complexité importante à noter ici est que le modèle de mémoire .NET a certaines garanties fortes qui font que votre pseudocode n'est pas exactement équivalent au (hypothétique, C # invalide ) switch (t) { case typeof(int): ... }parce que votre traduction implique que la variable t doit être extraite de la mémoire deux fois si t != typeof(int), alors que ce dernier le serait (putativement) lire toujours la valeur d' t une seule fois . Cette différence peut briser l'exactitude du code concurrent qui repose sur ces excellentes garanties. Pour plus d'informations à ce sujet, voir Programmation simultanée de
Glenn Slayden
0

Selon la documentation de l'instruction switch, s'il existe un moyen non ambigu de convertir implicitement l'objet en un type intégral, alors il sera autorisé. Je pense que vous vous attendez à un comportement où, pour chaque déclaration de cas, il serait remplacé par if (t == typeof(int)), mais cela ouvrirait toute une boîte de vers lorsque vous surchargeriez cet opérateur. Le comportement changerait lorsque les détails d'implémentation de l'instruction switch changeaient si vous écriviez votre remplacement == de manière incorrecte. En réduisant les comparaisons aux types intégraux et à la chaîne et aux éléments qui peuvent être réduits à des types intégraux (et sont destinés à), ils évitent les problèmes potentiels.

fryguybob
la source
0

a écrit:

"L'instruction switch effectue une branche à temps constant quel que soit le nombre de cas que vous avez."

Puisque le langage autorise l'utilisation du type de chaîne dans une instruction switch, je suppose que le compilateur est incapable de générer du code pour une implémentation de branche à temps constant pour ce type et doit générer un style if-then.

@mweerden - Ah je vois. Merci.

Je n'ai pas beaucoup d'expérience en C # et .NET mais il semble que les concepteurs de langage n'autorisent pas l'accès statique au système de types sauf dans des circonstances restreintes. Le mot-clé typeof renvoie un objet qui n'est donc accessible qu'au moment de l'exécution.

Henk
la source
0

Je pense que Henk l'a cloué avec le truc "pas d'accès statique au système de type"

Une autre option est qu'il n'y a pas d'ordre dans les types où peuvent être les nombres et les chaînes. Ainsi, un commutateur de type ne peut pas construire un arbre de recherche binaire, juste une recherche linéaire.

BCS
la source
0

Je suis d'accord avec ce commentaire selon lequel il est souvent préférable d'utiliser une approche basée sur une table.

En C # 1.0, cela n'était pas possible car il n'avait pas de génériques et de délégués anonymes. Les nouvelles versions de C # ont l'échafaudage pour que cela fonctionne. Avoir une notation pour les littéraux d'objet est également utile.

HS.
la source
0

Je n'ai pratiquement aucune connaissance de C #, mais je soupçonne que l'un ou l'autre des commutateurs a été simplement pris tel qu'il se produit dans d'autres langages sans penser à le rendre plus général ou le développeur a décidé que l'étendre n'en valait pas la peine.

À proprement parler, vous avez tout à fait raison de dire qu'il n'y a aucune raison de lui imposer ces restrictions. On pourrait soupçonner que la raison en est que pour les cas autorisés, l'implémentation est très efficace (comme suggéré par Brian Ensink ( 44921 )), mais je doute que l'implémentation soit très efficace (avec des instructions if) si j'utilise des entiers et des cas aléatoires (par exemple 345, -4574 et 1234203). Et dans tous les cas, quel mal y a-t-il à l'autoriser pour tout (ou du moins plus) et à dire qu'il n'est efficace que pour des cas spécifiques (comme des nombres (presque) consécutifs).

Je peux cependant imaginer que l'on puisse vouloir exclure des types pour des raisons telles que celle donnée par lomaxx ( 44918 ).

Edit: @Henk ( 44970 ): Si les chaînes sont partagées au maximum, les chaînes de contenu égal seront également des pointeurs vers le même emplacement mémoire. Ensuite, si vous pouvez vous assurer que les chaînes utilisées dans les cas sont stockées consécutivement en mémoire, vous pouvez implémenter très efficacement le commutateur (c'est-à-dire avec une exécution dans l'ordre de 2 compares, une addition et deux sauts).

mweerden
la source
0

C # 8 vous permet de résoudre ce problème de manière élégante et compacte en utilisant une expression de commutateur:

public string GetTypeName(object obj)
{
    return obj switch
    {
        int i => "Int32",
        string s => "String",
        { } => "Unknown",
        _ => throw new ArgumentNullException(nameof(obj))
    };
}

En conséquence, vous obtenez:

Console.WriteLine(GetTypeName(obj: 1));           // Int32
Console.WriteLine(GetTypeName(obj: "string"));    // String
Console.WriteLine(GetTypeName(obj: 1.2));         // Unknown
Console.WriteLine(GetTypeName(obj: null));        // System.ArgumentNullException

Vous pouvez en savoir plus sur la nouvelle fonctionnalité ici .

Smolchanovsky
la source