39

私は最近、よりテスト可能なコードを書くためのガイドラインについて、グーグルテストブログでこのエントリに出くわしました。私はこの時点まで著者に同意していました:

条件文よりもポリモーフィズムを優先する:switchステートメントが表示された場合は、ポリモーフィズムを考える必要があります。クラスの多くの場所で同じ条件が繰り返されている場合は、ポリモーフィズムをもう一度考える必要があります。ポリモーフィズムは、複雑なクラスをいくつかのより小さな単純なクラスに分割します。これらのクラスは、コードのどの部分が関連していて、一緒に実行されるかを明確に定義します。単純な/小さいクラスの方がテストが簡単なので、これはテストに役立ちます。

頭を包むことはできません。RTTI(または場合によってはDIY-RTTI)の代わりにポリモーフィズムを使用することは理解できますが、それは実稼働コードで実際に効果的に使用されているとは想像できないほど広いステートメントのようです。むしろ、コードを数十の個別のクラスに分割するよりも、switchステートメントを持つメソッドにテストケースを追加する方が簡単だと思います。

また、ポリモーフィズムは他のあらゆる種類の微妙なバグや設計上の問題を引き起こす可能性があるという印象を受けていたので、ここでのトレードオフに価値があるかどうかを知りたいと思います。誰かがこのテストガイドラインの意味を正確に説明できますか?

4

12 に答える 12

72

実際、これによりテストとコードの記述が容易になります。

内部フィールドに基づく1つのswitchステートメントがある場合、おそらく複数の場所に同じスイッチがあり、わずかに異なることを実行しています。これにより、新しいケースを追加するときに問題が発生します。これは、すべての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クラス内にキャプチャされている場合は、出荷前にすべての動物を定義する必要があります(クローズ/クローズ)。

于 2008-10-24T17:29:38.347 に答える
26

恐れないで...

あなたの問題はテクノロジーではなく、親しみやすさにあると思います。C++ OOP に慣れてください。

C++ は OOP 言語です

その複数のパラダイムの中で、OOP 機能があり、ほとんどの純粋な OO 言語との比較をサポートする以上の能力があります。

「C++ 内の C 部分」という理由で、C++ が他のパラダイムを処理できないと信じ込ませないでください。C++ は、多くのプログラミング パラダイムを非常に優雅に処理できます。その中でも、OOP C++ は、手続き型パラダイム (つまり、前述の「C 部分」) に次いで最も成熟した C++ パラダイムです。

ポリモーフィズムは本番環境で問題ありません

「微妙なバグ」や「製品コードに適していない」ことはありません。自分のやり方に固執する開発者もいれば、ツールの使い方を学び、各タスクに最適なツールを使用する開発者もいます。

スイッチとポリモーフィズムは[ほぼ]似ています...

...しかし、ポリモーフィズムはほとんどのエラーを取り除きました。

違いは、スイッチを手動で処理する必要があることです。一方、ポリモーフィズムは、継承メソッドのオーバーライドに慣れるとより自然になります。

スイッチを使用すると、型変数を異なる型と比較し、違いを処理する必要があります。ポリモーフィズムでは、変数自体がどのように動作するかを知っています。論理的な方法で変数を整理し、適切なメソッドをオーバーライドするだけで済みます。

しかし、最終的に、switch でケースを処理するのを忘れた場合、コンパイラは通知しませんが、純粋仮想メソッドをオーバーライドせずにクラスから派生した場合は通知されます。したがって、ほとんどのスイッチ エラーは回避されます。

全体として、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 オブジェクトまたはいずれかから派生させました。その派生オブジェクト:

これにより、次の階層が作成されます (ツリーとして表されます)。

[+] Command
 |
 +--[+] CommandServer
 |   |
 |   +--[+] CommandServerInitialize
 |   |
 |   +--[+] CommandServerInsert
 |   |
 |   +--[+] CommandServerUpdate
 |   |
 |   +--[+] CommandServerDelete
 |
 +--[+] CommandAction
 |   |
 |   +--[+] CommandActionStart
 |   |
 |   +--[+] CommandActionPause
 |   |
 |   +--[+] CommandActionEnd
 |
 +--[+] CommandMessage

これで、各オブジェクトのプロセスをオーバーライドするだけで済みました。

シンプルで、簡単に拡張できます。

たとえば、CommandAction が「before」、「while」、「after」の 3 つのフェーズでプロセスを実行することになっているとします。そのコードは次のようになります。

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

私が言ったように:理解しやすく(適切にコメントされていれば)、拡張も非常に簡単です。

