本文へスキップ
ウェブエンジニア問題集
第13章

C#のジェネリクス — 型パラメータとwhere制約の使い方

約6分
この章の目次開く

List<T>、Dictionary<TKey, TValue>、Task<T>。ここまで何度も出てきた <> の記法がジェネリクスです。

役割は「型を後から決められるようにする」こと。これにより、型の安全性を保ったまま同じコードを使い回せます。

なぜ必要なのか

ジェネリクスが無い場合、あらゆる型を入れられる入れ物は object で作ることになります。

var list = new ArrayList();   // 古い、型を持たないリスト
list.Add(1);
list.Add("テキスト");          // 何でも入ってしまう
 
int n = (int)list[1];          // 実行時に例外
csharp

List<T> なら、入れる型をコンパイル時に固定できます。

var list = new List<int>();
list.Add(1);
// list.Add("テキスト");  ← コンパイルエラー
 
int n = list[0];   // キャスト不要
csharp
ジェネリクスの利点は「キャストが要らない」ことと「間違った型を入れた瞬間にコンパイルエラーになる」ことです。

値型を object として扱うと、ヒープへコピーされるボックス化が発生し、性能面でも不利になります。ジェネリクスはこれも避けられます。

ジェネリッククラスを作る

型パラメータはクラス名のあとに <T> と書きます。

public class Box<T>
{
    private T _value;
 
    public Box(T value) => _value = value;
 
    public T Get() => _value;
}
 
var intBox = new Box<int>(42);
var nameBox = new Box<string>("田中");
 
int n = intBox.Get();       // int として取り出せる
string s = nameBox.Get();   // string として取り出せる
csharp
慣習的な名前意味
T型パラメータが1つのとき
TKey / TValueキーと値
TResult戻り値の型
TSource入力データの型

型パラメータ名は T で始めるのがC#の慣習です。

ジェネリックメソッド

クラス全体ではなく、メソッド単位でも型パラメータを取れます。

public static T? FirstOrNull<T>(IEnumerable<T> source, Func<T, bool> predicate)
    where T : class
{
    foreach (var item in source)
    {
        if (predicate(item)) return item;
    }
    return null;
}
csharp

呼び出すときは、引数から型が推論されるため <> を書く必要はありません。

var user = FirstOrNull(users, u => u.Age > 30);
csharp
組み立てるイメージ
ジェネリクスは「型という部品を差し替えられる設計図」を書く仕組みです

where制約

型パラメータは、そのままでは「何が入るか分からない型」なので、メンバーを呼べません。where で条件を付けると、その範囲でできることが増えます。

制約意味
where T : class参照型であること
where T : struct値型であること
where T : new()引数なしコンストラクタを持つこと
where T : IComparable<T>特定のインターフェースを実装していること
where T : User特定のクラスかその派生であること
where T : notnullnullを許容しない型であること
public static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}
csharp

IComparable<T> を要求したことで、CompareTo を呼べるようになりました。制約が無ければ、この行はコンパイルできません。

学習者学習者

TypeScriptの extends と同じような役割ですか?

考え方は同じです。TypeScriptの function max<T extends Comparable>(...) にあたるのが where T : IComparable<T> です。複数の制約を並べることもできます。

public class Repository<T> where T : class, IEntity, new()
{
}
csharp

TypeScriptのジェネリクスとの違い

最大の違いは、C#では型パラメータの情報が実行時にも残ることです。

var list = new List<int>();
Console.WriteLine(list.GetType());  // System.Collections.Generic.List`1[System.Int32]
csharp

TypeScriptのジェネリクスはコンパイル時に消えるため、実行時に T が何だったかを知る方法はありません。C#では実行時にも判別できるため、次のようなことが可能になります。

できること例
型ごとに別のインスタンスとして扱うList<int> と List<string> は実行時にも別の型
型を見て依存を解決するDIコンテナが IRepository<User> を解決する
型情報からシリアライズするJSONの変換で型に応じた処理を行う
「TypeScriptの型は消える、C#の型は残る」という 第1章で触れた違い が、いちばん具体的に効いてくるのがジェネリクスです。

実務での使いどころ

自分でジェネリッククラスを書く機会は、最初のうちはそれほど多くありません。まずは既存のジェネリック型を正しく読めることが重要です。

Task<List<User>> GetUsersAsync();
csharp

これは「いずれ User のリストを返す非同期処理」と読みます。入れ子になっていても、外側から順に読めば意味は取れます。

自分で書く典型例は、共通のデータアクセス層です。

public interface IRepository<T> where T : class
{
    Task<T?> FindAsync(int id);
    Task<List<T>> GetAllAsync();
    Task AddAsync(T entity);
}
csharp

IRepository<User> と IRepository<Order> を、同じ設計で扱えるようになります。

よくあるハマりどころ

制約が無くてメンバーを呼べない

public T Max<T>(T a, T b)
{
    return a.CompareTo(b) >= 0 ? a : b;  // コンパイルエラー
}
csharp

型パラメータに何が入るか分からない状態では、object が持つメンバーしか呼べません。必要な能力を where で宣言します。

何でもジェネリックにする

型パラメータが3つ4つと増えると、呼び出し側が型を書くだけで疲れます。実際に複数の型で使い回す見込みが立ってから導入すれば十分です。

default(T) の値を勘違いする

T value = default;   // 参照型なら null、値型なら 0 や false
csharp

T が値型か参照型かで結果が変わります。null を返したいなら where T : class を付けて T? を返すのが明確です。

先生先生

「使い回したい形が2回以上出てきたらジェネリック化」くらいの感覚でちょうどいいよ。

ちゃんと使うためのポイント

  • ジェネリクスは「型を後から決める」仕組み。キャスト不要で型安全になる
  • 型パラメータ名は T、TKey、TResult などの慣習に従う
  • 型パラメータのメンバーを使うには where 制約が必要
  • C#の型パラメータは実行時にも残る(TypeScriptとの決定的な差)
  • まずは既存のジェネリック型を読めること。自作は必要になってから

次の章では、いよいよ最後のテーマです。record とパターンマッチングという、モダンなC#を特徴づける機能を扱います。

参考リンク