web-dev-qa-db-ja.com

巨大な "switch"ステートメントの代わりにOOアプローチを使用するのはなぜですか?

私は.Net、C#ショップで働いており、私は、オブジェクト指向のアプローチではなく、多くの「ケース」を持つコードで巨大なSwitchステートメントを使用する必要があると主張し続ける同僚を抱えています。彼の議論は、Switchステートメントが「cpuジャンプテーブル」にコンパイルされるため、最も速いオプションであるという事実に一貫して戻ります(他の点では、チームは速度を気にしないと言われていますが)。

正直言ってこれについては議論はありません...彼が何を話しているのか私にはわからないからです。
彼は正しいですか?
彼は自分のお尻を話しているだけですか?
ここで学ぼうとしているだけです。

61
James P. Wright

彼はおそらく古いCハッカーであり、そうです、彼はお尻から声を出しています。 .NetはC++ではありません。 .Netコンパイラーは改善を続けており、今日の次の.Netバージョンではないにしても、ほとんどの巧妙なハックは逆効果です。 .Net JITはすべての機能を使用する前に一度だけ実行するため、小さな機能が望ましいです。したがって、プログラムのライフサイクル中にヒットしないケースがある場合は、これらをJITコンパイルするコストはかかりません。とにかく、速度が問題でなければ、最適化はありません。最初にプログラマー向けに、次にコンパイラー向けに記述します。同僚は簡単には納得できないので、私は、より適切に編成されたコードが実際に高速であることを経験的に証明します。私は彼の最悪の例の1つを選び、それらをより良い方法で書き直して、コードがより高速であることを確認します。必要ならチェリーピック。それを数百万回実行し、プロファイルを作成して見せます。それは彼によく教えるべきだ。

[〜#〜]編集[〜#〜]

ビル・ワーグナーはこう書いている:

項目11:小さな関数の魅力を理解する(有効なC#第2版)C#コードをマシン実行可能コードに変換することは、2ステップのプロセスであることを忘れないでください。 C#コンパイラは、アセンブリで提供されるILを生成します。 JITコンパイラーは、必要に応じて、各メソッド(またはインライン化が関係する場合はメソッドのグループ)のマシンコードを生成します。小さな関数は、JITコンパイラがそのコストを償却することをはるかに容易にします。小さな関数もインライン化の候補になる可能性が高くなります。それは単なる小ささではありません。よりシンプルな制御フローも同様に重要です。関数内の制御分岐が少ないと、JITコンパイラーが変数を登録しやすくなります。より明確なコードを書くことは単に良い習慣ではありません。実行時により効率的なコードを作成する方法です。

EDIT2:

つまり、1つの比較は対数であり、別の比較は線形であるため、switchステートメントはif/elseステートメントの束よりも高速で優れています。 http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html

ええと、巨大なswitchステートメントを置き換えるための私のお気に入りのアプローチは、それらに応答して呼び出される関数に値をマッピングするディクショナリ(または、enumまたは小さなintをオンにする場合は配列)を使用することです。そうすることで、多くの厄介な共有スパゲッティ状態を強制的に削除することになりますが、それは良いことです。大きなswitchステートメントは通常、メンテナンスの悪夢です。したがって、配列と辞書を使用すると、検索に一定の時間がかかり、余分なメモリがほとんど無駄になりません。

私はまだswitchステートメントの方が優れているとは思いません。

50
Job

同僚が証拠を提供できない限り、この変更がアプリケーション全体の規模で実際の測定可能な利点を提供することは、実際に提供するアプローチ(つまり、ポリモーフィズム)よりも劣ります。そのような利点:保守性。

ミクロ最適化はボトルネックが特定された後にのみ実行されます。 時期尚早な最適化はすべての悪の根源です

速度は定量化できます。 「アプローチAの方がアプローチBより速い」には、有用な情報はほとんどありません。質問は「どれくらい速いですか?」です。

39
back2dos

それがより速いかどうか誰が気にしますか?

リアルタイムソフトウェアを作成しているのでない限り、完全に非常識な方法で何かを行うことによって得られる可能性のある非常に小さなスピードアップで、クライアントに大きな違いが生じることはほとんどありません。私はこれをスピードの最前線で戦うことすらしません、この男は明らかに主題についての議論に耳を傾けるつもりはありません。

ただし、保守性はゲームの目的であり、巨大なswitchステートメントは少しでも保守可能ではありません。コードを介したさまざまなパスを新しい人にどのように説明しますか?ドキュメントはコード自体と同じ長さでなければなりません!

さらに、効果的にユニットテストを行うことができなくなり(インターフェースが不足している可能性があることは言うまでもなく、考えられるパスが多すぎるため)、コードの保守性がさらに低下します。

[関心のある側:JITterは小さなメソッドでより良いパフォーマンスを発揮するため、巨大なswitchステートメント(およびそれらの本質的に大きなメソッド)は、大きなアセンブリIIRCでの速度に悪影響を及ぼします。]

27
Ed James

Switchステートメントから離れる...

このタイプのswitchステートメントは、 Open Closed Principle に違反するため、ペストのように回避する必要があります。新しいコードを追加するだけではなく、新しい機能を追加する必要がある場合、チームは既存のコードを変更する必要があります。

14
Dakotah North

私はパフォーマンスの議論を買いません。それはすべてコードの保守性に関するものです。

BUT:時々、巨大なswitchステートメントは、抽象基本クラスの仮想関数をオーバーライドする小さなクラスの束よりも維持が簡単です(コードが少ない)。たとえば、CPUエミュレータを実装する場合、各命令の機能を個別のクラスに実装することはありません。オペコードの巨大なswtichにそれを詰め込むだけで、より複雑な命令のヘルパー関数を呼び出す可能性があります。

経験則:スイッチが何らかの形でTYPEで実行される場合は、継承と仮想関数を使用する必要があります。固定タイプのVALUE(上記のように命令オペコードなど)で切り替えを実行する場合は、そのままにしても問題ありません。

9
zvrba

大規模なswitchステートメントによって操作される大規模な有限状態マシンとして知られる悪夢を乗り切りました。さらに悪いことに、私の場合、FSMは3つのC++ DLLにまたがっており、コードがCに精通している誰かによって書かれたことは非常に明白でした。

注意が必要なメトリックは次のとおりです。

  • 変更を加える速度
  • 問題が発生したときの発見速度

その一連のDLLに新しい機能を追加するタスクが与えられ、3つのDLLを適切にオブジェクト指向の1つとして書き換えるのと同じくらい時間がかかることを管理者に納得させることができましたDLL私が修正プログラムを適用して、すでにそこにあるソリューションに陪審を提出することになるので、新しい機能をサポートするだけでなく、拡張がはるかに簡単だったため、書き換えは大成功でした。実際、通常は何も壊していないことを確認するのに1週間かかり、数時間かかることになります。

では、実行時間はどうですか?速度の増減はありませんでした。公平を期すために、システムドライバーによってパフォーマンスが抑制されたため、オブジェクト指向のソリューションが実際に遅い場合は、それがわかりません。

OO言語)の大量のswitchステートメントの何が問題になっていますか?

  • プログラム制御フローは、それが属しているオブジェクトから取り除かれ、オブジェクトの外部に配置されます
  • 外部制御の多くのポイントは、レビューが必要な多くの場所に変換されます
  • 特にスイッチがループ内にある場合、状態がどこに保存されているか不明
  • 最速の比較はまったく比較しないことです(優れたオブジェクト指向設計で多くの比較を行う必要を回避できます)。
  • オブジェクトタイプまたはコード化された列挙型に基づいてコードを変更するよりも、オブジェクトを反復処理してすべてのオブジェクトで常に同じメソッドを呼び出す方が効率的です。
8
Berin Loritsch

あなたは私にそれを納得させることができません:

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;
    }
}

