web-dev-qa-db-fra.com

Pourquoi utiliser une approche OO au lieu d'une instruction "switch" géante?

Je travaille dans une boutique .Net, C # et j'ai un collègue qui insiste pour que nous utilisions des instructions Switch géantes dans notre code avec beaucoup de "cas" plutôt que des approches plus orientées objet. Son argument revient constamment sur le fait qu'une instruction Switch se compile en une "table de saut cpu" et est donc l'option la plus rapide (même si dans d'autres choses notre équipe se fait dire que nous ne nous soucions pas de la vitesse).

Honnêtement, je n'ai aucun argument contre cela ... parce que je ne sais pas de quoi il parle.
A-t-il raison?
Est-ce qu'il parle juste de son cul?
J'essaye juste d'apprendre ici.

61
James P. Wright

Il est probablement un vieux hacker C et oui, il parle de son cul. .Net n'est pas C++; le compilateur .Net continue de s'améliorer et la plupart des hacks intelligents sont contre-productifs, sinon aujourd'hui, alors dans la prochaine version .Net. Les petites fonctions sont préférables car .Net JIT-s chaque fonction une fois avant son utilisation. Donc, si certains cas ne sont jamais touchés pendant le cycle de vie d'un programme, aucun coût n'est encouru lors de la compilation de JIT. Quoi qu'il en soit, si la vitesse n'est pas un problème, il ne devrait pas y avoir d'optimisations. Écrivez d'abord pour le programmeur, ensuite pour le compilateur. Votre collègue ne sera pas facilement convaincu, donc je prouverais empiriquement qu'un code mieux organisé est en fait plus rapide. Je choisirais l'un de ses pires exemples, les réécrirais d'une meilleure manière, puis m'assurerais que votre code est plus rapide. Choisissez si vous le souhaitez. Ensuite, exécutez-le quelques millions de fois, profilez-le et montrez-le. Cela devrait bien lui apprendre.

[~ # ~] modifier [~ # ~]

Bill Wagner a écrit:

Point 11: Comprendre l'attrait des petites fonctions (efficace C # Deuxième édition) N'oubliez pas que la traduction de votre code C # en code exécutable par la machine est un processus en deux étapes. Le compilateur C # génère IL qui est livré dans les assemblys. Le compilateur JIT génère du code machine pour chaque méthode (ou groupe de méthodes, lorsque l'inlining est impliqué), selon les besoins. Les petites fonctions permettent au compilateur JIT d'amortir ce coût beaucoup plus facilement. Les petites fonctions sont également plus susceptibles d'être candidates à l'inline. Ce n’est pas seulement la petitesse: un flux de contrôle plus simple est tout aussi important. Moins de branches de contrôle à l'intérieur des fonctions permettent au compilateur JIT d'enregistrer plus facilement les variables. Ce n’est pas seulement une bonne pratique d’écrire du code plus clair; c’est ainsi que vous créez du code plus efficace lors de l’exécution.

EDIT2:

Donc ... apparemment, une instruction switch est plus rapide et meilleure qu'un tas d'instructions if/else, car une comparaison est logarithmique et une autre est linéaire. http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html

Eh bien, mon approche préférée pour remplacer une énorme instruction switch est avec un dictionnaire (ou parfois même un tableau si j'allume des énumérations ou des petits caractères) qui mappe les valeurs aux fonctions qui sont appelées en réponse à celles-ci. Cela oblige à supprimer un grand nombre de spaghettis partagés, mais c'est une bonne chose. Une déclaration de gros commutateur est généralement un cauchemar de maintenance. Donc ... avec les tableaux et les dictionnaires, la recherche prendra un temps constant et il y aura peu de mémoire supplémentaire gaspillée.

Je ne suis toujours pas convaincu que la déclaration de changement soit meilleure.

50
Job

À moins que votre collègue ne puisse apporter la preuve que cette altération apporte un réel avantage mesurable à l'échelle de l'ensemble de l'application, elle est inférieure à votre approche (c'est-à-dire le polymorphisme), qui fournit effectivement un tel avantage : maintenabilité.

La microoptimisation doit uniquement être effectuée, après les goulots d'étranglement sont fixés. L'optimisation prématurée est la racine de tout mal .

La vitesse est quantifiable. Il y a peu d'informations utiles dans "l'approche A est plus rapide que l'approche B". La question est " Combien de temps plus rapide? ".

39
back2dos

Qui se soucie si c'est plus rapide?

À moins que vous n'écriviez un logiciel en temps réel, il est peu probable que la minuscule quantité d'accélération que vous pourriez obtenir en faisant quelque chose d'une manière complètement folle fasse beaucoup de différence pour votre client. Je ne voudrais même pas me battre contre celui-ci sur le front de la vitesse, ce type ne va clairement pas écouter d'argument sur le sujet.

La maintenabilité, cependant, est le but du jeu, et une déclaration de commutateur géant n'est même pas légèrement maintenable, comment expliquez-vous les différents chemins à travers le code à un nouveau gars? La documentation devra être aussi longue que le code lui-même!

De plus, vous avez alors l'incapacité totale de tester efficacement les unités (trop de chemins possibles, sans parler du manque probable d'interfaces, etc.), ce qui rend votre code encore moins maintenable.

