Menu

Обобщения (generics) в C#: обобщённые классы, методы и ограничения

Как работают обобщения в C#: обобщённые классы и методы с параметрами типа, вывод типов, ограничения через where (class, struct, new(), базовые классы, интерфейсы), default(T), статические члены для каждого типа и ковариантность через IEnumerable<out T>.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Обобщения (generics) позволяют написать тип или метод один раз и использовать его со многими типами, не теряя типобезопасности. Вместо List из object, который требует приведений и принимает что угодно, вы получаете List<int> и List<Order>, каждый проверяемый компилятором. Заполнитель в угловых скобках, по соглашению T, называется параметром типа; тип, который подставляет вызывающий код, это аргумент типа.

Зачем нужны обобщения: какую проблему они решают

До обобщений (C# 1) коллекции хранили object. Это компилировалось, но ошибки проявлялись только во время выполнения, а каждый int упаковывался при добавлении:

var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50");                  // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
    total += (decimal)p;              // InvalidCastException on the string

С List<decimal> второй Add это ошибка компиляции, циклу не нужно приведение, а decimal хранятся как decimal.

Обобщённые классы

Объявите параметры типа после имени класса, а затем используйте их внутри класса как любой другой тип:

Вывод:

Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False

Page<string> и Page<int> это два разных типа, полученных из одного определения. Класс может принимать несколько параметров типа (Dictionary<TKey, TValue>); называйте его T, когда он один, и TSomething, когда их несколько.

Обобщённые методы и вывод типов

У метода могут быть собственные параметры типа независимо от того, обобщённый ли его класс. Вызывающий код обычно опускает аргумент типа, потому что компилятор выводит его из аргументов:

Вывод:

2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR

Вывод работает только по аргументам, никогда по возвращаемому типу. Repeat(0, 3) не требует аннотации, а у метода вроде T Create<T>() выводить не из чего, поэтому вызывающие должны писать Create<Customer>(). Большая часть LINQ это обобщённые методы, опирающиеся на этот вывод: prices.Where(p => p > 1) это Where<decimal>, хотя вы этого не пишете.

Ограничения: каким может быть T

Внутри обобщённого метода компилятор разрешает только то, что работает для любого возможного T. Без ограничений это немногим больше, чем поддерживает object. Чтобы вызвать CompareTo, создать экземпляр или прочитать свойство, скажите компилятору через where, каким должен быть T:

Вывод:

17
plum
19.99
42 Zoe

Ограничения, которые вам понадобятся:

ОграничениеСмыслЧто позволяет
where T : classссылочный типприсваивать null, использовать as
where T : structтип значения, не допускающий nullиспользовать T? как Nullable<T>
where T : new()открытый конструктор без параметроввызывать new T() (указывается последним)
where T : Entityнаследуется от Entityиспользовать члены Entity
where T : IComparable<T>реализует интерфейсвызывать CompareTo
where T : Uнаследуется от другого параметра типасвязывать два параметра
where T : unmanaged (C# 7.3)структура без ссылок внутриуказатели, stackalloc
where T : Enum (C# 7.3)тип перечисленияпередавать в методы Enum
where T : notnull (C# 8)тип, не допускающий nullаннотации ссылочных типов, допускающих null

Несколько ограничений одного параметра разделяются запятыми; для нескольких параметров у каждого своё предложение where: class Cache<TKey, TValue> where TKey : notnull where TValue : class.

Отсутствующее ограничение проявляется как ошибка компиляции в месте использования члена, например CS1061 ('T' does not contain a definition for 'CompareTo') или CS0304 при вызове new T() без new().

default(T)

Обобщённому коду иногда нужно «пустое значение» типа, которого он не знает. default(T) даёт 0 для чисел, false для bool, null для ссылочных типов и типов, допускающих null, и обнулённую структуру в остальных случаях:

Вывод:

31
0
Porto
True
0001-01-01

Вторая строка показывает слабость этого приёма: для типов значений «не найдено» и «найден 0» выглядят одинаково. Поэтому методы фреймворка вместо этого используют форму TryGet (bool TryGetValue(TKey key, out TValue value)). Начиная с C# 7.1 можно писать короче return default;, когда тип ясен из контекста.

Статические члены у каждого сконструированного типа свои

Каждый сконструированный тип (Counter<int>, Counter<string>) получает собственную копию статических полей класса:

Вывод:

2
1
False

Иногда это полезно (кэш на каждый тип, например TypeInfo<T>.Name, вычисляемый один раз для каждого T), а иногда удивляет, если вы ожидали один общий счётчик. Общее состояние помещайте в необобщённый класс.

Обобщённые коллекции

Обобщённые коллекции покрывают большинство потребностей: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T> и их общие интерфейсы IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. Принимайте самый узкий интерфейс, который нужен методу (IEnumerable<T>, если он только перебирает), а возвращайте конкретный тип или тип только для чтения.

Ковариантность и контравариантность

Dog это Animal, но List<Dog> не является List<Animal>:

List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'

Если бы это было разрешено, animals.Add(new Cat()) положил бы кошку в список собак. Интерфейсы, которые только выдают значения, безопасны, и они объявлены ковариантными через out:

IEnumerable<Animal> readOnly = dogs;          // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs;        // fine: IReadOnlyList<out T>

Зеркальный случай, контравариантность, помеченная in, относится к интерфейсам и делегатам, которые только потребляют значения: Action<Animal> можно использовать там, где ожидается Action<Dog>, потому что всё, что умеет обработать любое животное, умеет обработать и собаку. IComparer<in T> контравариантен, а Func<in T, out TResult> использует оба маркера. Вариантность применяется только к интерфейсам и делегатам и только к ссылочным аргументам типа: IEnumerable<int> не преобразуется в IEnumerable<object>.

Частые ошибки

  • object там, где место параметру типа. Это отказ от проверок при компиляции и упаковка типов значений.
  • Сравнение двух значений T через ==. Без ограничения class это не компилируется (CS0019), а с ним сравниваются ссылки. Используйте EqualityComparer<T>.Default.Equals(a, b), который работает для любого T и учитывает собственный Equals типа.
  • Возврат default(T) как «не найдено». Для типов значений это неоднозначно. Используйте bool TryX(..., out T value).
  • Слишком много ограничений. Каждое сужает круг тех, кто может использовать метод. Ограничивайте только то, что действительно нужно телу.
  • Ожидание, что List<Derived> преобразуется в List<Base>. Вместо этого принимайте IEnumerable<Base> или IReadOnlyList<Base>.

Часто задаваемые вопросы

Что такое обобщения в C#?

Обобщения позволяют один раз написать класс, интерфейс или метод с типом-заполнителем T, который подставляет вызывающий код: List<int>, List<Customer>. Компилятор проверяет каждое использование по настоящему типу, поэтому вы получаете типобезопасность без приведений, а типы значений хранятся без упаковки. Самый знакомый пример это коллекции из System.Collections.Generic.

Как написать обобщённый метод в C#?

Поставьте параметры типа после имени метода: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. Вызывающий код обычно опускает аргумент типа, потому что компилятор выводит его из аргументов: Largest(prices) работает, когда prices это List<decimal>. Пишите его явно, Largest<decimal>(...), когда выводить не из чего.

Что означает where T : class в C#?

Это ограничение: T должен быть ссылочным типом, поэтому обобщённый код может присвоить T значение null и использовать as T. Другие ограничения: where T : struct (тип значения, не допускающий null), where T : new() (есть открытый конструктор без параметров, поэтому код может вызвать new T()), where T : SomeBaseClass и where T : ISomeInterface, которое позволяет коду вызывать члены этого интерфейса у T.

Что такое default(T) в C#?

Значение по умолчанию для того типа, которым окажется T: 0 для чисел, false для bool, null для ссылочных типов и типов, допускающих null, и обнулённая структура для других типов значений. Обобщённый код использует его, когда нужно «нет значения», не зная типа, например как результат неудачного поиска. C# 7.1 добавил более короткий литерал default.

Почему нельзя присвоить List<Dog> переменной List<Animal> в C#?

Потому что List<Animal> принимает любое животное: если бы присваивание было разрешено, код мог бы добавить Cat в то, что на самом деле является List<Dog>. Компилятор это отвергает (CS0029). Интерфейсы только для чтения ковариантны, поэтому IEnumerable<Animal> animals = dogs; и IReadOnlyList<Animal> animals = dogs; компилируются: через них ничего нельзя добавить.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