Ich arbeite in einem .Net-, C # -Shop und habe einen Kollegen, der immer wieder darauf besteht, dass wir in unserem Code Riesen-Switch-Anweisungen mit vielen "Fällen" anstelle von objektorientierteren Ansätzen verwenden sollten. Sein Argument geht konsequent auf die Tatsache zurück, dass eine Switch-Anweisung zu einer "CPU-Sprungtabelle" kompiliert wird und daher die schnellste Option ist (auch wenn unserem Team in anderen Dingen gesagt wird, dass uns die Geschwindigkeit egal ist).
Ich habe ehrlich gesagt kein Argument dagegen ... weil ich nicht weiß, wovon zum Teufel er spricht.
Hat er recht
Spricht er nur seinen Arsch aus?
Ich versuche nur hier zu lernen.
c#
.net
switch-statement
James P. Wright
quelle
quelle
Antworten:
Er ist wahrscheinlich ein alter C-Hacker und er redet aus seinem Arsch. .Net ist nicht C ++; Der .Net-Compiler wird immer besser und die meisten cleveren Hacks sind kontraproduktiv, wenn nicht heute, dann in der nächsten .Net-Version. Kleine Funktionen sind vorzuziehen, da .Net JIT-s jede Funktion einmal vor ihrer Verwendung ausführt. Wenn also einige Fälle während eines Lebenszyklus eines Programms nie betroffen sind, entstehen bei der JIT-Kompilierung keine Kosten. Auf jeden Fall sollten keine Optimierungen vorgenommen werden, wenn die Geschwindigkeit kein Problem darstellt. Schreiben Sie zuerst für den Programmierer, dann für den Compiler. Ihr Kollege lässt sich nicht leicht überzeugen, daher würde ich empirisch nachweisen, dass besser organisierter Code tatsächlich schneller ist. Ich würde eines seiner schlechtesten Beispiele auswählen, sie besser umschreiben und dann sicherstellen, dass Ihr Code schneller ist. Cherry-Pick, wenn Sie müssen. Dann starte es ein paar Millionen mal, profiliere und zeige es ihm.
BEARBEITEN
Bill Wagner schrieb:
Punkt 11: Die Anziehungskraft kleiner Funktionen verstehen (Effektive C # -Zweitausgabe) Beachten Sie, dass das Übersetzen Ihres C # -Codes in maschinenausführbaren Code ein zweistufiger Prozess ist. Der C # -Compiler generiert eine AWL, die in Assemblys bereitgestellt wird. Der JIT-Compiler generiert nach Bedarf Maschinencode für jede Methode (oder Gruppe von Methoden, wenn Inlining beteiligt ist). Kleine Funktionen erleichtern dem JIT-Compiler die Amortisation dieser Kosten. Kleine Funktionen sind auch eher Kandidaten für Inlining. Es ist nicht nur Kleinheit: Einfacherer Kontrollfluss ist genauso wichtig. Weniger Kontrollzweige in Funktionen erleichtern es dem JIT-Compiler, Variablen zu registrieren. Es ist nicht nur eine gute Übung, klareren Code zu schreiben. So erstellen Sie zur Laufzeit effizienteren Code.
EDIT2:
Also ... anscheinend ist eine switch-Anweisung schneller und besser als eine Reihe von if / else-Anweisungen, da ein Vergleich logarithmisch und ein anderer linear ist. http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html
Mein bevorzugter Ansatz, um eine große switch-Anweisung zu ersetzen, ist ein Dictionary (oder manchmal sogar ein Array, wenn ich Enums oder kleine Ints einschalte), das Werte auf Funktionen abbildet, die als Antwort darauf aufgerufen werden. Dies zwingt einen dazu, eine Menge gemeiner Spaghetti zu entfernen, aber das ist eine gute Sache. Eine große switch-Anweisung ist normalerweise ein Wartungsalptraum. Also ... bei Arrays und Wörterbüchern wird die Suche eine konstante Zeit in Anspruch nehmen und es wird wenig zusätzlicher Speicher verschwendet.
Ich bin immer noch nicht davon überzeugt, dass die switch-Anweisung besser ist.
quelle
Sofern Ihr Kollege nicht nachweisen kann, dass diese Änderung im Maßstab der gesamten Anwendung einen messbaren Nutzen bringt, steht sie Ihrem Ansatz (dh dem Polymorphismus), der tatsächlich einen solchen Nutzen bietet, unterlegen: die Wartbarkeit.
Die Mikrooptimierung sollte nur durchgeführt werden, nachdem Engpässe behoben wurden. Vorzeitige Optimierung ist die Wurzel allen Übels .
Geschwindigkeit ist quantifizierbar. Es gibt wenig nützliche Informationen in "Ansatz A ist schneller als Ansatz B". Die Frage ist " Wie viel schneller? ".
quelle
Wen kümmert es, wenn es schneller ist?
Wenn Sie keine Echtzeit-Software schreiben, ist es unwahrscheinlich, dass die winzige Geschwindigkeit, die Sie möglicherweise durch völlig verrückte Aktionen erzielen, einen großen Unterschied für Ihren Kunden bedeutet. Ich würde mich nicht einmal an der Geschwindigkeitsfront mit dieser messen, dieser Typ wird sich offensichtlich keine Argumente zu diesem Thema anhören.
Wartbarkeit ist jedoch das Ziel des Spiels, und eine riesige switch-Anweisung ist nicht einmal geringfügig wartbar. Wie erklären Sie die verschiedenen Pfade durch den Code einem neuen Spieler? Die Dokumentation muss so lang sein wie der Code selbst!
Außerdem haben Sie dann die völlige Unfähigkeit, Unit-Tests effektiv durchzuführen (zu viele mögliche Pfade, ganz zu schweigen vom wahrscheinlichen Mangel an Schnittstellen usw.), was Ihren Code noch weniger wartbar macht.
[Interessanterweise bietet der JITter eine bessere Leistung bei kleineren Methoden, sodass riesige switch-Anweisungen (und deren inhärent große Methoden) Ihrer Geschwindigkeit in großen Assemblys schaden (IIRC).]
quelle
Schritt weg von der switch-Anweisung ...
Diese Art von switch-Anweisung sollte wie eine Pest gemieden werden, da sie das Open Closed-Prinzip verletzt . Es zwingt das Team, Änderungen am vorhandenen Code vorzunehmen, wenn neue Funktionen hinzugefügt werden müssen, anstatt nur neuen Code hinzuzufügen.
quelle
Ich habe den Albtraum überlebt, der als massive endliche Zustandsmaschine bekannt ist, die durch massive Schaltanweisungen manipuliert wird. Schlimmer noch, in meinem Fall umfasste der FSM drei C ++ - DLLs und es war ganz klar, dass der Code von jemandem geschrieben wurde, der sich mit C auskennt.
Die Metriken, um die Sie sich kümmern müssen, sind:
Ich erhielt die Aufgabe, eine neue Funktion zu diesem Satz von DLLs hinzuzufügen, und konnte das Management davon überzeugen, dass es genauso lange dauern würde, die 3 DLLs als eine ordnungsgemäß objektorientierte DLL umzuschreiben, wie es für mich wäre, Affen-Patches zu erstellen und die Jury rüstet die Lösung für das, was bereits da war. Das Umschreiben war ein großer Erfolg, da es nicht nur die neue Funktionalität unterstützte, sondern auch einfacher zu erweitern war. Tatsächlich würde eine Aufgabe, die normalerweise eine Woche in Anspruch nimmt, um sicherzustellen, dass Sie nichts kaputtmachen, einige Stunden in Anspruch nehmen.
Wie wäre es mit Ausführungszeiten? Es gab keine Geschwindigkeitserhöhung oder -verringerung. Um fair zu sein, wurde unsere Leistung von den Systemtreibern gedrosselt. Wenn die objektorientierte Lösung also langsamer wäre, würden wir es nicht wissen.
Was ist los mit massiven switch-Anweisungen für eine OO-Sprache?
quelle
Ich kaufe das Leistungsargument nicht; Alles dreht sich um die Pflege von Code.
ABER: Manchmal ist eine riesige switch-Anweisung einfacher zu verwalten (weniger Code) als eine Reihe kleiner Klassen, die virtuelle Funktionen einer abstrakten Basisklasse überschreiben. Wenn Sie beispielsweise einen CPU-Emulator implementieren würden, würden Sie die Funktionalität jeder Anweisung nicht in einer separaten Klasse implementieren. Sie würden sie lediglich in einen riesigen Schalter auf dem Opcode einfügen und möglicherweise Hilfsfunktionen für komplexere Anweisungen aufrufen.
Faustregel: Wenn der Wechsel auf dem TYPE ausgeführt wird, sollten Sie wahrscheinlich Vererbungs- und virtuelle Funktionen verwenden. Wenn der Wechsel für einen WERT eines festen Typs durchgeführt wird (z. B. den Anweisungs-Opcode wie oben), ist es in Ordnung, ihn so zu belassen, wie er ist.
quelle
Das kannst du mich nicht überzeugen:
Ist deutlich schneller als:
Darüber hinaus ist die OO-Version einfacher zu warten.
quelle
Er hat Recht, dass der resultierende Maschinencode wahrscheinlich effizienter sein wird. Der Compiler Essential wandelt eine switch-Anweisung in eine Reihe von Tests und Verzweigungen um, bei denen es sich um relativ wenige Anweisungen handelt. Es besteht eine hohe Wahrscheinlichkeit, dass der Code, der sich aus abstrakteren Ansätzen ergibt, mehr Anweisungen erfordert.
JEDOCH : Mit ziemlicher Sicherheit muss sich Ihre Anwendung nicht um diese Art der Mikrooptimierung kümmern, oder Sie würden .net gar nicht erst verwenden. Wenn es sich nicht um sehr eingeschränkte Embedded-Anwendungen oder um CPU-intensive Arbeit handelt, sollten Sie den Compiler immer mit der Optimierung beauftragen. Konzentrieren Sie sich darauf, sauberen, wartbaren Code zu schreiben. Dies ist fast immer von weitaus größerem Wert als ein paar Zehntelsekunden Ausführungszeit.
quelle
Ein Hauptgrund für die Verwendung von Klassen anstelle von switch-Anweisungen besteht darin, dass switch-Anweisungen dazu neigen, zu einer großen Datei mit viel Logik zu führen. Dies ist sowohl ein Alptraum bei der Wartung als auch ein Problem bei der Quellcodeverwaltung, da Sie diese große Datei auschecken und bearbeiten müssen, anstatt andere kleinere Klassendateien zu verwenden
quelle
Eine switch-Anweisung im OOP-Code ist ein starker Hinweis auf fehlende Klassen
Probieren Sie es in beide Richtungen und führen Sie einige einfache Geschwindigkeitstests durch. Die Chancen stehen gut, dass der Unterschied nicht signifikant ist. Wenn dies der Fall ist und der Code zeitkritisch ist, behalten Sie die switch-Anweisung bei
quelle
Normalerweise hasse ich das Wort "vorzeitige Optimierung", aber das stinkt danach. Es ist erwähnenswert, dass Knuth dieses berühmte Zitat im Zusammenhang mit der Verwendung von
goto
Anweisungen verwendet hat, um den Code in kritischen Bereichen zu beschleunigen . Das ist der Schlüssel: kritische Pfade.Er
goto
schlug vor, den Code zu beschleunigen, warnte aber vor den Programmierern, die diese Art von Dingen auf der Grundlage von Aberglauben und Aberglauben für Code ausführen möchten, der nicht einmal kritisch ist.Zu bevorzugen
switch
Aussagen so weit wie möglich gleichmäßig über einen Code - Basis (ob eine schwere Last gehandhabt wird) ist das klassische Beispiel dafür , was Knuth nennt die „Penny-weise und Pfund-dumm“ Programmierer, der den ganzen Tag kämpfen verbringt behalten ihre „optimiert "Code, der sich als Ergebnis des Versuchs, Pennies über Pfund zu sparen, in einen Debug - Albtraum verwandelte. Ein solcher Code ist selten zu warten, geschweige denn überhaupt effizient.Er hat von der Grundperspektive der Effizienz her Recht. Meines Wissens kann kein Compiler polymorphen Code mit Objekten und dynamischem Versand besser optimieren als eine switch-Anweisung. Sie werden nie mit einer LUT oder einer Sprungtabelle zu Inline-Code aus polymorphem Code enden, da dieser Code in der Regel als Optimierungsbarriere für den Compiler dient (er weiß erst zum Zeitpunkt des dynamischen Versands, welche Funktion aufzurufen ist) tritt ein).
Es ist sinnvoller, nicht an diese Kosten in Form von Sprungtabellen zu denken, sondern eher in Form der Optimierungsbarriere. Aus Gründen des Polymorphismus kann
Base.method()
der Compiler beim Aufrufen nicht erkennen, welche Funktion tatsächlich aufgerufen wird, wenn siemethod
virtuell ist, nicht versiegelt und überschrieben werden kann. Da es nicht weiß, welche Funktion tatsächlich im Voraus aufgerufen wird, kann es den Funktionsaufruf nicht optimieren und mehr Informationen für Optimierungsentscheidungen verwenden, da es nicht wirklich weiß, auf welche Funktion zugegriffen wird die Zeit, zu der der Code kompiliert wird.Optimierer sind am besten geeignet, wenn sie einen Blick auf einen Funktionsaufruf werfen und Optimierungen vornehmen können, die entweder den Anrufer und den Angerufenen vollständig reduzieren oder zumindest den Anrufer optimieren, um mit dem Angerufenen am effizientesten zu arbeiten. Sie können das nicht tun, wenn sie nicht wissen, welche Funktion tatsächlich im Voraus aufgerufen wird.
Die Verwendung dieser Kosten, die sich oft auf ein paar Cent belaufen, um die Umwandlung in einen einheitlich angewendeten Kodierungsstandard zu rechtfertigen, ist im Allgemeinen sehr töricht, insbesondere für Orte, die Erweiterungsbedarf haben. Das ist die Hauptsache, auf die Sie bei echten vorzeitigen Optimierern achten sollten: Sie möchten geringfügige Leistungsprobleme in Codierungsstandards umwandeln, die auf einer Codebasis einheitlich angewendet werden, ohne auf die Wartbarkeit zu achten.
Ich ärgere mich ein wenig über das Zitat "alter C-Hacker", das in der akzeptierten Antwort verwendet wird, da ich einer von denen bin. Nicht jeder, der seit Jahrzehnten mit sehr begrenzter Hardware programmiert, hat sich in einen vorzeitigen Optimierer verwandelt. Dennoch habe ich auch mit denen zusammengearbeitet. Aber diese Typen messen niemals Dinge wie Verzweigungsfehlvorhersagen oder Cache-Fehler, sie denken, sie wissen es besser und stützen ihre Vorstellung von Ineffizienz auf eine komplexe Produktionscodebasis, die auf Aberglauben basiert, die heute nicht zutreffen und manchmal nie zutreffen. Menschen, die wirklich in leistungskritischen Bereichen gearbeitet haben, verstehen oft, dass eine effektive Optimierung eine effektive Priorisierung ist, und der Versuch, einen die Wartbarkeit verschlechternden Kodierungsstandard zu verallgemeinern, um ein paar Cent zu sparen, ist eine sehr ineffektive Priorisierung.
Pennies sind wichtig, wenn Sie eine billige Funktion haben, die nicht so viel Arbeit leistet, was in einer sehr engen, leistungskritischen Schleife milliardenfach genannt wird. In diesem Fall sparen wir am Ende 10 Millionen Dollar. Es lohnt sich nicht, ein paar Cent zu rasieren, wenn Sie eine Funktion haben, die zweimal aufgerufen wird und für die allein der Körper Tausende von Dollar kostet. Es ist nicht ratsam, beim Autokauf um ein paar Cent zu feilschen. Es lohnt sich, um ein paar Cent zu feilschen, wenn Sie eine Million Dosen Soda von einem Hersteller kaufen. Der Schlüssel zu einer effektiven Optimierung liegt darin, diese Kosten im richtigen Kontext zu verstehen. Jemand, der versucht, bei jedem Einkauf ein paar Cent zu sparen und den anderen vorschlägt, um ein paar Cent zu feilschen, egal was er kauft, ist kein erfahrener Optimierer.
quelle
Es hört sich so an, als wäre Ihr Mitarbeiter sehr um die Leistung besorgt. Es kann sein, dass in einigen Fällen eine große Gehäuse- / Switch-Struktur eine schnellere Leistung erbringt, aber hoffentlich würden Sie ein Experiment durchführen, indem Sie Timing-Tests für die OO-Version und die Switch- / Case-Version durchführen. Ich vermute, dass die OO-Version weniger Code enthält und einfacher zu folgen, zu verstehen und zu warten ist. Ich würde zunächst für die OO-Version argumentieren (da Wartung / Lesbarkeit anfangs wichtiger sein sollte) und die Switch / Case-Version nur dann berücksichtigen, wenn die OO-Version schwerwiegende Leistungsprobleme aufweist und gezeigt werden kann, dass ein Switch / Case einen Fehler verursacht deutliche Verbesserung.
quelle
Ein Vorteil der Wartbarkeit von Polymorphismus, den niemand erwähnt hat, ist, dass Sie Ihren Code mithilfe der Vererbung viel besser strukturieren können, wenn Sie immer die gleiche Liste von Fällen aktivieren, aber manchmal mehrere Fälle auf die gleiche Art und Weise und zu einem bestimmten Zeitpunkt behandelt werden nicht
Z.B. wenn Sie zwischen wechseln
Dog
,Cat
undElephant
, und manchmalDog
undCat
haben den gleichen Fall, können Sie sie beide von einer abstrakten Klasse erben machenDomesticAnimal
und jene Funktion in der abstrakten Klasse setzen.Außerdem war ich überrascht, dass einige Leute einen Parser als Beispiel dafür verwendeten, wo Sie keinen Polymorphismus verwenden würden. Für einen baumartigen Parser ist dies definitiv der falsche Ansatz. Wenn Sie jedoch eine Art Assembly haben, bei der jede Zeile etwas unabhängig ist, und mit einem Opcode beginnen, der angibt, wie der Rest der Zeile interpretiert werden soll, würde ich Polymorphismus verwenden und eine Fabrik. Jede Klasse kann Funktionen wie
ExtractConstants
oder implementierenExtractSymbols
. Ich habe diesen Ansatz für einen Spielzeug-BASIC-Interpreter verwendet.quelle
"Wir sollten kleine Wirkungsgrade vergessen, sagen wir in 97% der Fälle: Vorzeitige Optimierung ist die Wurzel allen Übels."
Donald Knuth
quelle
Auch wenn dies nicht schlecht für die Wartbarkeit war, glaube ich nicht, dass es für die Leistung besser sein wird. Ein virtueller Funktionsaufruf ist einfach eine zusätzliche Indirektion (wie der beste Fall für eine switch-Anweisung), sodass selbst in C ++ die Leistung ungefähr gleich sein sollte. In C #, in dem alle Funktionsaufrufe virtuell sind, sollte die switch-Anweisung schlechter sein, da Sie in beiden Versionen den gleichen Overhead für virtuelle Funktionsaufrufe haben.
quelle
Ihr Kollege spricht nicht von seinem Hintern, so weit der Kommentar zu Sprungtischen geht. Indem er dies jedoch verwendet, um das Schreiben von schlechtem Code zu rechtfertigen, geht er schief.
Der C # -Compiler konvertiert switch-Anweisungen mit nur wenigen Fällen in eine Reihe von if / else-Anweisungen. Dies ist also nicht schneller als die Verwendung von if / else. Der Compiler konvertiert größere switch-Anweisungen in ein Dictionary (die Sprungtabelle, auf die sich Ihr Kollege bezieht). Weitere Informationen finden Sie in dieser Antwort auf eine Stapelüberlauf-Frage zum Thema .
Eine große switch-Anweisung ist schwer zu lesen und zu pflegen. Ein Wörterbuch mit "Fällen" und Funktionen ist viel einfacher zu lesen. Da dies der Fall ist, sind Sie und Ihr Kollege gut beraten, Wörterbücher direkt zu verwenden.
quelle
Er redet nicht unbedingt aus seinem Arsch. Zumindest in C und C ++ können
switch
Anweisungen optimiert werden, um Tabellen zu überspringen, während ich es noch nie mit einem dynamischen Versand in einer Funktion gesehen habe, die nur Zugriff auf einen Basiszeiger hat. Zumindest erfordert letzteres einen viel intelligenteren Optimierer, der viel mehr umgebenden Code betrachtet, um genau herauszufinden, welcher Subtyp von einem virtuellen Funktionsaufruf über einen Basiszeiger / eine Basisreferenz verwendet wird.Darüber hinaus dient der dynamische Versand häufig als "Optimierungsbarriere", was bedeutet, dass der Compiler häufig nicht in der Lage ist, Code inline zu schreiben und Register optimal zuzuweisen, um Stapelverluste und all diese ausgefallenen Dinge zu minimieren, da er nicht herausfinden kann, was Die virtuelle Funktion wird über den Basiszeiger aufgerufen, um sie einzubinden und ihre gesamte Optimierungsmagie auszuführen. Ich bin mir nicht sicher, ob Sie wollen, dass der Optimierer so intelligent ist und versucht, indirekte Funktionsaufrufe zu optimieren, da dies möglicherweise dazu führen kann, dass viele Codezweige separat in einem bestimmten Aufrufstapel generiert werden müssen (eine Funktion, die Aufrufe haben
foo->f()
würden) einen völlig anderen Maschinencode als den aufrufenden zu erzeugenbar->f()
Durch einen Basiszeiger und die Funktion, die diese Funktion aufruft, müsste dann zwei oder mehr Codeversionen generieren, und so weiter - die Menge des generierten Maschinencodes wäre explosiv - vielleicht nicht so schlecht mit einer Ablaufverfolgungs-JIT, die generiert den Code im laufenden Betrieb, während er über Hot-Execution-Pfade nachverfolgt wird.Trotzdem, wie viele Antworten bestätigt haben, ist dies ein schlechter Grund, eine Schiffsladung von
switch
Aussagen zu bevorzugen, auch wenn sie geringfügig schneller sind. Wenn es um Mikroeffizienzen geht, haben Verzweigungen und Inlining im Vergleich zu Speicherzugriffsmustern normalerweise eine relativ niedrige Priorität.Trotzdem bin ich mit einer ungewöhnlichen Antwort hier reingesprungen. Ich möchte die Wartbarkeit von
switch
Aussagen gegenüber einer polymorphen Lösung begründen, wenn und nur dann, wenn Sie sicher sind, dass es nur einen Ort geben wird, an dem die Aussagen durchgeführt werden müssenswitch
.Ein Paradebeispiel ist ein zentraler Event-Handler. In diesem Fall gibt es im Allgemeinen nicht viele Orte, an denen Ereignisse verarbeitet werden, sondern nur einen (warum "zentral"). In diesen Fällen profitieren Sie nicht von der Erweiterbarkeit, die eine polymorphe Lösung bietet. Eine polymorphe Lösung ist vorteilhaft, wenn es viele Stellen gibt, an denen die analoge
switch
Aussage möglich ist. Wenn Sie sicher sind, dass es nur eine geben wird, kann eineswitch
Anweisung mit 15 Fällen viel einfacher sein als das Entwerfen einer Basisklasse, die von 15 Subtypen mit überschriebenen Funktionen geerbt wird, und einer Factory, um sie zu instanziieren, um dann in einer Funktion verwendet zu werden im gesamten System. In diesen Fällen ist das Hinzufügen eines neuen Untertyps viel mühsamer als das Hinzufügen einercase
Anweisung zu einer Funktion. Wenn überhaupt, würde ich für die Wartbarkeit argumentieren, nicht die Leistung,switch
Aussagen in diesem einen besonderen Fall, in dem Sie überhaupt nicht von der Erweiterbarkeit profitieren.quelle