Универсальные шаблоны (generics) в C#

уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.

Для начала, определимся с некоторыми терминами. В Сети можно встретить самые различные названия того, что Microsoft называет «универсальными шаблонами»: обобщения, просто «шаблоны» и, даже, дженерики (не путать с лекарственными средствами). Вне зависимости того, как будет называться универсальный шаблон или как вы привыкли его называть, под этим термином я буду подразумевать ровно тоже самое, что и разработчики C# — классы и методы с типами, спецификация которых отложена до момента объявления и создания экземпляров в клиентском коде. Теперь попробуем разобраться с универсальными шаблонами C# более подробно.

Пример универсального шаблона C#

В принципе, пока мы не знаем что такое и как работают универсальные шаблоны C#, представить что будет, если они исчезнут из языка нам достаточно просто. Например, посмотрим на давно изученные массивы C#. Если нам необходимо завести массив чисел, мы напишем так:

int[] intArray;

нужен массив строк — тоже не проблема:

string[] intArray;

А что, если на мы не знаем, какой тип данных будет нам нужен? Здесь нам и могут прийти на помощь универсальные шаблоны C#. Возможно, следующий пример будет «притянут за уши», но он неплохо демонстрирует суть использования шаблонов C#. Создадим класс, который будет содержать массив любого типа данных.

class ArrayClass<T>
{
    T[] data;
    uint index = 0;

    public ArrayClass(uint length)
    {
        data = new T[length];
    }

    public bool Add(T item)
    {
        if (index > data.Length)
            return false;
        data[index++] = item;
        return true;
    }

    public T Get(uint index)
    {
        return (index < this.index) && (index >= 0) ? data[index] : default(T);
    }

    public uint Count()
    {
        return index;
    }
}

Посмотрим, чем наш универсальный класс ArrayClass отличается от обычных классов C#. Во-первых, рядом с названием класса появились треугольные скобки с символом T внутри. Скобки <> указывают на то, что класс является универсальным, а тип T, заключенный в угловые скобки, будет использоваться этим классом. В принципе, вместо буквы T мы могли бы использовать другие буквы, но по умолчанию принято указывать универсальный тип как T (видимо, от слова Template — шаблон). При этом на этапе создания класса мы не знаем какой тип данных будет использоваться в классе, поэтому параметр T в угловых скобках также называется универсальным параметром, так как вместо него можно подставить любой тип.

Посмотрим на примеры использования нашего класса:

ArrayClass<int> intArray = new ArrayClass<int>(10);
intArray.Add(1);
intArray.Add(2);
intArray.Add(3);
intArray.Add(4);
intArray.Add(5);
Console.WriteLine(intArray.Count());//5
Console.WriteLine(intArray.Get(2));//2
Console.WriteLine(intArray.Get(100));//0

Здесь мы создали класс, который будет содержать массив целых чисел. Так как наш класс универсальный, то мы можем без проблем создать и класс с массивом строк:

ArrayClass<string> strArray = new ArrayClass<string>(10);
strArray.Add("1");
strArray.Add("2");
strArray.Add("3");
strArray.Add("4");
Console.WriteLine(intArray.Count());//4
Console.WriteLine(intArray.Get(1));//1
Console.WriteLine(intArray.Get(500));//0

Теперь у нас появился универсальный класс, используя который мы можем создавать объекты,  содержащие массивы любых типов — чисел, строк, собственно созданных объектов и так далее. Возможно, как я сказал выше, пример с массивом немного «притянут за уши», поэтому рассмотрим ещё один более жизненный пример, а также рассмотрим проблемы, с которыми мы могли бы столкнуться не будь в C# универсальных шаблонов.

Преимущества использования универсальных шаблонов

Достаточно часто мне приходится иметь дело с объектами в числе свойств которых имеется свойство Id (идентификатор). В различных ситуациях идентификатор может представлять собой целое число или строку. Например, если вы когда -нибудь столкнетесь с работой API того же Яндекс.Диска, то увидите, что идентификатор пользователя — это строка. А, например, в том же WordPress идентификаторами рубрик и постов являются целые числа. Допустим, мы определяем в нашей программе класс, который будет представлять нам аккаунты пользователей в какой-либо системе. Класс может выглядеть, например, так:

class Account
{
    public int Id { get; set; }
    public string Name { get; set; }
}
Класс Account определяет два свойства: Id — уникальный идентификатор и Name — имя пользователя. Идентификатор определен как числовое значение, то есть идентификаторы пользователей будут иметь значения 0, 1, 2, 3, 4, 5 и так далее (в том числе и отрицательные значения). При этом, мы также знаем, что идентификаторы могут быть и строками (например, в другой системе с которой мы может быть через несколько лет захотим поработать) На момент написания класса мы можем не знать абсолютно точно, что лучше выбрать для хранения идентификатора — строки или числа. А может наш класс будет вообще использоваться другими разработчиками и им потребуется задавать идентификатор каким-нибудь другим способом.

На первый взгляд, чтобы выйти из этой ситуации, мы можем определить свойство Id как свойство типа object. Так как мы уже знаем, что object является родителем всех типов данных в C#, соответственно в свойствах типа object мы можем сохранить и строки, и числа:

