C# ジェネリックとは?使い方・メリット・制約を初心者向けに徹底解説

はじめに

C#でプログラムを書いていると、List<string>Dictionary<int, string>のように、型名の後ろへ< >を付けたコードを頻繁に目にします。この仕組みが「ジェネリック」です。

ジェネリックを使うと、処理の内容は共通化しながら、扱うデータ型だけを後から指定できます。たとえば、文字列を保存するクラスと整数を保存するクラスを別々に作らなくても、1つのジェネリッククラスで両方に対応できます。

ジェネリックには、次のようなメリットがあります。

  • コンパイル時に型の間違いを検出できる

  • 同じ処理を複数の型で再利用できる

  • 不要なキャストを減らせる

  • 値型を扱う際のボックス化を避けられる

  • 共通処理を保守しやすくなる

一方で、型パラメーターに対して使用できる操作や、where句による制約など、初心者がつまずきやすいポイントもあります。

この記事では、C#のジェネリックとは何か、基本的な使い方、メリット、制約、実用例、注意点まで、サンプルコードを交えながら解説します。

1. C#のジェネリックとは?

1-1. ジェネリックの基本概念:型を後から指定できる仕組み

C#のジェネリックとは、クラスやメソッドを定義するときに具体的なデータ型を固定せず、使用時に型を指定できる仕組みです。

たとえば、値を1つ保存するクラスを考えてみましょう。文字列専用のクラスを作る場合は、次のように記述できます。

C#
public class StringBox
{
public string Value { get; set; }
}

整数も保存したくなった場合、ジェネリックを使わなければ別のクラスが必要です。

C#
public class IntBox
{
public int Value { get; set; }
}

この2つのクラスは、扱う型が違うだけで構造は同じです。ジェネリックを使えば、次のように1つのクラスへまとめられます。

C#
public class Box<T>
{
public T Value { get; set; }
}

使用時に、Tへ具体的な型を指定します。

C#
Box<string> stringBox = new Box<string>();
stringBox.Value = "C#";

Box<int> intBox = new Box<int>();
intBox.Value = 100;

Box<string>ではTstringに、Box<int>ではTintに置き換わります。

このように、処理や構造を共通化しながら、データ型だけを後から決められることがジェネリックの基本です。

1-2. なぜジェネリックが必要なのか

ジェネリックが必要とされる主な理由は、型安全性とコードの再利用性を両立するためです。

型ごとに同じクラスやメソッドを作る方法でも処理は実現できます。しかし、対応する型が増えるたびに似たコードを追加しなければなりません。

C#
public int GetFirst(int[] values)
{
return values[0];
}

public string GetFirst(string[] values)
{
return values[0];
}

この方法では、doubleDateTimeにも対応したくなると、さらにオーバーロードを追加する必要があります。

ジェネリックメソッドを使えば、1つの定義で複数の型に対応できます。

C#
public T GetFirst<T>(T[] values)
{
return values[0];
}

使用例は次のとおりです。

C#
int number = GetFirst(new[] { 10, 20, 30 });
string name = GetFirst(new[] { "田中", "佐藤", "鈴木" });

同じロジックを何度も書かずに済むため、修正箇所も少なくなります。型を間違えた場合はコンパイル時に検出されるので、実行時エラーの防止にもつながります。

1-3. 型パラメーター「T」の意味

ジェネリックでよく使われるTは、具体的な型の代わりとなる「型パラメーター」です。一般的に、Tは「Type」の頭文字として使われます。

C#
public class Box<T>
{
public T Value { get; set; }
}

ここでのTは変数ではなく、型を表すための仮の名前です。

C#
Box<string> box = new Box<string>();

このコードでは、Tstringとして扱われます。そのため、Valueプロパティの型もstringになります。

C#
box.Value = "Hello";

次のように整数を代入すると、コンパイルエラーになります。

C#
box.Value = 123; // コンパイルエラー

型パラメーター名はTである必要はありません。用途が分かる名前を付けることもできます。

C#
public class Result<TData>
{
public TData Data { get; set; }
}

複数の型パラメーターを使う場合は、役割に応じて次のような名前がよく使われます。

  • TKey:キーの型

  • TValue:値の型

  • TResult:戻り値の型

  • TItem:要素の型

  • TEntity:エンティティの型

1-4. object型やオーバーロードとの違い

さまざまな型を扱う方法として、ジェネリック以外にobject型やメソッドのオーバーロードがあります。

object型は、ほぼすべての型の値を受け取れます。

C#
public class ObjectBox
{
public object Value { get; set; }
}

次のように、文字列と整数のどちらも代入できます。

C#
ObjectBox box = new ObjectBox();

box.Value = "C#";
box.Value = 100;

柔軟に見えますが、値を取り出すときにキャストが必要です。

C#
box.Value = "C#";

string text = (string)box.Value;

型を間違えると、実行時に例外が発生します。

C#
box.Value = "C#";

int number = (int)box.Value; // 実行時に例外

一方、ジェネリックでは使用時に型が決まるため、不正な代入をコンパイル時に検出できます。

C#
Box<string> box = new Box<string>();
box.Value = "C#";

string text = box.Value;

オーバーロードは、引数の型や数が異なる同名メソッドを複数定義する仕組みです。

C#
public void Print(int value)
{
Console.WriteLine(value);
}

public void Print(string value)
{
Console.WriteLine(value);
}

型ごとに処理内容が異なる場合は、オーバーロードが適しています。一方、型が違っても同じ処理を行う場合は、ジェネリックの方が重複を減らせます。

C#
public void Print<T>(T value)
{
Console.WriteLine(value);
}

