Как задать ограничение (constraint) на тип для параметра обобщения (generic) в C#?

Ответ

Ограничения обобщенных типов задаются с помощью ключевого слова where. Они указывают компилятору, какие типы могут быть использованы в качестве аргументов типа, и предоставляют дополнительную информацию о этих типах внутри обобщенного кода.

Синтаксис и виды ограничений:

public class Repository<T> where T : class, IEntity, new()
{
    // T должен быть ссылочным типом (class),
    // реализовывать интерфейс IEntity,
    // и иметь открытый конструктор без параметров (new()).
}
Ограничение Описание Пример использования
where T : struct T должен быть значимым типом (не-nullable). T может быть int, DateTime, Guid. Не может быть string или class.
where T : class T должен быть ссылочным типом. T может быть string, List<int>, любой пользовательский class.
where T : notnull (C# 8.0+) T должен быть типом, не допускающим null. Запрещает T? для ссылочных типов. Полезно в контексте nullable reference types.
where T : unmanaged (C# 7.3+) T должен быть неуправляемым типом (не ссылочным и не содержащим ссылочных полей). T может быть int, double, struct только из примитивов. Используется для низкоуровневого кода (Span, указатели).
where T : new() T должен иметь открытый конструктор без параметров. Позволяет создавать экземпляры: T item = new T();
where T : BaseClass T должен быть или наследоваться от указанного базового класса. Гарантирует доступ к членам BaseClass.
where T : ISomeInterface T должен реализовывать указанный интерфейс. Гарантирует, что у T есть методы/свойства интерфейса.

Практические примеры:

  1. Ограничение для репозитория, работающего только с сущностями:

    public interface IEntity { int Id { get; set; } }
    public class GenericRepository<T> where T : class, IEntity
    {
        public T GetById(int id) { /* ... */ }
        // Компилятор знает, что у T есть свойство Id.
    }
  2. Ограничение для фабрики, создающей объекты:

    public static T CreateInstance<T>() where T : new()
    {
        return new T(); // Без ограничения new() эта строка не скомпилируется.
    }
  3. Ограничение для метода расширения, работающего с перечислениями:

    public static string GetDescription<T>(this T value) where T : Enum // Ограничение на Enum (C# 7.3+)
    {
        // Метод доступен только для типов перечисления.
        return typeof(T).GetField(value.ToString())?
                        .GetCustomAttribute<DescriptionAttribute>()?.Description;
    }
    // Использование: MyEnum.Value.GetDescription();
  4. Несколько параметров типа с разными ограничениями:

    public class Mapper<TFrom, TTo> 
        where TFrom : class 
        where TTo : class, new()
    {
        public TTo Map(TFrom source) { /* ... */ }
    }

Ограничения делают обобщенный код более безопасным, выразительным и производительным, предоставляя компилятору информацию для проверок и оптимизаций.

Ответ 18+ 🔞

А, ограничения обобщённых типов, ну это же просто конфетка, а не фича! Смотри, чтобы твой дженерик не превратился в какую-то всеядную хуйню, которая сожрёт что угодно, ему надо правила установить. Вот для этого и есть это волшебное слово where.

Представь, ты делаешь репозиторий для сущностей. Без ограничений ты мог бы туда запихнуть int, string или вообще какой-нибудь FileStream, и компилятор бы только ручкой помахал: «Ну раз ты такой умный, хули ж ты». А потом в рантайме — пиздец, исключения на ровном месте.

Так вот, where — это твой вышибала в коде. Он стоит на входе и говорит: «Слышь, тип T, у тебя есть открытый конструктор? Нет? На хуй пошёл. А ты, T, интерфейс IEntity реализуешь? Не? Иди на хуй отсюда».

Вот смотри, какие у этого вышибалы есть правила в уставе:

Ограничение Что оно означает простыми словами Пример, чтобы не еб... прости, не запутаться
where T : struct T должен быть значимым типом, то есть не ссылкой. Как int или DateTime. string сюда не пролезет — он ссылочный, пидорас. T может быть int, Guid. Не может быть object.
where T : class Полная противоположность. T должен быть ссылочным типом. string, List<int>, твой собственный класс — всё пойдёт. T может быть string, MyClass. Не может быть bool.
where T : notnull (C# 8.0+) T должен быть таким типом, который не может быть null. Это чтобы твои nullable reference types не разъебали логику. Запрещает string? для ссылочных типов в этом контексте.
where T : unmanaged (C# 7.3+) Это уже для суровых пацанов. T должен быть неуправляемым типом. То есть это примитив (int, double) или struct, которая сама состоит только из примитивов. Никаких ссылок внутри! Нужно для работы с памятью напрямую, через Span или указатели. int, double, struct Point { int X; int Y; } — да. struct Bad { string Name; } — нет, потому что string — ссылка.
where T : new() T должен иметь публичный конструктор без параметров. Без этого как ты создашь new T()? Никак. Позволяет писать T item = new T();. Если конструктора нет — компилятор пошлёт тебя нахуй.
where T : BaseClass T должен быть или наследником указанного базового класса. Это даёт гарантию, что у T есть все методы и свойства этого родителя. where T : Stream — значит, у T точно будут методы Read и Write.
where T : ISomeInterface T должен реализовывать указанный интерфейс. Самое частое и полезное ограничение, ебать его в сраку. where T : IDisposable — значит, у T можно вызвать .Dispose().

Ну и чтобы совсем жизнь мёдом не казалась, вот тебе живые примеры:

  1. Репозиторий для сущностей, который не примет левую хуйню:

    public interface IEntity { int Id { get; set; } }
    public class GenericRepository<T> where T : class, IEntity // T должен быть классом И реализовывать IEntity
    {
        public T GetById(int id)
        {
            // Компилятор теперь не тупит, он точно знает, что у каждого T есть свойство Id.
            // Можно спокойно писать: someEntity.Id
        }
    }
    // Попробуй создать GenericRepository<int> — получишь по ебалу от компилятора сразу.
  2. Фабрика, которая умеет создавать что угодно, но только с конструктором по умолчанию:

    public static T CreateInstance<T>() where T : new() // Ключевое ограничение!
    {
        return new T(); // Без where T : new() эта строка не скомпилируется вообще.
    }
    // CreateInstance<string>() — ок. CreateInstance<FileStream>() — не ок, у FileStream нет конструктора без параметров.
  3. Метод расширения, который прикрутится только к перечислениям (красота с C# 7.3):

    public static string GetDescription<T>(this T value) where T : Enum // Чётко и ясно — только Enum!
    {
        // Этот метод будет виден только у значений перечислений.
        // У int, string или твоего класса — него не будет.
        return typeof(T).GetField(value.ToString())?
                        .GetCustomAttribute<DescriptionAttribute>()?.Description;
    }
    // Использование: MyEnum.SomeValue.GetDescription();
  4. Когда параметров типа несколько, и у каждого свои тараканы:

    public class Mapper<TFrom, TTo>
        where TFrom : class          // Источник — ссылочный тип
        where TTo : class, new()     // Цель — ссылочный тип И с конструктором без параметров
    {
        public TTo Map(TFrom source)
        {
            var result = new TTo(); // new() позволяет это сделать
            // ... маппинг ...
            return result;
        }
    }

Короче, where — это не просто синтаксический сахар, это твой главный инструмент, чтобы сделать дженерики предсказуемыми, безопасными и быстрыми. Компилятор, получив эти ограничения, сразу отсекает хуйню на этапе компиляции, а не в рантайме, когда уже всё горит. И даёт тебе внутри метода уверенность, что у типа T есть нужные методы. Пиздец полезная штука.