C# のジェネリックは「型パラメーター T を使って書く」というだけではありません。where 制約の全種類・IComparable<T> や IEquatable<T> の実装・共変性(covariance)と反変性(contravariance)・default(T) の挙動・型ごとに独立した静的メンバーなど、実務で必ず直面する深い話題が多数あります。
本記事では基本構文から、コレクション設計・パフォーマンス・型システムの深部まで体系的に解説します。配列と List<T> の使い分けは配列とList完全ガイドを、値型とボクシングの基礎は値型と参照型完全ガイドをあわせて参照してください。
ジェネリックが解決する2つの問題
ジェネリックが導入される前は、型安全性と再利用性をどちらも満たすコードを書けませんでした。
// 問題①: 型安全でない(実行時エラー)
var list = new System.Collections.ArrayList();
list.Add(1);
list.Add("text"); // 異なる型でも追加できてしまう
int n = (int)list[1]; // InvalidCastException: 実行時に初めて分かる
// 問題②: 値型のボクシングでパフォーマンス劣化
list.Add(42); // int → object へのボクシング(ヒープアロケーション)
int x = (int)list[0]; // object → int へのアンボクシング
// List<T>: 両方の問題を解決
var nums = new List<int>();
nums.Add(42); // ボクシングなし(int のまま格納)
// nums.Add("text"); // コンパイルエラー → 実行前に気づける
int y = nums[0]; // キャスト不要
| 比較項目 | ArrayList(非ジェネリック) |
List<T>(ジェネリック) |
|---|---|---|
| 型安全 | ✗(実行時キャストエラー) | ○(コンパイル時に型チェック) |
| 値型の格納 | ボクシング発生(ヒープ) | ボクシングなし(直接格納) |
| 読み取り | キャスト必須 | キャスト不要 |
| IntelliSense | 利かない(object型) | 完全に機能する |
| 現代の推奨 | 使わない | 使う |
ジェネリッククラスの定義
クラス名の後に <T> を付けて型パラメーターを宣言します。T は慣習的な名前で、意味のある名前(TKey・TValue・TEntityなど)を使うこともあります。
// 単一型パラメーター
public class Box<T>
{
private T _value;
public Box(T value) => _value = value;
public T Value => _value;
public override string ToString() => $"Box({_value})";
}
// 複数の型パラメーター(型パラメーター名に意味をもたせる)
public class Pair<TFirst, TSecond>
{
public TFirst First { get; init; }
public TSecond Second { get; init; }
public Pair(TFirst first, TSecond second)
=> (First, Second) = (first, second);
// 型パラメーターを逆にしたペアを返すジェネリックメソッド
public Pair<TSecond, TFirst> Swap() => new(Second, First);
}
// 使い方
var box1 = new Box<string>("Hello");
var box2 = new Box<int>(42);
Console.WriteLine(box1.Value); // Hello
Console.WriteLine(box2.Value); // 42
var pair = new Pair<string, int>("Alice", 30);
Console.WriteLine(pair.First); // Alice
var swapped = pair.Swap();
Console.WriteLine(swapped.First); // 30(int)
ジェネリックメソッドと型推論
メソッド単体にも型パラメーターを付けられます。引数の型からコンパイラが T を推論するため、呼び出し時に <型> を書かなくて済む場合がほとんどです。
// ジェネリックメソッドの定義(クラスとは独立した型パラメーター)
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
// 型推論: int と推論される(<int> を明示しなくてよい)
int result1 = Max(10, 20); // 20(T = int と推論)
string result2 = Max("apple", "banana"); // "banana"(T = string と推論)
// 型推論できない場合は明示する
static TOut Convert<TIn, TOut>(TIn value, Func<TIn, TOut> converter)
=> converter(value);
// TIn = string は引数から推論できるが TOut = int は推論できないので明示
int len = Convert<string, int>("Hello", s => s.Length); // 5
// 汎用スワップ: ref で値を入れ替える
static void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);
int x = 1, y = 2;
Swap(ref x, ref y);
Console.WriteLine($"x={x}, y={y}"); // x=2, y=1
where 制約 — 型パラメーターに条件を付ける
where T : 条件 を使って、受け入れる型を絞り込めます。条件を付けることで T が持つメンバーにアクセスできるようになります。
| 制約 | 意味 | 使いどころ |
|---|---|---|
where T : struct |
値型(int・bool・struct等) | Nullable<T> のような実装 |
where T : class |
参照型(null代入可) | ORM・DIコンテナ |
where T : class? |
参照型(nullable許容、C# 8+) | nullable参照型を受け入れる場合 |
where T : notnull |
非nullの値型または非null参照型(C# 8+) | null禁止コンテナ |
where T : new() |
パラメーターなしコンストラクタを持つ | ジェネリック内で new T() を呼ぶ |
where T : SomeClass |
そのクラスの派生クラス | ドメインエンティティの基底クラス |
where T : IInterface |
そのインターフェースを実装する型 | IComparable<T> の実装必須 |
where T : unmanaged |
アンマネージド型(C# 7.3+) | 高性能な unsafe コード・Span<T> |
// struct 制約: 値型のみ受け入れる(Nullable<T> と同じ原理)
public class ValueContainer<T> where T : struct
{
public T? Value { get; set; } // T? は値型の場合に Nullable<T> になる
public bool HasValue => Value.HasValue;
}
// new() 制約: new T() で実体化できる
public class Factory<T> where T : class, new()
{
public T Create() => new T();
public List<T> CreateMany(int count)
=> Enumerable.Range(0, count).Select(_ => new T()).ToList();
}
// 複数の制約を組み合わせる(カンマ区切りで AND 条件)
public class SortedRepository<T>
where T : class, IComparable<T>, new()
{
private readonly List<T> _items = new();
public void Add(T item)
{
_items.Add(item);
_items.Sort((a, b) => a.CompareTo(b)); // IComparable<T> のメンバーを使える
}
public T? Min() => _items.Count > 0 ? _items[0] : null;
}
// unmanaged 制約: 参照を含まない値型のみ(int, float, struct of only value types)
static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);
Console.WriteLine(SizeOf<int>()); // 4
Console.WriteLine(SizeOf<double>()); // 8
// SizeOf<string>(); // コンパイルエラー: string は参照型
IComparable<T> と IEquatable<T> の実装
カスタム型をジェネリックコレクションでソートしたり等値比較したりするには、これらのインターフェースを実装します。
// 気温データを表す値型: ソートと等値比較を実装
public readonly struct Temperature
: IComparable<Temperature>, IEquatable<Temperature>
{
public double Celsius { get; }
public Temperature(double celsius) => Celsius = celsius;
// IComparable<T>: ソート・Max/Min の基礎
// 戻り値: 負 = this < other, 0 = 等しい, 正 = this > other
public int CompareTo(Temperature other)
=> Celsius.CompareTo(other.Celsius);
// IEquatable<T>: == / Equals での比較(object.Equals より効率的)
public bool Equals(Temperature other)
=> Celsius == other.Celsius;
// object.Equals のオーバーライドも必ず合わせる
public override bool Equals(object? obj)
=> obj is Temperature other && Equals(other);
// GetHashCode も合わせてオーバーライド(辞書・セットで正しく動作させるため)
public override int GetHashCode() => Celsius.GetHashCode();
public static bool operator ==(Temperature a, Temperature b) => a.Equals(b);
public static bool operator !=(Temperature a, Temperature b) => !a.Equals(b);
public static bool operator < (Temperature a, Temperature b) => a.CompareTo(b) < 0;
public static bool operator > (Temperature a, Temperature b) => a.CompareTo(b) > 0;
public override string ToString() => $"{Celsius}°C";
}
// 使い方: List<T>.Sort() / Max / Min がそのまま使える
var temps = new List<Temperature>
{
new(36.5), new(38.2), new(35.1), new(37.0)
};
temps.Sort(); // IComparable<T> を使って昇順ソート
Console.WriteLine(temps[0]); // 35.1°C(最小値)
Console.WriteLine(temps.Max()); // 38.2°C(LINQ の Max も使える)
// HashSet / Dictionary でも正しく動作(Equals + GetHashCode が必要)
var set = new HashSet<Temperature> { new(36.5), new(36.5), new(37.0) };
Console.WriteLine(set.Count); // 2(重複が除去される)
IComparable<T> は型自身が「自分の比較方法」を持つインターフェースです(class Temperature : IComparable<Temperature>)。IComparer<T> は「外部の比較者」で、同じ型を複数の基準でソートしたいとき(氏名順・年齢順など)に使います。List<T>.Sort(IComparer<T>) に渡せます。default(T) — 型パラメーターのデフォルト値
ジェネリックコードでは T が何型かわからないため、null や 0 を直接書けません。default(T)(または default)を使います。
// default(T) の値
Console.WriteLine(default(int)); // 0
Console.WriteLine(default(bool)); // False
Console.WriteLine(default(double)); // 0
Console.WriteLine(default(string)); // (null: 空文字列ではない)
Console.WriteLine(default(DateTime)); // 0001/01/01 00:00:00
// ジェネリックコードでの使い方
static T GetValueOrDefault<T>(T? value) where T : struct
=> value ?? default; // value が null の場合 default(T) を返す
Console.WriteLine(GetValueOrDefault<int>(null)); // 0
Console.WriteLine(GetValueOrDefault<double>(3.14)); // 3.14
// 参照型の default は null
static T? FirstOrDefault<T>(IEnumerable<T> source, T? fallback = default)
{
foreach (var item in source) return item;
return fallback; // default は null(参照型)または ゼロ系(値型)
}
// キャッシュパターンでの活用
public class LazyCache<T> where T : class
{
private T? _cache = default; // null(参照型)
public T Get(Func<T> factory)
=> _cache ??= factory(); // null なら factory を呼ぶ
}
// C# 7.1+ では型を省略して default だけでも使える
int x = default; // 0
bool b = default; // false
string? s = default; // null
共変性(covariance)と反変性(contravariance)
ジェネリック型の継承関係に関する「out/in」キーワードは、インターフェースやデリゲートで安全な型変換を可能にする重要な機能です。
共変性(out T)— より派生した型に代入できる
// 動物クラス階層
class Animal { public virtual string Name => "Animal"; }
class Dog : Animal { public override string Name => "Dog"; }
class Cat : Animal { public override string Name => "Cat"; }
// NG: List<Dog> は List<Animal> に代入できない
// List<Dog> dogs = new List<Dog> { new Dog() };
// List<Animal> animals = dogs; // コンパイルエラー!
// → animals.Add(new Cat()); が可能になってしまい、Dog リストに Cat が混入する危険
// OK: IEnumerable<T> は out T(共変)なので安全
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs; // OK! IEnumerable<Dog> → IEnumerable<Animal>
// → IEnumerable<T> は読み取り専用なので Cat を追加する危険がない
foreach (Animal a in animals)
Console.WriteLine(a.Name); // Dog, Dog
// IReadOnlyList<T> も out T(共変)
IReadOnlyList<Dog> dogList = new List<Dog> { new Dog() };
IReadOnlyList<Animal> animalList = dogList; // OK
// IReadOnlyCollection<T> も共変
IReadOnlyCollection<Dog> dogCol = new List<Dog> { new Dog() };
IReadOnlyCollection<Animal> animalCol = dogCol; // OK
反変性(in T)— より基底の型に代入できる
// Action<T>: in T(反変)の例 Action<Animal> printAnimal = a => Console.WriteLine(a.Name); // Action<Animal> を Action<Dog> に代入できる(反変) Action<Dog> printDog = printAnimal; // OK! // → Action<Animal> は Animal を受け取れる → Dog(Animal の派生)も受け取れる printDog(new Dog()); // Dog(Animal として扱われる) // IComparer<T>: in T(反変)の例 IComparer<Animal> animalComparer = Comparer<Animal>.Default; IComparer<Dog> dogComparer = animalComparer; // OK(反変) // Func<TIn, TOut>: TIn が反変(in)、TOut が共変(out) Func<Animal, string> getName = a => a.Name; Func<Dog, string> getDogName = getName; // OK(引数: 反変、戻り値: 共変) string name = getDogName(new Dog()); Console.WriteLine(name); // Dog
// out T: 共変インターフェース(T を返すのみ)
interface IProducer<out T>
{
T Produce(); // OK: T を返す(生産者)
// void Consume(T item); // NG: out T のインターフェースで T を受け取ると違反
}
// in T: 反変インターフェース(T を受け取るのみ)
interface IConsumer<in T>
{
void Consume(T item); // OK: T を受け取る(消費者)
// T Produce(); // NG: in T のインターフェースで T を返すと違反
}
// IProducer<out T> は共変なので派生型 → 基底型への代入が可能
class AnimalProducer : IProducer<Animal>
{
public Animal Produce() => new Animal();
}
IProducer<Animal> animalProducer = new AnimalProducer();
IProducer<object> objProducer = animalProducer; // OK(out T = 共変)
out T(共変): 「生産者」。T を返す(生み出す)だけで受け取らない →
IEnumerable<T>・IReadOnlyList<T>。代入方向: 派生型 → 基底型(IEnumerable<Dog> → IEnumerable<Animal>)。in T(反変): 「消費者」。T を受け取る(消費する)だけで返さない →
Action<T>・IComparer<T>。代入方向: 基底型 → 派生型(Action<Animal> → Action<Dog>)。なぜ List<T> は共変でないのか:
List<T> は T を追加(受け取り)も取得(返し)もできるため、out/in どちらも付けられません。クローズドジェネリック型の静的メンバー
ジェネリッククラスに静的メンバーを定義すると、型引数ごとに独立した静的メンバーが生成されます。これは C# の型システムの重要な特性です。
// 型別インスタンス数をカウントするジェネリッククラス
public class InstanceCounter<T>
{
// _count は InstanceCounter<int> と InstanceCounter<string> で独立している
private static int _count = 0;
public InstanceCounter() => Interlocked.Increment(ref _count);
public static int Count => _count;
}
var i1 = new InstanceCounter<int>();
var i2 = new InstanceCounter<int>();
var s1 = new InstanceCounter<string>();
Console.WriteLine(InstanceCounter<int>.Count); // 2 (int 用の _count)
Console.WriteLine(InstanceCounter<string>.Count); // 1 (string 用の _count)
// 応用: 型別キャッシュ(型安全なグローバルキャッシュ)
public static class TypeCache<T>
{
// T ごとに独立したキャッシュ辞書
private static readonly Dictionary<string, T> _cache = new();
public static void Set(string key, T value) => _cache[key] = value;
public static T? Get(string key) => _cache.TryGetValue(key, out var v) ? v : default;
public static int Size => _cache.Count;
}
TypeCache<int>.Set("age", 30);
TypeCache<string>.Set("name", "Alice");
Console.WriteLine(TypeCache<int>.Get("age")); // 30
Console.WriteLine(TypeCache<string>.Get("name")); // Alice
Console.WriteLine(TypeCache<int>.Size); // 1
Console.WriteLine(TypeCache<string>.Size); // 1(別キャッシュ)
ジェネリッククラスの静的フィールドは型引数ごとに独立しています。たとえばシングルトンパターンを実装する場合、
Singleton<int>とSingleton<string>は別々のインスタンスを持つことになります。型引数に関係なく1つの静的メンバーを共有したい場合は、非ジェネリックな基底クラスに静的メンバーを置いてください。ジェネリッククラスの継承
ジェネリッククラスを継承する方法は2種類あります。型パラメーターをそのまま引き継ぐ場合と、具体的な型に固定する場合です。
// 基底クラス
public class Repository<T> where T : class
{
protected readonly List<T> _items = new();
public virtual void Add(T item) => _items.Add(item);
public virtual IReadOnlyList<T> GetAll() => _items;
}
// パターン①: 型パラメーターをそのまま引き継ぐ
public class AuditRepository<T> : Repository<T> where T : class
{
private readonly List<string> _log = new();
public override void Add(T item)
{
_log.Add($"{DateTime.Now}: Added {typeof(T).Name}");
base.Add(item);
}
public IReadOnlyList<string> GetLog() => _log;
}
// パターン②: 型引数を具体的な型に固定する
public class User { public string Name { get; init; } = ""; }
public class UserRepository : Repository<User> // T = User に固定
{
public User? FindByName(string name)
=> _items.FirstOrDefault(u => u.Name == name);
}
// 使い方
var auditRepo = new AuditRepository<User>();
auditRepo.Add(new User { Name = "Alice" });
auditRepo.Add(new User { Name = "Bob" });
Console.WriteLine(auditRepo.GetLog()[0]); // ...: Added User
var userRepo = new UserRepository();
userRepo.Add(new User { Name = "Charlie" });
Console.WriteLine(userRepo.FindByName("Charlie")?.Name); // Charlie
カスタムジェネリックコレクションの実装例
標準コレクションの設計を理解するために、MinStack<T>(最小値を O(1) で取得できるスタック)を実装します。
// IComparable<T> 制約: T の大小比較が必要
public class MinStack<T> where T : IComparable<T>
{
// 通常スタック + 最小値履歴スタックの2本構成
private readonly Stack<T> _data = new();
private readonly Stack<T> _minData = new();
public int Count => _data.Count;
public bool IsEmpty => _data.Count == 0;
public void Push(T item)
{
_data.Push(item);
// 最小値スタックは「現在の最小値以下」のときだけ積む
if (_minData.Count == 0 || item.CompareTo(_minData.Peek()) <= 0)
_minData.Push(item);
}
public T Pop()
{
if (IsEmpty) throw new InvalidOperationException("スタックが空です");
var top = _data.Pop();
if (top.CompareTo(_minData.Peek()) == 0)
_minData.Pop();
return top;
}
public T Peek() => IsEmpty
? throw new InvalidOperationException("スタックが空です")
: _data.Peek();
// O(1) で現在の最小値を取得
public T Min() => _minData.Count == 0
? throw new InvalidOperationException("スタックが空です")
: _minData.Peek();
}
// 使い方
var stack = new MinStack<int>();
stack.Push(3);
stack.Push(1);
stack.Push(4);
stack.Push(1);
stack.Push(5);
Console.WriteLine(stack.Min()); // 1(O(1))
stack.Pop(); // 5
stack.Pop(); // 1(上の 1)
Console.WriteLine(stack.Min()); // 1(まだ残っている)
stack.Pop(); // 4
Console.WriteLine(stack.Min()); // 1
// string でも動作する(IComparable<string> 実装済み)
var strStack = new MinStack<string>();
strStack.Push("banana");
strStack.Push("apple");
strStack.Push("cherry");
Console.WriteLine(strStack.Min()); // apple(辞書順最小)
パフォーマンス — ボクシング回避の仕組み
ジェネリックが値型のパフォーマンスを守る理由は、CLR(JIT コンパイラ)の型特殊化にあります。
// 非ジェネリック: object に統一 → 値型はボクシング発生
static object BoxedMax(object a, object b)
=> ((IComparable)a).CompareTo(b) >= 0 ? a : b;
// ジェネリック: 値型用に JIT が特殊化されたコードを生成(ボクシングなし)
static T GenericMax<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
// GenericMax<int>: JIT が int 専用のネイティブコードを生成
// → int は値型なのでスタック上で計算、ヒープ確保ゼロ
int result = GenericMax(100_000, 200_000);
// BoxedMax: 引数をヒープにボクシング → 比較 → アンボクシング
object boxed = BoxedMax(100_000, 200_000); // ボクシング 2回発生
// CLR の型特殊化ルール:
// ・値型(int, double, struct等)ごとに別々のネイティブコードが生成される
// → List<int> と List<double> は完全に別のコードを持つ
// ・参照型はすべて同じネイティブコードを共有する(ポインタサイズが同じため)
// → List<string> と List<object> は同じコードを使いまわす
// List<T> の格納はボクシングなし
var ints = new List<int>(1_000_000);
for (int i = 0; i < 1_000_000; i++)
ints.Add(i); // ボクシングなし: int はそのまま内部配列に格納される
// ArrayList の場合: 全要素をボクシングするため遅く、メモリを多く使う
var boxedList = new System.Collections.ArrayList();
for (int i = 0; i < 1_000_000; i++)
boxedList.Add(i); // ボクシング 100万回発生
List<string>・List<object>・List<MyClass> はすべて同じ JIT コードを使い回します(ポインタのサイズが同じため)。値型(List<int>・List<double>・List<MyStruct>)はそれぞれ別のコードが生成されます。アセンブリが若干肥大化するトレードオフがありますが、ボクシングゼロの恩恵が上回ります。よくある質問
T が参照型か値型かをコード内で判定できますか?typeof(T).IsValueType で判定できます。ただしジェネリックメソッドの最適化はコンパイル時の制約(where T : struct / where T : class)に基づくため、実行時チェックより制約を使う方が型安全かつ高速です。IComparable<T> を実装すれば型安全ですが、IComparable(非ジェネリック)も実装しておくと旧 API との互換性が保てます。新規コードでは IComparable<T> のみで十分です。List<Dog> を List<Animal> に代入できないのに IEnumerable<Dog> は IEnumerable<Animal> に代入できるのはなぜですか?IEnumerable<T> は out T(共変)で宣言されており、T を「返す」しかできないためです。読み取り専用なので Cat を Dog のコレクションに追加してしまうような危険な操作が起きません。一方 List<T> は Add(T)(受け取り)も T this[int](返し)も持つため、共変・反変どちらも宣言できず代入はできません。int は整数除算、double は浮動小数点など)はオーバーロードが適切です。特定の型を優先する「特殊化」が必要なとき(string 用の高速パス)はオーバーロードと組み合わせます。まとめ
| 機能・概念 | ポイント |
|---|---|
| ジェネリックの目的 | ボクシングなし + コンパイル時型安全。ArrayListは使わない |
| 複数型パラメーター | <TKey, TValue>で2つ以上の型を汎用化できる |
| where制約 | 8種類: struct/class/class?/notnull/new()/基底クラス/インターフェース/unmanaged |
| IComparable<T> | Sort/Max/Min に必要。比較結果は 負/0/正 の3値 |
| IEquatable<T> | Equals を型安全に実装。HashSet/Dictionary のキーに必須 |
| default(T) | 値型: ゼロ系、参照型: null。ジェネリックコードで安全なデフォルト値を得る |
| out T(共変) | 返すのみ。派生型→基底型への代入可。IEnumerable<T>・IReadOnlyList<T> |
| in T(反変) | 受け取るのみ。基底型→派生型への代入可。Action<T>・IComparer<T> |
| 静的メンバー | 型引数ごとに独立した静的メンバーが生成される |
| パフォーマンス | 値型は JIT が特殊化しボクシングなし。参照型はコード共有 |
ジェネリックを使ったコレクションの詳細は配列とList完全ガイド、ジェネリックと深く関わる値型・参照型の仕組みは値型と参照型完全ガイド、ラムダ式での Func<T>・Expression<Func<T>> との関係はラムダ式完全ガイドを参照してください。