1-5. C#でジェネリックが使われる代表的な場面

C#では、標準ライブラリの多くにジェネリックが使われています。代表例は次のとおりです。

C#
List<string> names = new List<string>();
Dictionary<int, string> users = new Dictionary<int, string>();
Queue<int> numbers = new Queue<int>();
Stack<DateTime> dates = new Stack<DateTime>();

また、次のような場面でも利用されます。

  • 複数の型で共通利用するクラス

  • 配列やコレクションの検索処理

  • データの変換処理

  • APIレスポンスを表すクラス

  • Repositoryパターン

  • イベントやコールバック

  • LINQ

  • 非同期処理の戻り値

  • 成功・失敗を表す結果クラス

たとえば、APIの結果を共通化するクラスは次のように定義できます。

C#
public class ApiResponse<T>
{
public bool IsSuccess { get; set; }

public string Message { get; set; }

public T Data { get; set; }
}

使用時に、取得するデータの型を指定します。

C#
ApiResponse<User> userResponse = new ApiResponse<User>();
ApiResponse<List<Product>> productResponse =
new ApiResponse<List<Product>>();

2. C#ジェネリックのメリット

2-1. 型安全にコードを書ける

ジェネリックの大きなメリットは、型の間違いをコンパイル時に検出できることです。

たとえば、文字列専用のリストを作成した場合、文字列以外の値は追加できません。

C#
List<string> names = new List<string>();

names.Add("田中");
names.Add("佐藤");
names.Add(100); // コンパイルエラー

コレクションに保存される型が保証されているので、取り出すときも安全です。

C#
string name = names[0];

object型を使ったコレクションでは、格納されている値の型を開発者が確認しなければなりません。ジェネリックを使えば、コンパイラが型をチェックしてくれます。

その結果、次のような問題を防ぎやすくなります。

  • 間違った型の値を代入する

  • 不正なキャストを行う

  • 想定外の型がコレクションに混入する

  • 実行時にInvalidCastExceptionが発生する

2-2. 同じ処理を複数の型で再利用できる

ジェネリックを使うと、型だけが異なる同一処理を共通化できます。

2つの値を入れ替えるメソッドを例に見てみましょう。

C#
public static void Swap<T>(ref T first, ref T second)
{
T temporary = first;
first = second;
second = temporary;
}

このメソッドは、整数や文字列を含むさまざまな型で利用できます。

C#
int a = 10;
int b = 20;

Swap(ref a, ref b);

string firstName = "田中";
string secondName = "佐藤";

Swap(ref firstName, ref secondName);

ジェネリックを使わない場合は、型ごとに同じ内容のメソッドを定義しなければなりません。共通化によってコード量を減らし、修正漏れも防げます。

2-3. キャストを減らしてコードを読みやすくできる

object型で値を扱う場合、取り出すたびにキャストが必要になることがあります。

C#
object value = "C#";
string text = (string)value;

複数の要素を扱うコードでキャストが増えると、処理の本質が分かりにくくなります。

ジェネリックを使えば、値を取り出すときのキャストが不要です。

C#
Box<string> box = new Box<string>();
box.Value = "C#";

string text = box.Value;

コードを読むだけで、どの型を扱っているかが明確になります。IDEの入力補完も正しい型に基づいて動作するため、開発効率の向上にもつながります。

2-4. ボックス化・アンボックス化を避けてパフォーマンスを改善できる

intdoubleなどの値型をobject型として扱うと、ボックス化が発生します。

C#
int number = 100;
object value = number;

ボックス化とは、値型の値をobject型などの参照型として扱えるように変換する処理です。再び値型へ戻す処理は、アンボックス化と呼ばれます。

C#
int result = (int)value;

大量のデータを繰り返し処理する場合、ボックス化とアンボックス化は余分な処理やメモリ割り当ての原因になる可能性があります。

ジェネリックコレクションでは、値型をその型のまま扱えます。

C#
List<int> numbers = new List<int>();

numbers.Add(100);

int result = numbers[0];

通常はobjectへの変換が不要なので、非ジェネリックコレクションより効率的に値型を扱えます。

ただし、実際のパフォーマンスは処理内容やデータ量によって異なります。小規模な処理では差が目立たない場合もありますが、ジェネリックを使うことには型安全性や可読性という利点もあります。

2-5. 保守性の高い共通処理を作れる

型ごとに同じコードを作ると、不具合修正や仕様変更の際にすべてのコードを変更しなければなりません。

ジェネリックで共通化しておけば、変更箇所を1か所に集約できます。

C#
public class Result<T>
{
public bool IsSuccess { get; private set; }

public T Value { get; private set; }

public string ErrorMessage { get; private set; }

public static Result<T> Success(T value)
{
return new Result<T>
{
IsSuccess = true,
Value = value
};
}

public static Result<T> Failure(string errorMessage)
{
return new Result<T>
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
}

このクラスは、さまざまな処理の結果を共通形式で表せます。

C#
Result<int> numberResult = Result<int>.Success(100);

Result<string> textResult =
Result<string>.Failure("データを取得できませんでした");

共通のルールを一元管理できるため、コード全体の一貫性も保ちやすくなります。

3. C#ジェネリックの基本的な使い方

3-1. ジェネリッククラスの書き方

ジェネリッククラスは、クラス名の後ろに型パラメーターを記述して定義します。

C#
public class Box<T>
{
private T _value;

public void SetValue(T value)
{
_value = value;
}

public T GetValue()
{
return _value;
}
}

使用時に具体的な型を指定します。

C#
Box<string> textBox = new Box<string>();

textBox.SetValue("ジェネリック");
string text = textBox.GetValue();

整数を扱う場合は、同じクラスへintを指定します。

C#
Box<int> numberBox = new Box<int>();

numberBox.SetValue(123);
int number = numberBox.GetValue();

Box<string>Box<int>は、同じジェネリッククラスをもとにしていますが、C#上では異なる型として扱われます。

3-2. ジェネリックメソッドの書き方

ジェネリックメソッドは、メソッド名の後ろに型パラメーターを記述します。

C#
public static T GetFirst<T>(T[] values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}

if (values.Length == 0)
{
throw new ArgumentException(
"配列に要素がありません。",
nameof(values));
}

return values[0];
}