[Du côté intéressé: le JITter fonctionne mieux sur des méthodes plus petites, donc les instructions de commutation géantes (et leurs méthodes intrinsèquement grandes) nuiront à votre vitesse dans les grands assemblages, IIRC.]

27
Ed James

Éloignez-vous de l'instruction switch ...

Ce type de déclaration switch doit être évité comme un fléau car il viole le Open Closed Principle . Cela oblige l'équipe à apporter des modifications au code existant lorsque de nouvelles fonctionnalités doivent être ajoutées, par opposition à l'ajout d'un nouveau code.

14
Dakotah North

Je n'achète pas l'argument de la performance; c'est une question de maintenabilité du code.

MAIS: parfois, une instruction switch géante est plus facile à maintenir (moins de code) qu'un tas de petites classes remplaçant les fonctions virtuelles d'une classe de base abstraite. Par exemple, si vous implémentez un émulateur de CPU, vous n'implémentez pas la fonctionnalité de chaque instruction dans une classe distincte - vous la placez simplement dans un swtich géant sur l'opcode, appelant éventuellement des fonctions d'assistance pour des instructions plus complexes.

Règle générale: si le changement est en quelque sorte effectué sur le TYPE, vous devriez probablement utiliser l'héritage et les fonctions virtuelles. Si le changement est effectué sur une VALEUR d'un type fixe (par exemple, l'opcode d'instruction, comme ci-dessus), il est OK de le laisser tel quel.

9
zvrba

J'ai survécu au cauchemar connu sous le nom de machine à états finis massive manipulée par des déclarations massives d'interrupteurs. Pire encore, dans mon cas, le FSM s'étendait sur trois DLL C++ et il était assez clair que le code avait été écrit par quelqu'un versé en C.

Les mesures dont vous devez vous soucier sont:

  • Rapidité de faire un changement
  • Rapidité de détection du problème lorsqu'il survient

J'ai été chargé d'ajouter une nouvelle fonctionnalité à cet ensemble de DLL et j'ai pu convaincre la direction qu'il me faudrait autant de temps pour réécrire les 3 DLL en une seule correctement orientée objet DLL car ce serait pour moi de faire un patch de singe et de truquer la solution dans ce qui était déjà là. La réécriture a été un énorme succès, car elle a non seulement pris en charge la nouvelle fonctionnalité mais a été beaucoup plus facile à étendre. prenez une semaine pour vous assurer que vous n'avez rien cassé finirait par prendre quelques heures.

Alors qu'en est-il des délais d'exécution? Il n'y a eu aucune augmentation ou diminution de la vitesse. Pour être juste, nos performances ont été limitées par les pilotes du système, donc si la solution orientée objet était en fait plus lente, nous ne le saurions pas.

Quel est le problème avec les instructions switch massives pour une langue OO?

  • Le flux de contrôle du programme est retiré de l'objet auquel il appartient et placé à l'extérieur de l'objet
  • De nombreux points de contrôle externe se traduisent par de nombreux endroits que vous devez examiner
  • On ne sait pas où l'état est stocké, en particulier si le commutateur est à l'intérieur d'une boucle
  • La comparaison la plus rapide n'est pas du tout une comparaison (vous pouvez éviter d'avoir besoin de nombreuses comparaisons avec une bonne conception orientée objet)
  • Il est plus efficace d'itérer dans vos objets et d'appeler toujours la même méthode sur tous les objets que de changer votre code en fonction du type d'objet ou de l'énumération qui code le type.
8
Berin Loritsch

Vous ne pouvez pas me convaincre que:

void action1()
{}

void action2()
{}

void action3()
{}

void action4()
{}

void doAction(int action)
{
    switch(action)
    {
        case 1: action1();break;
        case 2: action2();break;
        case 3: action3();break;
        case 4: action4();break;
    }
}

