※少し長くなりますので、時間のない方はまず こちらの記事 を読んでみてください。
プログラミング言語仕様の中でも、名前のわかりづらさと概念のややこしさが相まって、際立ってとっつきにくい「共変性」「反変性」。
どこで何を読んでも頭がこんがらがるばかりという方。
なんとなく使えているけど仕組みはよくわかっていないという方。
そのもやもやをいったんお引き受けします。
(お返しすることになったらごめんなさい)
一般的な解説とはあえて違った切り口で、かつ本質から逸れることなく、図を使わずに文章とコードのみで説明してみたいと思います。
互換性の概念であって状態遷移やデータフローの話ではありませんので、混乱を避けるため、(そもそも用語がわかりづらいという仮定で)語源を無視して、「変換」とか「変更」とか「→」といった表現はなるべく使わないようにしました。
初めにプログラミング言語における「共変性」「反変性」の概念を紐解いていきますが、その時点では理解がぼんやりでも問題ありません。
その後に掲載するコード(C# 例)と照らし合わせ、最終的に「そういうことか!」となっていただくのが目的です。
1. 現実世界の例
イメージを浮かべていただくため、たとえ話から入ります。
こういうのが苦手な方は飛ばしていただいてもかまいません。
現金会計の牛丼屋さんがいます。
肉にこだわりはありません。
ある日風邪をひいて寝込んでしまいました。
噂を聞いて、クレジットカードも使えるこだわりのチバザビーフ専門牛丼(以下「チバビ丼」)屋さんがやってきました。
「今日はうち定休日だから、代わりにやってあげるよ」
「ありがとう、助かるよ」
その日、チバビ丼屋さんが現金を受け取ってチバビ丼を出し、無事に代わりを務めることができました。
何日かして、今度はチバビ丼屋さんが風邪をひいてしまいました。
次は僕の番と、牛丼屋さんがやってきます。
「今日はうち定休日だから、代わりにやってあげるよ」
「ありがとう、助かるよ」
牛丼屋さんが代わりを務めることになりました。
早速お客さんがやってきます。
「カード使えますよね?」
「ああ、ごめんなさい。現金だけなんです」
「え? 前は使えたと思うんだけどな」
またお客さんがやってきます。
「チバビ丼、並!」
「ああ、ごめんなさい。今日は神戸産なんですよ」
「ええ? チバザビーフじゃないの?」
この話のポイントは「代わりが務まるか?」というところです。
(細かい設定の不備はご容赦ください)
牛丼屋さんの何がいけなかったのでしょうか?
お客さんはお店に期待をしています。
その期待に応えられれば、お客さんを引き継ぐことができます。
お客さんは牛丼屋に
・現金で支払えること
・牛丼を作ってもらうこと
を期待します。
チバビ丼屋さんはどちらの期待にも応えることができたので、代わりが務まりました。
一方、チバビ丼屋のお客さんはお店に
・現金だけでなくカードも使えること
・チバザビーフを使った牛丼を作ってもらうこと
を期待します。
牛丼屋さんはどちらの期待にも応えることができず、代わりが務まりませんでした。
特に理不尽なところはありませんね。
この話に納得できたなら、「共変性」「反変性」の原理を感覚的に理解しているということになります。
後はプログラムの世界で整理するだけです。
2. そもそも何についての話なのか?
型の入出力規格の互換性の話です。
ここで入出力規格とは、入力(主に引数)型、出力(主に戻り値)型の定義を指しています。
型自体に直接の継承関係がなくても、入出力規格に互換性があれば、代入を許可しても問題ないことがあります。
しかし、それを実現するためには言語による明示的なサポートが必要です。
どのような入力型、出力型で構成された関係なら代入が許可されるか。
それがメインテーマとなります。
T t = (U)u;
この代入が可能か?
左辺の型 T
└ 機能
├ 入力型 TIn
└ 出力型 TOut
↑ 代入したい
右辺の型 U
└ 機能
├ 入力型 UIn
└ 出力型 UOut
(1) 入力型、出力型それぞれの互換(上記 TIn-UIn, TOut-UOut) (2) 入出力機能を擁する型の互換(上記 T-U) という異なる二つのレベルの互換があります。
(1) によって (2) が決まる、という関係です。
これらを混同しないことがポイントです。
ここでの関心の中心は (2) の互換です。
入力型、出力型の互換性が、その機能を擁する型の互換性にどう影響するのか。
上のメインテーマはそう言い換えることもできます。
3. 前提となる言葉の定義
「変性」は、クラス階層(継承関係)における型の互換性に関する概念です。
基底型変数には派生型インスタンスを代入して扱うことができますが、このとき「代入互換性」があると言います。
これと同じ方向に、派生型に対して互換性を持つことを「共変」と言います。
反対の方向に、基底型に対して互換性を持つことを「反変」と言います。
どちらの互換もない場合は「不変」です(.NET では値型は不変です)。
4. 最も短い説明
共変性
派生型を返す処理も扱える。
(チバザビーフ専門でも牛丼屋を営業できる)
反変性
基底型を受け取る処理も扱える。
(金種を問わないお店も現金専門店を営業できる)
5. もう少し詳しく
共変性がサポートされると
「基底型を返す変数」に「派生型を返すオブジェクト」を代入できます。
使用する側は、基底型が返されることを想定していますが、その中身が派生型であっても不都合はないはずです。
(お客さんは牛丼を注文しますが、牛がチバザビーフでも不都合はないはずです)
反変性がサポートされると
「派生型を受け取る変数」に「基底型を受け取るオブジェクト」を代入できます。
使用する側は、派生型を渡しますが、それが基底型として扱われても不都合はないはずです。
(お客さんはクレジットカードを渡しますが、それが代金として扱われて不都合はないはずです)
6. 興味のある方はさらに進んで(ちょっと寄り道)
リスコフの置換原則(LSP:Liskov Substitution Principle)との関係
リスコフの置換原則では、基底型変数の中身を基底型オブジェクトから派生型オブジェクトに置き換えても、動作の妥当性が損なわれないことを保証すべきとしています。
そうすると使用する側は、中身の型を(基底型なのか、あるいはどの派生型なのかを)意識しなくてすみますね。
上記「もう少し詳しく」に記載しました共変性/反変性の「不都合はないはず」は、その継承関係においてリスコフの置換原則が守られていることが前提となります。
リスコフの置換原則に準拠するためのルールには、契約(コントラクト)に関するルールと変性に関するルールがあります。1
[契約に関するルール]
- メソッド開始時に成立しているべき事前条件(通常は引数)を派生型で強化(厳しく)することはできない。つまり、基底メソッドの条件を満たす引数は、派生型オーバーライドでも通す必要がある。
- メソッド終了時に成立しているべき事後条件(プロパティや戻り値など)を派生型で緩めることはできない。つまり、派生型オーバーライドの戻り値は、最低限、基底メソッドの戻り値条件を満たす必要がある。
- 基底型の不変条件(常に満たしているべき条件)は派生型でも維持されなければならない。
※このような契約を検証するためのツールとして、.NET には「コードコントラクト」という仕組みが用意されています。(開発は停滞しているようですので、新たな導入には慎重になった方がよさそうです)
[変性に関するルール]
派生型メソッドの引数型には反変性、戻り値型には共変性がなくてはならない。
※.NET Framework の CLR (Common Language Infrastructure) では、ジェネリック型とデリゲートでこの概念が使われています。オーバーライドメソッドで引数型や戻り値型を変えることはできないため、型の継承関係においてはもとより適用されません。(new 修飾子によって隠ぺいすると別の型で再定義できますが、基底型変数からは呼ばれませんので、ここでの議論からは外れます)
いかがでしょう。
おおよその概念はつかんでいただけたでしょうか。
上に書いたとおり、この時点ではぼんやりの理解でも心配いりません。
コード例
たいへんお待たせしました。
いよいよコードを見ていきましょう。
.NET における「共変性」「反変性」の例をあげていきます。
上述の概念と照らし合わせてご覧ください。
■配列
― 配列の共変性 ―
基底型配列変数(基底型を返す)に、派生型配列(派生型を返す)を代入できる。
// 参照型 B を参照型 A に代入できるなら、配列 B[] も A[] に代入できる。
string[] strings = new string[] { "a", "b" };
object[] objects = strings;
// 代入後の配列インスタンスは同一参照
Assert.IsTrue(Object.ReferenceEquals(objects, strings));
// ただし、要素に別の派生型の値を設定しようとすると実行時例外が発生する。
// 配列には値を返すだけでなく受け取る役目もあるのに、
// 丸ごと(本来適用すべきでない受け取り操作を含めて)共変を許してしまったことの弊害である。
try
{
objects[1] = 1;
Assert.Fail();
}
catch (ArrayTypeMismatchException ex)
{
// 「配列と互換性のない型の要素にアクセスしようとしました」
Console.WriteLine(ex.ToString());
}
// 配列の共変性は、不変である値型には適用されない。
/* コンパイルエラー「型 'int[]' を型 'object[]' に暗黙的に変換できません。」
objects = new int[] { 0, 1 };
*/
■デリゲート
― デリゲートの共変性(.NET 2.0 ~) ―
基底型を返すデリゲート変数に、派生型を返すオブジェクトを代入できる。
public delegate Base BaseGetter();
public Base GetBase()
{
return new Base();
}
public Derived GetDerived()
{
return new Derived();
}
public void SupportCovariance()
{
BaseGetter returnsBase = this.GetBase;
// 共変性のサポートがこの代入を可能にする。
BaseGetter returnsDerived = this.GetDerived;
}
― デリゲートの反変性(.NET 2.0 ~) ―
派生型を受け取るデリゲート変数に、基底型を受け取るオブジェクトを代入できる。
// EventArgs 型の引数を受け取るイベントハンドラ
private void EventArgsHandler(object sender, EventArgs e)
{
// :
}
public Contravariance()
{
// KeyDown が期待するイベント引数型は KeyEventArgs だが、
// 反変性のサポートによって EventArgs 型引数のイベントハンドラも登録できる。
this.KeyDown += this.EventArgsHandler;
}
■ジェネリック
― ジェネリックの共変性(.NET 4 ~) ―
基底型を戻り値型とする変数に、派生型を戻り値型とするオブジェクトを代入できる。
// これは .NET 3.5 でもできた。
IEnumerable<Derived> derivedEnumerable = new List<Derived>();
// IEnumerable<out T> のように共変であることを表す out キーワードで修飾されるようになった。
// 列挙だけなら Derived 型オブジェクトを Base 型として扱っても問題ない。
IEnumerable<Base> baseEnumerable = derivedEnumerable;
// 設定もできる場合、共変は許されない。
// (List<Base> は Derived 以外の派生型オブジェクトを受け取ることもあるから)
/* コンパイルエラー「変換できません」
List<Base> baseList = new List<Derived>();
List<Base> baseList = (List<Base>)(new List<Derived>());
*/
― ジェネリックの反変性(.NET 4 ~) ―
派生型を引数型とする変数に、基底型を引数型とするオブジェクトを代入できる。
Action<Base> baseAction = (target) => { target.DoSomething(); };
// Action<in T> のように、反変であることを表す in キーワードで修飾されるようになった。
// 基底型の引数に派生型のオブジェクトが渡されても問題ない。
Action<Derived> derivedAction = baseAction;
// 引数を渡す側は、派生型を渡してそれが基底型として扱われても不都合はないはず。
derivedAction.Invoke(new Derived());
もやもや、少し晴れたでしょうか。
初めに大きなことを言ってしまいましたが、一度読んだだけで完全にすっきりというわけにはいかないと思います。
(余計に混乱した、という方がいらしたら申し訳ありません)
一つでもピンとくる表現があったなら、書いた意味がありました。
またもやもやしたら戻ってきてください。
正しく命名してみました。
「共変性」「反変性」が絶望的にわかりづらいので○○○○性と命名し直してみた
-
《参考文献》Gary McLean Hall (著), 長沢 智治(監訳) (その他), クイープ (翻訳)『C#実践開発手法』日経BP社 ↩