呼び出すときは、型を明示できます。

C#
int firstNumber = GetFirst<int>(new[] { 10, 20, 30 });

string firstName =
GetFirst<string>(new[] { "田中", "佐藤" });

引数から型を判断できる場合は、型指定を省略できます。

C#
int firstNumber = GetFirst(new[] { 10, 20, 30 });
string firstName = GetFirst(new[] { "田中", "佐藤" });

通常のクラス内にジェネリックメソッドだけを定義することも可能です。

C#
public class Converter
{
public string ToText<T>(T value)
{
return value?.ToString() ?? string.Empty;
}
}

3-3. ジェネリックインターフェイスの書き方

インターフェイスにも型パラメーターを指定できます。

C#
public interface IRepository<T>
{
T FindById(int id);

void Add(T item);

IReadOnlyList<T> GetAll();
}

Userを扱う実装クラスは、次のように定義できます。

C#
public class User
{
public int Id { get; set; }

public string Name { get; set; }
}
C#
public class UserRepository : IRepository<User>
{
private readonly List<User> _users = new List<User>();

public User FindById(int id)
{
return _users.FirstOrDefault(user => user.Id == id);
}

public void Add(User item)
{
_users.Add(item);
}

public IReadOnlyList<User> GetAll()
{
return _users;
}
}

インターフェイス側で共通の契約を定義し、実装時に扱う型を決められるのが特徴です。

3-4. 複数の型パラメーターを使う方法

ジェネリックでは、複数の型パラメーターを指定できます。

C#
public class Pair<TFirst, TSecond>
{
public TFirst First { get; set; }

public TSecond Second { get; set; }
}

使用例は次のとおりです。

C#
Pair<int, string> user =
new Pair<int, string>
{
First = 1,
Second = "田中"
};

メソッドでも複数の型を使用できます。

C#
public static TResult ConvertValue<TSource, TResult>(
TSource source,
Func<TSource, TResult> converter)
{
return converter(source);
}

呼び出し例は次のとおりです。

C#
int length = ConvertValue<string, int>(
"C#",
text => text.Length);

型パラメーターが複数ある場合は、T1T2よりも、TSourceTResultなど役割が分かる名前を付けると読みやすくなります。

3-5. 型推論で型指定を省略できるケース

ジェネリックメソッドでは、引数から型を判断できる場合、型パラメーターの指定を省略できます。

C#
public static T Echo<T>(T value)
{
return value;
}

次のコードでは、引数がstringなので、コンパイラはTstringだと推論します。

C#
string text = Echo("Hello");

整数の場合はintと推論されます。

C#
int number = Echo(100);

型を明示しても問題ありません。

C#
string text = Echo<string>("Hello");

ただし、戻り値だけから型を推論することは基本的にできません。

C#
public static T Create<T>()
{
return default;
}

次の呼び出しでは、引数がないためTを判断できません。

C#
// string text = Create(); // 型を推論できない

この場合は型を明示します。

C#
string text = Create<string>();

4. ジェネリックの実用例

4-1. List<T>で学ぶジェネリックコレクション

List<T>は、指定した型の要素を順番に保持するジェネリックコレクションです。

C#
List<string> names = new List<string>();

names.Add("田中");
names.Add("佐藤");
names.Add("鈴木");

Tstringを指定しているため、リストへ追加できるのは文字列だけです。

要素はインデックスやforeachで取得できます。

C#
string firstName = names[0];

foreach (string name in names)
{
Console.WriteLine(name);
}

整数のリストも同じ仕組みで作成できます。

C#
List<int> numbers = new List<int>
{
10,
20,
30
};

int total = numbers.Sum();

List<T>には、追加、削除、検索、並べ替えなどの機能があります。

C#
names.Remove("佐藤");

bool containsTanaka = names.Contains("田中");

names.Sort();

独自クラスを要素にすることもできます。

C#
List<User> users = new List<User>
{
new User { Id = 1, Name = "田中" },
new User { Id = 2, Name = "佐藤" }
};

4-2. Dictionary<TKey, TValue>でキーと値の型を指定する

Dictionary<TKey, TValue>は、キーと値の組み合わせを保存するジェネリックコレクションです。

C#
Dictionary<int, string> users =
new Dictionary<int, string>();

users.Add(1, "田中");
users.Add(2, "佐藤");

この例では、キーがint、値がstringです。

C#
string userName = users[1];

存在しないキーをインデクサーで取得すると例外が発生するため、TryGetValueを使うと安全です。

C#
if (users.TryGetValue(1, out string userName))
{
Console.WriteLine(userName);
}

独自クラスを値として保存することもできます。

C#
Dictionary<int, User> userMap =
new Dictionary<int, User>();

userMap[1] = new User
{
Id = 1,
Name = "田中"
};

キーと値の型が明確なので、値を取得する際のキャストは不要です。

4-3. 共通の検索・変換処理をジェネリックメソッドにする