class Account
{
    public object Id { get; set; }
    public int Sum { get; set; }
}

Теперь наш класс можно использовать для создания различных аккаунтов в программе, например:

Account account1 = new Account { Name = "Вася" };
Account account2 = new Account { Name = "Петя" };
account1.Id = 12;
account2.Id = "abcd-iklm-123";
int id1 = (int)account1.Id;
string id2 = (string)account2.Id;
Console.WriteLine(id1);
Console.WriteLine(id2);

Казалось бы, что всё замечательно работает и проблема решена, но такое решение не является оптимальным. Дело в том, что в этом случае мы сталкиваемся с такими явлениями как упаковка (boxing) и распаковка (unboxing). Так, при присвоении свойству Id значения типа int, происходит упаковка этого значения в тип Object:

account1.Id = 2;        // упаковка в значения int в тип Object

Чтобы обратно получить данные в переменную типов int, необходимо выполнить распаковку:

int id1 = (int)account1.Id;     // Распаковка в тип int

Упаковка (boxing) — это преобразование объекта значимого типа (например, типа int) к типу object. При упаковке общеязыковая среда CLR обертывает значение в объект типа System.Object и сохраняет его в управляемой куче (хипе).

Распаковка (unboxing) — это преобразование объекта типа object к значимому типу.

Упаковка и распаковка ведут к снижению производительности, так как системе надо осуществить необходимые преобразования. Также, существует проблема безопасности типов. Например, в следующем коде мы получим ошибку во время выполнения программы:

Account account2 = new Account { Name = "Петя" };
account2.Id = "adcdef-345";
int id2 = (int)account2.Id;     // Исключение InvalidCastException

Мы можем не знать, какой именно объект представляет Id, и, поэтому при попытке получить число в примере выше мы неизбежно столкнемся с исключением InvalidCastException.

В том числе именно эти проблемы и были призваны устранить универсальные типы (универсальными шаблоны) C#. Так, наиболее безопасный и оптимальный способ описания нашего аккаунта будет вот такой универсальный класс:

class Account<T>
{
    public T Id { get; set; }
    public int Sum { get; set; }
}

В этом классе мы можем использовать в качестве идентификатора хоть что — числа, строки, да хоть классы и наш код будет работать как швейцарские часы. При этом, при попытке присвоить значение свойства Id переменной другого типа мы получим ошибку компиляции:

Account<string> account2 = new Account<string> { Name = "Вася" };
account2.Id = "4356";
int id1 = account2.Id;  // ошибка компиляции

Таким образом мы решаем проблему типобезопасности в C#. Используя  универсальные шаблоны мы делаем код не только более безопасным, но и более оптимальным в плане производительности — нам больше не требуются упаковки/распаковки.

Значения по умолчанию

Иногда нам необходимо присвоить переменным универсальных параметров некоторое начальное значение, в том числе и null. Однако, напрямую мы его присвоить не сможем. На вот такой код:

T id = null;

компилятор ответит ошибкой:

Ошибка CS0403 Невозможно преобразовать Null к параметру типа «T», так как он может быть типом значения, не допускающим значения Null. Используйте вместо этого «default(T)».

В этом случае нам надо использовать оператор default(T), который присваивает ссылочным типам в качестве значения null, а типам значений — значение 0:

class Account<T>
{
    T id = default(T);
}

Кстати, обратите внимание, что этот же оператор мы использовали в первом примере с массивом, но немного в другом контексте.

Статические поля обобщенных классов

При типизации универсального класса определенным типом будет создаваться свой набор статических членов. Например, определим в классе Account статическое поле:

class Account<T>
{
    public static T session;
    
    public T Id { get; set; }
    public int Sum { get; set; }
}

Теперь типизируем класс двумя типами int и string:

Account<int> account1 = new Account<int> { Name = "Bob" };
Account<int>.session = 5436;

Account<string> account2 = new Account<string> { Name = "John" };
Account<string>.session = "abcde";

Console.WriteLine(Account<int>.session);      // 5436
Console.WriteLine(Account<string>.session);   // abcde

В итоге для Account<string> и для Account<int> будет создана своя переменная session.

Использование нескольких универсальных параметров

Универсальные шаблоны могут использовать несколько универсальных параметров одновременно, которые могут представлять различные типы, например:

class Account<T, U> 
{ 
    public T Id { get; set; } 
    public static U sessionId { get; set; }
    public string Name { get; set; } 
}

Здесь класс Account использует два универсальных параметра — Id (идентификатор аккаунта) и sessionId (идентификатор сессии). Типизировать такой универсальный класс несколькими типами данных также просто, как и при использовании одного универсального параметра:

Account<int, string> account = new Account<int, string>();

Обобщенные методы

Кроме универсальных классов можно также создавать универсальные методы, которые точно также будут использовать универсальные параметры. Например, напишем простой метод меняющий два значения местами:

    public static void Swap<T> (ref T x, ref T y)
    {
        T temp = x;
        x = y;
        y = temp;
    }

