0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】いい加減説明できるようにしたい共変性・反変性〜IEnumerable<out T>のout〜

0
Last updated at Posted at 2026-08-19

はじめに

直近の案件では、状態を持つ型をイミュータブルに保つ方針で書いています。
戻り値やDTOはsealed recordにして、コレクションはIReadOnlyList<T>IReadOnlyCollection<T>で受け渡します。

で、そのIReadOnlyList<T>の定義に飛んだらIReadOnlyList<out T>と書いてあって、イミュータブルを徹底したいのにoutを書いていいのか、そもそもoutって出力用じゃないのか、というところで引っかかりました。

自分は参照渡しのrefoutの記事を書いたことがあります。

同じoutですが完全に別物というか、調べていくほどに混乱してきたので学習したものをまとめます。
共変性・反変性という用語でも説明できず、「参照渡しのrefinよりは安全そう」くらいの理解のまま何年も使っていた自戒もこめて。

環境

.NET SDK 10.0.300
macOS
以下のエラー文は全部、実際にコンパイルして出たものを貼っています。

List<Dog>List<Animal>に入らない

こういう型があるとします。

public abstract record Animal;
public sealed record Dog : Animal;
public sealed record Cat : Animal;

DogAnimalなので、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のリストなので合法

animalsdogsは同じインスタンスなので、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>を見るたびに毎回引っかかっていました。
別物だと分けられただけで、だいぶすっきりしました。

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?