C# Generic 制約 (where T : …) — 業務SE が型安全コードを書く5パターン

みなさんこんにちは!ヒロポンです!

C# でジェネリック使ってて、こんな経験ないですか??

  • Repository<T> を書こうとして new T() でエラー出てきて詰まった
  • T item = null; で「型 'T' を 'null' に変換できません」って怒られた
  • where T : class, new()順序 をネットで調べても「とりあえずコピペ」で終わってる

俺も最初の業務系プロジェクトで DataAccess の共通化やろうとした時に、Repository<T>new T()MissingMethodException が出て、半日溶かしたんですよね。.NET Framework 4.7.2 / VS2019 / C# 7.3 の構成だったんですけど、当時はジェネリック制約の意味分かってなくて「なんで実行時に落ちるんや??」って状態でした。

ん?普通に new T() で動くんじゃないの??って思いますよね。

結論先出すと、ジェネリック制約は 5パターン に整理できて、書く 順序 も決まってます。今回は業務SE が Repository<T> 書ける状態にゴールを置いて、5パターン全部コード付きで解説します。コード6本構成。

目次

TL;DR

  • C# Generic 制約は5パターン: where T : class / where T : struct / where T : new() / where T : IDisposable(Interface)/ where T : EntityBase(基底クラス)
  • 制約の 書く順序: class または struct → 基底クラス → Interface → new()
  • 業務SE 必修2パターン: Repository<T> where T : class, new()Disposable<T> where T : class, IDisposable
  • T item = null; のエラーは where T : class で解決、default(T) でも代用可

なぜジェネリック制約が必要なのか — T item = null; がコンパイルエラーになる理由

ジェネリックメソッド書いてて、こういう書き方で詰まった人多いと思います。

// ❌ コンパイルエラー: CS0403
public static T GetDefault<T>()
{
    T item = null;  // 'T' を 'null' に変換できません
    return item;
}

このエラー、ヒロポン的に「ん?普通に null 入れるだけやろ??」って最初思ったんですけど、コンパイラからすると T が値型(int / DateTime / struct)だった時に困る のが理由です。

値型に null は代入できないので、「T が値型でも参照型でも安全に書ける形」をコンパイラが要求してくる。対処は2択あります。

// ✅ 対処1: where T : class で参照型に絞る
public static T GetDefault<T>() where T : class
{
    T item = null;  // OK
    return item;
}

// ✅ 対処2: default(T) を使う(制約なしでもOK)
public static T GetDefault2<T>()
{
    T item = default(T);  // 参照型なら null、値型なら 0 / false / DateTime.MinValue
    return item;
}

業務系の Repository<T> で扱うのは大体エンティティクラス(参照型)なので、対処1の class 制約寄せが現実解。値型も含めて汎用的に書く場合は対処2の default(T) を使う、こんな感じで覚えておくと迷わなくなります!!

パターン1: where T : class — 参照型のみ許容

最頻出パターン。T を参照型(クラス / インターフェース / デリゲート / 配列)に絞ります。

// ✅ T は参照型のみ
public class Cache<T> where T : class
{
    private readonly Dictionary<string, T> _store = new Dictionary<string, T>();

    public T Get(string key)
    {
        if (_store.TryGetValue(key, out var value))
            return value;
        return null;  // 参照型なので null 返せる
    }

    public void Set(string key, T value)
    {
        _store[key] = value;
    }
}

// 使い方
var userCache = new Cache<User>();         // OK: User はクラス
var stringCache = new Cache<string>();     // OK: string も参照型
// var intCache = new Cache<int>();        // ❌ コンパイルエラー: int は値型

where T : class が付くと null 比較が自由にできるので、「キャッシュにヒットしなければ null」とか「DB に存在しなければ null」とかの業務系パターンが書きやすくなります。

俺の現場でも Cache<User> Cache<Order> みたいな共通基盤をこんな感じで書く時に多用するパターン。業務系の DataAccess 系コードはほぼこれです

パターン2: where T : struct — 値型のみ許容

値型(int / DateTime / 構造体 / enum)のみを受け入れる制約。Nullable<T> のラッパーを書く時とかで使います。

// ✅ T は値型のみ(int / DateTime / struct / enum)
public class NullableWrapper<T> where T : struct
{
    private T? _value;  // Nullable<T> として宣言

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

    public bool HasValue => _value.HasValue;

    public T GetValueOrDefault(T defaultValue)
    {
        return _value ?? defaultValue;
    }
}