以下より大幅に高速です:

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();
}

さらに、OOバージョンは、より保守しやすくなっています。

5
Martin York

結果として得られるマシンコードはおそらくより効率的であると彼は正しい。コンパイラーは本質的に、switchステートメントを一連のテストとブランチに変換します。これらの命令は比較的少数です。より抽象化されたアプローチから作成されたコードでは、より多くの命令が必要になる可能性が高くなります。

[〜#〜] however [〜#〜]:特定のアプリケーションがこの種のマイクロ最適化について心配する必要がない場合、またはを使用しない場合はほぼ確実です。そもそもネット。非常に制約された組み込みアプリケーションやCPUを集中的に使用する作業が不足している場合は、常にコンパイラーに最適化を処理させる必要があります。クリーンで保守可能なコードの作成に集中してください。これは、ほとんどの場合、実行時間の数十分の一ナノ秒よりはるかに大きな価値があります。

4
Luke Graham

Switchステートメントの代わりにクラスを使用する主な理由の1つは、switchステートメントが多くのロジックを含む1つの巨大なファイルにつながる傾向があることです。これは、メンテナンスの悪夢であり、ソース管理の問題でもあります。別の小さなクラスファイルではなく、その巨大なファイルをチェックアウトして編集する必要があるためです。

3
Homde

OOPコードのswitchステートメントは、欠落しているクラスの強力な兆候です

両方の方法を試して、簡単な速度テストをいくつか実行してください。チャンスは違いは重要ではありません。それらがおよびコードがタイムクリティカルであるの場合は、switchステートメントを保持します

3
Steven A. Lowe

通常、私は「時期尚早の最適化」という言葉は嫌いですが、これはそれを連想させます。 Knuthがcritical領域のコードを高速化するためにgotoステートメントの使用を推進するコンテキストでこの有名な引用を使用したことは注目に値します。それが鍵です:クリティカルパス。

彼はコードを高速化するためにgotoを使用することを提案していましたが、それほど重要ではないコードのハンチや迷信に基づいてこれらのタイプのことをしたいプログラマーに対して警告しました。

switchステートメントをできるだけ優先する均一に(重い負荷が処理されるかどうかにかかわらず)コードベース全体で、Knuthが「ペニーワイズおよびポンド」と呼ぶ典型的な例ですポンドを超えてペニーを節約しようとした結果、デバッグの悪夢に変わった「最適化された」コードを維持するために一日中奮闘している愚かな」プログラマー。そのようなコードは、そもそも効率的であることはもちろん、めったに保守可能ではありません。

彼は正しいですか?

彼は非常に基本的な効率の観点から正しいです。私の知る限り、コンパイラはオブジェクトや動的ディスパッチを含むポリモーフィックコードをswitchステートメントよりも最適化できません。このようなコードはコンパイラーのオプティマイザーバリアとして機能する傾向があるため、LUTまたはジャンプテーブルで多態性コードから最終的に終了することは決してありません(動的ディスパッチが実行されるまで、どの関数を呼び出すかわからないためです)。発生します)。