Теперь применим этот универсальный метод:

        int x = 7;
        int y = 25;
        Swap<int>(ref x, ref y); 
        Console.WriteLine($"x={x} y={y}");// x=25   y=7

        string s1 = "hello";
        string s2 = "bye";
        Swap<string>(ref s1, ref s2);

Ограничения универсальных типов

Иногда бывает необходимо ограничить типы данных на основании которых можно создавать универсальный класс (универсальный шаблон). Для этого, в C# используется следующая языковая конструкция:

class Name<T> where T: XXXX

где XXXX может принимать одно из следующих значений:

  • Class — означает, что шаблон может быть создан только для классов. При этом, класс не должен быть объявлен как sealed, иначе его использование не имеет смысла;
  • struct — шаблон может быть создан на основе структур (универсальный тип может быть заменен только на структуру)
  • new() — универсальный тип должен быть классом, имеющим конструктор по умолчанию;
  • Имя_класса — шаблон может быть создан только для типов данных, являющихся наследником данного класса;
  • Имя_интерфейса — шаблон может быть создан только для классов, реализующих указанный интерфейс.

Например, мы можем ограничить наш класс из первого примера так, чтобы он мог хранить только классы-наследники Account<T>:

class ArrayClass<T> where T: Account<T>

Такие ограничения универсальных типов бывают очень удобны, когда нам необходимо хранить данные только определенного типа.

Наследование универсальных типов

Один универсальный класс класс может быть унаследован от другого универсального класса. При этом можно использовать различные варианты наследования. Разберемся с наследованием универсальных классов в C#, используя наш класс Account<T>, рассмотренный выше.

class Account<T>
{
    public T Id { get; private set; }
    public Account(T _id)
    {
        Id = _id;
    }
}

Создание класса-наследника, типизированного тем же классом, что и базовый

Пример такого наследования показан ниже:

class UniversalAccount<T> : Account<T>
{
    public UniversalAccount(T id) : base(id)
    {
             
    }
}

Класс UniversalAccount<T> унаследован от базового с тем же универсальным типом, то есть применение нового класса может быть таким:

Account<string> acc1 = new Account<string>("34");
Account<int> acc3 = new UniversalAccount<int>(45);
UniversalAccount<int> acc2 = new UniversalAccount<int>(33);
Console.WriteLine(acc1.Id);
Console.WriteLine(acc2.Id);
Console.WriteLine(acc3.Id);

Создание не универсального класса-наследника

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

class StringAccount : Account<string>
{
    public StringAccount(string id) : base(id)
    {
    }
}

Теперь в объекте класса StringAccount невозможно использовать Id в виде чисел — только строки.

Типизация производного класса параметром другого типа

В этом случае для базового класса также надо указать используемый тип:

class IntAccount<T> : Account<int>
{
    public T Code { get; set; }
    public IntAccount(int id) : base(id)
    {
    }
}

Здесь тип IntAccount типизирован еще одним типом, который может не совпадать с типом, который используется базовым классом. Применение класса:

IntAccount<string> acc7 = new IntAccount<string>(5) { Code = "r4556" };
Account<int> acc8 = new IntAccount<long>(7) { Code = 4587 };
Console.WriteLine(acc7.Id);
Console.WriteLine(acc8.Id);

По сути, этот вариант наследования практически не отличается от предыдущего, за исключением того, что класс IntAccount также получается универсальным.

Использование универсального параметра из базового класса с применением своих параметров

Также в классах-наследниках можно сочетать использование универсального параметра из базового класса с применением своих параметров, расширяя тем самым возможности класса-наследника, например:

class MixedAccount<T, K> : Account<T>
    where K : struct
{
    public K Code { get; set; }
    public MixedAccount(T id) : base(id)
    {


    }
}

Здесь в дополнение к унаследованному от базового класса параметру T добавляется новый параметр K. Также если необходимо при этом задать ограничения, мы их можем указать после названия базового класса. Стоит учитывать, что если на уровне базового класса для универсального параметра установлено ограничение, то подобное ограничение должно быть определено и в производных классах, которые также используют этот параметр:

class Account<T> where T : class
{
    public T Id { get; private set; }
    public Account(T _id)
    {
        Id = _id;
    }
}
class UniversalAccount<T> : Account<T>
    where T: class
{
    public UniversalAccount(T id) : base(id)
    {
             
    }
}

То есть если в базовом классе в качестве ограничение указано class, то есть любой класс, то в производном классе также надо указать в качестве ограничения class, либо же какой-то конкретный класс.

Итого

Сегодня мы рассмотрели довольно интересную и важную тему — применение универсальных шаблонов в C#. Универсальные шаблоны (generics) в C# — это классы и методы с типами, спецификация которых отложена до момента объявления и создания экземпляров в клиентском коде. Так же, как и обычные классы, универсальные классы могут выступать предками (родителями, базовыми классами) для других классов. Универсальными могут быть не только классы но и отдельные методы. При необходимости, универсальные шаблоны в C# можно ограничить типами данных, с использованием которых может быть создан шаблон.

 

уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.
Подписаться
Уведомить о
guest
0 Комментарий
Межтекстовые Отзывы
Посмотреть все комментарии