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本構成。

目次

忙しい人向けに最初にまとめ

  • 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仲間いたら、どんどんシェア待ってるぜ!!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次