C# ジェネリック関数とは?初心者でもわかる使い方・型制約・実践例を徹底解説
はじめに
C#で複数の型に対して同じ処理を行いたいとき、型ごとに似たメソッドを作成するとコードが重複してしまいます。
たとえば、受け取った値をそのまま返すメソッドを整数、文字列、小数のそれぞれに用意すると、次のようになります。
C#static int EchoInt(int value)
{
return value;
}
static string EchoString(string value)
{
return value;
}
static double EchoDouble(double value)
{
return value;
}
処理内容は同じなのに、型が違うだけで複数のメソッドが必要です。このような問題を解決する機能が、C#のジェネリック関数です。
ジェネリック関数を使えば、型を固定せずにメソッドを定義し、呼び出すときに使用する型を決められます。
C#static T Echo<T>(T value)
{
return value;
}
この1つのメソッドだけで、int、string、doubleなど、さまざまな型を扱えます。
本記事では、C#のジェネリック関数について、基本構文、型制約、実践例、よくあるエラー、設計上の注意点まで初心者向けに詳しく解説します。
1. C#のジェネリック関数とは?
1-1. ジェネリック関数は「型を後から決められるメソッド」
ジェネリック関数とは、使用する型をメソッドの定義時ではなく、呼び出し時に決められる仕組みです。
次のコードでは、Tが具体的な型の代わりとして使われています。
C#static T Echo<T>(T value)
{
return value;
}
呼び出すときにintを指定すれば、Tはintとして扱われます。
C#int number = Echo<int>(100);
stringを指定した場合、Tはstringになります。
C#string text = Echo<string>("Hello");
つまり、同じメソッドを異なる型で再利用できるのがジェネリック関数の特徴です。
1-2. C#では「ジェネリックメソッド」と呼ばれることが多い
一般的なプログラミング用語では「ジェネリック関数」と表現されることがありますが、C#ではクラスや構造体の内部に処理を定義するため、正式には「ジェネリックメソッド」と呼ばれることが一般的です。
C#public static T GetValue<T>(T value)
{
return value;
}
この記事では検索されることの多い「C# ジェネリック関数」という表現を使いますが、基本的にはジェネリックメソッドと同じ意味です。
1-3. 通常の関数との違い
通常のメソッドでは、引数や戻り値の型を定義時に決めます。
C#static int GetNumber(int value)
{
return value;
}
このメソッドが受け取れるのはintだけです。文字列を渡すことはできません。
C#int result = GetNumber(10);
// コンパイルエラー
// string result = GetNumber("Hello");
一方、ジェネリック関数では型パラメーターを使います。
C#static T GetValue<T>(T value)
{
return value;
}
これにより、複数の型に対応できます。
C#int number = GetValue(10);
string text = GetValue("Hello");
DateTime date = GetValue(DateTime.Now);
ジェネリック関数は、型の違いに依存しない共通処理をまとめるための機能です。
1-4. ジェネリック関数を使うメリット
ジェネリック関数には、主に次のようなメリットがあります。
第一のメリットは、コードの重複を減らせることです。型ごとに同じ処理を実装する必要がなくなります。
第二のメリットは、型安全性を保てることです。コンパイル時に型が確認されるため、想定していない型の値を扱うミスを防ぎやすくなります。
第三のメリットは、キャストを減らせることです。object型を使った実装では、値を取り出すときに型変換が必要ですが、ジェネリック関数では具体的な型のまま受け取れます。
C#static T Echo<T>(T value)
{
return value;
}
int number = Echo(100);
string text = Echo("C#");
第四のメリットは、値型をobjectとして扱う際に発生するボックス化を避けやすいことです。状況によっては、型安全性だけでなくパフォーマンス面でも有利になります。
2. C#ジェネリック関数の基本構文
2-1. 型パラメーター<T>の書き方
C#のジェネリック関数では、メソッド名の後ろに山括弧を付け、その中に型パラメーターを書きます。
C#static void MethodName<T>()
{
}
Tは「Type」の頭文字として広く使われる名前です。実際にTという型が存在するわけではなく、呼び出し時に具体的な型へ置き換えられます。
C#static void ShowType<T>()
{
Console.WriteLine(typeof(T).Name);
}
呼び出し例は次のとおりです。
C#ShowType<int>();
ShowType<string>();
実行結果は次のようになります。
Int32
String
2-2. 引数と戻り値にTを使う方法
型パラメーターTは、引数の型、戻り値の型、ローカル変数の型などに使用できます。
C#static T GetFirst<T>(T first, T second)
{
T result = first;
return result;
}
使用例は次のとおりです。
C#int number = GetFirst<int>(10, 20);
string text = GetFirst<string>("A", "B");
このメソッドでは、2つの引数が同じTとして扱われます。そのため、異なる型を混在させることは基本的にできません。
C#// intとstringを同じTとして扱えないためコンパイルエラー
// var value = GetFirst(10, "A");
2-3. 複数の型パラメーター<T1, T2>を使う方法
ジェネリック関数では、複数の型パラメーターを定義できます。
C#static void PrintPair<T1, T2>(T1 first, T2 second)
{
Console.WriteLine($"1つ目: {first}");
Console.WriteLine($"2つ目: {second}");
}
使用例は次のとおりです。
C#PrintPair<int, string>(100, "C#");
PrintPair<string, DateTime>("日時", DateTime.Now);
役割が分かる場合は、T1やT2ではなく、意味のある名前を付けると読みやすくなります。
C#static TResult ConvertValue<TSource, TResult>(
TSource source,
Func<TSource, TResult> converter)
{
return converter(source);
}
この例では、変換元の型をTSource、変換後の型をTResultとしています。
2-4. 型推論により<T>を省略できるケース
ジェネリック関数を呼び出すとき、コンパイラが引数から型を判断できる場合は、型引数を省略できます。
C#static T Echo<T>(T value)
{
return value;
}
明示的に型を指定する書き方は次のとおりです。
C#int number = Echo<int>(100);
しかし、引数の100がintであることはコンパイラが判断できるため、通常は次のように書けます。
C#int number = Echo(100);
string text = Echo("Hello");
これを型推論と呼びます。
一方、引数から型を判断できないメソッドでは、型引数を省略できません。
C#static T CreateDefault<T>()
{
return default!;
}
このメソッドには型を判断できる引数がないため、呼び出し時に型を指定します。
C#int number = CreateDefault<int>();
string text = CreateDefault<string>();
基本的に、戻り値を代入する変数の型だけでは、ジェネリックメソッドの型推論は行われません。
2-5. staticメソッドでジェネリック関数を定義する方法
ジェネリック関数は、インスタンスメソッドとしてもstaticメソッドとしても定義できます。
ユーティリティ処理として使用する場合は、staticメソッドにすることがよくあります。
C#public static class GenericUtility
{
public static T Echo<T>(T value)
{
return value;
}
}
呼び出すときは、クラスのインスタンスを作成する必要がありません。
C#int number = GenericUtility.Echo(100);
string text = GenericUtility.Echo("Hello");
オブジェクト固有の状態を使用しない処理は、staticメソッドとして定義すると用途が分かりやすくなります。
3. 初心者向け:ジェネリック関数の基本的な使い方
3-1. 値をそのまま返すジェネリック関数の例
最も簡単な例は、受け取った値をそのまま返すメソッドです。
C#static T Identity<T>(T value)
{
return value;
}
使用例は次のとおりです。
C#int number = Identity(123);
string message = Identity("ジェネリック");
bool enabled = Identity(true);
Console.WriteLine(number);
Console.WriteLine(message);
Console.WriteLine(enabled);
Identityメソッドの処理内容は単純ですが、引数と戻り値で同じ型を維持できることが分かります。
3-2. 配列やList<T>を受け取る関数の例
ジェネリック関数は、配列やList<T>を扱う処理にも適しています。
配列の最初の要素を取得するメソッドは、次のように作成できます。
C#static T GetFirst<T>(T[] items)
{
if (items.Length == 0)
{
throw new ArgumentException("配列が空です。", nameof(items));
}
return items[0];
}
使用例は次のとおりです。
C#int firstNumber = GetFirst(new[] { 10, 20, 30 });
string firstName = GetFirst(new[] { "田中", "佐藤", "鈴木" });
Console.WriteLine(firstNumber);
Console.WriteLine(firstName);
List<T>を受け取る場合も、ほぼ同じように書けます。
C#static T GetLast<T>(List<T> items)
{
if (items.Count == 0)
{
throw new ArgumentException("リストが空です。", nameof(items));
}
return items[^1];
}
C#var numbers = new List<int> { 10, 20, 30 };
var names = new List<string> { "田中", "佐藤", "鈴木" };
Console.WriteLine(GetLast(numbers));
Console.WriteLine(GetLast(names));
3-3. 2つの値を入れ替えるSwap関数の例
2つの値を入れ替えるSwapメソッドは、ジェネリック関数の代表的な例です。
C#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);
Console.WriteLine(a); // 20
Console.WriteLine(b); // 10
文字列にも同じメソッドを使用できます。
C#string firstName = "田中";
string secondName = "佐藤";
Swap(ref firstName, ref secondName);
Console.WriteLine(firstName); // 佐藤
Console.WriteLine(secondName); // 田中
refを付けることで、メソッド内から呼び出し元の変数を書き換えています。
3-4. 任意の型を表示するPrint関数の例
任意の型を受け取り、コンソールに表示するメソッドも簡単に作成できます。
C#static void Print<T>(T value)
{
Console.WriteLine(value);
}
使用例は次のとおりです。
C#Print(100);
Print("Hello");
Print(3.14);
Print(DateTime.Now);
型名も一緒に表示したい場合は、typeof(T)を利用できます。
C#static void PrintWithType<T>(T value)
{
Console.WriteLine($"型: {typeof(T).Name}, 値: {value}");
}
C#PrintWithType(100);
PrintWithType("Hello");
ただし、すべての型で表示形式を細かく制御したい場合は、ToStringの結果だけに頼らず、書式指定や変換処理を引数として受け取る設計も検討します。
3-5. 型ごとに同じ処理を使い回す実践例
リストの各要素を順番に表示する処理を考えてみましょう。
C#static void PrintAll<T>(IEnumerable<T> items)
{
foreach (T item in items)
{
Console.WriteLine(item);
}
}
使用例は次のとおりです。
C#var numbers = new List<int> { 1, 2, 3 };
var names = new List<string> { "田中", "佐藤", "鈴木" };
PrintAll(numbers);
PrintAll(names);
引数をList<T>ではなくIEnumerable<T>にすると、配列、リスト、LINQの結果など、より多くのコレクションを受け取れます。
C#PrintAll(new[] { 10, 20, 30 });
PrintAll(Enumerable.Range(1, 5));
ジェネリック関数では、具体的な型だけでなく、インターフェースを活用して汎用性を高めることが重要です。
4. C#ジェネリック関数で使える型制約
4-1. 型制約とは?
型制約とは、型パラメーターとして指定できる型の条件を設定する機能です。
制約のないTでは、どのような型が渡されるか分かりません。そのため、特定のクラスやインターフェースが持つメンバーを自由に呼び出すことはできません。
型制約は、whereキーワードを使って指定します。
C#static void Process<T>(T value)
where T : class
{
}
型制約を使うと、次のことが可能になります。
使用できる型を限定する
特定のメソッドやプロパティを安全に呼び出す
不適切な型の使用をコンパイル時に防ぐ
メソッドが必要とする条件をコード上で明確にする
4-2. where T : class の使い方
where T : classは、Tを参照型に限定する制約です。
C#static bool IsNull<T>(T value)
where T : class
{
return value is null;
}
使用例は次のとおりです。
C#string? text = null;
bool result = IsNull(text);
Console.WriteLine(result); // True
intなどの値型は指定できません。
C#// コンパイルエラー
// bool result = IsNull(10);
Null許容参照型を有効にしているプロジェクトでは、classとclass?で意図が異なります。
C#static void ProcessNonNullable<T>(T value)
where T : class
{
}
static void ProcessNullable<T>(T value)
where T : class?
{
}
classは非Null許容の参照型、class?はNull許容を含む参照型を想定した制約として利用できます。
4-3. where T : struct の使い方
where T : structは、TをNull非許容の値型に限定する制約です。
C#static T GetDefaultValue<T>()
where T : struct
{
return default;
}
使用例は次のとおりです。
C#int number = GetDefaultValue<int>();
DateTime date = GetDefaultValue<DateTime>();
Console.WriteLine(number); // 0
stringや通常のクラスは指定できません。
C#// コンパイルエラー
// string text = GetDefaultValue<string>();
struct制約を指定したTには、通常のintやDateTimeは指定できますが、int?のようなNull許容値型は指定できません。
4-4. where T : new() の使い方
where T : new()は、引数なしのパブリックコンストラクターを持つ型に限定する制約です。
C#static T CreateInstance<T>()
where T : new()
{
return new T();
}
使用するクラスを定義します。
C#public class User
{
public string Name { get; set; } = string.Empty;
}
Userには引数なしコンストラクターがあるため、次のように生成できます。
C#User user = CreateInstance<User>();
user.Name = "田中";
引数なしのパブリックコンストラクターが存在しない型は指定できません。
C#public class Product
{
public Product(string name)
{
Name = name;
}
public string Name { get; }
}
// コンパイルエラー
// Product product = CreateInstance<Product>();
複数の制約と組み合わせる場合、new()制約は最後に記述します。
C#static T CreateService<T>()
where T : class, new()
{
return new T();
}
4-5. where T : インターフェース の使い方
インターフェース制約を使用すると、指定したインターフェースを実装する型だけを受け取れます。
C#public interface IHasName
{
string Name { get; }
}
C#static void PrintName<T>(T value)
where T : IHasName
{
Console.WriteLine(value.Name);
}
実装クラスを用意します。
C#public class Customer : IHasName
{
public string Name { get; set; } = string.Empty;
}
public class Employee : IHasName
{
public string Name { get; set; } = string.Empty;
}
どちらの型にも同じメソッドを使用できます。
C#var customer = new Customer { Name = "山田商事" };
var employee = new Employee { Name = "佐藤" };
PrintName(customer);
PrintName(employee);
インターフェース制約を指定しているため、メソッド内でNameプロパティへ安全にアクセスできます。
4-6. where T : 基底クラス の使い方
基底クラスを型制約に指定すると、そのクラスまたは派生クラスだけを受け取れます。
C#public abstract class Entity
{
public int Id { get; set; }
}
C#public class UserEntity : Entity
{
public string Name { get; set; } = string.Empty;
}
C#static void PrintId<T>(T entity)
where T : Entity
{
Console.WriteLine(entity.Id);
}
使用例は次のとおりです。
C#var user = new UserEntity
{
Id = 1,
Name = "田中"
};
PrintId(user);
Tが必ずEntityを継承していることが保証されるため、Idプロパティを直接参照できます。
4-7. 複数の型制約を組み合わせる方法
複数の型制約は、カンマで区切って指定します。
C#public interface IValidatable
{
bool IsValid();
}
C#static T CreateAndValidate<T>()
where T : class, IValidatable, new()
{
T instance = new T();
if (!instance.IsValid())
{
throw new InvalidOperationException("検証に失敗しました。");
}
return instance;
}
この例でTに必要な条件は次のとおりです。
参照型である
IValidatableを実装しているパブリックな引数なしコンストラクターを持つ
複数の型パラメーターがある場合は、それぞれにwhereを指定できます。
C#static TResult Map<TSource, TResult>(TSource source)
where TSource : class
where TResult : class, new()
{
return new TResult();
}
制約には記述順があります。基底クラスやclassなどの主要な制約を先に書き、インターフェース制約を続け、new()を最後に書きます。
4-8. 型制約を使うべき場面
型制約は、単に受け付ける型を減らすためではなく、メソッドの処理に必要な能力を示すために使います。
たとえば、値を比較する処理では、比較可能であることを制約として表現できます。
C#static T Max<T>(T first, T second)
where T : IComparable<T>
{
return first.CompareTo(second) >= 0 ? first : second;
}
型制約を検討すべき代表的な場面は、次のとおりです。
特定のプロパティやメソッドを呼び出したい
new T()でインスタンスを生成したい値を比較したい
特定の基底クラスに共通する処理を行いたい
参照型または値型だけを受け付けたい
一方、処理に不要な制約を追加すると再利用できる範囲が狭くなります。メソッドの実装に本当に必要な条件だけを指定することが大切です。
5. ジェネリック関数の実践例
5-1. List<T>から条件に合う要素を探す関数
条件に一致する最初の要素を探すメソッドは、Func<T, bool>と組み合わせて作成できます。
C#static T? FindFirst<T>(
IEnumerable<T> items,
Func<T, bool> predicate)
{
foreach (T item in items)
{
if (predicate(item))
{
return item;
}
}
return default;
}
使用例は次のとおりです。
C#var numbers = new List<int> { 10, 20, 30, 40 };
int result = FindFirst(numbers, number => number >= 25);
Console.WriteLine(result); // 30
クラスのリストにも使用できます。
C#public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
C#var users = new List<User>
{
new() { Id = 1, Name = "田中" },
new() { Id = 2, Name = "佐藤" },
new() { Id = 3, Name = "鈴木" }
};
User? user = FindFirst(users, item => item.Id == 2);
Console.WriteLine(user?.Name); // 佐藤
実際の開発ではLINQのFirstOrDefaultを使える場面も多いですが、ジェネリック関数の仕組みを理解する例として有効です。
5-2. 配列の最大値・最小値を取得する関数
最大値を求めるには、要素同士を比較する必要があります。そのため、IComparable<T>制約を使用します。
C#static T GetMax<T>(IEnumerable<T> items)
where T : IComparable<T>
{
using IEnumerator<T> enumerator = items.GetEnumerator();
if (!enumerator.MoveNext())
{
throw new ArgumentException(
"要素が1件以上必要です。",
nameof(items));
}
T max = enumerator.Current;
while (enumerator.MoveNext())
{
if (enumerator.Current.CompareTo(max) > 0)
{
max = enumerator.Current;
}
}
return max;
}
最小値も同じ考え方で実装できます。
C#static T GetMin<T>(IEnumerable<T> items)
where T : IComparable<T>
{
using IEnumerator<T> enumerator = items.GetEnumerator();
if (!enumerator.MoveNext())
{
throw new ArgumentException(
"要素が1件以上必要です。",
nameof(items));
}
T min = enumerator.Current;
while (enumerator.MoveNext())
{
if (enumerator.Current.CompareTo(min) < 0)
{
min = enumerator.Current;
}
}
return min;
}
使用例は次のとおりです。
C#int maxNumber = GetMax(new[] { 8, 3, 15, 6 });
int minNumber = GetMin(new[] { 8, 3, 15, 6 });
string maxText = GetMax(new[] { "Apple", "Orange", "Banana" });
Console.WriteLine(maxNumber); // 15
Console.WriteLine(minNumber); // 3
Console.WriteLine(maxText);
比較方法を外部から変更できるようにする場合は、IComparer<T>を引数として受け取る設計も有効です。
C#static T GetMax<T>(
IEnumerable<T> items,
IComparer<T>? comparer = null)
{
comparer ??= Comparer<T>.Default;
using IEnumerator<T> enumerator = items.GetEnumerator();
if (!enumerator.MoveNext())
{
throw new ArgumentException(
"要素が1件以上必要です。",
nameof(items));
}
T max = enumerator.Current;
while (enumerator.MoveNext())
{
if (comparer.Compare(enumerator.Current, max) > 0)
{
max = enumerator.Current;
}
}
return max;
}
5-3. DTOやモデルを変換する汎用メソッド
DTOから画面表示用モデルへ変換する処理などでは、変換関数を受け取るジェネリックメソッドが役立ちます。
C#static TResult ConvertModel<TSource, TResult>(
TSource source,
Func<TSource, TResult> converter)
{
ArgumentNullException.ThrowIfNull(converter);
return converter(source);
}
変換元と変換先のクラスを用意します。
C#public class UserDto
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
public class UserViewModel
{
public int Id { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
使用例は次のとおりです。
C#var dto = new UserDto
{
Id = 1,
FirstName = "太郎",
LastName = "田中"
};
UserViewModel viewModel = ConvertModel(
dto,
source => new UserViewModel
{
Id = source.Id,
DisplayName = $"{source.LastName} {source.FirstName}"
});
Console.WriteLine(viewModel.DisplayName);
複数のデータを変換するメソッドにも応用できます。
C#static List<TResult> ConvertAll<TSource, TResult>(
IEnumerable<TSource> sources,
Func<TSource, TResult> converter)
{
ArgumentNullException.ThrowIfNull(sources);
ArgumentNullException.ThrowIfNull(converter);
var results = new List<TResult>();
foreach (TSource source in sources)
{
results.Add(converter(source));
}
return results;
}
5-4. インターフェース制約を使った共通処理
複数のモデルが共通インターフェースを実装している場合、インターフェース制約を使って共通処理を作成できます。
C#public interface IIdentifiable
{
int Id { get; }
}
C#public class Customer : IIdentifiable
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public class Product : IIdentifiable
{
public int Id { get; set; }
public string ProductName { get; set; } = string.Empty;
}
IDが一致する要素を検索するメソッドは、次のように作成できます。
C#static T? FindById<T>(
IEnumerable<T> items,
int id)
where T : IIdentifiable
{
foreach (T item in items)
{
if (item.Id == id)
{
return item;
}
}
return default;
}
C#var customers = new List<Customer>
{
new() { Id = 1, Name = "A社" },
new() { Id = 2, Name = "B社" }
};
Customer? customer = FindById(customers, 2);
Console.WriteLine(customer?.Name); // B社
型制約により、TがIdプロパティを持つことが保証されています。
5-5. APIレスポンスや結果クラスで使う例
APIやサービス層では、成功データの型が処理ごとに異なる一方、成功・失敗を表す構造は共通していることがあります。
まず、汎用的な結果クラスを定義します。
C#public class ApiResult<T>
{
public bool IsSuccess { get; init; }
public T? Data { get; init; }
public string? ErrorMessage { get; init; }
}
成功結果を作成するジェネリック関数は、次のように実装できます。
C#static ApiResult<T> Success<T>(T data)
{
return new ApiResult<T>
{
IsSuccess = true,
Data = data
};
}
失敗結果も作成できます。
C#static ApiResult<T> Failure<T>(string errorMessage)
{
return new ApiResult<T>
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
使用例は次のとおりです。
C#ApiResult<User> userResult = Success(
new User
{
Id = 1,
Name = "田中"
});
ApiResult<List<User>> usersResult = Success(
new List<User>
{
new() { Id = 1, Name = "田中" },
new() { Id = 2, Name = "佐藤" }
});
ApiResult<User> errorResult =
Failure<User>("ユーザーが見つかりません。");
結果の構造を統一しながら、中に格納するデータの型を変更できます。
5-6. ユーティリティクラスで使う例
ジェネリック関数は、複数の場所から利用するユーティリティクラスにも適しています。
C#public static class CollectionUtility
{
public static bool IsNullOrEmpty<T>(
IEnumerable<T>? items)
{
return items is null || !items.Any();
}
public static List<T> ToListOrEmpty<T>(
IEnumerable<T>? items)
{
return items?.ToList() ?? new List<T>();
}
public static T? FirstOrDefault<T>(
IEnumerable<T>? items)
{
if (items is null)
{
return default;
}
foreach (T item in items)
{
return item;
}
return default;
}
}
使用例は次のとおりです。
C#List<int>? numbers = null;
bool isEmpty = CollectionUtility.IsNullOrEmpty(numbers);
List<int> safeNumbers =
CollectionUtility.ToListOrEmpty(numbers);
Console.WriteLine(isEmpty); // True
Console.WriteLine(safeNumbers.Count); // 0
ただし、標準ライブラリやLINQに同等の機能がある場合は、独自メソッドを増やしすぎないよう注意が必要です。
6. ジェネリック関数と関連機能の違い
6-1. ジェネリック関数とジェネリッククラスの違い
ジェネリック関数は、メソッド単位で型パラメーターを定義します。
C#public static T Echo<T>(T value)
{
return value;
}
ジェネリッククラスは、クラス全体で使用する型パラメーターを定義します。
C#public class Box<T>
{
public T? Value { get; set; }
}
使用例は次のとおりです。
C#var intBox = new Box<int>
{
Value = 100
};
var stringBox = new Box<string>
{
Value = "Hello"
};
クラス内の複数のプロパティやメソッドで同じ型を使用する場合は、ジェネリッククラスが適しています。
一方、一部のメソッドだけを汎用化したい場合は、ジェネリック関数が適しています。
ジェネリッククラスの中に、別の型パラメーターを持つジェネリックメソッドを定義することもできます。
C#public class Box<T>
{
public T? Value { get; set; }
public TResult Convert<TResult>(
Func<T, TResult> converter)
{
if (Value is null)
{
throw new InvalidOperationException(
"Valueが設定されていません。");
}
return converter(Value);
}
}
6-2. ジェネリック関数とobject型の違い
object型は、C#におけるすべての型の基礎となる型です。そのため、さまざまな値を受け取れます。
C#static object EchoObject(object value)
{
return value;
}
使用時には、戻り値を元の型にキャストする必要があります。
C#int number = (int)EchoObject(100);
string text = (string)EchoObject("Hello");
間違った型にキャストすると、実行時エラーが発生します。
C#object value = EchoObject("Hello");
// 実行時にInvalidCastExceptionが発生
// int number = (int)value;
ジェネリック関数では、具体的な型を維持できます。
C#static T Echo<T>(T value)
{
return value;
}
int number = Echo(100);
string text = Echo("Hello");
キャストが不要であり、型の誤りをコンパイル時に検出しやすい点が大きな違いです。
また、値型をobjectとして扱う場合はボックス化が発生します。ジェネリック関数では、処理内容や実行環境にもよりますが、値型をそのまま扱えるためボックス化を避けられる場合があります。
6-3. ジェネリック関数とオーバーロードの違い
オーバーロードは、同じメソッド名で異なる引数の組み合わせを定義する機能です。
C#static void Print(int value)
{
Console.WriteLine($"整数: {value}");
}
static void Print(string value)
{
Console.WriteLine($"文字列: {value}");
}
型ごとに処理内容が異なる場合は、オーバーロードが適しています。
ジェネリック関数は、型が違っても同じ処理を行う場合に適しています。
C#static void Print<T>(T value)
{
Console.WriteLine(value);
}
ジェネリック関数とオーバーロードは併用できます。
C#static void Print<T>(T value)
{
Console.WriteLine($"汎用表示: {value}");
}
static void Print(string value)
{
Console.WriteLine($"文字列専用表示: {value}");
}
この場合、文字列を渡すと、より具体的なstring版のオーバーロードが選択されます。
6-4. ジェネリック関数とdynamicの違い
dynamicを使うと、メンバーの存在確認などが実行時まで遅延されます。
C#static dynamic Add(dynamic first, dynamic second)
{
return first + second;
}
C#Console.WriteLine(Add(10, 20));
Console.WriteLine(Add("A", "B"));
柔軟に見えますが、対応していない演算を行うと実行時エラーになります。
C#// 組み合わせによっては実行時エラー
// var result = Add(new User(), new User());
ジェネリック関数は、基本的にコンパイル時に型が決まります。
C#static T Echo<T>(T value)
{
return value;
}
そのため、型安全性、IDEの入力補完、リファクタリングのしやすさでは、通常ジェネリック関数のほうが有利です。
dynamicは、動的なデータ、COM連携、実行時に構造が決まるオブジェクトなどを扱う特殊な場面で使用します。単に複数の型へ対応したいだけであれば、まずジェネリックを検討します。
6-5. どの方法を選ぶべきか
選択の目安は次のとおりです。
| 方法 | 適した場面 |
|---|---|
| ジェネリック関数 | 型が違っても同じ処理を行う |
| ジェネリッククラス | クラス全体で同じ型を保持・利用する |
object | 型情報を保持する必要がなく、共通の値として扱う |
| オーバーロード | 型によって処理内容や引数構成が異なる |
dynamic | 実行時まで型やメンバーが確定しない |
| インターフェース | 複数の型に共通する能力や契約を表現する |
型が違っても処理の意味が同じなら、ジェネリック関数が有力です。
型ごとに異なる処理を行うなら、オーバーロードやポリモーフィズムを検討します。単にすべてをobjectやdynamicで受け取る設計は、型安全性を下げる可能性があるため慎重に判断します。
7. ジェネリック関数でよくあるエラーと対処法
7-1. 型パラメーターTの使い方を間違える
型パラメーターは、メソッド名の後ろに宣言する必要があります。
正しい書き方は次のとおりです。
C#static T Echo<T>(T value)
{
return value;
}
<T>を宣言せずに引数や戻り値でTを使用すると、コンパイラはTが何を表すのか判断できません。
C#// Tが宣言されていないためコンパイルエラー
// static T Echo(T value)
// {
// return value;
// }
また、同じTを使った引数には、基本的に同じ型を渡す必要があります。
C#static T SelectFirst<T>(T first, T second)
{
return first;
}
C#int result = SelectFirst(10, 20);
// 型が一致しないため推論できない
// var value = SelectFirst(10, "Hello");
異なる型を受け取りたい場合は、複数の型パラメーターを使います。
C#static TFirst SelectFirst<TFirst, TSecond>(
TFirst first,
TSecond second)
{
return first;
}
7-2. 型制約がないためメソッドやプロパティを呼び出せない
制約のないTでは、特定のプロパティを呼び出せません。
C#static void PrintName<T>(T value)
{
// TにNameがある保証がないためコンパイルエラー
// Console.WriteLine(value.Name);
}
解決するには、必要なメンバーをインターフェースとして定義し、型制約を指定します。
C#public interface IHasName
{
string Name { get; }
}
C#static void PrintName<T>(T value)
where T : IHasName
{
Console.WriteLine(value.Name);
}
「実行時には必ずそのプロパティがあるはず」と考えてdynamicへ変換する方法もありますが、型安全性が失われます。可能な限り、インターフェースや基底クラスによる制約を使用します。
7-3. new T()が使えない場合の対処法
制約のないTに対して、直接new T()を実行することはできません。
C#static T Create<T>()
{
// コンパイルエラー
// return new T();
return default!;
}
new()制約を追加すると解決できます。
C#static T Create<T>()
where T : new()
{
return new T();
}
ただし、この方法で呼び出せるのは、引数なしのパブリックコンストラクターを持つ型だけです。
引数が必要なインスタンスを生成したい場合は、生成処理をデリゲートとして受け取る方法があります。
C#static T Create<T>(Func<T> factory)
{
ArgumentNullException.ThrowIfNull(factory);
return factory();
}
C#Product product = Create(
() => new Product("キーボード"));
この方法なら、引数付きコンストラクターやDIコンテナーによる生成にも対応しやすくなります。
7-4. nullを扱うときの注意点
ジェネリック関数のTには、参照型と値型の両方が入る可能性があります。そのため、nullの扱いには注意が必要です。
値がnullかどうか確認するだけなら、パターンマッチングを使うと安全です。
C#static bool IsNull<T>(T value)
{
return value is null;
}
C#Console.WriteLine(IsNull<string?>(null)); // True
Console.WriteLine(IsNull(10)); // False
Null許容参照型を有効にしている場合は、メソッドがnullを受け入れるのか、戻り値がnullになる可能性があるのかを型注釈で表現します。
参照型だけを対象にする例は次のとおりです。
C#static T? FindOrNull<T>(
IEnumerable<T> items,
Func<T, bool> predicate)
where T : class
{
foreach (T item in items)
{
if (predicate(item))
{
return item;
}
}
return null;
}
すべての型で使用できる検索結果を明確に表したい場合は、成功したかどうかをboolで返す方法もあります。
C#static bool TryFind<T>(
IEnumerable<T> items,
Func<T, bool> predicate,
out T? result)
{
foreach (T item in items)
{
if (predicate(item))
{
result = item;
return true;
}
}
result = default;
return false;
}
7-5. 型推論が効かない場合の対処法
型推論は、主にメソッドの引数から行われます。
C#static T Echo<T>(T value)
{
return value;
}
int number = Echo(10);
一方、型パラメーターが引数に現れない場合、型推論はできません。
C#static T CreateDefault<T>()
{
return default!;
}
次の呼び出しでは型を判断できません。
C#// 型引数を推論できないためコンパイルエラー
// var value = CreateDefault();
型を明示すると解決します。
C#int value = CreateDefault<int>();
nullを直接渡す場合も、具体的な型を推論できないことがあります。
C#static void Print<T>(T value)
{
Console.WriteLine(value);
}
// 型を推論できない場合がある
// Print(null);
この場合は型引数を明示します。
C#Print<string?>(null);
ラムダ式を渡すジェネリックメソッドでも、引数や戻り値の型が曖昧だと推論できない場合があります。ラムダ式の引数型を明示するか、ジェネリックメソッドの型引数を指定します。
7-6. コンパイルエラーを防ぐ書き方
ジェネリック関数のコンパイルエラーを防ぐには、次の点を意識します。
まず、処理に必要な型制約を明示します。
C#static T GetGreater<T>(T first, T second)
where T : IComparable<T>
{
return first.CompareTo(second) >= 0
? first
: second;
}
次に、型推論が難しいメソッドでは、型引数を明示します。
C#string text = CreateDefault<string>();
さらに、型パラメーターの役割が複数ある場合は、意味のある名前を付けます。
C#static TResult Convert<TSource, TResult>(
TSource source,
Func<TSource, TResult> converter)
{
return converter(source);
}
コンパイルエラーを避けるためにobjectやdynamicへ安易に置き換えるのではなく、必要なインターフェース、型制約、デリゲートを使って設計することが重要です。
8. ジェネリック関数を使うときのベストプラクティス
8-1. TやTResultなど型パラメーター名の付け方
型パラメーターが1つで、役割が明らかな場合はTが一般的です。
C#static T Echo<T>(T value)
{
return value;
}
型パラメーターが複数ある場合は、役割が分かる名前を付けます。
C#static TResult Map<TSource, TResult>(
TSource source,
Func<TSource, TResult> converter)
{
return converter(source);
}
よく使われる名前には、次のようなものがあります。
| 名前 | 意味 |
|---|---|
T | 一般的な型 |
TItem | コレクションの要素型 |
TKey | キーの型 |
TValue | 値の型 |
TSource | 変換元の型 |
TResult | 戻り値や変換結果の型 |
TEntity | エンティティの型 |
TModel | モデルの型 |
TRequest | リクエストの型 |
TResponse | レスポンスの型 |
型パラメーター名は、通常Tから始める命名規則が使われます。
8-2. 汎用化しすぎない設計の考え方
ジェネリック関数を使えば、多くの処理を汎用化できます。しかし、汎用化すれば必ず良い設計になるわけではありません。
たとえば、型によって処理の意味が大きく異なるにもかかわらず、複雑な条件分岐で1つのメソッドにまとめると、かえって理解しにくくなります。
C#static void Process<T>(T value)
{
if (value is int number)
{
Console.WriteLine(number * 2);
}
else if (value is string text)
{
Console.WriteLine(text.ToUpper());
}
else if (value is DateTime date)
{
Console.WriteLine(date.ToString("yyyy/MM/dd"));
}
}
このような実装では、型を追加するたびに条件分岐が増えます。型ごとに処理内容が異なるなら、オーバーロード、インターフェース、ポリモーフィズムなどのほうが適切な場合があります。
ジェネリック関数は、「型は異なるが、処理の意味は同じ」という場面で使うのが基本です。
8-3. 型制約を適切に使う
型制約は、メソッドが必要とする機能を明示するために使います。
C#static T Max<T>(T first, T second)
where T : IComparable<T>
{
return first.CompareTo(second) >= 0
? first
: second;
}
このメソッドでは比較処理が必要なので、IComparable<T>制約には明確な理由があります。
一方、実装で必要としていない制約を付けると、利用できる型を不必要に減らしてしまいます。
C#// 参照型である必要がないならclass制約は不要
static void Print<T>(T value)
{
Console.WriteLine(value);
}
型制約は多いほど安全になるわけではありません。必要最小限にすることが大切です。
8-4. 可読性を下げないための注意点
型パラメーター、制約、デリゲートが増えすぎると、メソッドの宣言が読みにくくなります。
C#static TResult Process<
TSource,
TIntermediate,
TResult>(
TSource source,
Func<TSource, TIntermediate> firstConverter,
Func<TIntermediate, TResult> secondConverter)
where TSource : class
where TIntermediate : class
where TResult : class
{
TIntermediate intermediate =
firstConverter(source);
return secondConverter(intermediate);
}
処理が複雑になった場合は、次のような改善を検討します。
処理を複数のメソッドへ分割する
デリゲートを専用インターフェースへ置き換える
ジェネリッククラスとして責務をまとめる
型パラメーターを減らす
メソッド名や型パラメーター名で役割を明確にする
汎用性だけでなく、呼び出し側の読みやすさも確認する必要があります。
8-5. パフォーマンス面でのメリット
ジェネリック関数は、objectを使う実装と比べて、値型のボックス化やアンボックス化を避けられる場合があります。
objectを使う例は次のとおりです。
C#static object EchoObject(object value)
{
return value;
}
object boxed = EchoObject(100);
int number = (int)boxed;
intをobjectとして扱う際にボックス化が発生します。
ジェネリック関数では、型情報を維持したまま扱えます。
C#static T Echo<T>(T value)
{
return value;
}
int number = Echo(100);
ただし、ジェネリック関数を使えば常に高速になるわけではありません。実際の性能は、処理内容、型、JITコンパイル、メモリアロケーションなどの影響を受けます。
パフォーマンスが重要な処理では、推測だけで判断せず、ベンチマークを行うことが重要です。
8-6. 保守しやすいジェネリック関数の設計
保守しやすいジェネリック関数には、次の特徴があります。
メソッドの責務が1つに絞られている
型パラメーターの役割が分かりやすい
必要な型制約だけが指定されている
呼び出し側のコードが簡潔である
例外が発生する条件が明確である
nullや空のコレクションに対する動作が決まっている標準ライブラリと重複していない
たとえば、次のメソッドは変換処理だけに責務を絞っています。
C#static List<TResult> MapAll<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(selector);
var results = new List<TResult>();
foreach (TSource item in source)
{
results.Add(selector(item));
}
return results;
}
責務が明確なため、テストや変更がしやすくなります。
9. C#ジェネリック関数の理解を深める応用知識
9-1. ジェネリック関数とLINQの関係
LINQで使用する多くのメソッドは、ジェネリックメソッドとして実装されています。
たとえば、Whereは任意の要素型に対して絞り込み処理を行えます。
C#var numbers = new List<int>
{
10, 20, 30, 40
};
IEnumerable<int> filtered =
numbers.Where(number => number >= 30);
文字列のコレクションにも同じWhereを使用できます。
C#var names = new List<string>
{
"田中",
"佐藤",
"鈴木"
};
IEnumerable<string> filteredNames =
names.Where(name => name.StartsWith("佐"));
概念を単純化すると、Whereは次のようなジェネリックメソッドとして考えられます。
C#static IEnumerable<T> Filter<T>(
IEnumerable<T> source,
Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
{
yield return item;
}
}
}
Selectは、変換元と変換先の2つの型パラメーターを持つ代表的な例です。
C#static IEnumerable<TResult> Map<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
foreach (TSource item in source)
{
yield return selector(item);
}
}
LINQを理解するうえでも、ジェネリック関数とデリゲートの知識は重要です。
9-2. Func<T>やAction<T>との組み合わせ
Funcは、戻り値を持つ処理を表すジェネリックデリゲートです。
C#Func<int, int> doubleValue =
number => number * 2;
int result = doubleValue(10);
ジェネリック関数の引数としてFuncを受け取ると、処理内容を呼び出し側から指定できます。
C#static TResult Execute<TSource, TResult>(
TSource value,
Func<TSource, TResult> operation)
{
return operation(value);
}
C#int doubled = Execute(
10,
number => number * 2);
string formatted = Execute(
DateTime.Now,
date => date.ToString("yyyy/MM/dd"));
Action<T>は、戻り値を持たない処理を表します。
C#static void ForEach<T>(
IEnumerable<T> items,
Action<T> action)
{
foreach (T item in items)
{
action(item);
}
}
C#ForEach(
new[] { 1, 2, 3 },
number => Console.WriteLine(number));
ジェネリック関数とFunc、Actionを組み合わせることで、データの型だけでなく、実行する処理も外部から変更できるようになります。
9-3. 拡張メソッドでジェネリック関数を使う方法
拡張メソッドを使うと、既存の型にメソッドを追加したような形式で呼び出せます。
ジェネリックな拡張メソッドは、次のように定義します。
C#public static class EnumerableExtensions
{
public static bool IsNullOrEmpty<T>(
this IEnumerable<T>? source)
{
return source is null || !source.Any();
}
}
使用例は次のとおりです。
C#List<int>? numbers = null;
bool result = numbers.IsNullOrEmpty();
要素を区切り文字で連結する拡張メソッドも作成できます。
C#public static class CollectionExtensions
{
public static string JoinToString<T>(
this IEnumerable<T> source,
string separator)
{
ArgumentNullException.ThrowIfNull(source);
return string.Join(
separator,
source.Select(item => item?.ToString()));
}
}
C#var numbers = new[] { 10, 20, 30 };
string text = numbers.JoinToString(", ");
Console.WriteLine(text); // 10, 20, 30
拡張メソッドは便利ですが、追加しすぎると候補が増えて分かりにくくなるため、汎用性と利用頻度が高い処理に限定します。
9-4. 非同期処理Task<T>とジェネリック関数
非同期処理では、結果を返すためにTask<T>がよく使われます。
任意の型の結果を非同期で取得するジェネリック関数は、次のように定義できます。
C#static async Task<T> ExecuteAsync<T>(
Func<Task<T>> operation)
{
ArgumentNullException.ThrowIfNull(operation);
return await operation();
}
使用例は次のとおりです。
C#int number = await ExecuteAsync(async () =>
{
await Task.Delay(100);
return 100;
});
string text = await ExecuteAsync(async () =>
{
await Task.Delay(100);
return "完了";
});
キャンセルに対応する場合は、CancellationTokenを受け取ります。
C#static async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
return await operation(cancellationToken);
}
例外処理を共通化することもできます。
C#static async Task<ApiResult<T>> TryExecuteAsync<T>(
Func<Task<T>> operation)
{
try
{
T data = await operation();
return new ApiResult<T>
{
IsSuccess = true,
Data = data
};
}
catch (Exception exception)
{
return new ApiResult<T>
{
IsSuccess = false,
ErrorMessage = exception.Message
};
}
}
実際のアプリケーションでは、すべての例外を一律に処理してよいとは限りません。キャンセル例外、入力エラー、通信エラーなどを適切に区別する設計が必要です。
9-5. リポジトリパターンや共通処理での活用
データアクセス層では、複数のエンティティに共通する処理をジェネリック化することがあります。
C#public interface IEntity
{
int Id { get; set; }
}
C#public interface IRepository<TEntity>
where TEntity : class, IEntity
{
TEntity? FindById(int id);
IEnumerable<TEntity> GetAll();
void Add(TEntity entity);
void Remove(TEntity entity);
}
インメモリで動作する簡単な実装例は次のとおりです。
C#public class InMemoryRepository<TEntity>
: IRepository<TEntity>
where TEntity : class, IEntity
{
private readonly List<TEntity> _items = new();
public TEntity? FindById(int id)
{
return _items.FirstOrDefault(
item => item.Id == id);
}
public IEnumerable<TEntity> GetAll()
{
return _items;
}
public void Add(TEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
_items.Add(entity);
}
public void Remove(TEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
_items.Remove(entity);
}
}
エンティティを定義します。
C#public class UserEntity : IEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public class ProductEntity : IEntity
{
public int Id { get; set; }
public string ProductName { get; set; } = string.Empty;
}
それぞれの型に対して同じリポジトリ実装を利用できます。
C#var userRepository =
new InMemoryRepository<UserEntity>();
userRepository.Add(
new UserEntity
{
Id = 1,
Name = "田中"
});
UserEntity? user =
userRepository.FindById(1);
var productRepository =
new InMemoryRepository<ProductEntity>();
productRepository.Add(
new ProductEntity
{
Id = 100,
ProductName = "キーボード"
});
ジェネリックなリポジトリは重複を減らせますが、すべてのデータアクセスを無理に共通化すると、エンティティ固有の検索条件や更新処理を表現しにくくなる場合があります。
共通処理はジェネリック化し、固有処理は専用のインターフェースやサービスへ分離することが重要です。
まとめ
C#のジェネリック関数は、使用する型を呼び出し時に決められるメソッドです。C#では、一般的にジェネリックメソッドと呼ばれます。
基本構文では、メソッド名の後ろに<T>を付けます。
C#static T Echo<T>(T value)
{
return value;
}
引数から型を判断できる場合は、呼び出し時の型指定を省略できます。
C#int number = Echo(100);
string text = Echo("Hello");
特定の能力を持つ型だけを受け取りたい場合は、whereによる型制約を使用します。
C#static T Max<T>(T first, T second)
where T : IComparable<T>
{
return first.CompareTo(second) >= 0
? first
: second;
}
C#のジェネリック関数を使う主なメリットは、コードの重複を減らし、型安全性を維持しながら、さまざまな型に同じ処理を適用できることです。
一方で、型によって処理内容が大きく異なる場合や、条件分岐が増える場合は、オーバーロードやインターフェース、ポリモーフィズムのほうが適切なこともあります。
「型は異なるが、処理の意味は同じか」という視点で判断し、必要最小限の型制約と分かりやすい型パラメーター名を使うことが、保守しやすいジェネリック関数を設計するポイントです。

