C#ジェネリック入門:.NET 10で検証した制約・変性・ボックス化

C#のジェネリックメソッド・クラス・型制約を.NET 10で実行して検証。2021年の例のコンパイルエラー、制約違反のエラーコード、allows ref struct、INumber<T>、共変性、ArrayListのボックス化の実測まで。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語で書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、2つの例は実数リテラルとサフィックスの間に空白があって(178.1 f)コンパイルできず(error CS1002)、制約の例では同じ名前のクラスを2つ宣言していました(error CS0101 ほか3件)。元記事の制約表にあった「インターフェイス制約はクラスだけ」という説明も誤りでした。修正したコードに加え、2021年以降に入った制約(notnull、allows ref struct)、Generic Math、変性、ボックス化のコストの実測を足しています。

List<T> は毎日使っているけれど、where T : ... の書き方や IEnumerable<out T> の out の意味があいまいな人、保守中のコードで ArrayList に出会って置き換えるべきか迷っている人向けの記事です。先に結論を書くと、ジェネリックは型を引数として受け取るコードで、T に入れた型ごとに別々の「閉じた型」ができます。制約が必要な理由も、static フィールドが型ごとに分かれる理由も、ボックス化が消える理由も、この1点から説明できます。

ジェネリックメソッド:型ごとにメソッドを書き写さない

int 用、float 用、string 用と表示メソッドを別々に作ると、本体がまったく同じメソッドが3つできます。違うのは引数の型だけなので、型そのものを引数 T に切り出せば1つにまとめられます。

元記事の例は、.NET 10 ではそのままビルドできませんでした。

float height = 178.1 f;   // 2021年の元記事のまま
Program.cs(8,34): error CS1002: ; が必要です

178.1 と f の間の空白のせいで、コンパイラーは 178.1 で文が終わったと判断してセミコロンを要求します。ブログのエディターがコードを整形したときに入った空白と思われます。同じく print < int > (age) のように山かっこの前後に入った空白は文法上問題なく、そのまま通りました。修正版と実行結果です。

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

int age = 35;
float height = 178.1f;
string name = "Superman";

Print<int>(age);
Print<float>(height);
Print(name);          // 型引数を省略:コンパイラーが T = string と推論
35
178.1
Superman

最後の行のように引数から T が分かる場合は <string> を省略でき、実務のコードもほとんど省略しています。型パラメーター名は T でなくても構いませんが、1つなら T、複数なら TKey・TValue のように T で始まる名前を付けるのが .NET の慣例です。メソッド名も慣例に合わせて Print にしました。

ジェネリッククラスと「閉じた型」

クラスも class Box<T> のように型パラメーターを受け取れます。元記事の例は配列1つを包んだ List<T> というクラスで、リテラルの空白(2.2 f、ここでも CS1002)を消すと次のように動きました(3行目は元記事の韓国語の文字列で「ジェネリッククラス」の意味です)。

10
2.2
일반화 클래스

ただし名前には注意が必要です。.NET 6 以降のプロジェクトは ImplicitUsings が有効で System.Collections.Generic が自動的に読み込まれるため、自作の List<T> が標準の List<T> と名前で衝突します。同じ名前空間の型が優先されるのでコンパイルは通りますが、読む人は標準の List<T> だと思い込みます。例であっても Box<T> や FixedArray<T> のような別名にしておくのが無難です。

Box<int> や Box<string> のように型引数をすべて埋めた型を閉じた型(closed type)と呼びます。閉じた型は実行時には別々の型なので、static フィールドもそれぞれ持ちます。キャッシュやカウンターをジェネリッククラスの static フィールドに置いたら、別の T から見ると値が「消えていた」というバグはここから来ます。

Counter<int>.Hit(); Counter<int>.Hit(); Counter<string>.Hit();
Console.WriteLine($"int={Counter<int>.Count} string={Counter<string>.Count} double={Counter<double>.Count}");

static class Counter<T>
{
    public static int Count;
    public static void Hit() => Count++;
}
int=2 string=1 double=0
図:static フィールド Count を持つジェネリッククラス Counter<T> から3つの閉じた型ができる。T = int なら Counter<int> で、Hit を2回呼んだので Count は2。T = string なら Counter<string> で、1回呼んだので Count は1。T = double なら Counter<double> で、一度も呼んでいないので Count は0。3つは static フィールドを共有しない。
ジェネリック定義は1つでも、閉じた型は3つ。Counter<int>・Counter<string>・Counter<double> は実行時には別の型なので、static な Count もそれぞれ別に持つ(.NET 10 で実行した出力)。

逆にこの性質をわざと利用することもあります。型ごとに1回だけ計算すればよい値(リフレクションの結果など)を static class Cache<T> の static フィールドに置けば、Dictionary を引かない型別キャッシュになります。また、T が何か分からないまま「空の値」が必要なときは default を使います。default(int) は 0、default(string) は null でした。

制約の一覧(元記事の表を訂正)