このコストをジャンプテーブルの観点から考えるのではなく、最適化バリアの観点から考える方が便利です。ポリモーフィズムの場合、Base.method()を呼び出しても、methodが仮想であり、シールされておらず、オーバーライドできる場合、コンパイラーは実際にどの関数が呼び出されるかを知ることができません。実際にどの関数が呼び出されるかは事前にわからないため、関数呼び出しを最適化して最適化の決定を行う際に多くの情報を利用することはできません。これは、どの関数が呼び出されるかが実際にわからないためです。コードがコンパイルされている時間。

オプティマイザーは、関数呼び出しを詳しく調べて、呼び出し元と呼び出し先を完全にフラット化するか、少なくとも呼び出し元を最適化して呼び出し先を最も効率的に操作できる場合に最適です。実際にどの関数が事前に呼び出されるのかがわからない場合は、これを行うことはできません。

彼はただ彼のお尻を話しているのですか?

このコストは、多くの場合数ペニーに相当しますが、これを均一に適用されるコーディング標準に変えることを正当化することは、特に拡張性が必要な場所では、一般的に非常に愚かです。これは、本物の時期尚早のオプティマイザで注意したい主な事柄です。彼らは、軽微なパフォーマンスの懸念を、保守性をまったく考慮せずにコードベース全体に均一に適用されるコーディング標準に変えたいと考えています。

私はそのうちの1人なので、受け入れられた回答で使用されている「古いCハッカー」の引用には少し腹を立てます。非常に限られたハードウェアから数十年にわたってコーディングを行ってきたすべての人が、時期尚早なオプティマイザに変わったわけではありません。それでも私はそれらに出会い、一緒に働いたことがあります。しかし、これらのタイプはブランチの予測ミスやキャッシュミスのようなものを測定することはなく、彼らはよりよく知っていると考え、非効率性の概念を、今日は成り立たない、時には決して成り立たない迷信に基づく複雑な製品コードベースに基づいています。パフォーマンス重視の分野で真に働いている人は、効果的な最適化が効果的な優先順位付けであることをよく理解しています。保守性を低下させるコーディング標準を一般化してコストを節約しようとすると、非常に効果的ではありません。

ペニーは、非常にタイトでパフォーマンスが重要なループで10億回も呼び出される、あまり機能しない安価な関数がある場合に重要です。その場合、私たちは1000万ドルを節約することになります。本体だけで数千ドルもかかる2回呼び出される関数がある場合、1セントを削る価値はありません。車の購入中にペニーを飲みながら時間を費やすのは賢明ではありません。あなたが製造業者から百万缶のソーダを購入しているなら、それはペニーをぶらぶらする価値があります。効果的な最適化の鍵は、これらのコストを適切な状況で理解することです。すべての購入でペニーを節約しようとし、購入しているものに関係なく、他のすべての人がペニーを試してみるように提案する人は、熟練したオプティマイザではありません。

3
user204677

あなたの同僚はパフォーマンスについて非常に心配しているようです。場合によっては、大きなケース/スイッチ構造の方が速く実行される可能性がありますが、うまくいけば、OOバージョンとスイッチ/ケースバージョンのタイミングテストを実行して実験を行うことができます。 OOバージョンはコードが少なく、追跡、理解、保守が容易です。OOバージョンを最初に主張します(保守/読みやすさがOOバージョンに重大なパフォーマンスの問題があり、スイッチ/ケースが大幅に改善することが示されている場合にのみ、スイッチ/ケースバージョンを最初に考慮してください).