スイッチは最小限に抑えられ (つまり、if のように、Windows コマンドを Windows の既定の手順に委譲する必要があったため)、RTTI (またはさらに悪いことに、社内 RTTI) は必要ありません。

スイッチ内の同じコードは、非常に面白いと思います (私が作業中のアプリで見た「歴史的な」コードの量から判断するだけなら)。

于 2008-10-24T19:48:49.313 に答える
10

オブジェクト指向プログラムのユニットテストとは、各クラスをユニットとしてテストすることを意味します。あなたが学びたい原則は「拡張に対してオープンであり、修正に対してクローズ」です。それはHeadFirstDesignPatternsから入手しました。ただし、基本的には、既存のテスト済みコードを変更せずにコードを簡単に拡張できるようにする必要があることを示しています。

ポリモーフィズムは、これらの条件文を排除することでこれを可能にします。この例を考えてみましょう。

武器を運ぶ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

ポリモーフィズムでは、キャラクターは武器の種類を「知る」必要はなく、単に

weapon.attack()

動作します。新しい武器が発明された場合はどうなりますか?ポリモーフィズムがないと、条件ステートメントを変更する必要があります。ポリモーフィズムでは、新しいクラスを追加し、テストしたCharacterクラスをそのままにしておく必要があります。

于 2008-10-24T17:33:11.410 に答える
8

私は少し懐疑的です: 継承は多くの場合、削除するよりも複雑さを増すと思います。

しかし、あなたは良い質問をしていると思います。私が考えていることの1つは次のとおりです。

さまざまなことを扱っているため、複数のクラスに分割していますか? それとも、まったく同じことで、別の方法で行動しているのでしょうか?

それが本当に new typeである場合は、先に進んで新しいクラスを作成してください。しかし、それが単なるオプションである場合、私は通常、同じクラスに保ちます.

デフォルトの解決策は単一クラスのものであり、責任はプログラマーが継承を提案してそのケースを証明することであると私は信じています。

于 2008-10-24T17:56:33.330 に答える
5

テストケースへの影響の専門家ではありませんが、ソフトウェア開発の観点から:

  • オープンクローズの原則-クラスは変更に対してはクローズである必要がありますが、拡張に対してはオープンである必要があります。条件付き構文を介して条件付き操作を管理する場合、新しい条件が追加されると、クラスを変更する必要があります。ポリモーフィズムを使用する場合、基本クラスを変更する必要はありません。

  • 繰り返さないでください-ガイドラインの重要な部分は「条件が同じ」です。これは、クラスに因数分解できるいくつかの異なる操作モードがあることを示しています。次に、そのモードのオブジェクトをインスタンス化すると、その条件がコード内の1か所に表示されます。また、新しいコードが登場した場合は、1つのコードを変更するだけで済みます。

于 2008-10-24T17:33:40.380 に答える
2

ポリモーフィズムはOOの要の1つであり、確かに非常に便利です。複数のクラスに懸念を分割することにより、分離されたテスト可能なユニットを作成します。したがって、切り替えを行う代わりに...いくつかの異なるタイプまたは実装でメソッドを呼び出す場合は、複数の実装を持つ統一されたインターフェイスを作成します。実装を追加する必要がある場合、switch...caseの場合のようにクライアントを変更する必要はありません。これはリグレッションを回避するのに役立つため、非常に重要です。

インターフェイスという1つのタイプだけを処理することで、クライアントアルゴリズムを単純化することもできます。

私にとって非常に重要なのは、ポリモーフィズムは純粋なインターフェイス/実装パターン(由緒あるShape <-Circleなど)で最もよく使用されることです。テンプレートメソッド(別名フック)を使用して具象クラスにポリモーフィズムを設定することもできますが、複雑さが増すにつれてその有効性は低下します。

ポリモーフィズムは、当社のコードベースを構築する基盤であるため、非常に実用的だと思います。

于 2008-10-24T17:32:45.983 に答える
2

スイッチとポリモーフィズムは同じことを行います。

ポリモーフィズム (および一般的なクラスベース プログラミング) では、関数を型ごとにグループ化します。スイッチを使用する場合は、タイプを機能別にグループ化します。どちらのビューが適しているかを判断してください。

したがって、インターフェースが固定されていて、新しいタイプのみを追加する場合、ポリモーフィズムはあなたの味方です。ただし、インターフェイスに新しい関数を追加する場合は、すべての実装を更新する必要があります。

場合によっては、一定量の型があり、新しい関数が登場する可能性があり、その場合はスイッチの方が優れています。ただし、新しいタイプを追加すると、すべてのスイッチを更新する必要があります。