制約のない T で使えるのは object のメンバー(ToString()、Equals() など)だけです。where で T になれる型を絞ると、その代わりにその型の機能が使えるようになります。元記事では次の2つの宣言を並べて書いていましたが、1つのファイルに置くとコンパイルできません。

class List<T> where T : class { }
class List<U> where U : struct { }
error CS0264: 'List<T>' の partial 宣言では、同じ型パラメーター名を同じ順序で指定しなければなりません
error CS0265: 'List<T>' の partial 宣言には、型パラメーター 'T' に対して矛盾する制約が含まれています
error CS0699: 'List<T>' は、型のパラメーター 'U' を定義しません
error CS0101: 名前空間 '<global namespace>' は既に 'List' の定義を含んでいます

制約はオーバーロードの区別に使われないため、名前と型パラメーターの数が同じなら同じクラスとして扱われます。両方必要なら RefList<T>、ValList<T> のように名前を分けます。元記事の where T : Parent の例(親クラスと子クラス)はそのままコンパイルでき、「부모 클래스(親クラス)」「자식 클래스(子クラス)」と表示されました。

元記事の表では「where T : インターフェイス → T はそのインターフェイスを実装したクラス」と書いていましたが、構造体も指定できます。where T : IComparable<T> のメソッドに 3 を渡すと、そのままコンパイルが通りました。現在の C# の制約と、わざと違反したときに .NET 10 が出した診断コードをまとめます。

