はじめに
直近の案件では、状態を持つ型をイミュータブルに保つ方針で書いています。
戻り値やDTOはsealed recordにして、コレクションはIReadOnlyList<T>やIReadOnlyCollection<T>で受け渡します。
で、そのIReadOnlyList<T>の定義に飛んだらIReadOnlyList<out T>と書いてあって、イミュータブルを徹底したいのにoutを書いていいのか、そもそもoutって出力用じゃないのか、というところで引っかかりました。
自分は参照渡しのrefとoutの記事を書いたことがあります。
同じoutですが完全に別物というか、調べていくほどに混乱してきたので学習したものをまとめます。
共変性・反変性という用語でも説明できず、「参照渡しのrefやinよりは安全そう」くらいの理解のまま何年も使っていた自戒もこめて。
環境
.NET SDK 10.0.300
macOS
以下のエラー文は全部、実際にコンパイルして出たものを貼っています。
List<Dog>はList<Animal>に入らない
こういう型があるとします。
public abstract record Animal;
public sealed record Dog : Animal;
public sealed record Cat : Animal;
DogはAnimalなので、DogのリストはAnimalのリストとして扱えそうな気がしますよね。
通りません。
var dogs = new List<Dog> { new Dog() };
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<Dog>' to 'System.Collections.Generic.List<Animal>'
ICollection<Animal>で受けても同じです。
ICollection<Animal> animals = dogs;
// error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<Dog>' to 'System.Collections.Generic.ICollection<Animal>'. An explicit conversion exists (are you missing a cast?)
ところがIEnumerable<Animal>にすると何も言われずに通ります。
IEnumerable<Animal> animals = dogs; // 通る
IReadOnlyList<Animal> animals2 = dogs; // これも通る
同じList<Dog>を渡しているのに、受け側の型で結果が変わります。
うん????
なぜIEnumerableなら入るのか
危ないほうから考えると分かりやすいです。
仮にList<Animal> animals = dogs;が通るとしたら、こう書けてしまいます。
List<Animal> animals = dogs; // 通ると仮定する(本当はエラー)
animals.Add(new Cat()); // Animalのリストなので合法
animalsとdogsは同じインスタンスなので、List<Dog>の中にCatが入ります。
だめですよね。
危ないのはAdd(T)のように、Tを引数で受け取る場所(入力位置)です。
List<T>やICollection<T>はこれを持っているので、代入ごと禁止されています。
逆に、Tが戻り値として出てくる場所(出力位置)しかない型なら、この事故は起こりようがありません。
IEnumerable<Animal> animals = dogs; // 通る
foreach (var animal in animals)
{
// 出てくるのは実体としては全部Dog
// DogはAnimalなので、Animalとして扱える
}
IEnumerable<T>やIReadOnlyList<T>にAddはなく、Tは出てくるだけです。
出てくるものが全部Dogなら、それをAnimalとして受け取っても何も困りません。
この「Tは出力位置にしか使いません」をコンパイラに約束するキーワードがoutです。
約束できた型だけ、型引数を派生型から基底型の向きに代入できるようになります。
これが共変性で、C# 4.0で入りました。
共変は継承と同じ向き、反変は逆向きです。
反変については後述します。
インターフェースで試すと分かりやすいです。
public interface IBox<out T>
{
T Take(); // 出力位置。これだけなら通る
void Put(T item); // 入力位置。ここで落ちる
}
// error CS1961: Invalid variance: The type parameter 'T' must be contravariantly valid on 'IBox<T>.Put(T)'. 'T' is covariant.
T Take();だけのときはビルドが通ります。
void Put(T item);を1行追加したらエラーです。
outは読み取り専用にした型にしか付けられません。
実践
現在設計しているゲーム案件で利用してみました。
例えばミッションの達成判定サービスがあって、ゲーム内の出来事はMissionEventを基底とする継承階層で表しているとします。
public abstract record MissionEvent;
public sealed record RewardGrantedMissionEvent : MissionEvent;
public interface IMissionService
{
ValueTask<MissionEvaluationResult> EvaluateAsync(
Guid playerId,
IReadOnlyCollection<MissionEvent> missionEvents);
}
呼び出し側はList<RewardGrantedMissionEvent>をそのまま渡しています。
これが通っていたのはIReadOnlyCollection<out T>が共変だからで、引数をICollection<MissionEvent>に変えると即座に止まってくれます。
// void Evaluate(ICollection<MissionEvent> events) だった場合
Evaluate(events);
// error CS1503: Argument 1: cannot convert from 'System.Collections.Generic.List<RewardGrantedMissionEvent>' to 'System.Collections.Generic.ICollection<MissionEvent>'
なので当初の理解は間違えていて、
イミュータブルなのにoutが付いているのではなく、イミュータブル(出力専用)だからoutを付けられるが正しい解釈になるかと思います。
読み取り専用で設計したから共変にできる、という関係です。
反変(in)は向きが逆
outの逆がinで、Tを引数にしか使わない型に付きます。
デリゲートの共変・反変は実務で書いたことがなかったんですが、勉強のために書いてみました。
Action<Animal> animalAction = a => Console.WriteLine(a);
Action<Dog> dogAction = animalAction; // 通る
Action<Animal>はAnimalを受け取って処理するデリゲートで、Animalを処理できるものは当然Dogも処理できるので、基底型から派生型の向きに代入できるわけですね。
逆向きは通りません。
Action<Dog> dogAction = d => Console.WriteLine(d);
Action<Animal> animalAction = dogAction;
// error CS0266: Cannot implicitly convert type 'System.Action<Dog>' to 'System.Action<Animal>'. An explicit conversion exists (are you missing a cast?)
FuncのほうはTが戻り値なのでFunc<out TResult>、つまり共変です。
Func<Dog> dogFactory = () => new Dog();
Func<Animal> animalFactory = dogFactory; // 通る
ここまでを整理すると3種類しかありません。
| キーワード | Tを使える位置 | 代入できる向き | 代表的な型 | |
|---|---|---|---|---|
| 不変 | なし | 入力にも出力にも | 同じ型だけ |
List<T> ICollection<T>
|
| 共変 | out T |
出力(戻り値)だけ | 派生型→基底型 |
IEnumerable<out T> IReadOnlyList<out T> Func<out TResult>
|
| 反変 | in T |
入力(引数)だけ | 基底型→派生型 | Action<in T> |
配列は昔からゆるい
ここまでのエラーは全部コンパイル時に出ます。
配列だけは共変なのにコンパイラが止めてくれません。
Animal[] array = new Dog[1];
array[0] = new Cat();
// System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.
ジェネリクスの分散指定はC# 4.0で入りましたが、配列の共変性は.NET 1.0時代からある仕様です。
代わりに、要素を書き込むたび実行時の型チェックが回っています。
まとめ
調べた結果、自分の書き方が変わるところは特にありませんでした。
IEnumerable<T>もIReadOnlyList<T>もBCL側がoutを付けてくれているので、イミュータブル方針で書いていれば何もしなくても共変の代入が使えます。
自作のインターフェースにoutを付けるかどうかは、読み取り専用で確定しているかだけで決めればいいと思います。
取得系だけのつもりでoutを付けた型に、あとから登録系のメソッドを足したくなっても、CS1961が出て足せません。
inは自分で書く場面が思いつきませんでした。
デリゲートを受け取る自作のAPIを設計するならありそうですが、そこまでの必要に迫られたことはないです。
あと、参照渡しのref/out/inとは名前が同じだけで無関係なので、根本から理解間違えていました。
恥ずかしい。
自分はここを混同していたせいで、IReadOnlyList<out T>を見るたびに毎回引っかかっていました。
別物だと分けられただけで、だいぶすっきりしました。