多態性または条件はより良い設計を促進しますか?
-
04-07-2019 - |
質問
最近このエントリを見つけましたよりテスト可能なコードを記述するためのガイドラインについては、Googleテストブログをご覧ください。私はこの点まで著者と同意していました:
条件よりもポリモーフィズムを好む:switchステートメントが表示される場合、ポリモーフィズムを考える必要があります。クラス内の多くの場所で同じif条件が繰り返される場合は、ポリモーフィズムを再度考えてください。ポリモーフィズムは、複雑なクラスをいくつかの小さな単純なクラスに分割し、どのクラスのコードが関連し、一緒に実行されるかを明確に定義します。これは、クラスが単純/小さいほどテストが簡単になるため、テストに役立ちます。
単純に頭を包むことはできません。 RTTI(または場合によってはDIY-RTTI)の代わりにポリモーフィズムを使用することは理解できますが、それは実際の運用コードで実際に効果的に使用されているとは想像できないほど広い声明のようです。むしろ、コードを数十の個別のクラスに分解するよりも、switchステートメントを持つメソッドのテストケースを追加する方が簡単だと思われます。
また、ポリモーフィズムはあらゆる種類のその他の微妙なバグや設計上の問題につながる可能性があるという印象を受けていたので、ここでのトレードオフに価値があるかどうか知りたいです。誰かがこのテストガイドラインの意味を正確に説明できますか?
解決
実際、これによりテストとコードの記述が容易になります。
内部フィールドに基づいたswitchステートメントが1つある場合、おそらく複数の場所で同じスイッチがわずかに異なることをしていることになります。すべてのswitchステートメントを更新する必要があるため、新しいケースを追加すると問題が発生します(見つかった場合)。
ポリモーフィズムを使用すると、仮想関数を使用して同じ機能を取得できます。新しいケースは新しいクラスであるため、チェックする必要があるものをコードで検索する必要はなく、クラスごとに分離されます。
class Animal
{
public:
Noise warningNoise();
Noise pleasureNoise();
private:
AnimalType type;
};
Noise Animal::warningNoise()
{
switch(type)
{
case Cat: return Hiss;
case Dog: return Bark;
}
}
Noise Animal::pleasureNoise()
{
switch(type)
{
case Cat: return Purr;
case Dog: return Bark;
}
}
この単純なケースでは、新しい動物の原因ごとに、両方のswitchステートメントを更新する必要があります。
忘れた?デフォルトは何ですか?バン!!
多態性の使用
class Animal
{
public:
virtual Noise warningNoise() = 0;
virtual Noise pleasureNoise() = 0;
};
class Cat: public Animal
{
// Compiler forces you to define both method.
// Otherwise you can't have a Cat object
// All code local to the cat belongs to the cat.
};
多型を使用すると、Animalクラスをテストできます。
次に、各派生クラスを個別にテストします。
また、これにより、Animalクラス(変更のため閉鎖中)をバイナリライブラリの一部として出荷できます。ただし、Animalヘッダーから派生した新しいクラスを派生させることで、新しい動物を追加できます(拡張用に開く)。このすべての機能がAnimalクラス内でキャプチャされた場合、出荷前にすべての動物を定義する必要があります(クローズ/クローズ)。
他のヒント
恐れないでください...
あなたの問題はテクノロジーではなく、親しみにあると思います。 C ++ OOPに精通します。
C ++はOOP言語です
複数のパラダイムの中でも、OOP機能を備えており、ほとんどの純粋なOO言語との比較をサポートできます。
C ++ <!> quot内の<!> quot; C部分を許可しないでください。 C ++が他のパラダイムに対処できないと信じさせてください。 C ++は、多くのプログラミングパラダイムを非常に優雅に処理できます。その中でも、OOP C ++は、手続き型パラダイム(つまり、前述の<!> quot; C part <!> quot;)に次いで最も成熟したC ++パラダイムです。
ポリモーフィズムは本番でも問題ありません
<!> quot;微妙なバグはありません<!> quot;または<!> quot;量産コードには適していません<!> quot;事。自分のやり方で設定されたままの開発者と、ツールの使用方法を学び、各タスクに最適なツールを使用する開発者がいます。
スイッチとポリモーフィズムは[ほぼ]類似しています...
...しかし、ポリモーフィズムはほとんどのエラーを削除しました。
違いは、スイッチを手動で処理する必要があることです。一方、継承メソッドのオーバーライドに慣れると、ポリモーフィズムがより自然になります。
スイッチを使用すると、型変数を異なる型と比較し、違いを処理する必要があります。ポリモーフィズムでは、変数自体が動作方法を知っています。論理的な方法で変数を整理し、適切なメソッドをオーバーライドするだけです。
しかし、最後に、スイッチでケースを処理するのを忘れると、コンパイラは通知しませんが、純粋な仮想メソッドをオーバーライドせずにクラスから派生した場合は通知されます。したがって、ほとんどの切り替えエラーが回避されます。
全体として、2つの機能は選択に関するものです。しかし、ポリモーフィズムを使用すると、より複雑になり、同時により自然になり、選択が容易になります。
RTTIを使用してオブジェクトのタイプを見つけることを避けます
RTTIは興味深い概念であり、役に立つ場合があります。しかし、ほとんどの場合(つまり、95%の時間)、メソッドのオーバーライドと継承は十分すぎるほどであり、ほとんどのコードは処理されるオブジェクトの正確なタイプさえ知らないはずですが、正しいことをすることを信頼します。
RTTIを美化されたスイッチとして使用する場合、ポイントがありません。
(免責事項:私はRTTIコンセプトとdynamic_castsの大ファンです。しかし、手元のタスクに適切なツールを使用する必要があり、ほとんどの場合、RTTIは美化されたスイッチとして使用されますが、これは間違っています)
動的多型と静的多型の比較
コードがコンパイル時にオブジェクトの正確なタイプを知らない場合は、動的なポリモーフィズム(つまり、古典的な継承、仮想メソッドのオーバーライドなど)を使用します
コードがコンパイル時に型を知っている場合、おそらく静的ポリモーフィズム、つまりCRTPパターンを使用できます。 http://en.wikipedia.org/wiki/Curiously_Recurring_Template_Pattern
CRTPを使用すると、動的ポリモーフィズムに似たコードを使用できますが、そのメソッド呼び出しはすべて静的に解決されるため、非常に重要なコードに最適です。
生産コードの例
(メモリから)このコードに似たコードが本番環境で使用されます。
簡単な解決策は、メッセージループ(Win32のWinProcですが、簡単にするために単純なバージョンを作成しました)によって呼び出されるプロシージャを中心に展開しました。要約すると、次のようなものでした:
void MyProcedure(int p_iCommand, void *p_vParam)
{
// A LOT OF CODE ???
// each case has a lot of code, with both similarities
// and differences, and of course, casting p_vParam
// into something, depending on hoping no one
// did a mistake, associating the wrong command with
// the wrong data type in p_vParam
switch(p_iCommand)
{
case COMMAND_AAA: { /* A LOT OF CODE (see above) */ } break ;
case COMMAND_BBB: { /* A LOT OF CODE (see above) */ } break ;
// etc.
case COMMAND_XXX: { /* A LOT OF CODE (see above) */ } break ;
case COMMAND_ZZZ: { /* A LOT OF CODE (see above) */ } break ;
default: { /* call default procedure */} break ;
}
}
コマンドを追加するたびにケースが追加されました。
問題は、一部のコマンドが類似しており、その実装を部分的に共有していることです。
ケースを混在させることは、進化のリスクでした。
Commandパターンを使用して問題を解決しました。つまり、1つのprocess()メソッドでベースCommandオブジェクトを作成しました。
それで、メッセージプロシージャを書き直し、危険なコード(つまり、void *などで遊ぶ)を最小限に抑えて、再度触れる必要はありません:
void MyProcedure(int p_iCommand, void *p_vParam)
{
switch(p_iCommand)
{
// Only one case. Isn't it cool?
case COMMAND:
{
Command * c = static_cast<Command *>(p_vParam) ;
c->process() ;
}
break ;
default: { /* call default procedure */} break ;
}
}
そして、可能なコマンドごとに、手順にコードを追加し、同様のコマンドからコードを混合(またはさらに悪いことに、コピー/貼り付け)する代わりに、新しいコマンドを作成し、Commandオブジェクトから派生させました。またはその派生オブジェクトの1つ:
これにより、階層(ツリーとして表示)が作成されました:
[+] Command
|
+--[+] CommandServer
| |
| +--[+] CommandServerInitialize
| |
| +--[+] CommandServerInsert
| |
| +--[+] CommandServerUpdate
| |
| +--[+] CommandServerDelete
|
+--[+] CommandAction
| |
| +--[+] CommandActionStart
| |
| +--[+] CommandActionPause
| |
| +--[+] CommandActionEnd
|
+--[+] CommandMessage
今、私がする必要があるのは、各オブジェクトのプロセスをオーバーライドすることだけでした。
シンプルで簡単に拡張できます。
たとえば、CommandActionが3つのフェーズでプロセスを実行することになっているとします。<!> quot; before <!> quot;、<!> quot; while <!> quot;および<!> quot; after <!> quot;。そのコードは次のようになります:
class CommandAction : public Command
{
// etc.
virtual void process() // overriding Command::process pure virtual method
{
this->processBefore() ;
this->processWhile() ;
this->processAfter() ;
}
virtual void processBefore() = 0 ; // To be overriden
virtual void processWhile()
{
// Do something common for all CommandAction objects
}
virtual void processAfter() = 0 ; // To be overriden
} ;
そして、たとえば、CommandActionStartは次のようにコーディングできます:
class CommandActionStart : public CommandAction
{
// etc.
virtual void processBefore()
{
// Do something common for all CommandActionStart objects
}
virtual void processAfter()
{
// Do something common for all CommandActionStart objects
}
} ;
私が言ったように:理解しやすく(適切にコメントされている場合)、非常に簡単に拡張できます。
スイッチは最小限に抑えられており(つまり、WindowsコマンドをWindowsのデフォルトプロシージャに委任する必要があるためifに似ています)、RTTI(またはさらに悪いことに社内RTTI)は不要です。
スイッチ内の同じコードは非常に面白いと思います(<!> quot; historical <!> quotの量だけで判断した場合、作業中のアプリで見たコード)
OOプログラムのユニットテストとは、各クラスをユニットとしてテストすることです。習得したい原則は、<!> quot;拡張機能にオープン、変更に閉鎖<!> quot;です。これはHead First Design Patternsから取得しました。ただし、既存のテスト済みコードを変更せずにコードを簡単に拡張できるようにしたいと基本的に述べています。
ポリモーフィズムは、これらの条件ステートメントを削除することでこれを可能にします。この例を考えてみましょう:
武器を運ぶCharacterオブジェクトがあるとします。このような攻撃方法を書くことができます:
If (weapon is a rifle) then //Code to attack with rifle else
If (weapon is a plasma gun) //Then code to attack with plasma gun
etc。
ポリモーフィズムでは、キャラクターは<!> quot; know <!> quot;をする必要はありません。武器の種類、単に
weapon.attack()
動作します。新しい武器が発明されたらどうなりますか?多態性がなければ、条件文を修正する必要があります。ポリモーフィズムでは、新しいクラスを追加し、テスト済みのCharacterクラスをそのままにする必要があります。
私は少し懐疑的です。継承はしばしば、それが取り除くよりも複雑さを増すと思います。
しかし、あなたは良い質問をしていると思います、そして私が考える一つのことはこれです:
異なるものを扱っているため、複数のクラスに分割していますか?それとも、同じ方法で行動するのと同じですか?
本当に新しいタイプの場合は、先に進んで新しいクラスを作成します。しかし、それが単なるオプションである場合、通常は同じクラスに保持します。
デフォルトの解決策はシングルクラスの解決策であり、プログラマーが自分たちのケースを証明するために継承を提案することに責任があると思います。
テストケースへの影響の専門家ではなく、ソフトウェア開発の観点から:
-
オープンクローズド原則-クラスは変更のために閉じられるべきですが、拡張に対しては開かれるべきです。条件付きコンストラクトを介して条件付き操作を管理する場合、新しい条件が追加されると、クラスを変更する必要があります。ポリモーフィズムを使用する場合、基本クラスを変更する必要はありません。
-
繰り返さないでください-ガイドラインの重要な部分は、<!> quot; 同じ if条件です。<!> quot;これは、クラスに含めることができるいくつかの異なる動作モードがクラスにあることを示しています。次に、その条件は、コード内の1か所に表示されます(そのモードのオブジェクトをインスタンス化するとき)。繰り返しになりますが、新しいコードが登場した場合は、コードを1つ変更するだけで済みます。
多態性はオブジェクト指向の重要な要素の1つであり、非常に便利です。 懸念を複数のクラスに分割することにより、分離されたテスト可能なユニットを作成します。 したがって、スイッチを実行する代わりに、複数の異なるタイプまたは実装でメソッドを呼び出す場合、複数の実装を持つ統合インターフェースを作成します。 実装を追加する必要がある場合、switch ... caseの場合のように、クライアントを変更する必要はありません。これは回帰を回避するのに役立つため、非常に重要です。
インターフェイスを1つだけ扱うことで、クライアントアルゴリズムを簡素化することもできます。
私にとって非常に重要なのは、ポリモーフィズムが純粋なインターフェース/実装パターン(由緒あるShape <!> lt;-Circleなど...)と共に使用するのが最適であることです。 また、テンプレートメソッド(別名フック)を使用して、具体的なクラスにポリモーフィズムを持たせることもできますが、複雑さが増すにつれてその効果は低下します。
ポリモーフィズムは、当社のコードベースが構築される基盤であるため、非常に実用的であると考えています。
スイッチとポリモーフィズムは同じことを行います。
ポリモーフィズム(および一般的なクラスベースのプログラミング)では、タイプごとに関数をグループ化します。スイッチを使用する場合、機能ごとにタイプをグループ化します。自分に適したビューを決定します。
したがって、インターフェイスが修正され、新しい型のみを追加する場合、ポリモーフィズムが味方です。 ただし、インターフェイスに新しい関数を追加する場合は、すべての実装を更新する必要があります。
場合によっては、型の数が固定されている可能性があり、新しい機能が追加される可能性があるため、スイッチの方が優れています。ただし、新しいタイプを追加すると、すべてのスイッチが更新されます。
スイッチを使用すると、サブタイプリストを複製します。ポリモーフィズムでは、操作リストを複製しています。別の問題を取得するために問題を交換しました。これはいわゆる式の問題であり、私が知っているプログラミングパラダイムでは解決されません。問題の根本は、コードを表すために使用されるテキストの1次元の性質です。
ここではプロポリモーフィズムのポイントについて詳しく説明しているため、プロスイッチポイントを提供します。
OOPには、よくある落とし穴を避けるための設計パターンがあります。手続き型プログラミングにもデザインパターンがあります(ただし、まだ誰も書いていないので、それらのベストセラーブックを作成するには別の新しいGang of Nが必要です...)。 1つのデザインパターンは、常にデフォルトケースを含める です。
スイッチは正しく実行できます:
switch (type)
{
case T_FOO: doFoo(); break;
case T_BAR: doBar(); break;
default:
fprintf(stderr, "You, who are reading this, add a new case for %d to the FooBar function ASAP!\n", type);
assert(0);
}
このコードは、ケースの処理を忘れた場所にお気に入りのデバッガーを向けます。コンパイラーはインターフェースの実装を強制できますが、これによりコードを徹底的にテストすることを強制します(少なくとも新しいケースを確認するには、注意してください)。
もちろん、特定のスイッチが複数の場所で使用される場合、機能に切り捨てられます(繰り返さないでください)。
これらのスイッチを拡張する場合は、grep 'case[ ]*T_BAR' rn .
(Linuxの場合)を実行するだけで、見る価値のある場所が吐き出されます。コードを見る必要があるため、新しいケースを正しく追加する方法を支援するコンテキストが表示されます。ポリモーフィズムを使用すると、呼び出しサイトはシステム内に隠され、ドキュメントの正確性(存在する場合)に依存します。
既存のケースを変更せず、新しいケースを追加するだけなので、スイッチを延長してもOCPが破損することはありません。
スイッチは、コードに慣れて理解しようとする次の人にも役立ちます:
- 考えられるケースは目の前です。これは、コードを読む際に良いことです(ジャンプしません)。
- しかし、仮想メソッド呼び出しは通常のメソッド呼び出しと同じです。呼び出しが仮想であるか通常であるかを(クラスを調べずに)知ることはできません。それは悪いです。
- ただし、呼び出しが仮想の場合、考えられるケースは明白ではありません(すべての派生クラスを見つけることなく)それも悪い。
サードパーティにインターフェースを提供し、サードパーティがシステムに動作とユーザーデータを追加できるようにする場合、それは別の問題です。 (コールバックとポインターをユーザーデータに設定でき、それらにハンドルを与えます)
詳細については、 http://c2.com/cgi/wiki?SwitchStatementsSmell
<!> quot; C-hacker's syndrome <!> quot;が怖いそして、反OOP主義は最終的にここで私の評判をすべて焼き払うでしょう。しかし、手続き型Cシステムに何かをハッキングしたりボルトで固定したりする必要があるときは、非常に簡単であることがわかりました。制約の欠如、カプセル化の強制、抽象化レイヤーの削減により、<!> quot; just do <!> quot;になります。しかし、ソフトウェアの存続期間中に数十の抽象化レイヤーが互いの上に積み重なったC ++ / C#/ Javaシステムでは、他のプログラマーのすべての制約と制限を正しく回避する方法を見つけるために、何日も何日も費やす必要があります他の人を避けるためにシステムに組み込まれている<!> quot;クラス<!> quot;。
これは主に知識のカプセル化に関係しています。本当に明白な例から始めましょう-toString()。これはJavaですが、C ++に簡単に転送できます。デバッグ目的で、オブジェクトの人間に優しいバージョンを印刷するとします。あなたができる:
switch(obj.type): {
case 1: cout << "Type 1" << obj.foo <<...; break;
case 2: cout << "Type 2" << ...
しかし、これは明らかにばかげています。どこかで1つのメソッドがすべてを印刷する方法を知っている必要がある多くの場合、オブジェクト自体が自分自身を印刷する方法を知っている方が良いでしょう。例:
cout << object.toString();
これにより、toString()はキャストを必要とせずにメンバーフィールドにアクセスできます。これらは個別にテストできます。簡単に変更できます。
ただし、オブジェクトの印刷方法をオブジェクトに関連付けるのではなく、印刷メソッドに関連付ける必要があると主張することもできます。この場合、別のデザインパターンが役立ちます。これは、Double Dispatchを偽造するために使用されるVisitorパターンです。完全に説明するのはこの回答には長すぎますが、ここで良い説明を読むことができます。
どこでもswitchステートメントを使用している場合、アップグレード時に更新が必要な1つの場所を見逃す可能性があります。
非常にうまく機能します。理解すれば。
多型には2つのフレーバーもあります。最初はjava-esqueで理解するのは非常に簡単です:
interface A{
int foo();
}
final class B implements A{
int foo(){ print("B"); }
}
final class C implements A{
int foo(){ print("C"); }
}
BとCは共通のインターフェースを共有しています。この場合のBとCは拡張できないため、どのfoo()を呼び出しているかは常に確認できます。 C ++についても同様です。A:: fooを純粋な仮想にするだけです。
2つ目は、実行時のポリモーフィズムです。擬似コードでは見た目が悪くありません。
class A{
int foo(){print("A");}
}
class B extends A{
int foo(){print("B");}
}
class C extends B{
int foo(){print("C");}
}
...
class Z extends Y{
int foo(){print("Z");
}
main(){
F* f = new Z();
A* a = f;
a->foo();
f->foo();
}
しかし、それはかなりトリッキーです。特に、foo宣言の一部が仮想であり、継承の一部が仮想であるC ++で作業している場合。これに対する答え:
A* a = new Z;
A a2 = *a;
a->foo();
a2.foo();
期待どおりではないかもしれません。
実行中のポリモーフィズムを使用しているかどうかは、自分が何をしているかわからないことをよく知っておいてください。自信過剰にならないでください。実行時になにをするかわからない場合は、テストしてください。
すべてのスイッチ文を見つけることは、成熟したコードベースでは些細なプロセスではない可能性があることを繰り返して言わなければなりません。いずれかを逃した場合、デフォルトの設定がない限り、一致しないcaseステートメントが原因でアプリケーションがクラッシュする可能性があります。
<!> quot; Martin Fowlers <!> quot;もチェックしてください。 <!> quot;リファクタリング<!> quot;
の本
ポリモーフィズムの代わりにスイッチを使用するのはコードのにおいです。
それは本当にあなたのプログラミングスタイルに依存します。これはJavaまたはC#では正しいかもしれませんが、ポリモーフィズムを使用することを自動的に決定するのが正しいことに同意しません。たとえば、コードを多数の小さな関数に分割し、関数ポインター(コンパイル時に初期化された)を使用して配列検索を実行できます。 C ++では、ポリモーフィズムとクラスが過剰に使用されることがよくあります-おそらく、強力なOOP言語からC ++にやってくる人々が犯す最大の設計ミスは、すべてがクラスに入ることです-これは真実ではありません。クラスには、全体として機能する最小限のもののみを含める必要があります。サブクラスまたは友人が必要な場合は、そうする必要がありますが、それらは標準ではありません。クラスに対する他の操作は、同じ名前空間内の自由な関数でなければなりません。 ADLでは、これらの関数をルックアップなしで使用できます。
C ++はOOP言語ではありません。作成しないでください。 C ++でCをプログラミングするのと同じくらい悪い。