Est nettement plus rapide que:

struct IAction
{
    virtual ~IAction() {}
    virtual void action() = 0;
}

struct Action1: public IAction
{
    virtual void action()    { }
}

struct Action2: public IAction
{
    virtual void action()    { }
}

struct Action3: public IAction
{
    virtual void action()    { }
}

struct Action4: public IAction
{
    virtual void action()    { }
}

void doAction(IAction& actionObject)
{
    actionObject.action();
}

De plus, la version OO est juste plus maintenable.

5
Martin York

Il a raison de dire que le code machine résultant probablement sera plus efficace. L'essentiel du compilateur transforme une instruction switch en un ensemble de tests et de branches, qui seront relativement peu d'instructions. Il y a de fortes chances que le code résultant d'approches plus abstraites nécessite plus d'instructions.

CEPENDANT : Il est presque certain que votre application particulière n'a pas besoin de se soucier de ce type de micro-optimisation, sinon vous ne l'utiliseriez pas. net en premier lieu. Pour tout ce qui ne correspond pas à des applications embarquées très limitées ou à un travail intensif en CPU, vous devez toujours laisser le compilateur gérer l'optimisation. Concentrez-vous sur l'écriture de code propre et maintenable. Cela a presque toujours une valeur bien supérieure à quelques dixièmes de nano-seconde de temps d'exécution.

4
Luke Graham

L'une des principales raisons d'utiliser des classes au lieu des instructions switch est que les instructions switch ont tendance à conduire à un énorme fichier qui a beaucoup de logique. C'est à la fois un cauchemar de maintenance et un problème avec la gestion des sources car vous devez extraire et éditer ce fichier énorme au lieu de fichiers de classe plus petits différents

3
Homde

une instruction switch dans OOP code est une forte indication des classes manquantes

essayez dans les deux sens et exécutez quelques tests de vitesse simples; les chances sont que la différence n'est pas significative. S'ils sont et que le code est critique dans le temps alors conservez l'instruction switch

3
Steven A. Lowe

Normalement, je déteste le mot "optimisation prématurée", mais cela pue. Il convient de noter que Knuth a utilisé cette célèbre citation dans le contexte de l'utilisation des instructions goto afin d'accélérer le code dans les zones critique. C'est la clé: critique chemins.

Il suggérait d'utiliser goto pour accélérer le code mais mettait en garde contre les programmeurs qui voudraient faire ce genre de choses en se basant sur des intuitions et des superstitions pour du code qui n'est même pas critique.

Favoriser autant que possible les instructions switchniformément dans une base de code (que toute charge lourde soit ou non gérée) est l'exemple classique de ce que Knuth appelle les "penny -wise et pound- programmeur stupide qui passe toute la journée à lutter pour maintenir son code "optimisé" qui s'est transformé en un cauchemar de débogage à la suite d'essayer d'économiser des sous sur des livres. Un tel code est rarement maintenable et encore moins efficace en premier lieu.

A-t-il raison?

Il a raison du point de vue de l'efficacité très basique. Aucun compilateur à ma connaissance ne peut optimiser un code polymorphe impliquant des objets et une répartition dynamique mieux qu'une instruction switch. Vous ne vous retrouverez jamais avec une LUT ou une table de saut vers du code en ligne à partir de code polymorphe, car un tel code a tendance à servir de barrière d'optimisation pour le compilateur (il ne saura quelle fonction appeler jusqu'à l'heure à laquelle la répartition dynamique se produit).

Il est plus utile de ne pas penser à ce coût en termes de tables de sauts mais plus en termes de barrière d'optimisation. Pour le polymorphisme, l'appel de Base.method() ne permet pas au compilateur de savoir quelle fonction finira par être appelée si method est virtuelle, non scellée et peut être remplacée. Puisqu'il ne sait pas à l'avance quelle fonction va être appelée, il ne peut pas optimiser l'appel de fonction et utiliser plus d'informations pour prendre des décisions d'optimisation, car il ne sait pas vraiment quelle fonction va être appelée à l'heure de compilation du code.

Les optimiseurs sont à leur meilleur lorsqu'ils peuvent scruter un appel de fonction et effectuer des optimisations qui aplatissent complètement l'appelant et l'appelé, ou au moins optimisent l'appelant pour travailler plus efficacement avec l'appelé. Ils ne peuvent pas faire cela s'ils ne savent pas quelle fonction va être appelée à l'avance.

