ホーム < ゲームつくろー! < オブジェクト指向設計編 < 親の決まりを子が破っちゃいけない原則 : LSP

その7 親の決まりを子が破っちゃいけない原則 : LSP


 オブジェクト指向には、守るべき原則がいくつかあります。その3ではOCP(Open-Closed Principle:開放閉鎖原則)という大原則を紹介しました。この章で紹介するLSP(Liskov Substitution Principle:リスコフの置換原則)もまた大切な原則です。どういうものであるか、さっそく見ていくことにしましょう。



@ LSPって何だ?

 LSP(Liskov Substitution Principle:リスコフの置換原則)というのは、Barbara Liskovさんが1988年に唱えたオブジェクト指向の継承に関わる原則です(コードコンプリート第2版上巻p175)。これは、基本型で決められた約束を派生型で破ってはいけないという原則です。例えば、皆さんがCYourClassというクラスを作ったとします。クラスというのは振る舞いを持ちますから、そこには「ルール」が存在してきます。ある日、あなたはそのクラスを機能アップさせたいと思い派生クラスを作成しました。ところが、あなたはうっかりと基本型で定めた「ルール」を破る実装をしてしまったのです。月日が流れ、別の人があなたが作った派生クラスを利用することになりました。その時以下のような関数で問題が生じてきます。

void Func( CYourClass *p ){
   // あなたが作成した基本クラスのルールに従った計算をする関数。
   // しかし、後日作成された派生クラスのオブジェクトでは
   // ルールが破られているので正しく計算されない!!

}


 Func関数を作った人は「CYourClassは定められたルールに従っている」と思い込んでこの関数を作ります。ところが、派生クラスはそのルールを破っていますので、考えてもいない結果が出力されてしまいます。この関数を作った人は「何でだ!」とパニックに陥ってしまうわけです!

 このようにオブジェクト指向において基本クラスの約束を破った派生クラスを作成すると、とんでもない所で性質の悪いバグが出現してしまいます。
 「置換」というのは基本クラスを派生クラスに置き換えても、その意味が変わらないという事を表す「置換」です。もしくは「親の決まりを子が破っちゃいけない」とも言える原則です。次に、1つの例を示します。



A 肌で感じるLSPその1 : 消費税2重計算エラー

 その昔「消費税」などというものが無かった時に、ある人が商品クラスを作りました。

商品クラス
class Goods
{
protected:
   int Price;

public:
   int GetPrice();
   virtual void SetPrice( int price )
   {
      Price = price;   // 原価代入
   };
}
;

もちろん、Priceは原価です。この時、原価の決め方は色々あるかもしれないので、SetPrice関数を仮想関数として定義しました。

 1989年4月1日、日本に消費税が導入されました(調べましたよ(笑))。その時の税率は3%です。そこである人が(いらぬ)気をきかせて、上のクラスから消費税を計算したクラスを作りました。

内税商品クラス
class TaxGoods : public Goods
{
protected:
   int Price;

public:
   virtual void SetPrice( int price )
   {
      Price = (int)(price * 1.03);   // 消費税を計算
   }
}
;

 仮想関数をうまく使ったわけです。ちゃんと計算がされて便利なのですが、当初のクラスの約束はなくなってしまいました。それとほぼ同時期に、別の人が買い物の集計をするクラスを作成しました。

合計金額算出クラス
class CTotalPrice
{
protected:
   list<Goods*> GoodsList;
   double TaxRate;   // 消費税率

public:
   void RegistGoods( Goods *p );
   void SetTaxRate( double taxrate );
   int GetTotalPrice()
   {
      int TotalPrice = 0;
      list<Goods>::iterator it = GoodsList.begin();
      for(; it=GoodsList.end(); it++)
         TotalPrice += (int)( (*it)->GetPrice() * TaxRate );   // 消費税率を掛け算
   }
};

 CTotalPriceクラスは「引数のGoodsは原価だから消費税を掛けなくては」と考えたわけです。まったくもって正しい判断です。
 ところが、TaxGoodsクラスの内部計算を見ることが出来ない人が、このクラスを使って商品の値段を代入していき、それをCTotalPrice::RegistGoods関数にどんどん渡し、そして合計を計算しました。