ジェネリックメソッドは、検索や変換などの共通処理に向いています。

条件に一致する最初の要素を取得するメソッドは、次のように定義できます。

C#
public static T FindFirst<T>(
IEnumerable<T> items,
Func<T, bool> condition)
{
foreach (T item in items)
{
if (condition(item))
{
return item;
}
}

return default;
}

整数を検索する例です。

C#
List<int> numbers = new List<int>
{
10,
20,
30
};

int result = FindFirst(numbers, number => number >= 20);

ユーザーを検索する場合も、同じメソッドを利用できます。

C#
User user = FindFirst(
users,
item => item.Id == 2);

変換処理もジェネリック化できます。

C#
public static List<TResult> ConvertAll<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> converter)
{
List<TResult> results = new List<TResult>();

foreach (TSource item in source)
{
results.Add(converter(item));
}

return results;
}

使用例は次のとおりです。

C#
List<int> lengths = ConvertAll(
new[] { "C#", "Java", "Python" },
text => text.Length);

実際の開発では、同様の処理をLINQのFirstOrDefaultSelectで実現できます。自作の共通処理を設計する際は、標準機能で代用できないか確認することも重要です。

4-4. Repositoryパターンでジェネリッククラスを活用する

Repositoryパターンでは、データの取得や保存といった操作を専用クラスへまとめます。

複数のエンティティで共通する処理は、ジェネリックインターフェイスとして定義できます。

C#
public interface IEntity
{
int Id { get; set; }
}
C#
public interface IRepository<TEntity>
where TEntity : IEntity
{
TEntity FindById(int id);

IReadOnlyList<TEntity> GetAll();

void Add(TEntity entity);

bool Remove(int id);
}

メモリ上へデータを保存する実装例は次のとおりです。

C#
public class InMemoryRepository<TEntity>
: IRepository<TEntity>
where TEntity : class, IEntity
{
private readonly List<TEntity> _items =
new List<TEntity>();

public TEntity FindById(int id)
{
return _items.FirstOrDefault(
item => item.Id == id);
}

public IReadOnlyList<TEntity> GetAll()
{
return _items;
}

public void Add(TEntity entity)
{
_items.Add(entity);
}

public bool Remove(int id)
{
TEntity entity = FindById(id);

if (entity == null)
{
return false;
}

return _items.Remove(entity);
}
}

Userクラスで利用する場合は、次のように記述します。

C#
public class User : IEntity
{
public int Id { get; set; }

public string Name { get; set; }
}
C#
IRepository<User> repository =
new InMemoryRepository<User>();

repository.Add(new User
{
Id = 1,
Name = "田中"
});

エンティティごとに同じCRUD処理を作る必要がなくなり、共通部分を一元管理できます。

ただし、すべてのデータ操作を無理に1つのジェネリックRepositoryへまとめると、エンティティ固有の検索処理を表現しにくくなる場合があります。共通化する範囲を適切に見極めることが大切です。

4-5. 戻り値や引数にジェネリックを使う実践例

ジェネリック型は、メソッドの引数や戻り値として利用できます。

C#
public class ServiceResult<T>
{
public bool IsSuccess { get; init; }

public T Data { get; init; }

public string ErrorMessage { get; init; }
}

ユーザーを取得するメソッドでは、戻り値をServiceResult<User>にします。

C#
public ServiceResult<User> GetUser(int id)
{
User user = FindUser(id);

if (user == null)
{
return new ServiceResult<User>
{
IsSuccess = false,
ErrorMessage = "ユーザーが見つかりません。"
};
}

return new ServiceResult<User>
{
IsSuccess = true,
Data = user
};
}

一覧を返す場合は、ジェネリック型の中へ別のジェネリック型を指定できます。

C#
public ServiceResult<List<User>> GetUsers()
{
List<User> users = LoadUsers();

return new ServiceResult<List<User>>
{
IsSuccess = true,
Data = users
};
}

引数へジェネリック型を使用する例は次のとおりです。

C#
public void PrintResult<T>(ServiceResult<T> result)
{
if (result.IsSuccess)
{
Console.WriteLine(result.Data);
}
else
{
Console.WriteLine(result.ErrorMessage);
}
}

5. ジェネリック制約とは?

5-1. where句で型パラメーターに条件を付ける

ジェネリック制約とは、型パラメーターとして指定できる型に条件を付ける仕組みです。

制約はwhere句で指定します。

C#
public class Repository<T>
where T : class
{
}

この例では、Tに指定できるのは参照型だけです。

制約を付ける主な目的は、次の2つです。

  1. 使用できる型を限定する

  2. 型パラメーターに対して特定のメンバーを呼び出せるようにする

たとえば、すべての型がIdプロパティを持っているとは限りません。そのため、制約なしのTからIdを参照することはできません。

C#
public int GetId<T>(T item)
{
// return item.Id; // コンパイルエラー
return 0;
}

Idを定義したインターフェイスで制約すると、参照できるようになります。

C#
public interface IEntity
{
int Id { get; }
}
C#
public int GetId<T>(T item)
where T : IEntity
{
return item.Id;
}

5-2. class制約とstruct制約の違い

class制約は、型パラメーターを参照型に限定します。

C#
public class ReferenceBox<T>
where T : class
{
public T Value { get; set; }
}

次の指定は可能です。

C#
ReferenceBox<string> textBox =
new ReferenceBox<string>();

ReferenceBox<User> userBox =
new ReferenceBox<User>();

intなどの値型は指定できません。

C#
// ReferenceBox<int> numberBox =
// new ReferenceBox<int>();

struct制約は、型パラメーターをnull非許容の値型に限定します。