Est-ce qu'il parle juste de son cul?

L'utilisation de ce coût, qui équivaut souvent à des sous, pour justifier de le transformer en une norme de codage appliquée uniformément est généralement très stupide, en particulier pour les endroits qui ont un besoin d'extensibilité. C'est la principale chose à laquelle vous devez faire attention avec les véritables optimiseurs prématurés: ils veulent transformer les problèmes de performances mineurs en normes de codage appliquées uniformément dans une base de code sans aucun souci de maintenabilité.

Je prends une petite offense à la citation de "vieux pirate C" utilisée dans la réponse acceptée, puisque je suis de ceux-là. Tous ceux qui codent depuis des décennies à partir d'un matériel très limité ne sont pas tous devenus un optimiseur prématuré. Pourtant, j'ai rencontré et travaillé avec eux aussi. Mais ces types ne mesurent jamais des choses comme les erreurs de prédiction de branche ou les ratés de cache, ils pensent qu'ils savent mieux et fondent leurs notions d'inefficacité dans une base de code de production complexe basée sur des superstitions qui ne sont pas vraies aujourd'hui et parfois ne l'ont jamais été. Les personnes qui ont véritablement travaillé dans des domaines critiques pour les performances comprennent souvent qu'une optimisation efficace est une priorisation efficace, et essayer de généraliser une norme de codage dégradant la maintenabilité pour économiser des sous est une priorisation très inefficace.

Les sous sont importants lorsque vous avez une fonction bon marché qui ne fait pas tant de travail qui est appelée un milliard de fois dans une boucle très serrée et critique en termes de performances. Dans ce cas, nous finissons par économiser 10 millions de dollars. Il ne vaut pas la peine de raser quelques centimes lorsque vous avez une fonction appelée deux fois pour laquelle le corps coûte à lui seul des milliers de dollars. Il n'est pas sage de passer votre temps à marchander des sous lors de l'achat d'une voiture. Il vaut la peine de marchander des sous si vous achetez un million de canettes de soda auprès d'un fabricant. La clé d'une optimisation efficace est de comprendre ces coûts dans leur contexte. Quelqu'un qui essaie d'économiser des sous sur chaque achat et suggère que tout le monde essaie de marchander des sous, peu importe ce qu'ils achètent, n'est pas un optimiseur qualifié.

3
user204677

Il semble que votre collègue soit très préoccupé par les performances. Il se peut que dans certains cas, une grande structure boîtier/commutateur fonctionne plus rapidement, mais j'espère que vous feriez une expérience en effectuant des tests de synchronisation sur la version OO et la version commutateur/boîtier. I Je suppose que la version OO a moins de code et est plus facile à suivre, à comprendre et à maintenir. Je plaiderais pour la version OO en premier (comme la maintenance/la lisibilité devraient être initialement plus important), et ne considérer la version commutateur/boîtier que si la version OO présente de graves problèmes de performances et il peut être démontré qu'un commutateur/boîtier apportera une amélioration significative.

Un avantage de maintenabilité du polymorphisme que personne n'a mentionné est que vous serez en mesure de structurer votre code beaucoup plus bien en utilisant l'héritage si vous basculez toujours sur la même liste de cas, mais parfois plusieurs cas sont traités de la même manière et parfois ils ne sont pas

Par exemple. si vous basculez entre Dog, Cat et Elephant, et parfois Dog et Cat ont le même cas, vous pouvez les faire les deux héritent d'une classe abstraite DomesticAnimal et placent ces fonctions dans la classe abstraite.

De plus, j'ai été surpris que plusieurs personnes utilisent un analyseur comme exemple où vous n'utiliseriez pas le polymorphisme. Pour un analyseur en forme d'arbre, c'est certainement la mauvaise approche, mais si vous avez quelque chose comme Assembly, où chaque ligne est quelque peu indépendante, et commencez par un opcode qui indique comment le reste de la ligne doit être interprété, j'utiliserais totalement le polymorphisme et une usine. Chaque classe peut implémenter des fonctions comme ExtractConstants ou ExtractSymbols. J'ai utilisé cette approche pour un interprète BASIC jouet.

2
jwg

"Il faut oublier les petites efficacités, disons environ 97% du temps: l'optimisation prématurée est la racine de tout mal"

Donald Knuth

0
thorsten müller

