「牛が牧草を食うのが共変継承なのか?」の続きです。共変性(covariance)の問題を考えてみます。
まず、補足説明から
誤解を避けるため、いくつかの補足をしておきます。
- キッカケはEiffel FAQだが、Eiffel(あるいは他の何か特定言語)の話題ではなくて、一般的な話。
- Eiffel FAQのなかに、「共変規則」、「反変規則」という言葉は出てくるが、「共変(反変)の継承」という言い方は僕が採用したもの;“共変(反変)規則に従った継承”の意味です。
- 牛の例は、Eiffel FAQにほんとに書いてあります!
この話題の焦点は、サブクラスにおけるメソッドのオーバライド(引数型を変更しての再定義)に関する事柄だから、「共変(反変)オーバライド」、「共変(反変)再定義」とかの言葉が適切かもしれないですね。でも、「共変(反変)の継承」は、このエントリーでも使い続けます。
牛の継承は変な感じがする
継承における「反変 vs 共変」のハナシを、「混乱した奇妙な議論」と僕は言ったけど、問題設定/定式化がなんかオカシイと思うのですよ。なんでもかんでも“継承”という枠内で考えよう、解決しようって感じがするわけ。プログラミング言語が提供する機能が継承しかないなら、それも致し方ないけど、今やインターフェースや総称(generics)が(一部の言語では)利用可能だし、概念的には形式仕様だって使えますよ。継承に固執しなくてもいいでしょ。
それで、共変の継承だとされている事例の一部は(全部とは言わない)、継承でも何でもなくて、型パラメータの具体化に過ぎないのじゃないかと思うのですよ。あの牛の例も、型パラメータの具体化だろうと感じるのだけど:
class 草食動物 { public void eat(植物 food) { // ... } // ... } class 牛 extends 草食動物 { public void eat(牧草 food) { // ... } // ... }
牛に詳しくない僕には、この例を扱う能力はありません。別な例にします。
class List { public void add(Object item) { // ... } // ... } class IntegerList extends List { public void add(Integer item) { // ... } // ... }
共変の継承の問題点:再確認
上のList(常識的なリストだと思ってください)の例で次のコードは何の問題もありません。
List list = new List(); list.add("Hello"); list.add(new Date());
ここで、List型インスタンスの代わりにIntegerList型インスタンスを使用してみます。
List list = new IntegerList(); list.add("Hello"); list.add(new Date());
IntegerList型では、追加できる項目を型により制約しているので、list.add("Hello");
もlist.add(new Date());
も許されません。とは言っても、コンパイラがこのテの問題を完全に検出するのは不可能なので、実行時の型エラーとなるでしょう。
リスコフの置換原則(の教訓としての解釈)は、「スーパータイプのインスタンスを、サブタイプの対応するインスタンスに置き換えても、何の問題も起きないようにせよ」と主張しているので、IntegerList extends List という共変の継承はリスコフの置換原則を破っています。
「リスコフの置換原則が、そんなにエライのかよ、えっ、おい?」とか、「継承とサブタイプ/スーパータイプは別物と考えよう」とかの意見はあるでしょうが、ここでは次のことを原則にしておきます。
- サブタイプ/スーパータイプに関して、リスコフの置換原則は守る。
- 継承があれば、サブタイプ/スーパータイプの関係もあるとみなす。
これが僕の信条ってわけではないけど、何かの基準がないと話を進められないですからね。
総称リスト型とその具体化
ListとIntegerListの関係は、継承とみるよりは、次のような総称(型パラメータ付き)リスト型の2つの具体化とみなすほうが自然な気がします。
interface List<E> { public void add(E item); // ... }
List<E>はクラスではなくてインターフェースであり、ArrayList<Integer>などが実際の具体的リスト実装となります(Javaのコレクション・ライブラリがこうなってます)。
さて、問題は、List<Object>とList<Integer>の関係です。IntegerがObjectのサブタイプになっているので、List<Integer>もList<Object>のサブタイプになっているような気がします。が、既にみたように、List<Integer>とList<Object>はリスコフの置換原則を破るので、サブタイプ/スーパータイプだとは認められません。これは、継承として定式化しようが、総称の具体化として定式化しようが、それに関係なく、事実として原則違反があるからダメってことです。
それでは、List<Object>がList<Integer>のサブタイプになっている(型の上下関係が逆転の)可能性はどうでしょうか? これもダメです。次のコードを見てください。
// オートボクシング/アンボクシングが働くとする List<Integer> list = new ArrayList<Integer>(); // listへの操作 // あれこれ int x = list.get(3);
コンパイラは、int x = list.get(3);
の行で(IndexOutOfBoundsExceptionの発生はあり得るが)型エラーは起きないことを確信できます。しかし、ArrayList<Integer>をArrayList<Object>に置き換えると(それはできるとしても)、キャストがないint x = list.get(3);
を許すことはできません。このように取り扱いを変えなくてはいけないのでは、とてもサブタイプなんて呼べません。
法則性を期待しても、そうじゃないのだから仕方ない
以上のことから、FがEのサブタイプであっても:
- List<F>がList<E>のサブタイプとはならない。
- List<E>がList<F>のサブタイプともならない。
ことがわかりました。これは何か問題でしょうか?
型パラメータ付きのリスト型を、型を受け取って新しい型を返す一種の関数のように考えましょう。Listは、E → List<E> という対応を与えるわけです。いま臨時に集合の記号を拝借して F⊆E で「FがEのサブタイプ」を表すとすると、上に述べたことは次のように表現できます。
- F⊆Eであっても、List<F>⊆List<E> ではない。
- F⊆Eであっても、List<E>⊆List<F> ではない。
一般に、型を受け取って新しい型を返す関数Tが「E⊆F ならば T
- Listは共変に単調ではない。
- Listは反変に単調ではない。
となります。これは事実なので、文句を言っても仕方ありません。すべての型構成子(型を受け取って新しい型を返す関数)が単調なんてことはないのです。普通の関数に関しても、「すべての関数が単調(増加関数または減少関数)」なんて法則は聞いたことないでしょう。
これで解決したわけではない
さて、共変の継承が必要そうな事態が起きたとき、いつでもそれを総称と具体化で定式化し直せるか、というと、それは保証できません。そんなにうまくいくとは思えません。
例えばEiffelの設計者であるメイヤー(Bertrand Meyer)は、ヨットとカタマラン(双胴艇)という例を出してます。カタマランはヨットのサブクラスであり、カタマランの乗員(スキッパーやクルー)は、たんなるヨット乗りではなくて、カタマラン乗り(ヨット乗りのサブクラス)だそうです。ヨットを総称化(パラメータ化)して、その具体化としてカタマランを定義するのは、確かに難しそうな感じです。感じがするだけでよくわかりません。僕、カタマラン、あまり詳しくないし。
メイヤー先生の著書にはお世話になりました。随分と影響も受けました。でもでも、だけどね、牛*1だのカタマランだの、もう、そういう例出すのやめてくださいよ! ジェンジェン分からんもん。
カタマランはともかくとして、クラス/型のあいだの関係が、継承関係なのか、仕様と実装の関係か、それとも総称型とその具体化なのか、判断に苦しむことがあります。考えるべきことはまだあるってことですが、とりあえず僕は言っておきたい -- 牛で考えるのはやめよう。
*1:牛はメイヤー先生ご本人の例ではないようだけど。