誰も言及していないポリモーフィズムの保守性の利点の1つは、常に同じケースのリストを切り替える場合、継承を使用してコードをより適切に構造化できることですが、いくつかのケースは同じ方法で処理される場合があります。じゃない

例えば。 DogCatElephantを切り替える場合、およびDogCatが同じケースの場合は、どちらも抽象クラスDomesticAnimalを継承し、それらの関数を抽象クラスに配置します。

また、ポリモーフィズムを使用しない場合の例として、パーサーを使用している人が何人かいることに驚きました。ツリーのようなパーサーの場合、これは明らかに間違ったアプローチですが、各行がある程度独立しているアセンブリのようなものがあり、残りの行がどのように解釈されるべきかを示すオペコードで始まる場合、私は完全にポリモーフィズムを使用しますそして工場。各クラスは、ExtractConstantsExtractSymbolsなどの関数を実装できます。私はおもちゃのBASICインタープリターにこのアプローチを使用しました。

2
jwg

「私たちは小さな効率を忘れるべきです。時間の約97%を言います。時期尚早な最適化がすべての悪の根源です」

ドナルド・クヌース

0

これが保守性に問題がなかったとしても、パフォーマンスが向上するとは思いません。仮想関数呼び出しは、1つの追加の間接指定(switchステートメントの最良の場合と同じ)にすぎないため、C++でもパフォーマンスはほぼ同じになります。すべての関数呼び出しが仮想であるC#では、両方のバージョンで同じ仮想関数呼び出しのオーバーヘッドがあるため、switchステートメントの方が悪いはずです。

0
Dirk Holsopple

彼は必ずしも彼のお尻から話しているわけではありません。少なくともCおよびC++では、switchステートメントを最適化してテーブルをジャンプできますが、ベースポインターにしかアクセスできない関数の動的ディスパッチでは、これが発生することはありません。少なくとも後者は、仮想関数呼び出しからベースポインター/参照を介してどのサブタイプが使用されているかを正確に把握するために、よりスマートなオプティマイザーでより多くの周辺コードを調べる必要があります。

その上、動的ディスパッチはしばしば「最適化バリア」として機能します。つまり、コンパイラーはコードをインライン化できず、レジスターを最適に割り当てて、スタックの流出やその他すべての凝ったものを最小限に抑えることができません。仮想関数は、ベースポインターを介して呼び出され、インライン化され、その最適化の魔法のすべてを実行します。オプティマイザーをとてもスマートにして間接的な関数呼び出しを最適化しようと思っているかどうかはわかりません。これにより、特定のコールスタック(foo->f()は、ベースポインターを通じてbar->f()を呼び出すものとはまったく異なるマシンコードを生成する必要があり、その関数を呼び出す関数は、2つ以上のバージョンのコードを生成する必要があります。 4番目-生成されるマシンコードの量は爆発的です-ホット実行パスをたどるときにオンザフライでコードを生成するトレースJITでそれほど悪くないかもしれません)。

ただし、多くの回答が反響しているため、わずかな速度でハンドダウンが速くても、大量のswitchステートメントを支持するのは悪い理由です。さらに、マイクロ効率に関して言えば、分岐やインライン化のようなものは、メモリアクセスパターンのようなものに比べて、通常、かなり低い優先順位です。

とはいえ、私は変わった答えでここに飛び込んできました。 switchステートメントを維持する必要があるのは、ポリモーフィックソリューションに対するswitchステートメントの保守性について、switchステートメントを実行する場所が多数ある場合に役立ちます。 1つしかないことが確実にわかっている場合、15のケースを持つswitchステートメントは、オーバーライドされた関数とそれらをインスタンス化するためのファクトリを備えた15のサブタイプによって継承される基本クラスを設計するよりもはるかに簡単です。その後、システム全体の1つの機能で使用されます。このような場合、新しいサブタイプを追加することは、1つの関数にcaseステートメントを追加するよりもずっと面倒です。どちらかと言えば、拡張性の恩恵をまったく受けないこの1つの特異なケースでは、switchステートメントのパフォーマンスではなく、保守性を主張します。

0
user204677

ジャンプテーブルに関するコメントに関する限り、あなたの同僚は彼の裏側から話していません。しかし、それを使って悪いコードを書くことを正当化することは、彼が間違っているところです。

C#コンパイラは、数例のスイッチステートメントを一連のif/elseに変換するため、if/elseを使用するよりも高速ではありません。コンパイラーは、より大きなswitchステートメントを辞書(同僚が参照しているジャンプテーブル)に変換します。 詳細については、トピックに関するスタックオーバーフローの質問に対するこの回答を参照してください

大きなswitchステートメントは読みにくく、保守が困難です。 「ケース」と機能の辞書は非常に読みやすいです。それがスイッチになっているので、あなたとあなたの同僚は辞書を直接使用することをお勧めします。

0
David Arno