// 使い方
var intWrapper = new NullableWrapper<int>();       // OK: int は値型
var dateWrapper = new NullableWrapper<DateTime>(); // OK: DateTime は値型
// var strWrapper = new NullableWrapper<string>(); // ❌ string は参照型

ここでちょっとハマりやすいのが、where T : struct を付けると Nullable<T> 自体は除外される という挙動。Nullable は struct でありながら特殊な扱いを受けてて、ジェネリック制約からは外されます。

// ❌ Nullable<int> は Nullable<T> where T : struct の制約を満たさない扱い
// var wrapper = new NullableWrapper<int?>(); // CS0453: 値型でない

業務系で値型のラッパー書きたい時は where T : struct を使って、内部で T? を保持 するのが定石。Nullable をさらにラップしたい場合は別 overload か制約を緩める必要があるので、そこだけ注意です。

パターン3: where T : new() — 引数なしコンストラクタを持つ型のみ

これが業務SE のハマりポイント No.1。new T() で実行時例外が出る原因のやつです。

// ❌ 制約なしだと実行時 MissingMethodException リスクあり
public class FactoryBroken<T>
{
    public T Create()
    {
        return new T();  // T が引数必須コンストラクタの型だと実行時エラー
    }
}

// ✅ new() 制約でコンパイル時に保証
public class Factory<T> where T : new()
{
    public T Create()
    {
        return new T();  // OK: T は引数なしコンストラクタを持つことが保証されてる
    }
}

// 使い方
var factory = new Factory<User>();   // User クラスに引数なしコンストラクタが必要
var user = factory.Create();         // new User() 相当

new() 制約がないと、Tnew T() できない型(引数必須のクラス)だった時に実行時 MissingMethodException で落ちます。これコンパイル時に検出できないので、new() 制約を付けてコンパイラに保証させるのが業務SE 鉄則。

俺も最初の業務系プロジェクトでこれやらかしました。Repository<T>new T()アプリ起動直後に死んで 半日デバッグ、原因が「Entity クラスに引数なしコンストラクタなかった」っていう。new() 制約を後付けでコンパイル時検出に切り替えたら、即気付ける構造になって学習体験としては大きかったです。

パターン4: where T : IDisposable — Interface 実装制約

Interface を制約に使うパターン。using で安全に使える T だけを許容します。

// ✅ T は IDisposable 実装のみ
public class DisposableScope<T> where T : IDisposable
{
    private readonly T _resource;

    public DisposableScope(T resource)
    {
        _resource = resource;
    }

    public void Use(Action<T> action)
    {
        using (_resource)  // T が IDisposable なので using で囲める
        {
            action(_resource);
        }
    }
}

// 使い方
using (var conn = new SqlConnection("..."))
{
    var scope = new DisposableScope<SqlConnection>(conn);
    scope.Use(c => c.Open());
}

SqlConnection SqlCommand FileStream などの IDisposable 実装型を扱うラッパーを書く時に頻出。Interface 制約は 複数指定もできる ので、where T : IDisposable, IComparable<T> みたいに連ねるパターンもあります。

ハマりポイント1つ。as キャスト と組み合わせる時、where T : class 制約も同時に必要になります。is/as の挙動については C# Interface と is/as の暗黙的キャスト で詳しく書いてます。

パターン5: where T : EntityBase — 基底クラス制約

特定クラスの派生型のみを許容するパターン。業務系の Entity 階層を表現する時に使います。

// 基底クラス
public abstract class EntityBase
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class User : EntityBase
{
    public string Name { get; set; }
}

public class Order : EntityBase
{
    public decimal Amount { get; set; }
}

// ✅ T は EntityBase の派生のみ
public class Repository<T> where T : EntityBase, new()
{
    public T FindById(int id)
    {
        var entity = new T();   // new() 制約で保証
        entity.Id = id;          // EntityBase 制約で Id プロパティ参照可能
        // ... 実際は DB から取得して詰める
        return entity;
    }
}

// 使い方
var userRepo = new Repository<User>();    // OK: User は EntityBase 派生
var orderRepo = new Repository<Order>();  // OK: Order も派生
// var strRepo = new Repository<string>(); // ❌ string は EntityBase 派生じゃない

これが業務系 Repository<T> パターンの完成形。基底クラス制約 + new() 制約 で、「EntityBase 派生 + new T() できる型」だけを受け入れる Repository が書けます。

俺の現場でも DataAccess 共通化する時にこのパターンよく使うんですけど、最初は where T : EntityBase だけ書いて new() を忘れて MissingMethodException 出してた、っていう経験あります。基底クラス制約と new() 制約はセットで覚える のが現実解です。

複数制約の組み合わせ — 順序ルール