C#
public class ValueBox<T>
where T : struct
{
public T Value { get; set; }
}

使用例は次のとおりです。

C#
ValueBox<int> intBox = new ValueBox<int>();
ValueBox<DateTime> dateBox = new ValueBox<DateTime>();

参照型は指定できません。

C#
// ValueBox<string> textBox =
// new ValueBox<string>();

class制約は参照型を扱う処理、struct制約は値型を扱う処理で利用します。

5-3. new()制約でインスタンス生成を許可する

型パラメーターから新しいインスタンスを生成したい場合は、new()制約を指定します。

C#
public static T Create<T>()
where T : new()
{
return new T();
}

new()制約を付けると、引数なしで呼び出せるコンストラクターを持つ型を指定できます。

C#
public class User
{
public User()
{
}

public string Name { get; set; }
}
C#
User user = Create<User>();

new()制約がなければ、new T()は使用できません。

C#
public static T CreateWithoutConstraint<T>()
{
// return new T(); // コンパイルエラー
return default;
}

複数の制約と組み合わせる場合、new()は制約リストの最後に記述します。

C#
public class Factory<T>
where T : class, IEntity, new()
{
public T Create()
{
return new T();
}
}

5-4. インターフェイス制約で使えるメンバーを限定する

インターフェイス制約を使うと、そのインターフェイスを実装した型だけを受け付けられます。

C#
public interface IPrintable
{
void Print();
}
C#
public static void ExecutePrint<T>(T item)
where T : IPrintable
{
item.Print();
}

TIPrintableを実装していることが保証されるため、Printメソッドを安全に呼び出せます。

C#
public class Report : IPrintable
{
public void Print()
{
Console.WriteLine("レポートを印刷します。");
}
}
C#
Report report = new Report();
ExecutePrint(report);

インターフェイス制約は、具体的なクラスへ依存せず、必要な機能だけを要求したい場合に適しています。

5-5. 基底クラス制約を使うケース

基底クラス制約では、指定したクラス、またはその派生クラスだけを型パラメーターとして使用できます。

C#
public abstract class Entity
{
public int Id { get; set; }
}
C#
public class EntityService<T>
where T : Entity
{
public int GetId(T entity)
{
return entity.Id;
}
}

UserEntityを継承していれば、型パラメーターとして指定できます。

C#
public class User : Entity
{
public string Name { get; set; }
}
C#
EntityService<User> service =
new EntityService<User>();

基底クラス制約を使うと、基底クラスが持つプロパティやメソッドをジェネリックコード内で利用できます。

ただし、単に特定の機能を要求したいだけであれば、基底クラスよりインターフェイス制約の方が柔軟なことがあります。C#ではクラスの多重継承ができないため、不要に基底クラスを強制しない設計も重要です。

5-6. 複数の制約を組み合わせる書き方

複数の制約は、カンマで区切って指定します。

C#
public class Repository<T>
where T : class, IEntity, new()
{
public T Create()
{
return new T();
}

public int GetId(T entity)
{
return entity.Id;
}
}

この例でTに求められる条件は次のとおりです。

  • 参照型である

  • IEntityを実装している

  • 引数なしで呼び出せるコンストラクターを持つ

複数の型パラメーターへ、それぞれ異なる制約を付けることもできます。

C#
public class Converter<TSource, TResult>
where TSource : class
where TResult : class, new()
{
public TResult Convert(TSource source)
{
return new TResult();
}
}

制約には記述順のルールがあります。代表的には、次の順序を意識します。

  1. classstructなどの制約

  2. 基底クラスやインターフェイスの制約

  3. new()制約

6. ジェネリックを使うときの注意点・落とし穴

6-1. 型パラメーターでは使えない操作がある

制約のない型パラメーターTについて、コンパイラが保証できる機能は限られています。

たとえば、すべての型で加算演算子が使えるわけではないため、次のコードはそのままではコンパイルできません。

C#
public static T Add<T>(T left, T right)
{
// return left + right; // コンパイルエラー
return default;
}

Tintかもしれませんが、UserDateTimeかもしれません。そのため、コンパイラは+演算子を使用できると判断できません。

数値演算をジェネリック化するときは、対応する.NETおよびC#の環境で利用可能な数値インターフェイスを制約として使う方法があります。

C#
using System.Numerics;

public static T Add<T>(T left, T right)
where T : INumber<T>
{
return left + right;
}

また、比較処理ではIComparable<T>を制約にできます。

C#
public static T Max<T>(T left, T right)
where T : IComparable<T>
{
return left.CompareTo(right) >= 0
? left
: right;
}

6-2. 制約なしではメソッドやプロパティを呼び出せない

制約のないTに対しては、その型固有のプロパティやメソッドを使用できません。

C#
public static void ShowName<T>(T item)
{
// Console.WriteLine(item.Name);
}

Tが必ずNameプロパティを持つとは限らないからです。

必要なメンバーをインターフェイスに定義し、制約として指定します。

C#
public interface IHasName
{
string Name { get; }
}
C#
public static void ShowName<T>(T item)
where T : IHasName
{
Console.WriteLine(item.Name);
}

制約は型を不必要に限定するためのものではなく、ジェネリックコード内で必要な操作を保証するために使います。

6-3. default(T)の挙動に注意する

default(T)は、型パラメーターの既定値を返します。

C#
public static T GetDefault<T>()
{
return default(T);
}

省略形としてdefaultと書くこともできます。

C#
public static T GetDefault<T>()
{
return default;
}

既定値は型によって異なります。