「ちがうじゃん!」

計算結果はずれてしまっています。理由は言わずもがなですが、TaxGoodsクラスで一度消費税率が掛けられ、CTotalPriceでもう一度消費税が掛けられてしまったからです。

 このエラーの直接の原因はTaxGoodsクラスを作成した人が、基本クラスであるCGoodsの取り決めであった「原価を登録する」というルールを破ったところにあります。言わば、作る必要の無いクラスを作ってしまったわけです。CTotalPriceを作った人にしてみたら、まさかCTotalPrice::GetTotalPrice関数の引数に原価ではない値段がやってくるとは夢にも思っていないわけで、正に基本クラスのルールを破った派生クラスがもたらしたエラーといえます。

 明確であれ暗黙であれ、親クラスのルールを完全に守って子クラスを派生しないと、予期しないバグがいくらでも発生してしまうわけです。コンパイラが教えてくれないバグとしては、かなりに怖い部類に入ります。LSPを満たしていれば、このエラーは未然に回避されることになります。



B 素直な継承と正しいメンバ関数名を

 LSPは、親クラスのルールを子クラスがちゃんと守っているか否かで判断するわけですが、素直な継承をしている間は殆どがちゃんとLSPを満たしています。「素直」というのは曖昧な表現ですが、動物クラスから猫クラスを派生するといういわゆる通常感覚の派生のことです。以下の派生は危険な兆候です。

 ・ こじれた技巧的な派生
 ・ 基本クラスの一部分の機能しか使っていない+追加機能を付けた派生

 いずれも基本クラスの本来の性質が派生クラスで妙に変わってしまっている状態です。例えば、鳥クラスの派生型として「飛び魚クラス」「飛行機クラス」という派生をしてしまうのはLSPに違反していますこれは「鳥=飛ぶ」という認識とそれに関するインターフェイスから来るものですが、鳥が鳥類で生き物であるというクラスの基本性質を双方の派生クラスはちゃんと受け継いでいません。よって、「飛行機が餌を食べる」という類のエラーが起こる可能性があります。大抵の場合、変な派生をした時には違和感を覚えます。その時には、LSPを満たしていない可能性があります。

 クラスの基本性質は「メンバ関数」が物語ります。メンバ関数はクラスの振る舞いだからです。ですから、メンバ関数(仮想関数)の名前と振る舞いが一致していないとLSPが満たされず痛い目に会います。これは特に仮想関数を派生クラスにおいてオーバーライドする時に発生しやすいものです。例えば、直交座標の2D座標を定義するCPositionというクラスでSetPosition(float x, float y)という仮想関数を定義していたとします。次に、同じ2次元だからと極座標(距離と角度で定義)を定義するクラスをそこから派生させたとします。その時、SetPositionの引数を(x, y)ではなくて、(l, θ)とするよう勝手に変更してしまいました。こうなるとCPositionへのポインタを引数に取る関数が困ってしまいます。自分が扱っているのは直交座標か極座標か?それを気にする必要が出てきてしまうのです。そこに狂いが生じ、後戻り不可能なバグになってしまうのです。同じ「float型2つで位置を表す」という定義であったとしても、仮想関数の引数やメンバ変数の意味するところが違うわけですから、CPositonから極座標クラスを派生してはいけないわけです(正しくはCPositionクラスを極座標クラスでラップするか、素直に極座標クラスを新規に作ることです)。この手のミスは、気が付かないうちにしているものでして、それを防ぐためにも継承を使う時に「LSPに違反していないか」と自問自答する事が大切になってきます。



 LSPが満たされると、各クラスは自分の仕事に集中できるようになります。またOCPも成し遂げやすくなりますので、ぜひとも忘れないでおきたい原則です。