制約は複数組み合わせられますが、書く順序が決まってる のでそこだけ注意。

// 順序ルール: class または struct → 基底クラス → Interface → new()

// ✅ 正しい順序
public class FullRepo<T> where T : class, EntityBase, IDisposable, new()
{
    // ...
}

// ❌ 順序が違うとコンパイルエラー (CS0449 等)
// public class WrongRepo<T> where T : new(), class, IDisposable  // NG

業務SE が覚えておくべき組み合わせは、こんな感じで2つだけ。

// 組み合わせ1: 業務系 Repository<T>
public class Repository<T> where T : class, new() { /* ... */ }

// 組み合わせ2: IDisposable + using で安全に使える参照型
public class SafeScope<T> where T : class, IDisposable { /* ... */ }

この2パターン覚えておけば、業務系の DataAccess / Service 層の共通基盤は大体書けます。

ハマりポイント整理 — default(T) vs null の使い分け

ジェネリックメソッドで「未設定」を表現する時、nulldefault(T) のどっち使うか迷う場面があります。

public class GenericContainer<T>
{
    private T _value;

    // ❌ 制約なしで null 比較すると CS0019 など
    // public bool IsEmpty() => _value == null;  // T が値型だと比較できない

    // ✅ default(T) を使えば制約不要
    public bool IsEmpty() => EqualityComparer<T>.Default.Equals(_value, default(T));

    // ✅ where T : class なら null 比較で OK
}

public class RefContainer<T> where T : class
{
    private T _value;
    public bool IsEmpty() => _value == null;  // class 制約で null 比較可能
}

EqualityComparer<T>.Default を使うパターンは 制約なしで書ける汎用ヘルパー で、業務系では null 比較と default(T) 比較を意識せず書けるので便利。.NET Framework 4.7.2 でも標準で使えます。

まとめ

C# Generic 制約 5パターンの整理:

制約 意味 業務SE 使用頻度
where T : class 参照型のみ ★★★(Cache / Repository)
where T : struct 値型のみ ★(Nullable ラッパー)
where T : new() 引数なしコンストラクタ持ち ★★★(Repository とセット)
where T : IDisposable Interface 実装 ★★(using で安全に使う型)
where T : BaseClass 基底クラス派生 ★★(Entity 階層)

業務SE 必修パターン2つ:

  1. Repository<T> where T : class, new() — DataAccess 共通化
  2. Disposable<T> where T : class, IDisposable — using で安全に扱う参照型

複数制約の 書く順序: class または struct → 基底クラス → Interface → new()。順序間違えるとコンパイルエラーになるので、ここだけ気を付けてください。

ジェネリック制約は最初取っ付きにくいけど、5パターンに整理してしまえば業務系で詰まる場面は減りますRepository<T>new T() 落ちた経験ある人は、where T : class, new() の組み合わせをコピペできる状態にしておくと、いい感じに防御できます。

よくある質問

Q1. T item = null; がコンパイルエラーになるのはなぜ?

A. ジェネリック T は値型(struct)である可能性があるためです。値型に null は代入できないので、コンパイラがエラーにします。対処は2択: (1)where T : class 制約を付けて T を参照型に絞る、または(2)default(T) を使う(参照型なら null、値型なら既定値)。業務系の Repository パターンでは class 制約寄せが現実解です。

Q2. new T()MissingMethodException が出るのを防ぐには?

A. where T : new() 制約を付けることで防げます。この制約がないと、T が引数なしコンストラクタを持たない型だった時に実行時 MissingMethodException が出ます。new() 制約を付けるとコンパイル時に「T は new T() できる型のみ」と保証されるので、実行時例外が消えます。

Q3. 複数制約を書く時の順序は?

A. 順序ルールは「class または struct → 基底クラス → Interface → new()」。例えば where T : class, EntityBase, IDisposable, new() の順。順序を間違えるとコンパイルエラー(CS0449 等)になります。

Q4. where T : structNullable<T> の関係は?

A. where T : struct は値型のみ許容しますが、Nullable(= T?)は除外されます。値型のラッパーを書きたい時は where T : struct と書いて、内部で T? を使う、というのが定石です。

Q5. ジェネリック制約は実行時パフォーマンスに影響する?

A. 影響しません。ジェネリック制約はコンパイル時のみの検証で、ランタイムには影響しないので、ジェネリックメソッドの実行コストは制約あり / なしで同じです。ただし IDisposable 制約付きの T を仮想呼び出しすると JIT の挙動が変わる微細な差はありますが、業務系で気にするレベルじゃないので「制約は安心して付けてOK」と覚えておけば十分です。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次