C#
int number = GetDefault<int>();       // 0
bool flag = GetDefault<bool>(); // false
DateTime date = GetDefault<DateTime>(); // 既定のDateTime
string text = GetDefault<string>(); // null

検索メソッドで対象が見つからない場合にdefaultを返すと、値によっては「見つからなかった」のか「既定値の要素が見つかった」のか判別しにくくなります。

C#
int result = FindFirst(
new[] { 0, 1, 2 },
number => number > 100);

この場合、戻り値が0でも、要素の0が見つかったわけではありません。

曖昧さを避ける方法として、成功したかどうかをboolで返す設計があります。

C#
public static bool TryFindFirst<T>(
IEnumerable<T> items,
Func<T, bool> condition,
out T result)
{
foreach (T item in items)
{
if (condition(item))
{
result = item;
return true;
}
}

result = default;
return false;
}

6-4. 型が増えすぎると設計が複雑になる

ジェネリックは便利ですが、型パラメーターを増やしすぎると、コードの意味が分かりにくくなります。

C#
public class Processor<TInput, TOutput, TContext, TOptions, TError>
{
}

このようなクラスは、呼び出し側の記述も複雑になります。

C#
Processor<
Request,
Response,
UserContext,
ProcessOptions,
ProcessError> processor;

型パラメーターが多い場合は、クラスが複数の責務を持っていないか確認しましょう。必要に応じて、次のような改善を検討します。

  • クラスを役割ごとに分割する

  • 一部の型を通常のクラスとしてまとめる

  • インターフェイスを導入する

  • ジェネリックにする範囲を狭める

  • 具体的な型へ固定する

ジェネリック化できることと、ジェネリック化すべきことは同じではありません。

6-5. 初心者がやりがちなジェネリックの誤用

初心者がやりがちな誤用の1つは、型が変わらない処理までジェネリック化することです。

C#
public void Save<T>(T value)
{
}

実際には文字列しか保存しないのであれば、通常のメソッドで十分です。

C#
public void Save(string value)
{
}

また、実行時に型を判定して処理を分岐させるコードにも注意が必要です。

C#
public void Process<T>(T value)
{
if (value is string text)
{
Console.WriteLine($"文字列: {text}");
}
else if (value is int number)
{
Console.WriteLine($"整数: {number}");
}
}

型ごとに処理内容が大きく異なる場合、ジェネリックでまとめるより、オーバーロードやポリモーフィズムを使った方が自然なことがあります。

C#
public void Process(string value)
{
Console.WriteLine($"文字列: {value}");
}

public void Process(int value)
{
Console.WriteLine($"整数: {value}");
}

ジェネリックは、型に依存しない共通ロジックがある場合に利用するのが基本です。

7. ジェネリックと非ジェネリックの違い

7-1. ArrayListとList<T>の違い

ArrayListは、要素をobject型として保存する非ジェネリックコレクションです。

C#
ArrayList values = new ArrayList();

values.Add("C#");
values.Add(100);
values.Add(DateTime.Now);

異なる型を同じコレクションへ追加できますが、値を取り出すときにキャストが必要です。

C#
string text = (string)values[0];

型を間違えると、実行時に例外が発生します。

C#
int number = (int)values[0];

List<T>では、保存する型をあらかじめ指定します。

C#
List<string> values = new List<string>();

values.Add("C#");

文字列以外は追加できません。

C#
// values.Add(100); // コンパイルエラー

主な違いは次のとおりです。

比較項目ArrayListList<T>
要素の型object指定した型
型チェック主に実行時コンパイル時
キャスト必要になりやすい基本的に不要
値型の扱いボックス化が発生しやすい型のまま扱える
推奨度既存コード向け新規開発で一般的

新しくコードを書く場合は、通常List<T>を選びます。

7-2. HashtableとDictionary<TKey, TValue>の違い

Hashtableは、キーと値をobject型として扱う非ジェネリックコレクションです。

C#
Hashtable users = new Hashtable();

users.Add(1, "田中");
users.Add("admin", 100);

さまざまな型を保存できますが、取得時にキャストが必要です。

C#
string userName = (string)users[1];

Dictionary<TKey, TValue>では、キーと値の型を指定します。

C#
Dictionary<int, string> users =
new Dictionary<int, string>();

users.Add(1, "田中");

型が保証されるため、安全に取得できます。

C#
string userName = users[1];

異なる型のキーや値を追加するとコンパイルエラーになります。

C#
// users.Add("admin", 100);

新規開発では、特別な理由がない限りDictionary<TKey, TValue>を利用するのが一般的です。

7-3. 非ジェネリックを使うべきケースはあるのか

現在のC#開発では、型安全性や保守性の観点からジェネリックコレクションを使うことが基本です。

非ジェネリック型を使う可能性があるのは、主に次のようなケースです。

  • 古い.NETコードを保守している

  • 既存APIが非ジェネリック型を要求している

  • 型情報が実行時まで完全に不明である

  • レガシーライブラリとの互換性が必要である

ただし、「複数の型を格納したい」という理由だけでArrayListを選ぶ必要はありません。共通の基底クラスやインターフェイスを使う方法もあります。

C#
public interface IMessage
{
string GetText();
}
C#
List<IMessage> messages = new List<IMessage>();

複数の異なるデータをひとまとまりで管理する場合は、専用のクラスやレコードを設計する方が、型の意味を明確にできることもあります。

7-4. 既存コードをジェネリックに置き換える判断基準

既存の非ジェネリックコードを置き換える場合は、次の点を確認します。

  • コレクションに保存される型が決まっているか

  • キャストが多数存在しているか

  • 実行時の型エラーが起きる可能性があるか

  • 値型のボックス化が頻繁に発生しているか

  • 公開APIの互換性に影響しないか

  • 変更後に十分なテストを実施できるか