制約 意味 違反例 → 結果
where T : class 参照型 RefOnly(42) → error CS0452
where T : struct null 非許容の値型(int? は不可) ValOnly("abc")、ValOnly<int?>(null) → error CS0453
where T : new() 引数なしの public コンストラクター Make<string>() → error CS0310
where T : notnull(C# 8) null 非許容の型 string? の NoNull(maybe) → warning CS8714
where T : unmanaged(C# 7.3) 参照を含まない値型 Size<string>() → error CS8377
where T : 基底クラス そのクラスか派生クラス —
where T : インターフェイス それを実装した型(構造体も含む) Iface(3) は通る
where T : allows ref struct(C# 13) Span<T> などの ref struct も許可 次の節

notnull だけはエラーではなく警告です。null 許容参照型の解析の一部なので、警告を無視すればそのままビルドできてしまいます。チームで <WarningsAsErrors>nullable</WarningsAsErrors> を設定しておく理由がこれです。

C# 13:allows ref struct

Span<T> や ReadOnlySpan<T> のような ref struct はスタック上にしか置けないため、長いあいだジェネリックの型引数にできませんでした。C# 13(.NET 9)から「反制約」allows ref struct を付けると許可できます。.NET 10 での確認結果です。

static void F<T>(T x) { }
ReadOnlySpan<char> span = "hello".AsSpan();
F(span);
error CS9244: 'ReadOnlySpan<char>' 型は、ジェネリック型またはメソッド 'F<T>(T)' のパラメーター 'T' として使用するためには、ref 構造体または ref 構造体を許容する型パラメーターにすることはできません。
ref struct Holder<T> where T : allows ref struct
{
    public static string Describe(T value) => typeof(T).Name;
}
Console.WriteLine(Holder<ReadOnlySpan<char>>.Describe(span));   // ReadOnlySpan`1

ほかの制約が「使える型を減らす」のに対し、これは範囲を広げる代わりに T のボックス化や通常クラスのフィールドへの保存を禁止します。自分で書く機会は少ないものの、標準ライブラリにはすでに入っています。.NET 10 でリフレクション(GenericParameterAttributes)を調べると、IEnumerable<T>、IEnumerator<T>、Func<T>、Action<T>、IComparer<T> の T には AllowByRefLike が付いていて、List<T> には付いていませんでした。

Generic Math:INumber

2021年の時点では「数値型なら何でも受け取って足す」ジェネリックメソッドは書けませんでした。T に + 演算子があることを表す手段がなかったからです。.NET 7 からはインターフェイスの静的抽象メンバーにより、INumber<T> 制約で書けます。

using System.Numerics;

static T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var x in xs) total += x;
    return total;
}

Console.WriteLine(Sum(new[] { 1, 2, 3 }));
Console.WriteLine(Sum(new[] { 1.5, 2.25 }));
Console.WriteLine(Sum(new decimal[] { 0.1m, 0.2m }));
Console.WriteLine(Sum(new byte[] { 200, 100 }));
6
3.75
0.3
44

最後の行が落とし穴です。byte の合計300は範囲を超え、何の例外もなく44になりました(300 − 256)。合計の型が入力と同じ型だからです。ループの中を total = checked(total + x); に変えると、同じ入力で OverflowException が発生しました。Generic Math でも checked はそのまま効くので、小さい整数型が渡される可能性があれば checked を使うか、long や decimal などの広い型で合計するオーバーロードを用意します。

共変性と反変性:IEnumerable

string は object の派生型なのに、List<string> を List<object> に代入するとエラーになります。一方で IEnumerable<object> には代入できます。

IEnumerable<string> names = new List<string> { "a", "b" };
IEnumerable<object> objs = names;            // OK:IEnumerable<out T>
List<object> lo = new List<string>();         // error CS0029
IEnumerable<object> eo = new List<int>();     // error CS0266

Action<object> printObj = o => Console.WriteLine($"obj:{o}");
Action<string> printStr = printObj;           // OK:Action<in T>
printStr("contra");                           // obj:contra

List<T> は Add(T) で値を入れられるので、List<string> を List<object> として見せてしまうと int を入れて壊せます。IEnumerable<out T> は値を取り出すだけなので安全で、それを宣言しているのが out(共変)です。Action<in T> や IComparer<in T> は値を受け取るだけなので、逆方向(object 用を string 用として使う)が許される反変です。3行目のように List<int> は IEnumerable<object> になりません。変性は参照型にしか働かないからで、int を object として扱うにはボックス化が必要です。

実測:ArrayList と List のボックス化

ジェネリック以前のコレクション ArrayList は、すべての要素を object として保持します。int を追加するたびにヒープへ箱を作るボックス化が起き、取り出すときにはキャスト(アンボックス化)が必要です。どれだけ差が出るかを測りました。容量を先に確保して1000万個を追加し、foreach で合計するまでの時間と、GC.GetAllocatedBytesForCurrentThread() の前後差による割り当て量です。

var list = new ArrayList(n);
for (int i = 0; i < n; i++) list.Add(i);        // ボックス化
long sum = 0;
foreach (object o in list) sum += (int)o;        // アンボックス化
// List<int> 版は new List<int>(n) と foreach (int v in list) で同じことをする

.NET 10.0.14、Release ビルド、Apple Silicon Mac で、ウォームアップ後に3回実行した結果です。

1回目 2回目 3回目 割り当て 1要素あたり
ArrayList 520.7 ms 451.7 ms 432.5 ms 305.2 MB 32 B
List<int> 21.1 ms 15.5 ms 19.2 ms 38.1 MB 4 B
1000万要素で確保したメモリの棒グラフ:ArrayList は 305.2MB、List<int> は 38.1MB。ArrayList は8倍のメモリを確保する。
int を1000万個追加して合計するまでに確保したメモリ。ArrayList は int を1個ずつボックス化(1要素32バイト)、List<int> はそのまま格納(1要素4バイト)。.NET 10、Release、Apple Silicon Mac。

1要素32バイトは、内部の object[] が持つ参照8バイトと、ボックス化された int の箱24バイトの合計です。List<int> は int[] に値をそのまま入れるので4バイトです。20〜30倍の時間差の大部分は、1000万個の小さなオブジェクトを作り、GC がそれを追跡するコストです。新しいコードで ArrayList や Hashtable を使う理由はなく、古いコードを触るときに最初に置き換える候補です。

どれをいつ使うか

状況 おすすめ
型だけ違って処理が同じメソッド ジェネリックメソッド。型引数は推論に任せる
T のメソッドやコンストラクターが必要 必要な分だけ where 制約(IComparable<T>、new() など)
数値計算をジェネリックに where T : INumber<T>(オーバーフローが心配なら checked)
Span<T> も受け取る汎用 API where T : allows ref struct(C# 13)
読み取り専用で渡すコレクション IEnumerable<T> / IReadOnlyList<T> 引数(共変性で派生型のリストも渡せる)
型ごとのキャッシュ static class Cache<T> の static フィールド(閉じた型ごとに別)
値型のコレクション ArrayList ではなく List<T>(実測で割り当ては1/8)

ジェネリックと組み合わせることが多い Func<T, TResult> は Func と Action デリゲート、ラムダ式は C#のラムダ式、IEnumerable<T> を扱う LINQ は LINQ の基本 で続けて読めます。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon Mac)。

よくある質問

where T : class と where T : struct を付けた同名クラスを2つ並べて書ける?

書けません。class List<T> where T : class と class List<U> where U : struct を1つのファイルに置くと、.NET 10 では error CS0101(既に定義を含んでいます)と CS0264・CS0265・CS0699 になります。制約はオーバーロードの区別に使われないので、名前を変えるか型パラメーターの数を変えます。

ArrayList の代わりに List<int> を使うと実際どれくらい違う?

int を1000万個追加して合計するまでに、ArrayList は 305.2MB(1要素32バイト)を確保して 432〜521ms、List<int> は 38.1MB(1要素4バイト)で 16〜21ms でした(.NET 10、Release、Apple Silicon Mac)。差は int を object にボックス化するコストです。

ジェネリッククラスの static フィールドはすべての T で共有される?

共有されません。Counter<int> と Counter<string> は実行時には別の型で、static フィールドもそれぞれ持ちます。Counter<int>.Hit() を2回、Counter<string>.Hit() を1回呼ぶと 2 と 1 になり、呼んでいない Counter<double> は 0 のままでした。

  • #C#
  • #ジェネリック
  • #.NET
  • #型制約
  • #Generic Math

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