Même si ce n'était pas mauvais pour la maintenabilité, je ne pense pas que ce sera meilleur pour les performances. Un appel de fonction virtuelle est simplement une indirection supplémentaire (la même chose que dans le meilleur des cas pour une instruction switch) donc même en C++ les performances doivent être à peu près égales. En C #, où tous les appels de fonction sont virtuels, l'instruction switch devrait être pire, car vous avez la même surcharge d'appel de fonction virtuelle dans les deux versions.

0
Dirk Holsopple

Il ne parle pas forcément de son cul. Au moins en C et C++ switch les instructions peuvent être optimisées pour sauter des tables alors que je n'ai jamais vu cela se produire avec une répartition dynamique dans une fonction qui n'a accès qu'à un pointeur de base. À tout le moins, ce dernier nécessite un optimiseur beaucoup plus intelligent qui examine un code beaucoup plus environnant pour déterminer exactement quel sous-type est utilisé à partir d'un appel de fonction virtuelle via un pointeur/référence de base.

En plus de cela, la répartition dynamique sert souvent de "barrière d'optimisation", ce qui signifie que le compilateur ne sera souvent pas en mesure de coder en ligne et d'allouer de manière optimale les registres pour minimiser les déversements de pile et toutes ces choses fantaisistes, car il ne peut pas comprendre ce que La fonction virtuelle va être appelée via le pointeur de base pour l'intégrer et faire toute sa magie d'optimisation. Je ne suis pas sûr que vous souhaitiez même que l'optimiseur soit si intelligent et essaye d'optimiser les appels de fonction indirects, car cela pourrait potentiellement conduire à générer de nombreuses branches de code séparément dans une pile d'appels donnée (une fonction qui appelle foo->f() devrait générer un code machine totalement différent de celui qui appelle bar->f() via un pointeur de base, et la fonction qui appelle cette fonction devrait alors générer deux ou plusieurs versions de code, et ainsi - la quantité de code machine générée serait explosive - peut-être pas si mal avec un JIT de trace qui génère le code à la volée car il trace à travers des chemins d'exécution à chaud).

Cependant, comme de nombreuses réponses ont fait écho, c'est une mauvaise raison de privilégier une cargaison d'instructions switch même si elle est de loin la plus rapide d'une certaine quantité marginale. En outre, en ce qui concerne les micro-efficacités, des choses comme la ramification et l'incrustation ont généralement une priorité assez faible par rapport à des choses comme les modèles d'accès à la mémoire.

Cela dit, j'ai sauté ici avec une réponse inhabituelle. Je veux plaider en faveur de la maintenabilité des instructions switch sur une solution polymorphe quand, et seulement quand, vous savez avec certitude qu'il n'y aura qu'un seul endroit qui devra effectuer les switch.

Un excellent exemple est un gestionnaire d'événements central. Dans ce cas, vous n'avez généralement pas beaucoup d'endroits pour gérer les événements, un seul (pourquoi c'est "central"). Dans ces cas, vous ne bénéficiez pas de l'extensibilité fournie par une solution polymorphe. Une solution polymorphe est avantageuse lorsqu'il existe de nombreux endroits qui feraient l'instruction analogique switch. Si vous savez avec certitude qu'il n'y en aura qu'un, une instruction switch avec 15 cas peut être beaucoup plus simple que de concevoir une classe de base héritée de 15 sous-types avec fonctions remplacées et une fabrique pour les instancier, uniquement pour puis être utilisé dans une fonction dans l'ensemble du système. Dans ces cas, l'ajout d'un nouveau sous-type est beaucoup plus fastidieux que l'ajout d'une instruction case à une fonction. Si quoi que ce soit, je plaiderais pour la maintenabilité, et non pour les performances, des instructions switch dans ce cas particulier où vous ne bénéficiez d'aucune extensibilité.

0
user204677

Votre collègue ne parle pas de son derrière, en ce qui concerne le commentaire concernant les tables de saut. Cependant, utiliser cela pour justifier l'écriture d'un mauvais code est un problème.

Le compilateur C # convertit les instructions switch avec seulement quelques cas en une série de if/else, donc n'est pas plus rapide que d'utiliser if/else. Le compilateur convertit les plus grandes instructions de commutateur en un dictionnaire (la table de saut à laquelle votre collègue fait référence). Veuillez voir cette réponse à une question de débordement de pile sur le sujet pour plus de détails .

Une grande instruction switch est difficile à lire et à maintenir. Un dictionnaire de "cas" et de fonctions est beaucoup plus facile à lire. Comme c'est ce qui devient le commutateur, vous et votre collègue seriez bien avisé d'utiliser directement les dictionnaires.

0
David Arno