たとえば、次のコードがあるとします。

C#
ArrayList names = new ArrayList();

names.Add("田中");
names.Add("佐藤");

foreach (object value in names)
{
string name = (string)value;
Console.WriteLine(name);
}

文字列しか保存しないのであれば、List<string>へ置き換えられます。

C#
List<string> names = new List<string>();

names.Add("田中");
names.Add("佐藤");

foreach (string name in names)
{
Console.WriteLine(name);
}

一方、公開ライブラリのメソッドシグネチャを変更すると、利用側のコードに影響する可能性があります。内部実装から段階的に置き換えるなど、互換性を考慮して進める必要があります。

8. ジェネリックの応用知識

8-1. 共変性と反変性とは

共変性と反変性は、継承関係を持つ型のジェネリックインターフェイスやデリゲートを、どの方向へ変換できるかを表す仕組みです。

まず、次のクラスを考えます。

C#
public class Animal
{
public string Name { get; set; }
}

public class Dog : Animal
{
}

DogAnimalとして扱えます。

C#
Dog dog = new Dog();
Animal animal = dog;

共変性は、より具体的な型から、より抽象的な型への変換を可能にします。

C#
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs;

IEnumerable<T>の型パラメーターは共変として定義されているため、この変換が可能です。

独自の共変インターフェイスでは、型パラメーターへoutを付けます。

C#
public interface IProducer<out T>
{
T Create();
}

反変性は、より抽象的な型を扱うものを、より具体的な型向けとして利用できる仕組みです。

C#
Action<Animal> showAnimal =
animal => Console.WriteLine(animal.Name);

Action<Dog> showDog = showAnimal;

独自の反変インターフェイスでは、型パラメーターへinを付けます。

C#
public interface IConsumer<in T>
{
void Consume(T item);
}

基本的な考え方は次のとおりです。

  • out T:主に戻り値として提供する型

  • in T:主に引数として受け取る型

共変性と反変性は、参照型に関する型変換で利用されます。

8-2. IEnumerable<T>とジェネリックの関係

IEnumerable<T>は、指定した型の要素を順番に列挙できることを表すジェネリックインターフェイスです。

C#
IEnumerable<string> names =
new List<string>
{
"田中",
"佐藤",
"鈴木"
};

foreachを使って要素を取り出せます。

C#
foreach (string name in names)
{
Console.WriteLine(name);
}

メソッドの引数をIEnumerable<T>にすると、List<T>だけでなく、配列や他のコレクションも受け取れます。

C#
public static void PrintAll<T>(
IEnumerable<T> items)
{
foreach (T item in items)
{
Console.WriteLine(item);
}
}

次のどちらも渡せます。

C#
PrintAll(new[] { 1, 2, 3 });

PrintAll(new List<string>
{
"A",
"B",
"C"
});

引数側で追加や削除を必要としない場合、具体的なList<T>ではなくIEnumerable<T>を受け取ることで、利用可能なコレクションの範囲を広げられます。

8-3. Action<T>・Func<T>などのジェネリックデリゲート

Action<T>は、引数を受け取り、戻り値を返さない処理を表すジェネリックデリゲートです。

C#
Action<string> print =
text => Console.WriteLine(text);

print("Hello");

複数の引数も指定できます。

C#
Action<int, int> showTotal =
(left, right) =>
Console.WriteLine(left + right);

showTotal(10, 20);

Func<T, TResult>は、引数を受け取って値を返す処理を表します。最後の型パラメーターが戻り値の型です。

C#
Func<int, int> doubleNumber =
number => number * 2;

int result = doubleNumber(10);

引数が2つの場合は、次のように記述します。

C#
Func<int, int, int> add =
(left, right) => left + right;

int total = add(10, 20);

戻り値のみで引数がない場合は、型パラメーターを1つ指定します。

C#
Func<DateTime> getCurrentTime =
() => DateTime.Now;

Action<T>Func<T>は、コールバック、イベント処理、LINQ、共通処理への関数の受け渡しなどで広く使われています。

8-4. Nullable<T>と値型ジェネリック

通常、intboolなどの値型にはnullを代入できません。

C#
// int number = null;

Nullable<T>を使うと、値型へnullを代入できるようになります。

C#
Nullable<int> number = null;

一般的には、?を使った省略記法で記述します。

C#
int? number = null;

int?は、概念的にはNullable<int>を表します。

値が存在するかどうかは、HasValueで確認できます。

C#
if (number.HasValue)
{
Console.WriteLine(number.Value);
}

null合体演算子を使って、nullの場合の値を指定することもできます。

C#
int result = number ?? 0;

Nullable<T>Tには、null非許容の値型が指定されます。参照型を指定するものではありません。

8-5. LINQでジェネリックが使われている仕組み

LINQの多くのメソッドは、ジェネリックメソッドとして定義されています。

たとえば、WhereIEnumerable<T>から条件に一致する要素を抽出します。

C#
List<int> numbers = new List<int>
{
1,
2,
3,
4,
5
};

IEnumerable<int> evenNumbers =
numbers.Where(number => number % 2 == 0);

Selectは、要素を別の型へ変換できます。

C#
IEnumerable<string> texts =
numbers.Select(number => $"番号: {number}");

ここでは、入力型がint、出力型がstringです。

LINQの処理を簡略化して表現すると、次のようなジェネリックメソッドだと考えられます。

C#
IEnumerable<TResult> Select<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> selector)

