継承と拡張と特化と部分集合?
「クラス、オブジェクト、型; なんだか変じゃない? - 檜山正幸のキマイラ飼育記 (はてなBlog)」について考えてみます。
「この継承は変な感じ」の「Point3D extends Point2Dは気持ち悪い」について。
概念としては、Point2Dは2次元平面の座標で、Point3Dは3次元空間の座標を表すわけだから、「Point3D extends Point2D」は「2次元座標を拡張して、3次元座標にする」という意味になります。これは特に問題はなさそうに聞こえます。
では、クラスの継承関係のことをUML界隈では「汎化-特化」といいます。これに当てはめて言うと「2次元座標を特化させて3次元座標にする」、または「3次元座標を汎化したものが2次元座標である」となりますね。なんだかこれは直感に反するように思えます。「Aを特化したものがBである。」と言う場合、BはAの部分集合になるはずですが、2次元座標が3次元座標を含んでいるなんてことはないでしょう。
これが「気持ち悪い」の正体でしょうか?
そこで、さらに別の書き方をしてみます。
(x, y) を 拡張 ⇒ (x, y, z) (x, y, undefined) を特化 ⇒ (x, y, z)
2行目に突然 undefined
が出現しています。
これはJavaScriptなんかのundefinedです。実際に、JavaScriptでPoint2Dクラスを作って、そのz
にアクセスすれば、その値はundefined
になるでしょう。この場合の「特化」は、「zが不定なPoint2Dから、zが具体的な値を持つPoint3Dへと特化させる」ということになります。これならな納得でるかも。
なので、「Point3D extends Point2D」は必ずしも間違いではないと思います。
ここまでが概念の話。
で、実際のコードのレベルでの話では、Point3DがPoint2Dを継承する価値はまったくないと思います。実際にコードを書いてみても、実装を共有できる部分はあまりないだろうし、インターフェース継承という面で見ても、Point2D型とPoint3D型をごちゃまぜにして扱いたいような処理は一つも考えつかないので。
また、Point2Dで引数にx,yを受け取るメソッドについてPoint3Dでどう振舞うかを決めなくてはなりません。普通はz=0として処理すると思いますが。Point2D型を引数に受け取るメソッドがあった場合にさらに厄介です。例えば、2点間の距離を求めるとか。A-B間の距離とB-A間の距離が違ったりするかも知れません。その場合、そのメソッドはヘルパークラスとかに移してしまえば厄介ごとを避けれるかも。
「こういう型をクラスで定義したいのだけど」の場合
- ASCII文字だけからなる文字列 AsciiString
- 非負の整数 NonNegativeInteger
- テストの点数として0から100までの整数 Score100
- 同様に0から1000までの整数 Score1000
これは明らかに「拡張」ではなく「特化」ですね。
この場合、僕は特に問題ないように思います。とくにjavaの場合、IntegerやStringは不変クラスなので。
2番なら以下のような感じでよいのでは?
public class NonNegativeInteger extends Integer { public NonNegativeInteger(int value) { super(value); if(value < 0) throw new IllegalArgumentException(); } }
実際にはあと、equalsとcompareToとhashCodeはオーバーライドしなければならないかも。その場合、「new NonNegativeInteger(1).equals(new Integer(1))
は true
か?」という問題があるけど、それは今は置いておきます。
なお、この実装の場合スーパークラスとサブクラスはコンストラクタしか違わないので、リスコフの置換原則にも違反しません。
Score100とScore1000も同じように作れますが、Integerを継承させる意味が不明なのでやめておいたほうがよいと思います。
「こういう型達の関係は」について
これも「特化」の事例。
僕ならとりあえず以下のように作ります。
class UserID { protected final int number; protected final String handle; UserID(int number) { this.number = number; } UserID(String handle) { this.handle = handle; } /** 会員番号、または0。0の場合はgetHandle()を使うこと。 */ int getNumber() { return nubmer; } /** ハンドルネーム、またはnull。nullの場合はgetNumber()を使うこと。 */ String getHandle() { return handle; } // ... }
あえて、さらにUserNumberクラスとUserHandleクラスがあってそれらが継承関係を持つなら、
abstract class UserID { /** @throws UnsupportedOperationException*/ abstract int getNumber() ; /** @throws UnsupportedOperationException*/ abstract String getHandle() ; // ... } class UserNumber extends UserID { protected final int number; UserNumber (int number) { this.number = number; } int getNumber() { return nubmer; } String getHandle() { throw new UnsupportedOperationException(); } } class UserHandle extends UserID { protected final String handle; UserHandle(int number) { this.number = number; } String getHandle() { return handle; } int getNumber() { throw new UnsupportedOperationException(); } }
でしょうか?どうにもメリットがなさそうですが。
「どっちに味方しますか」について
Aさんは、TextViewerを「拡張」してTextEditorを作ろうとしている。
Bさんは、TextEditorを表示機能のみに「特化」させてTextViewerを作ろうとしている。
Bさんの方針では、TextViewerはTextEditorの一種であるということになり、TextEditorは実際には編集可能かどうかは不定であると言うことです。リスコフの置換原則を破らないようにするためにも、TextEditorには isEditable()
メソッドが必要になるでしょう。*1
なのでAさんの方がスマートな気がします。実際の作業量はBさんの方が多分少なくなると思いますが。