スイッチを使用すると、サブタイプ リストが複製されます。ポリモーフィズムを使用すると、操作リストが複製されます。問題を交換して別の問題を手に入れました。これは、私が知っているどのプログラミング パラダイムでも解決できない、いわゆる式の問題です。問題の根本は、コードを表すために使用されるテキストの一次元の性質にあります。

ポリモーフィズムを促進するポイントについてはここで十分に議論されているので、切り替えを促進するポイントを提示させてください。

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

私の「C-ハッカー症候群」と反 OOP 主義が、最終的にここでの私の評判をすべて焼き尽くしてしまうのではないかと心配しています。しかし、手続き型 C システムに何かをハックしたりボルトで固定したりする必要があったり、そうしなければならなかったりするときはいつでも、それは非常に簡単であることがわかりました。制約がなく、カプセル化が強制され、抽象化レイヤーが少ないため、「ただやるだけ」です。しかし、ソフトウェアの存続期間中に何十もの抽象化レイヤーが互いに積み重なる C++/C#/Java システムでは、他のプログラマーが直面するすべての制約と制限を正しく回避する方法を見つけるために、何時間も、時には何日も費やす必要があります。他の人が「クラスをいじる」のを避けるために、システムに組み込まれています。

于 2013-03-13T15:17:07.717 に答える
1

これは主に知識のカプセル化と関係があります。本当に明白な例であるtoString()から始めましょう。これはJavaですが、C++に簡単に移行できます。デバッグの目的で、人間にわかりやすいバージョンのオブジェクトを印刷するとします。あなたができること:

switch(obj.type): {
case 1: cout << "Type 1" << obj.foo <<...; break;   
case 2: cout << "Type 2" << ...

しかし、これは明らかにばかげているでしょう。なぜ1つのメソッドがすべてを印刷する方法を知っている必要があるのですか。多くの場合、オブジェクト自体がそれ自体を印刷する方法を知っている方がよいでしょう。例:

cout << object.toString();

これにより、toString()はキャストを必要とせずにメンバーフィールドにアクセスできます。それらは独立してテストすることができます。簡単に交換できます。

ただし、オブジェクトの印刷方法をオブジェクトに関連付けるのではなく、printメソッドに関連付ける必要があると主張することもできます。この場合、別のデザインパターンが役立ちます。これは、ダブルディスパッチを偽造するために使用されるVisitorパターンです。それを完全に説明することはこの答えには長すぎますが、ここで良い説明を読むことができます。

于 2008-10-24T17:39:45.267 に答える
0

どこでもswitchステートメントを使用している場合、アップグレード時に更新が必要な1つの場所を見逃す可能性があります。

于 2008-12-13T19:26:21.193 に答える
0

すべてのスイッチステートメントを見つけることは、成熟したコードベースでは簡単ではないプロセスになる可能性があることを繰り返し述べなければなりません。いずれかを見逃すと、デフォルトが設定されていない限り、caseステートメントが一致しないためにアプリケーションがクラッシュする可能性があります。

また、「リファクタリング」に関する「MartinFowlers」の本もチェックしてください
。ポリモーフィズムの代わりにスイッチを使用するのはコードの臭いです。

于 2009-01-19T16:42:44.853 に答える
0

あなたがそれを理解するなら、それは非常にうまく機能します。

ポリモーフィズムには 2 つのフレーバーもあります。最初のものは、Java 風に理解するのが非常に簡単です。

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

あなたが期待するものではないかもしれません。

自分が何をしているのか、ランタイム ポリモーフィズムを使用しているかどうかはわからないことに注意してください。過信しないでください。実行時に何かがどうなるかわからない場合は、テストしてください。

于 2008-10-24T18:23:27.333 に答える
-1

それは本当にあなたのプログラミングのスタイルに依存します。これはJavaまたはC#では正しいかもしれませんが、ポリモーフィズムを使用することを自動的に決定することが正しいことに同意しません。たとえば、コードを多くの小さな関数に分割し、関数ポインター(コンパイル時に初期化される)を使用して配列ルックアップを実行できます。C ++では、ポリモーフィズムとクラスが多用されることがよくあります。おそらく、強力なOOP言語からC ++に移行する人々が犯した最大の設計ミスは、すべてがクラスに入るということです。これは真実ではありません。クラスには、全体として機能させるための最小限のセットのみを含める必要があります。サブクラスや友達が必要な場合はそうですが、それが標準であってはなりません。クラスに対する他の操作は、同じ名前空間内の無料の関数である必要があります。ADLでは、これらの関数をルックアップなしで使用できます。

C++はOOP言語ではありません。OOP言語にしないでください。C++でCをプログラミングするのと同じくらい悪いです。

于 2008-10-24T17:39:50.353 に答える