ジェネリックによって、LINQは整数、文字列、独自クラスなど、さまざまな型のコレクションに対して同じ記述方法を提供できます。

C#
IEnumerable<string> userNames =
users.Select(user => user.Name);

型推論が働くため、通常はTSourceTResultを明示する必要がありません。

9. C#ジェネリックのよくある質問

9-1. T以外の名前を使ってもよい?

型パラメーター名はTでなくても問題ありません。

C#
public class Box<TItem>
{
public TItem Value { get; set; }
}

型パラメーターが1つで役割が明確な場合は、単純なTがよく使われます。

C#
public class Box<T>
{
}

複数ある場合や役割を明確にしたい場合は、説明的な名前を使います。

C#
public class Mapper<TSource, TDestination>
{
}

一般的には、型パラメーターであることが分かるようにTから始まる名前を使用します。

9-2. ジェネリックとテンプレートの違いは?

C#のジェネリックとC++のテンプレートは、どちらも型を抽象化してコードを再利用する仕組みですが、言語上の実装や型チェックの仕組みが異なります。

C#のジェネリックでは、型パラメーターに対して実行できる操作が、定義時の制約に基づいてチェックされます。

C#
public static void Print<T>(T value)
{
Console.WriteLine(value);
}

特定のメンバーを使うには、インターフェイスなどの制約が必要です。

C#
public static int GetId<T>(T value)
where T : IEntity
{
return value.Id;
}

C++のテンプレートは、具体的な型で実体化される際に、使用している操作が成立するか評価されるという特徴があります。

両者は目的が似ていますが、完全に同じ仕組みではありません。C#を学ぶ際は「C#における型安全な汎用化の仕組み」と理解するとよいでしょう。

9-3. ジェネリックは初心者でも使うべき?

初心者でも、基本的なジェネリックは積極的に使うべきです。

特に、次の型はC#の開発で頻繁に登場します。

C#
List<T>
Dictionary<TKey, TValue>
IEnumerable<T>
Task<T>
Action<T>
Func<T, TResult>

最初から高度なジェネリッククラスを設計する必要はありません。まずは次の順番で学ぶと理解しやすくなります。

  1. List<T>Dictionary<TKey, TValue>を使う

  2. 簡単なジェネリックメソッドを作る

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

  4. where句による制約を学ぶ

  5. インターフェイスやLINQとの関係を理解する

  6. 共変性や反変性を学ぶ

ジェネリックは特殊な機能ではなく、現代のC#プログラミングを支える基本機能の1つです。

9-4. ジェネリック制約はいつ必要?

ジェネリック制約は、型パラメーターに対して特定の条件や機能を要求するときに必要です。

代表的なケースは次のとおりです。

  • 参照型だけを受け付けたい

  • 値型だけを受け付けたい

  • 特定の基底クラスを継承した型だけを扱いたい

  • 特定のインターフェイスを実装した型だけを扱いたい

  • new T()でインスタンスを生成したい

  • 比較や数値演算などの操作を保証したい

たとえば、Idプロパティを使うには、その存在を制約で保証します。

C#
public static int GetId<T>(T entity)
where T : IEntity
{
return entity.Id;
}

反対に、値の保存や入れ替えなど、型固有の操作を必要としない処理では、制約が不要な場合もあります。

C#
public static void Swap<T>(
ref T first,
ref T second)
{
T temporary = first;
first = second;
second = temporary;
}

必要以上に強い制約を付けると、利用できる型が狭くなります。処理に必要な最小限の制約を指定することが重要です。

9-5. ジェネリックを使わない方がよいケースは?

次のような場合は、無理にジェネリックを使わない方が分かりやすいことがあります。

  • 扱う型が1種類に決まっている

  • 型ごとに処理内容が大きく異なる

  • 型パラメーターを導入してもコードが再利用されない

  • 実行時の型判定ばかりが増える

  • 型パラメーターが多くなり、設計が理解しにくくなる

  • 通常の継承やインターフェイスで十分に表現できる

たとえば、ユーザー専用の登録処理を汎用化する必要がないなら、具体的な型を使う方が意図を明確にできます。

C#
public void RegisterUser(User user)
{
}

型パラメーターを使っても内部でUserかどうかを確認するだけなら、ジェネリック化の効果はほとんどありません。

C#
public void Register<T>(T value)
{
if (value is User user)
{
// ユーザー登録
}
}

ジェネリックを使うかどうかは、「型が異なっても共通する本質的な処理があるか」を基準に判断するとよいでしょう。

まとめ

C#のジェネリックは、クラスやメソッドを定義するときに具体的な型を固定せず、使用時に型を指定できる仕組みです。

C#
public class Box<T>
{
public T Value { get; set; }
}

使用時にTへ具体的な型を指定します。

C#
Box<string> textBox = new Box<string>();
Box<int> numberBox = new Box<int>();

ジェネリックを使う主なメリットは、次のとおりです。

  • 型の間違いをコンパイル時に検出できる

  • 同じ処理を複数の型で再利用できる

  • 不要なキャストを減らせる

  • 値型のボックス化を避けやすい

  • 共通処理の保守性を高められる

型パラメーターに特定の条件が必要な場合は、where句でジェネリック制約を指定します。

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

一方で、すべての処理をジェネリック化すればよいわけではありません。型ごとに処理が異なる場合や、扱う型が最初から決まっている場合は、通常のクラスやメソッドの方が分かりやすいこともあります。

まずはList<T>Dictionary<TKey, TValue>IEnumerable<T>などの標準的なジェネリック型を使い、次に簡単なジェネリックメソッドやクラスを作ると、仕組みを理解しやすくなります。