Основы объектного программирования в классах на C# 3.0

Универсальность. Классы с родовыми параметрами

Разбить на страницы
Показывать лекцию целиком

Проект к данной лекции Вы можете скачать здесь.

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

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

public void Swap(ref T x1, ref T x2)
  {
     T temp;
     temp = x1; x1 = x2; x2 = temp;
  }

Если тип T - это вполне определенный тип, например, int, string или Person, то никаких проблем не существует, все совершенно прозрачно. Но как быть, если возникает необходимость обмена данными и типа int, и типа string, и типа Person? Неужели нужно писать копии этой процедуры для каждого типа? Проблема легко решается в языках, где нет контроля типов, - там достаточно иметь единственный экземпляр такой процедуры, прекрасно работающий, но лишь до тех пор, пока передаются аргументы одного типа. Когда же процедуре будут переданы фактические аргументы разного типа, то немедленно возникнет ошибка периода выполнения, и это слишком дорогая плата за универсальность.

В типизированных языках, не обладающих механизмом универсализации, выхода практически нет - приходится писать многочисленные копии Swap. В строго типизированном языке C#, скажете Вы, есть универсальный тип object, переменным которого можно присваивать значения любого типа, что позволяет написать следующую процедуру обмена значениями:

public void SwapObject( ref object item1, ref object item2)
    {
    object temp = item1;
    item1 = item2; item2 = temp;
    }

Эта процедура нормально компилируется, но, заметьте, ей нельзя передать аргументы, тип которых отличается от object. Дело в том, что формальному аргументу ref object соответствует только такой же тип фактического аргумента, поскольку для этого аргумента присваивание выполняется в обе стороны, а присваивание из типа object в конкретный тип T должно быть явным. Так что использовать этот метод клиент может, если только сам будет выполнять приведение типа.

В примерах, приводимых ниже, как обычно, построено решение с именем Ch8. В это решение вложен консольный проект ConsoleGeneric. В проект добавлен класс Generic, содержащий методы, подлежащие исследованию, в частности метод SwapObject. В проект также добавлен класс Testing, являющийся клиентом класса Generic:

class Testing
    {
        Generic gen = new Generic();
      //тестирующие методы
}

Рассмотрим пример тестирования работы метода SwapObject:

public void TestSwapObject()
 {
   int n1 = 7, n2 = 11;
   double x1 = 7.7, x2 = 11.1;
   Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
   Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);
   Console.WriteLine("Попытка прямого обмена данными одного типа");
           // gen.SwapObject(ref n1, ref n2);
           // gen.SwapObject(ref x1, ref x2);
   Console.WriteLine("заканчивается ошибкой еще на этапе компиляции!");
   Console.WriteLine("Необходимо явное приведение к типу! ");
            object obj1, obj2;
            obj1 = n1; obj2 = n2;
            gen.SwapObject(ref obj1, ref obj2);
            n1 = (int)obj1; n2 = (int)obj2;
            Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
            obj1 = x1; obj2 = x2;
            gen.SwapObject(ref obj1, ref obj2);
            x1 = (double)obj1; x2 = (double)obj2;
            Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);

Здесь клиент сам выполняет приведение данных к типу object и обратно, так что все работает нормально. Более того, можно, используя процедуру обмена, выполнить обмен данными разного типа после их приведения к универсальному типу object. Но, если меняются значениями переменные типа int и Person, то после обмена попытка привести тип Person к типу int приведет к ошибке периода выполнения.

Console.WriteLine("Обмен данными разного типа");
  obj1 = n1; obj2 = x1;
  gen.SwapObject(ref obj1, ref obj2);
  Console.WriteLine("Приводит к ошибке периода выполнения!");
  //n1 = (int)obj1; x1 = (double)obj2;

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

Для достижения универсальности процедуры Swap следует рассматривать тип T как ее параметр, такой же, как и сами аргументы x1 и x2. Суть универсальности в том, чтобы в момент вызова процедуры передавать ей не только фактические аргументы, но и их фактический тип. Вот как можно в C# объявить процедуру Swap с параметром, задающим тип аргументов:

public void Swap<T>(ref T item1, ref T item2)
 {
  T temp = item1;
  item1 = item2; item2 = temp;
 }

Вот как клиент может вызывать этот метод для данных разных типов:

public void TestSwapT()
   {
       int n1 = 7, n2 = 11;
       double x1 = 7.7, x2 = 11.1;
       Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
       Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);
       Console.WriteLine("После обмена данными одного типа");
       gen.Swap<int>(ref n1, ref n2);
       gen.Swap<double>(ref x1, ref x2);
       Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
       Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);

Заметьте, в момент вызова метода ему передаются как объекты, подлежащие обмену, так и их тип, одинаковый для обоих объектов. Что произойдет, если попытаться обменять объекты разных типов?

Console.WriteLine("Попытка обмена данными разного типа");
//gen.Swap<int>(ref n1, ref x1);
//gen.Swap<double>(ref x1, ref n1);
Console.WriteLine("заканчивается ошибкой еще на этапе компиляции!");

Ошибка, естественно, возникнет. Но! Это ошибка периода компиляции, а не выполнения, она уведомляет разработчика, что нельзя так просто смешивать "королей" и "капусту". Так что механизм работает должным образом. Рассмотрим, как универсальность распространяется на класс.

Под универсальностью (genericity) понимается способность класса объявлять используемые им типы как параметры. Класс с параметрами, задающими типы, называется универсальным классом (generic class). Терминология не устоялась, и синонимами термина "универсальный класс" являются термины: родовой класс, параметризованный класс, класс с родовыми параметрами. В языке С++ универсальные классы называются шаблонами (template).

Синтаксис универсального класса

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

class MyClass<T1, … Tn> {…}

Как и всякие формальные параметры, Ti являются именами (идентификаторами). В теле класса эти имена могут задавать типы некоторых полей класса, локальных переменных, типы аргументов и возвращаемых значений методов класса. В некоторый момент (об этом скажем чуть позже) формальные имена типов будут заменены фактическими параметрами, представляющими уже конкретные типы - имена встроенных классов, классов библиотеки FCL, классов, определенных пользователем.

В C# универсальными могут быть как классы, так и все их частные случаи - интерфейсы, структуры, делегаты, события.

Класс с универсальными методами

Специальным частным случаем универсального класса является класс, не объявляющий сам параметров, но разрешающий делать это своим методам. Давайте начнем рассмотрение универсальности с этого частного случая. Построенный нами класс Generic является примером такого класса:

class Generic
    {
        public void Swap<T>(ref T item1, ref T item2)
        {
            T temp = item1;
            item1 = item2; item2 = temp;
        }
        public void SwapObject( ref object item1, ref object item2)
        {
            object temp = item1;
            item1 = item2; item2 = temp;
        }
    }

Как видите, сам класс в данном случае не имеет родовых параметров, но зато универсальным является один из методов класса - Swap, имеющий родовой параметр T. Типу Т принадлежат аргументы метода и локальная переменная temp. Всякий раз при вызове метода ему, наряду с фактическими аргументами, будет передаваться и фактический тип, заменяющий тип T в описании метода. О некоторых деталях технологии подстановки и выполнения метода поговорим в конце лекции, сейчас же лишь отмечу, что реализация вызова универсального метода в C# не приводит к существенным накладным расходам.

Два основных механизма объектной технологии

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

Эти механизмы взаимно дополняют друг друга. Универсальность можно ограничить (об этом подробнее будет сказано ниже), указав, что тип, задаваемый родовым параметром, обязан быть наследником некоторого класса и/или ряда интерфейсов. С другой стороны, когда формальный тип T заменяется фактическим типом TFact, то там, где разрешено появляться объектам типа TFact, разрешены и объекты, принадлежащие классам-потомкам TFact.

Эти механизмы в совокупности обеспечивают бесшовный процесс разработки программных систем, начиная с этапов спецификации и проектирования системы и заканчивая этапами реализации и сопровождения. На этапе задания спецификаций появляются абстрактные, универсальные классы, которые в ходе разработки становятся вполне конкретными классами с конкретными типами данных. Механизмы наследования и универсализации позволяют существенно сократить объем кода, описывающего программную систему, поскольку потомки не повторяют наследуемый код своих родителей, а единый код универсального класса используется при каждой конкретизации типов данных. На рис. 8.1 показан схематически процесс разработки программной системы.

(рис 8.1) Этапы процесса разработки программной системы

На этапе спецификации, как правило, создается абстрактный, универсальный класс, где задана только сигнатура методов, но не их реализация, где определены имена типов, но не их конкретизация. Здесь же, используя возможности тегов класса, формально или не формально задаются спецификации, описывающие семантику методов класса. Далее в ходе разработки, благодаря механизму наследования, появляются потомки абстрактного класса, каждый из которых задает реализацию методов. На следующем этапе, благодаря механизму универсализации, появляются экземпляры универсального класса, каждый из которых выполняет операции класса над данными соответствующих типов.

Для наполнения этой схемы реальным содержанием давайте рассмотрим некоторый пример с прохождением всех трех этапов.

Стек. От абстрактного, универсального класса к конкретным версиям

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

abstract public class GenStack<T>
    {
        abstract public T item();
        abstract public void remove();
        abstract public void put(T t);
        abstract public bool empty();
    }

В таком виде абстрактный класс задает сигнатуру методов класса, что в совокупности представляет сигнатуру класса. На этом этапе проектирования класса крайне важно задать не только сигнатуру класса, но и спецификацию методов. Язык C# позволяет сделать это с помощью специальных документирующих комментариев, используя которые, специальный инструментарий позволяет построить документацию проекта. Подробнее об этих комментариях поговорим в следующей лекции, а сейчас для задания спецификаций будем применять неоднократно появляющиеся в наших примерах теги summary, играющие три важные роли. С одной стороны - это комментарии, важные для разработчика проекта. На этапе сопровождения проекта значение этой роли только усиливается. Теги summary появляются как интеллектуальная подсказка у клиентов класса при работе с классом и его методами, - в этом их вторая роль. Наконец, третья роль - они позволяют автоматическое построение документации - XML-отчета.

Вот как выглядит наш абстрактный класс, дополненный подробными спецификациями:

/// <summary>
    /// Абстрактный класс GenStack{T} задает контейнер с доступом LIFO:   
    /// Функции:
    /// конструктор new: -> GenStack{T}
    /// запросы:
    /// item: GenStack -> T
    /// empty: GenStack -> Boolean
    /// процедуры:
    /// put: GenStack*T -> GenStack
    /// remove: GenStack -> GenStack
    /// Аксиомы:
    /// remove(put(s,x)) = s
    /// item(put(s,x)) = x
    /// empty(new)= true
    /// empty(put(s,x)) = false
    /// </summary>    
    abstract public class GenStack<T>
    {
        /// <summary>
        /// require: not empty();
        /// </summary>
        /// <returns>элемент вершины(последний пришедший)</returns>
        abstract public T item();

        /// <summary>
        /// require: not empty();
        /// ensure: удален элемент вершины(последний пришедший)
        /// </summary>
        abstract public void remove();

        /// <summary>
        /// require: true; ensure: elem находится в вершине стека
        /// </summary>
        /// <param name="elem"></param>
        abstract public void put(T elem);

        /// <summary>
        /// require: true;
        /// </summary>
        /// <returns>true если стек пуст, иначе false </returns>
        abstract public bool empty();
    }// class GenStack

В приведенном примере программного текста - чуть-чуть. Основной текст задает описание спецификации класса и его методов. Заметьте, здесь спецификации заданы достаточно формально с использованием аксиом, характеризующих смысл операций, которые выполняются над стеком. Не хочется вдаваться в математические подробности, отмечу лишь, что, если задать последовательность операций над стеком, то аксиомы позволяют точно определить состояние стека в результате выполнения этих операций. Как неоднократно отмечалось с первых лекций курса, XML-отчет, построенный по этому проекту, будет содержать в читаемой форме все спецификации нашего класса. Отмечу еще, что все потомки класса должны удовлетворять этим спецификациям, хотя могут добавлять и собственные ограничения. К сожалению, потомки не наследуют текста, заданного в комментариях, - в тегах summary и других тегах, так что для потомков приходится повторно задавать теги summary.

Наш класс определен как универсальный класс с родовым параметром - стек может хранить элементы любого типа, и конкретизация типа будет производиться в момент создания экземпляра стека.

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

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

Реализация стека списком

Вот как выглядит первый потомок абстрактного класса:

/// <summary>
    /// Стек, построенный на односвязных элементах списка GenLinkable{T}
    /// </summary>
    public class OneLinkStack<T> : GenStack<T>
    {
        /// <summary>
        /// ссылка на стек (вершину стека)
        /// </summary>
        GenLinkable<T> last; 
        /// <summary>
        /// Конструктор без аргументов
        /// </summary>
        public OneLinkStack()
        {
            last = null;
        }        
        /**
         * <remarks>Реализация методов абстрактного класса</remarks>
         * */
        /// <summary>
        /// require: not empty();
        /// </summary>
        /// <returns>элемент вершины(последний пришедший)</returns>
        public override T item()
        {
            return (last.Item);
        }//item      
        /// <summary>
        /// require: true;
        /// </summary>
        /// <returns>true если стек пуст, иначе false </returns>
        public override bool empty()
        {
            return (last == null);
        }//empty      
        /// <summary>
        /// require: true; ensure: elem находится в вершине стека
        /// </summary>
        /// <param name="elem"></param>
        public override void put(T elem)
        {
            GenLinkable<T> newitem = new GenLinkable<T>();
            newitem.Item = elem; newitem.Next = last;
            last = newitem;
        }//put       
        /// <summary>
        /// require: not empty();
        /// ensure: удален элемент вершины(последний пришедший)
        /// </summary>
        public override void remove()
        {
            last = last.Next;
        }//remove
    }//class OneLinkStack

Посмотрите, что происходит при наследовании от универсального класса. Во-первых, сам потомок также является универсальным классом:

public class OneLinkStack<T> : GenStack<T>

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

GenLinkable<T> last; //ссылка на стек (элемент стека)

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

public override void put(T elem)

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

/// <summary>
    /// Элемент односвязного списка
    /// содержит два поля - информационное и ссылку    
    /// </summary>
    /// <typeparam name="T">тип элементов, хранимых в списке</typeparam>
    public class GenLinkable<T>
    {
        /// <summary>
        /// Информационное поле - объект типа T
        /// </summary>
        public T Item;
        
        /// <summary>
        /// Ссылка на следующий элемент списка
        /// </summary>
        public GenLinkable<T> Next;

        /// <summary>
        /// Конструктор без аргументов
        /// Инициализиует поля значениями по умолчанию
        /// </summary>
        public GenLinkable()
        { Item = default(T); Next = null; }
    }

Класс устроен достаточно просто: у него два поля, одно для хранения элементов, помещаемых в стек и имеющее тип T, другое - указатель на следующий элемент. Обратите внимание на конструктор класса, в котором для инициализации элемента используется конструкция default(T), возвращающая значение, устанавливаемое по умолчанию для типа T.

Реализация стека массивом

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

public class ArrayUpStack<T> : GenStack<T>
   {
      int SizeOfStack;
      T[] stack;
      int top;
      /// <summary>
      /// конструктор
      /// </summary>
      /// <param name="size">размер стека</param>
      public ArrayUpStack(int size)
      { SizeOfStack = size; stack = new T[SizeOfStack]; top = 0; }
      /// <summary>
      /// require: (top < SizeOfStack)
      /// </summary>
      /// <param name="x"> элемент, помещаемый в стек</param>
      public override void put(T x)
      { stack[top] = x; top++; }
      
      public override void remove()
      { top--; }      
      public override T item()
      { return (stack[top-1]); }
      public override bool empty()
      { return (top == 0); }
   }//class ArrayUpStack

Созданные в результате наследования классы-потомки перестали быть абстрактными, но все еще остаются универсальными. На третьем этапе порождаются конкретные экземпляры потомков - универсальных классов, в этот момент и происходит конкретизация типов, и два экземпляра одного универсального класса могут работать с данными различных типов. Этот процесс создания экземпляров с подстановкой конкретных типов называют родовым порождением экземпляров. Вот как в тестирующей процедуре создаются экземпляры созданных нами классов:

public void TestStacks()
  {
    OneLinkStack<int> stack1 = new OneLinkStack<int>();
    OneLinkStack<string> stack2 = new OneLinkStack<string>();
    ArrayUpStack<double> stack3 = new ArrayUpStack<double>(10);

    stack1.put(11); stack1.put(22);
    int x1 = stack1.item(), x2 = stack1.item();
    if ((x1 == x2)  (x1 == 22)) Console.WriteLine("OK!");   
    stack1.remove(); x2 = stack1.item();
    if ((x1 != x2)  (x2 == 11)) Console.WriteLine("OK!");
    stack1.remove(); x2 = (stack1.empty())? 77 : stack1.item();
    if ((x1 != x2)  (x2 == 77)) Console.WriteLine("OK!");

    stack2.put("first"); stack2.put("second");
    stack2.remove(); string s = stack2.item();
    if (!stack2.empty()) Console.WriteLine(s);

    stack3.put(3.33); stack3.put(Math.Sqrt(Math.PI));
    double res = stack3.item();
    stack3.remove(); res += stack3.item();
    Console.WriteLine("res= {0}", res);
  }

В трех первых строках этой процедуры порождаются три экземпляра стеков. Все они имеют общего родителя - абстрактный универсальный класс GenStack, но каждый из них работает с данными своего типа и по-разному реализует методы родителя. На рис. 8.2 показаны результаты работы этой процедуры.

(рис 8.2) Три стека, порожденных универсальным классом

Дополним наше рассмотрение еще одним примером работы с вариацией стеков, в том числе - хранящим объекты класса Person:

public void TestPerson()
 {
    OneLinkStack<int> stack1 = new OneLinkStack<int>();
    OneLinkStack<string> stack2 = new OneLinkStack<string>();
    ArrayUpStack<double> stack3 = new ArrayUpStack<double>(10);
    ArrayUpStack<Person> stack4 = new ArrayUpStack<Person>(7);

    stack2.put("Петров"); stack2.put("Васильев"); stack2.put("Шустов");
    stack1.put(27); stack1.put(45); stack1.put(53);
    stack3.put(21550.5); stack3.put(12345.7); stack3.put(32458.8);

    stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));
    stack1.remove(); stack2.remove(); stack3.remove();
    stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));
    stack1.remove(); stack2.remove(); stack3.remove();
    stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));

    Person pers = stack4.item(); pers.PrintPerson();
    stack4.remove(); pers = stack4.item(); pers.PrintPerson();
    stack4.remove(); pers = stack4.item(); pers.PrintPerson();
    stack4.remove(); if (stack4.empty()) Console.WriteLine("OK!");
 }

Результаты работы этой процедуры приведены на рис. 8.3.

(рис 8.3) Работа со стеком Person

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

Хорошо, когда есть свобода. Еще лучше, когда свобода ограничена. Аналогичная ситуация существует и с универсальностью. Универсальность следует ограничивать. На типы универсального класса, являющиеся его параметрами, следует накладывать ограничения. Звучит парадоксально, но, наложив ограничения на типы, программист получает гораздо большую свободу в работе с объектами этих типов.

Если немного подумать, то это совершенно естественная ситуация. Когда имеется неограниченная универсальность, над объектами типов можно выполнять только те операции, которые допускают все типы, - в C# это эквивалентно операциям, разрешенным над объектами типа object, прародителя всех типов. В нашем предыдущем примере, где речь шла о свопинге, над объектами выполнялась единственная операция присваивания. Поскольку присваивание внутри одного типа разрешено для всех типов, неограниченная универсальность приемлема в такой ситуации. Но что произойдет, если попытаться выполнить сложение элементов, сравнение их или даже простую проверку элементов на равенство? Немедленно возникнет ошибка еще на этапе компиляции. Эти операции не разрешены для всех типов, поэтому в случае компиляции такого проекта ошибка могла бы возникнуть на этапе выполнения, когда вместо формального типа появился бы тип конкретный, не допускающий подобную операцию. Нельзя ради универсальности пожертвовать одним из важнейших механизмов C# и Framework .Net - безопасностью типов, поддерживаемой статическим контролем типов. Именно поэтому неограниченная универсальность существенно ограничена. Ее ограничивает статический контроль типов. Бывают, разумеется, ситуации, когда необходимо на типы наложить ограничения, позволяющие ослабить границы статического контроля, что, повторяю, дает большую свободу программисту.

В языке C# допускаются три вида ограничений накладываемых на родовые параметры.

  • Ограничение наследования. Это основный вид ограничений, указывающий, что тип T является наследником некоторого класса и ряда интерфейсов. Следовательно, над объектами типа T можно выполнять все операции, заданные базовым классом и интерфейсами. Эти операции статический контроль типов будет допускать, и обеспечивать для них интеллектуальную поддержку, показывая список разрешенных операций. Ограничение наследования позволяет выполнять над объектами больше операций, чем в случае неограниченной универсальности. Синтаксически ограничение выглядит так: where T: BaseClass, I1, …Ik.
  • Ограничение конструктора.Это ограничение указывает, что тип T имеет конструктор без аргументов и, следовательно, позволяет создавать объекты типа T. Синтаксически ограничение выглядит так: where T: new().
  • Ограничение value/reference. Это ограничение указывает, к значимым или к ссылочным типам относится тип T. Для указания значимого типа задается слово struct, для ссылочных - class. Так что синтаксически этот тип ограничений выглядит так: where T: struct.
  • Возникает законный вопрос: насколько полна предлагаемая система ограничений? Конечно, речь идет о практической полноте, а не о математически строгих определениях. С позиций практики систему хотелось бы дополнить, в первую очередь, введением ограничений операций, указывающим допустимые знаки операций в выражениях над объектами соответствующего типа. Хотелось бы, например, указать, что к объектам типа T применима операция сложения + или операция сравнения <. Позже я покажу, как можно справиться с этой проблемой, но предлагаемое решение довольно сложно. Наличие ограничения, допускающего операции, намного элегантнее решало бы эту проблему.

    Синтаксис ограничений

    Уточним некоторые синтаксические правила записи ограничений. Если задан универсальный класс с типовыми параметрами T1, … Tn, то на каждый параметр могут быть наложены ограничения всех типов. Ограничения задаются предложением where, начинающегося соответствующим ключевым словом, после которого следует имя параметра, а затем через двоеточие - ограничения первого, второго или третьего типа, разделенные запятыми. Порядок их важен: если присутствует ограничение третьего типа, то оно записывается первым. Заметьте, предложения where для разных параметров отделяются лишь пробелами; как правило, они записываются на отдельных строчках. Предложения where записываются в конце заголовка класса после имени и списка его типовых параметров, после родительских классов и интерфейсов, если они заданы для универсального класса. Вот синтаксически корректные объявления классов с ограничением универсальности:

    public class Father<T1, T2>
       { }
       public class Base
       {
          public void M1() { }
          public void M2() { }
       }
       public class Child<T1,T2> :Father<T1,T2>
          where T1:Base,IEnumerable<T1>, new()
          where T2:struct,IComparable<T2>
       {   }

    Универсальный класс Child задан с ограничениями на родовые параметры. Благодаря этому для данных типа T1 можно вызывать методы M1 и M2 базового класса Base, так же как и методы интерфейса IEnumerable<T1>. Можно создавать объекты типа T1, используя конструктор по умолчанию. Фактический тип, подставляемый вместо формального типа T2, должен быть значимым, и объекты этого типа разрешается сравнивать между собой.

    Список с возможностью поиска элементов по ключу

    Ключевые идеи ограниченной универсальности, надеюсь, понятны. Давайте теперь рассмотрим пример построения подобного класса, где можно будет увидеть все детали. Возьмем классическую и саму по себе интересную задачу построения списка с курсором. Как и всякий контейнер данных, список следует сделать универсальным, допускающим хранение данных разного типа. С другой стороны, мы не хотим, чтобы в одном списке происходило смешение типов, - уж если там хранятся персоны, то чисел int в нем не должно быть. По этим причинам класс должен быть универсальным, имея в качестве параметра тип T, задающий тип хранимых данных. Мы потребуем также, чтобы данные хранились с их ключами. И поскольку не хочется заранее накладывать ограничения на тип ключей - они могут быть строковыми или числовыми, - тип хранимых ключей будет еще одним параметром нашего класса. Мы хотим определить над списком операцию поиска по ключу, значит, нам придется выполнять проверку ключей на равенство, поэтому универсальность типа ключей должна быть ограниченной, проще всего сделать этот тип наследником стандартного интерфейса IComparable.

    Чтобы не затемнять ситуацию сложностью списка, рассмотрим достаточно простой односвязный список с курсором. Элементы этого списка будут принадлежать классу Node, два поля которого будут хранить ключ и сам элемент, а третье поле будет задавать указатель на следующий элемент списка. Очевидно, что этот класс должен быть универсальным классом. Вот как выглядит текст этого класса:

    public class Node<K, T> where K:IComparable<K>
       {
          public Node()
          {
             next = null; key = default(K);
             item = default( T);
          }
          public K key;
          public T item;
          public Node<K, T> next;
       }

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

    Рассмотрим теперь организацию односвязного списка. Начнем с того, как устроены его данные:

    public class OneLinkList<K, T>   where K : IComparable<K>
    {
          protected Node<K, T> first, cursor;
    }

    Являясь клиентом универсального класса Node, наш класс сохраняет родовые параметры клиента и ограничения, накладываемые на них. Два поля класса - first и cursor - задают указатели на первый и текущий элементы списка. Операции над списком связываются с курсором, позволяя перемещать курсор по списку. Рассмотрим вначале набор операций, перемещающих курсор:

    public void start()
          { cursor = first; }
    public void forth()
          { if (cursor.next != null) cursor = cursor.next; }

    Операция start передвигает курсор к началу списка, а forth - к следующему элементу справа от курсора. Операции finish и forth определены только для непустых списков. Конец списка является барьером, и курсор не переходит через барьер. Нарушая принципы ради краткости текста, я не привожу формальных спецификаций методов, записанных в тегах summary.

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

    public void add(K key, T item)
    {
       Node<K, T> newnode = new Node<K, T>();
       if (first == null)
       {
          first = newnode; cursor = newnode;
          newnode.key = key; newnode.item = item;
       }
       else
       {
          newnode.next = cursor.next; cursor.next = newnode;
          newnode.key = key; newnode.item = item;
       }
    }

    Заметьте, аргументы метода имеют соответствующие родовые параметры, чем и обеспечивается универсальный характер списка. При добавлении элемента в список различаются два случая - добавление первого элемента и всех остальных.

    Рассмотрим теперь операцию поиска элемента по ключу, реализация которой потребовала ограничения универсальности типа ключа K:

    public bool findstart(K key)
    {
       Node<K, T> temp = first;
       while (temp != null)
       {
          if (temp.key.CompareTo(key) == 0)
              {cursor = temp; return(true);}
          temp = temp.next;
       }
       return (false);
    }

    Искомые элементы разыскиваются во всем списке. Если элемент найден, то курсор устанавливается на найденном элементе и метод возвращает значение true. Если элемента с заданным ключом нет в списке, то позиция курсора не меняется, а метод возвращает значение false. В процессе поиска для каждого очередного элемента списка вызывается допускаемый ограничением метод CompareTo интерфейса IComparable. При отсутствии ограничений универсальности вызов этого метода или операции эквивалентности приводил бы к ошибке, обнаруживаемой на этапе компиляции.

    Два метода класса являются запросами, позволяющими извлечь ключ и элемент списка, который отмечен курсором:

    public K Key()
       {
          return (cursor.key);
       }
       public T Item()
       {
          return(cursor.item);
      }

    Давайте рассмотрим теперь тестирующую процедуру - клиента нашего списка,- демонстрирующую работу со списками, в которых элементы и ключи имеют разные типы:

    public void TestConstraint()
    {
       OneLinkList<int, string> list1 = new OneLinkList<int, string>();
       list1.add(33, "thirty three"); list1.add(22, "twenty two");
       if(list1.findstart(33)) Console.WriteLine("33 - найдено!");
       else Console.WriteLine("33 - не найдено!");
       if (list1.findstart(22)) Console.WriteLine("22 - найдено!");
       else Console.WriteLine("22 - не найдено!");
       if (list1.findstart(44)) Console.WriteLine("44 - найдено!");
       else Console.WriteLine("44 - не найдено!");
       Person pers1 = new Person("Савлов", 25, 1500);
       Person pers2 = new Person("Павлов", 35, 2100);
       OneLinkList<string, Person> list2 = new OneLinkList< string, Person>();
       list2.add("Савл", pers1); list2.add( "Павел", pers2);
       if (list2.findstart("Павел")) Console.WriteLine("Павел - найдено!");
       else Console.WriteLine("Павел - не найдено!");
       if (list2.findstart("Савл")) Console.WriteLine("Савл - найдено!");
       else Console.WriteLine("Савл - не найдено!");
       if (list2.findstart("Иоанн")) Console.WriteLine("Иоанн - найдено!");
       else Console.WriteLine("Иоанн - не найдено!");
       Person pers3 = new Person("Иванов", 33, 3000);
       list2.add("Иоанн", pers3); list2.start();
       Person pers = list2.Item(); pers.PrintPerson();
       list2.findstart("Иоанн"); pers = list2.Item(); pers.PrintPerson();
    }

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

    OneLinkList<int, string> list1 = new OneLinkList<int, string>();
    OneLinkList<string, Person> list2 = new OneLinkList< string, Person>();

    У списка list1 ключи имеют тип int, у списка list2 - string. Заметьте, оба фактических типа, согласно обязательствам, реализуют интерфейс IComparable. У первого списка тип элементов - string, у второго - Person. Все работает прекрасно. Вот результаты вычислений по этой процедуре.

    (рис 8.4) Поиск в списке с ограниченной универсальностью

    Как справиться с арифметикой

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

    Как уже говорилось, наличие ограничения операции, где можно было бы указать, что над элементами определена операция +, решало бы проблему. Но такого типа ограничений нет. Хуже того, нет и интерфейса INumeric, аналогичного IComparable, определяющего метод сложения Add. Так что нам не может помочь и ограничение наследования.

    Вот один из возможных выходов, предлагаемых в такой ситуации. Стратегия следующая: определим абстрактный универсальный класс Calc с методами, выполняющими вычисления. Затем создадим конкретизированных потомков этого класса. В классе, задающем список с суммированием, введем поле класса Calc. При создании экземпляров класса будем передавать фактические типы ключа и элементов, а также соответствующий калькулятор, но уже не как тип, а как аргумент конструктора класса. Этот калькулятор, согласованный с типом элементов, и будет выполнять нужные вычисления. Давайте приступим к реализации этой стратегии. Начнем с определения класса Calc:

    public abstract class Calc<T>
     {
       public abstract T Add(T a, T b);
       public abstract T Sub(T a, T b);
       public abstract T Mult(T a, T b);
       public abstract T Div(T a, T b);
    }

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

    public class IntCalc : Calc<int>
    {
      public override int Add(int a, int b) { return (a + b); }
      public override int Sub(int a, int b) { return (a - b); }
      public override int Mult(int a, int b) { return (a * b); }
      public override int Div(int a, int b) { return (a / b); }
    }
     public class DoubleCalc : Calc<double>
    {
      public override double Add(double a, double b) { return (a + b); }
      public override double Sub(double a, double b) { return (a - b); }
      public override double Mult(double a, double b) { return (a * b); }
      public override double Div(double a, double b) { return (a / b); }
    }
    public class StringCalc : Calc<string>
    {
       public override string Add(string a, string b) { return (a + b); }
       public override string Sub(string a, string b) { return (a ); }
       public override string Mult(string a, string b) { return (a ); }
       public override string Div(string a, string b) { return (a); }
    }

    Здесь определяются три разных калькулятора: один - над целочисленными данными, другой - над данными с плавающей точкой, третий - над строковыми данными. В последнем случае определена, по сути, только операция сложения строк (конкатенации).

    Теперь нам нужно ввести изменения в ранее созданный класс OneLinkList. Обратите внимание на важный технологический принцип работы с объектными системами. Пусть уже есть нормально работающий класс с нормально работающими клиентами класса. Не следует изменять этот класс.Он закрыт для изменений. Используйте наследование и открывайте класс потомок, в который и вносите изменения, учитывающие добавляемую специфику класса. Принцип "Закрыт - Открыт" является одним из важнейших принципов построения программных систем в объектном стиле.

    В полном соответствии с этим принципом построим класс SumList - потомок класса OneLinkList. То, что родительский класс является универсальным, ничуть не мешает строить потомка класса, сохраняющего универсальный характер родителя.

    public class SumList<K, T> : OneLinkList<K, T> where K : IComparable<K>
    {
       Calc<T> calc;
       T sum;
       public SumList(Calc<T> calc)
       { this.calc = calc; sum = default(T); }
    
       public new void add(K key, T item)
       {
          Node<K, T> newnode = new Node<K, T>();
          if (first == null)
          {
            first = newnode; cursor = newnode;
            newnode.key = key; newnode.item = item;
            sum = calc.Add(sum, item);
          }
          else
          {
            newnode.next = cursor.next; cursor.next = newnode;
            newnode.key = key; newnode.item = item;
            sum = calc.Add(sum, item);
         }
       }
       public T Sum()
       {return (sum);   }
    }//SumList

    У класса добавилось поле sum, задающее сумму хранимых элементов, и поле calc - калькулятор, выполняющий вычисления. Метод add, объявленный в классе с модификатором new, скрывает родительский метод add, задавая собственную реализацию этого метода. Родительский метод можно было бы определить как виртуальный, переопределив его у потомка, но я не стал трогать код родительского класса. К классу добавился еще один запрос, возвращающий значение поля sum.

    Проведем теперь эксперименты с новыми вариантами списков, допускающих суммирование элементов:

    public void TestSum()
      {
          SumList<string, int> list1 = 
            new SumList<string, int>(new IntCalc());
          list1.add("Петр", 33); list1.add("Павел", 44);
          Console.WriteLine("sum= {0}", list1.Sum());
          SumList<string, double> list2 = 
             new SumList<string, double>(new DoubleCalc());
          list2.add("Петр", 33.33); list2.add("Павел", 44.44);
          Console.WriteLine("sum= {0}", list2.Sum());
          SumList<string, string> list3 = 
             new SumList<string, string>(new StringCalc());
          list3.add("Мама", " Мама мыла "); 
          list3.add("Маша", "Машу мылом!");
          Console.WriteLine("sum= {0}", list3.Sum());
      }

    Обратите внимание на создание списков:

    SumList<string, int> list1 = new SumList<string, int>(new IntCalc());
       SumList<string, double> list2 = new SumList<string, double>(new DoubleCalc());
       SumList<string, string> list3 = new SumList<string, string>(new StringCalc());

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

    (рис 8.5) Списки с суммированием

    Наследование и встраивание. Совместное использование

    Прием, использованный для того, чтобы справиться с арифметикой, имеет более широкое методическое применение. Он основан на сочетании встраивания и наследования, и его полезно применять в разных ситуациях. В чем его суть. Мы хотели классу SumList добавить новые возможности - арифметику. Простым наследованием этого сделать нельзя, хотя бы потому, что у класса SumList уже есть родительский класс. Поэтому в класс SumList встроен объект абстрактного класса Calc, обладающий свойствами арифметики, - здесь работает встраивание. У класса Calc есть потомки, каждый из которых по-своему реализует арифметику. Здесь в полной мере используются возможности наследования. Конструктору объектов класса SumList передается нужный потомок класса Calc, и наш объект получает возможность работать с арифметикой.

    Давайте посмотрим, как этот же прием позволяет справляться с проблемой множественного наследования, обходя ограничение одного родителя. На практике часто возникает необходимость построить класс, который был бы потомком двух родителей. Пусть, например, созданы классы " дом " и " автомобиль ", а хочется построить класс " домомобиль ", объединяющий свойства уже построенных классов. Аналогично, классы " дом " и " вагон " могут порождать класс " спальный вагон ", а классы " самолет " и " пароход " - " гидросамолет ".

    Вот схема возможного решения этой проблемы:

    public class Home
        {
            // описывает свойства дома        
        }
        public abstract class Transport
        {
            // описывает свойства транспортных средств 
        }
        public class Car : Transport
        {
            // описывает свойства автомобиля
        }
        public class Carriage : Transport
        {
            // описывает свойства вагона
        }
        public class Spaceship : Transport
        {
            // описывает свойства космического корабля
        }

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

    public class Home_Car : Home
        {
            Transport transport;
            public Home_Car(Transport transport)
            {
                this.transport = transport;
            }
        }
        public class Home_Carriage : Home
        {
            Transport transport;
            public Home_Carriage(Transport transport)
            {
                this.transport = transport;
            }
        }
        public class Home_Spaceship
        {
            Transport transport;
            public Home_Spaceship(Transport transport)
            {
                this.transport = transport;
            }
        }

    Если теперь некоторый клиент захочет создать объект, моделирующий космический корабль и предназначенный для межзвездных путешествий, то он может вначале создать объект класса Spaceship и передать его конструктору, создающему объект класса Home_Spaceship.

    public void TestSpace()
      {
          Spaceship spaceship = new Spaceship();
          Home_Spaceship home_ship = 
              new Home_Spaceship(spaceship);
          Console.WriteLine("И корабль плывет!");
      }

    Предложение using

    До сих пор рассматривалась ситуация родового порождения экземпляров универсального класса. Фактические типы задавались в момент создания экземпляра. Это наглядно показывает преимущества применяемой технологии, поскольку очевидно, что не создается дублирующий код для каждого класса, порожденного универсальным классом. И все-таки остается естественный вопрос: можно ли объявить класс путем подстановки фактических параметров в универсальный класс, а потом спокойно использовать этот класс обычным образом? Такая вещь возможна. Это можно сделать не совсем обычным путем - не в программном коде, а в предложении using, назначение которого и состоит в выполнении подобных подстановок. Предложение using не создает реальный класс. Это лишь форма сокращения записи, но содержательно его можно рассматривать как объявление конкретного класса.

    Давайте вернемся к универсальному классу OneLinkStack<T>, введенному в начале этой лекции, и объявим класс IntStack, заменив формальный параметр T фактическим - int. Для этого достаточно задать следующее предложение using:

    using IntStack = ConsoleGeneric.OneLinkStack<int>;

    Вот тест, в котором создаются несколько объектов этого класса:

    public void TestIntStack()
          {
             IntStack stack1 = new IntStack();
             IntStack stack2 = new IntStack();
             IntStack stack3 = new IntStack();
    
             stack1.put(11); stack1.put(22);
             int x1 = stack1.item(), x2 = stack1.item();
             if ((x1 == x2)  (x1 == 22)) Console.WriteLine("OK!");
             stack1.remove(); x2 = stack1.item();
             if ((x1 != x2)  (x2 == 11)) Console.WriteLine("OK!");
             stack1.remove(); x2 = (stack1.empty()) ? 77 : stack1.item();
             if ((x1 != x2)  (x2 == 77)) Console.WriteLine("OK!");
    
             stack2.put(55); stack2.put(66);
             stack2.remove(); int s = stack2.item();
             if (!stack2.empty()) Console.WriteLine(s);
    
             stack3.put(333); stack3.put((int)Math.Sqrt(Math.PI));
             int res = stack3.item();
             stack3.remove(); res += stack3.item();
             Console.WriteLine("res= {0}", res);
          }

    Все работает заданным образом, можете поверить.

    Универсальность и специальные случаи классов

    Универсальность - это механизм, воздействующий на все элементы языка. Поэтому он применим ко всем частным случаям классов C# .

    Универсальные структуры

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

    public struct Point<T>
       {
           T x, y;//координаты точки, тип которых задан параметром
          // другие свойства и методы структуры
       }

    Универсальные интерфейсы

    Интерфейсы чаще всего следует делать универсальными, предоставляя большую гибкость для позднейших этапов создания системы. Возможно, вы заметили применение в наших примерах универсальных интерфейсов библиотеки FCL - IComparable<T> и других. Введение универсальности, в первую очередь, сказалось на библиотеке FCL - внутренних классов, определяющих поведение системы. В частности, для большинства интерфейсов появились универсальные двойники с параметрами. Если бы в наших примерах мы использовали не универсальный интерфейс, а обычный, то потеряли бы в эффективности, поскольку сравнение объектов потребовало бы создание временных объектов типа object, выполнения операций boxing и unboxing.

    Универсальные делегаты

    Делегаты также могут иметь родовые параметры. Чаще встречается ситуация, когда делегат объявляется в универсальном классе и использует в своем объявлении параметры универсального класса. Давайте рассмотрим ситуацию с делегатами более подробно. Вот объявление универсального класса, названного UniversalDelegates, в котором объявляется функциональный тип:

    class UniversalDelegates<T>
        {
            public delegate T Unidel(T a, T b);
        }

    Как видите, тип аргументов и возвращаемого значения в сигнатуре функционального типа определяется параметром класса Delegate.

    Добавим в класс функцию высшего порядка FunAr, одним из аргументов которой будет функция типа Unidel, заданного делегатом. Эта функция будет применяться к элементам массива, передаваемого также функции FunAr. Приведу описание:

    public T FunAr(T[] arr, T a0, Unidel f)
          {
             T temp = a0;
             for(int i =0; i<arr.Length; i++)
             {
                temp = f(temp, arr[i]);
             }
             return (temp);
          }

    Эта универсальная функция с успехом может применяться для вычисления сумм, произведения, минимума и других подобных характеристик массива.

    Рассмотрим теперь клиентский класс Testing, в котором определен набор функций:

    public int max2(int a, int b)
          { return (a > b) ? a : b; }
    public double min2(double a, double b)
          { return (a < b) ? a : b; }
    public string sum2(string a, string b)
          { return a + b; }
    public float prod2(float a, float b)
          { return a * b; }

    Хотя все функции имеют разные типы, все они соответствуют определению класса Unidel - имеют два аргумента одного типа и возвращают результат того же типа. Посмотрим, как они применяются в тестирующем методе класса Testing:

    public void TestFun()
      {
          int[] ar1 = { 3, 5, 7, 9 };
          double[] ar2 = { 3.5, 5.7, 7.9 };
          string[] ar3 = { "Мама ", "мыла ", "Машу ", "мылом." };
          float[] ar4 = { 5f, 7f, 9f, 11f };
          UniversalDelegates<int> d1 = 
              new UniversalDelegates<int>();
          UniversalDelegates<int>.Unidel del1;
          del1 = this.max2;
          int max = d1.FunAr(ar1, ar1[0], del1);
          Console.WriteLine("max= {0}", max);
    
          UniversalDelegates<double> d2 = 
              new UniversalDelegates<double>();
          UniversalDelegates<double>.Unidel del2;
          del2 = this.min2;
          double min = d2.FunAr(ar2, ar2[0], del2);
          Console.WriteLine("min= {0}", min);
    
          UniversalDelegates<string> d3 = 
              new UniversalDelegates<string>();
          UniversalDelegates<string>.Unidel del3;
          del3 = this.sum2;
          string sum = d3.FunAr(ar3, "", del3);
          Console.WriteLine("concat= {0}", sum);
    
          UniversalDelegates<float> d4 = 
              new UniversalDelegates<float>();
          UniversalDelegates<float>.Unidel del4;
          del4 = this.prod2;
          float prod = d4.FunAr(ar4, 1f, del4);
          Console.WriteLine("prod= {0}", prod);
      }

    Обратите внимание на объявление экземпляра делегата:

    UniversalDelegates<int>.Unidel del1;

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

    del1= this.max2;

    При выполнении этого присваивания проверяется соответствие сигнатуры функции в правой части и экземпляра делегата, в случае успеха экземпляр делегата связывается с функцией.

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

    public void TestLanbdaFun()
    {
        int[] ar1 = { 3, 5, 7, 9 };
        double[] ar2 = { 3.5, 5.7, 7.9 };
        string[] ar3 = 
        { "Мама ", "мыла ", "Машу ", "мылом." };
        float[] ar4 = { 5f, 7f, 9f, 11f };
     
        UniversalDelegates<int> d1 =
            new UniversalDelegates<int>();   
        int max = d1.FunAr(ar1, ar1[0],
          (int a, int b)=> { return (a > b) ? a : b; });
        Console.WriteLine("max= {0}", max);
    
        UniversalDelegates<double> d2 =
            new UniversalDelegates<double>();    
        double min = d2.FunAr(ar2, ar2[0],
         (double a, double b)=> {return (a < b) ? a : b;});       
        Console.WriteLine("min= {0}", min);
    
        UniversalDelegates<string> d3 =
            new UniversalDelegates<string>();   
        string sum = d3.FunAr(ar3, "",
            (string a, string b)=> {return a + b; });
        Console.WriteLine("concat= {0}", sum);
    
        UniversalDelegates<float> d4 =
            new UniversalDelegates<float>();   
        float prod = d4.FunAr(ar4, 1f,
            (float a, float b) => {return a*b;});       
        Console.WriteLine("prod= {0}", prod);
    }

    Покажем, что и сам функциональный тип - делегат - можно объявлять с родовыми параметрами. Вот пример такого объявления:

    public delegate T1 FunOneArg<T1>(T1 a);

    Добавим в наш тестовый пример код, демонстрирующий работу с этим делегатом:

    FunOneArg<double> F = (double x) => 
        {return Math.Sin(x) + Math.Cos(x);};
        Console.WriteLine("x = {0}, Sin(x) + Cos(x) = {1}",
            5.0, F(5.0));

    Вот как выглядят результаты работы тестового примера.

    (рис 8.6) Результаты работы с универсальными делегатами

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

    public void delegate EventHandler<T> (object sender, T args)
                   where T:EventArgs

    Этот делегат может применяться и для событий с собственными аргументами, поскольку вместо параметра T может быть подставлен конкретный тип - потомок класса EventArgs, дополненный нужными аргументами.

    Framework .Net и универсальность

    Универсальность принадлежит к основным механизмам языка. Ее введение в язык C# не могло не сказаться на всех его основных свойствах. Как уже говорилось, классы и все частные случаи стали обладать этим свойством. Введение универсальности не должно было ухудшить уже достигнутые свойства языка - статический контроль типов, динамическое связывание и полиморфизм. Не должна была пострадать и эффективность выполнения программ, использующих универсальные классы.

    Решение этих задач потребовало введения универсальности не только в язык C#, но и поддержки на уровне каркаса Framework .Net и языка IL, включающем теперь параметризованные типы. Универсальный класс C# не является шаблоном, на основе которого строится конкретизированный класс, компилируемый далее в класс (тип) IL. Компилятору языка C# нет необходимости создавать классы для каждой конкретизации типов универсального класса. Вместо этого происходит компиляция универсального класса C# в параметризованный тип IL. Когда же CLR занимается исполнением управляемого кода, то вся необходимая информация о конкретных типах извлекается из метаданных, сопровождающих объекты.

    При этом дублирования кода не происходит и на уровне JIT-компиляторов, которые, однажды сгенерировав код для конкретного типа, сохраняют ссылку на этот участок кода и передают ее, когда такой код понадобится вторично. Это справедливо как для ссылочных, так и для значимых типов.

    Естественно, что универсальность потребовала введения в библиотеку FCL соответствующих классов, интерфейсов, делегатов и методов классов, обладающих этим свойством.

    Так, например, в класс System.Array добавлен ряд универсальных статических методов. Вот один из них:

    public static int BinarySearch<T>(T[] array, T value);

    В табл. 8.1 показаны некоторые универсальные классы и интерфейсы библиотеки FCL 2.0 из пространства имен System.Collections.Generic и их аналоги из пространства System.Collections.

    Соответствие между универсальными классами и их обычными двойниками
    Универсальный класс Обычный класс Универсальный интерфейс Обычный интерфейс
    Comparer<T> Comparer ICollection<T> ICollection
    Dictionary<K,T> HashTable IComparable<T> IComparable
    LinkedList<T> ---- IDictionary<K,T> IDictionary
    List<T> ArrayList IEnumerable<T> IEnumerable
    Queue<T> Queue IEnumerator<T> IEnumerator
    SortedDictionary<K,T> SortedList IList<T> IList
    Stack<T> Stack

    Проекты

  • Постройте DLL с классом Sorting, содержащим различные методы сортировки. Методы должны быть универсальными и позволять сортировать объекты разных типов - персоны, машины, числа, строки. Методы должны быть функциями высших порядков, допускающими сортировать данные типа T по разным критериям, например, сортировать машины по маркам, по номерам, по фамилиям владельцев. В Windows-проекте постройте классы Person, Car и другие классы, демонстрирующие работу с классом Sorting. В клиентском классе предусмотрите построение набора функций, позволяющих сравнивать персон по разным критериям. Предусмотрите возможность задания таких функций лямбда-выражениями. В интерфейсе проекта предусмотрите возможность сравнения методов сортировки по времени. Постройте метод, вычисляющий время сортировки, как функцию высшего порядка, которой передается в качестве аргумента метод сортировки.
  • Постройте DLL, включающую собственные аналоги библиотечных классов Stack<T>, Queue<T>, List<T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую собственные аналоги библиотечных классов List<T>, LinkedList<T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую собственные аналоги библиотечных классов Dictionary<K, T>, SortedDictionary<K, T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую класс BinaryTreeSearch<K, T> - бинарное дерево поиска. Элементы, хранимые в дереве, обладают ключом типа K и информационным полем типа T. Ключи элементов уникальны. Для дерева поиска справедливо свойство: Ключ элемента, хранимого в корне дерева, больше ключей всех элементов, хранимых в левом поддереве, и меньше ключей всех элементов, хранимых в правом поддереве. Постройте Windows-проект для работы с этим классом.
  • (**) Постройте DLL с классами, реализующими сбалансированные бинарные деревья поиска - идеально сбалансированные деревья, АВЛ - деревья, красно-черные деревья. Постройте Windows-проект для работы с этим классом. Проект должен позволять оценить время работы на одних и тех же данных при использовании разных типов деревьев.
  • Страницы:

    Проект к данной лекции Вы можете скачать здесь.

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

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

    public void Swap(ref T x1, ref T x2)
      {
         T temp;
         temp = x1; x1 = x2; x2 = temp;
      }

    Если тип T - это вполне определенный тип, например, int, string или Person, то никаких проблем не существует, все совершенно прозрачно. Но как быть, если возникает необходимость обмена данными и типа int, и типа string, и типа Person? Неужели нужно писать копии этой процедуры для каждого типа? Проблема легко решается в языках, где нет контроля типов, - там достаточно иметь единственный экземпляр такой процедуры, прекрасно работающий, но лишь до тех пор, пока передаются аргументы одного типа. Когда же процедуре будут переданы фактические аргументы разного типа, то немедленно возникнет ошибка периода выполнения, и это слишком дорогая плата за универсальность.

    В типизированных языках, не обладающих механизмом универсализации, выхода практически нет - приходится писать многочисленные копии Swap. В строго типизированном языке C#, скажете Вы, есть универсальный тип object, переменным которого можно присваивать значения любого типа, что позволяет написать следующую процедуру обмена значениями:

    public void SwapObject( ref object item1, ref object item2)
        {
        object temp = item1;
        item1 = item2; item2 = temp;
        }

    Эта процедура нормально компилируется, но, заметьте, ей нельзя передать аргументы, тип которых отличается от object. Дело в том, что формальному аргументу ref object соответствует только такой же тип фактического аргумента, поскольку для этого аргумента присваивание выполняется в обе стороны, а присваивание из типа object в конкретный тип T должно быть явным. Так что использовать этот метод клиент может, если только сам будет выполнять приведение типа.

    В примерах, приводимых ниже, как обычно, построено решение с именем Ch8. В это решение вложен консольный проект ConsoleGeneric. В проект добавлен класс Generic, содержащий методы, подлежащие исследованию, в частности метод SwapObject. В проект также добавлен класс Testing, являющийся клиентом класса Generic:

    class Testing
        {
            Generic gen = new Generic();
          //тестирующие методы
    }

    Рассмотрим пример тестирования работы метода SwapObject:

    public void TestSwapObject()
     {
       int n1 = 7, n2 = 11;
       double x1 = 7.7, x2 = 11.1;
       Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
       Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);
       Console.WriteLine("Попытка прямого обмена данными одного типа");
               // gen.SwapObject(ref n1, ref n2);
               // gen.SwapObject(ref x1, ref x2);
       Console.WriteLine("заканчивается ошибкой еще на этапе компиляции!");
       Console.WriteLine("Необходимо явное приведение к типу! ");
                object obj1, obj2;
                obj1 = n1; obj2 = n2;
                gen.SwapObject(ref obj1, ref obj2);
                n1 = (int)obj1; n2 = (int)obj2;
                Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
                obj1 = x1; obj2 = x2;
                gen.SwapObject(ref obj1, ref obj2);
                x1 = (double)obj1; x2 = (double)obj2;
                Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);

    Здесь клиент сам выполняет приведение данных к типу object и обратно, так что все работает нормально. Более того, можно, используя процедуру обмена, выполнить обмен данными разного типа после их приведения к универсальному типу object. Но, если меняются значениями переменные типа int и Person, то после обмена попытка привести тип Person к типу int приведет к ошибке периода выполнения.

    Console.WriteLine("Обмен данными разного типа");
      obj1 = n1; obj2 = x1;
      gen.SwapObject(ref obj1, ref obj2);
      Console.WriteLine("Приводит к ошибке периода выполнения!");
      //n1 = (int)obj1; x1 = (double)obj2;

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

    Для достижения универсальности процедуры Swap следует рассматривать тип T как ее параметр, такой же, как и сами аргументы x1 и x2. Суть универсальности в том, чтобы в момент вызова процедуры передавать ей не только фактические аргументы, но и их фактический тип. Вот как можно в C# объявить процедуру Swap с параметром, задающим тип аргументов:

    public void Swap<T>(ref T item1, ref T item2)
     {
      T temp = item1;
      item1 = item2; item2 = temp;
     }

    Вот как клиент может вызывать этот метод для данных разных типов:

    public void TestSwapT()
       {
           int n1 = 7, n2 = 11;
           double x1 = 7.7, x2 = 11.1;
           Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
           Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);
           Console.WriteLine("После обмена данными одного типа");
           gen.Swap<int>(ref n1, ref n2);
           gen.Swap<double>(ref x1, ref x2);
           Console.WriteLine("n1 = {0}, n2 = {1}", n1, n2);
           Console.WriteLine("x1 = {0}, x2 = {1}", x1, x2);

    Заметьте, в момент вызова метода ему передаются как объекты, подлежащие обмену, так и их тип, одинаковый для обоих объектов. Что произойдет, если попытаться обменять объекты разных типов?

    Console.WriteLine("Попытка обмена данными разного типа");
    //gen.Swap<int>(ref n1, ref x1);
    //gen.Swap<double>(ref x1, ref n1);
    Console.WriteLine("заканчивается ошибкой еще на этапе компиляции!");

    Ошибка, естественно, возникнет. Но! Это ошибка периода компиляции, а не выполнения, она уведомляет разработчика, что нельзя так просто смешивать "королей" и "капусту". Так что механизм работает должным образом. Рассмотрим, как универсальность распространяется на класс.

    Под универсальностью (genericity) понимается способность класса объявлять используемые им типы как параметры. Класс с параметрами, задающими типы, называется универсальным классом (generic class). Терминология не устоялась, и синонимами термина "универсальный класс" являются термины: родовой класс, параметризованный класс, класс с родовыми параметрами. В языке С++ универсальные классы называются шаблонами (template).

    Синтаксис универсального класса

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

    class MyClass<T1, … Tn> {…}

    Как и всякие формальные параметры, Ti являются именами (идентификаторами). В теле класса эти имена могут задавать типы некоторых полей класса, локальных переменных, типы аргументов и возвращаемых значений методов класса. В некоторый момент (об этом скажем чуть позже) формальные имена типов будут заменены фактическими параметрами, представляющими уже конкретные типы - имена встроенных классов, классов библиотеки FCL, классов, определенных пользователем.

    В C# универсальными могут быть как классы, так и все их частные случаи - интерфейсы, структуры, делегаты, события.

    Класс с универсальными методами

    Специальным частным случаем универсального класса является класс, не объявляющий сам параметров, но разрешающий делать это своим методам. Давайте начнем рассмотрение универсальности с этого частного случая. Построенный нами класс Generic является примером такого класса:

    class Generic
        {
            public void Swap<T>(ref T item1, ref T item2)
            {
                T temp = item1;
                item1 = item2; item2 = temp;
            }
            public void SwapObject( ref object item1, ref object item2)
            {
                object temp = item1;
                item1 = item2; item2 = temp;
            }
        }

    Как видите, сам класс в данном случае не имеет родовых параметров, но зато универсальным является один из методов класса - Swap, имеющий родовой параметр T. Типу Т принадлежат аргументы метода и локальная переменная temp. Всякий раз при вызове метода ему, наряду с фактическими аргументами, будет передаваться и фактический тип, заменяющий тип T в описании метода. О некоторых деталях технологии подстановки и выполнения метода поговорим в конце лекции, сейчас же лишь отмечу, что реализация вызова универсального метода в C# не приводит к существенным накладным расходам.

    Два основных механизма объектной технологии

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

    Эти механизмы взаимно дополняют друг друга. Универсальность можно ограничить (об этом подробнее будет сказано ниже), указав, что тип, задаваемый родовым параметром, обязан быть наследником некоторого класса и/или ряда интерфейсов. С другой стороны, когда формальный тип T заменяется фактическим типом TFact, то там, где разрешено появляться объектам типа TFact, разрешены и объекты, принадлежащие классам-потомкам TFact.

    Эти механизмы в совокупности обеспечивают бесшовный процесс разработки программных систем, начиная с этапов спецификации и проектирования системы и заканчивая этапами реализации и сопровождения. На этапе задания спецификаций появляются абстрактные, универсальные классы, которые в ходе разработки становятся вполне конкретными классами с конкретными типами данных. Механизмы наследования и универсализации позволяют существенно сократить объем кода, описывающего программную систему, поскольку потомки не повторяют наследуемый код своих родителей, а единый код универсального класса используется при каждой конкретизации типов данных. На рис. 8.1 показан схематически процесс разработки программной системы.

    (рис 8.1) Этапы процесса разработки программной системы

    На этапе спецификации, как правило, создается абстрактный, универсальный класс, где задана только сигнатура методов, но не их реализация, где определены имена типов, но не их конкретизация. Здесь же, используя возможности тегов класса, формально или не формально задаются спецификации, описывающие семантику методов класса. Далее в ходе разработки, благодаря механизму наследования, появляются потомки абстрактного класса, каждый из которых задает реализацию методов. На следующем этапе, благодаря механизму универсализации, появляются экземпляры универсального класса, каждый из которых выполняет операции класса над данными соответствующих типов.

    Для наполнения этой схемы реальным содержанием давайте рассмотрим некоторый пример с прохождением всех трех этапов.

    Стек. От абстрактного, универсального класса к конкретным версиям

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

    abstract public class GenStack<T>
        {
            abstract public T item();
            abstract public void remove();
            abstract public void put(T t);
            abstract public bool empty();
        }

    В таком виде абстрактный класс задает сигнатуру методов класса, что в совокупности представляет сигнатуру класса. На этом этапе проектирования класса крайне важно задать не только сигнатуру класса, но и спецификацию методов. Язык C# позволяет сделать это с помощью специальных документирующих комментариев, используя которые, специальный инструментарий позволяет построить документацию проекта. Подробнее об этих комментариях поговорим в следующей лекции, а сейчас для задания спецификаций будем применять неоднократно появляющиеся в наших примерах теги summary, играющие три важные роли. С одной стороны - это комментарии, важные для разработчика проекта. На этапе сопровождения проекта значение этой роли только усиливается. Теги summary появляются как интеллектуальная подсказка у клиентов класса при работе с классом и его методами, - в этом их вторая роль. Наконец, третья роль - они позволяют автоматическое построение документации - XML-отчета.

    Вот как выглядит наш абстрактный класс, дополненный подробными спецификациями:

    /// <summary>
        /// Абстрактный класс GenStack{T} задает контейнер с доступом LIFO:   
        /// Функции:
        /// конструктор new: -> GenStack{T}
        /// запросы:
        /// item: GenStack -> T
        /// empty: GenStack -> Boolean
        /// процедуры:
        /// put: GenStack*T -> GenStack
        /// remove: GenStack -> GenStack
        /// Аксиомы:
        /// remove(put(s,x)) = s
        /// item(put(s,x)) = x
        /// empty(new)= true
        /// empty(put(s,x)) = false
        /// </summary>    
        abstract public class GenStack<T>
        {
            /// <summary>
            /// require: not empty();
            /// </summary>
            /// <returns>элемент вершины(последний пришедший)</returns>
            abstract public T item();
    
            /// <summary>
            /// require: not empty();
            /// ensure: удален элемент вершины(последний пришедший)
            /// </summary>
            abstract public void remove();
    
            /// <summary>
            /// require: true; ensure: elem находится в вершине стека
            /// </summary>
            /// <param name="elem"></param>
            abstract public void put(T elem);
    
            /// <summary>
            /// require: true;
            /// </summary>
            /// <returns>true если стек пуст, иначе false </returns>
            abstract public bool empty();
        }// class GenStack

    В приведенном примере программного текста - чуть-чуть. Основной текст задает описание спецификации класса и его методов. Заметьте, здесь спецификации заданы достаточно формально с использованием аксиом, характеризующих смысл операций, которые выполняются над стеком. Не хочется вдаваться в математические подробности, отмечу лишь, что, если задать последовательность операций над стеком, то аксиомы позволяют точно определить состояние стека в результате выполнения этих операций. Как неоднократно отмечалось с первых лекций курса, XML-отчет, построенный по этому проекту, будет содержать в читаемой форме все спецификации нашего класса. Отмечу еще, что все потомки класса должны удовлетворять этим спецификациям, хотя могут добавлять и собственные ограничения. К сожалению, потомки не наследуют текста, заданного в комментариях, - в тегах summary и других тегах, так что для потомков приходится повторно задавать теги summary.

    Наш класс определен как универсальный класс с родовым параметром - стек может хранить элементы любого типа, и конкретизация типа будет производиться в момент создания экземпляра стека.

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

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

    Реализация стека списком

    Вот как выглядит первый потомок абстрактного класса:

    /// <summary>
        /// Стек, построенный на односвязных элементах списка GenLinkable{T}
        /// </summary>
        public class OneLinkStack<T> : GenStack<T>
        {
            /// <summary>
            /// ссылка на стек (вершину стека)
            /// </summary>
            GenLinkable<T> last; 
            /// <summary>
            /// Конструктор без аргументов
            /// </summary>
            public OneLinkStack()
            {
                last = null;
            }        
            /**
             * <remarks>Реализация методов абстрактного класса</remarks>
             * */
            /// <summary>
            /// require: not empty();
            /// </summary>
            /// <returns>элемент вершины(последний пришедший)</returns>
            public override T item()
            {
                return (last.Item);
            }//item      
            /// <summary>
            /// require: true;
            /// </summary>
            /// <returns>true если стек пуст, иначе false </returns>
            public override bool empty()
            {
                return (last == null);
            }//empty      
            /// <summary>
            /// require: true; ensure: elem находится в вершине стека
            /// </summary>
            /// <param name="elem"></param>
            public override void put(T elem)
            {
                GenLinkable<T> newitem = new GenLinkable<T>();
                newitem.Item = elem; newitem.Next = last;
                last = newitem;
            }//put       
            /// <summary>
            /// require: not empty();
            /// ensure: удален элемент вершины(последний пришедший)
            /// </summary>
            public override void remove()
            {
                last = last.Next;
            }//remove
        }//class OneLinkStack

    Посмотрите, что происходит при наследовании от универсального класса. Во-первых, сам потомок также является универсальным классом:

    public class OneLinkStack<T> : GenStack<T>

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

    GenLinkable<T> last; //ссылка на стек (элемент стека)

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

    public override void put(T elem)

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

    /// <summary>
        /// Элемент односвязного списка
        /// содержит два поля - информационное и ссылку    
        /// </summary>
        /// <typeparam name="T">тип элементов, хранимых в списке</typeparam>
        public class GenLinkable<T>
        {
            /// <summary>
            /// Информационное поле - объект типа T
            /// </summary>
            public T Item;
            
            /// <summary>
            /// Ссылка на следующий элемент списка
            /// </summary>
            public GenLinkable<T> Next;
    
            /// <summary>
            /// Конструктор без аргументов
            /// Инициализиует поля значениями по умолчанию
            /// </summary>
            public GenLinkable()
            { Item = default(T); Next = null; }
        }

    Класс устроен достаточно просто: у него два поля, одно для хранения элементов, помещаемых в стек и имеющее тип T, другое - указатель на следующий элемент. Обратите внимание на конструктор класса, в котором для инициализации элемента используется конструкция default(T), возвращающая значение, устанавливаемое по умолчанию для типа T.

    Реализация стека массивом

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

    public class ArrayUpStack<T> : GenStack<T>
       {
          int SizeOfStack;
          T[] stack;
          int top;
          /// <summary>
          /// конструктор
          /// </summary>
          /// <param name="size">размер стека</param>
          public ArrayUpStack(int size)
          { SizeOfStack = size; stack = new T[SizeOfStack]; top = 0; }
          /// <summary>
          /// require: (top < SizeOfStack)
          /// </summary>
          /// <param name="x"> элемент, помещаемый в стек</param>
          public override void put(T x)
          { stack[top] = x; top++; }
          
          public override void remove()
          { top--; }      
          public override T item()
          { return (stack[top-1]); }
          public override bool empty()
          { return (top == 0); }
       }//class ArrayUpStack

    Созданные в результате наследования классы-потомки перестали быть абстрактными, но все еще остаются универсальными. На третьем этапе порождаются конкретные экземпляры потомков - универсальных классов, в этот момент и происходит конкретизация типов, и два экземпляра одного универсального класса могут работать с данными различных типов. Этот процесс создания экземпляров с подстановкой конкретных типов называют родовым порождением экземпляров. Вот как в тестирующей процедуре создаются экземпляры созданных нами классов:

    public void TestStacks()
      {
        OneLinkStack<int> stack1 = new OneLinkStack<int>();
        OneLinkStack<string> stack2 = new OneLinkStack<string>();
        ArrayUpStack<double> stack3 = new ArrayUpStack<double>(10);
    
        stack1.put(11); stack1.put(22);
        int x1 = stack1.item(), x2 = stack1.item();
        if ((x1 == x2)  (x1 == 22)) Console.WriteLine("OK!");   
        stack1.remove(); x2 = stack1.item();
        if ((x1 != x2)  (x2 == 11)) Console.WriteLine("OK!");
        stack1.remove(); x2 = (stack1.empty())? 77 : stack1.item();
        if ((x1 != x2)  (x2 == 77)) Console.WriteLine("OK!");
    
        stack2.put("first"); stack2.put("second");
        stack2.remove(); string s = stack2.item();
        if (!stack2.empty()) Console.WriteLine(s);
    
        stack3.put(3.33); stack3.put(Math.Sqrt(Math.PI));
        double res = stack3.item();
        stack3.remove(); res += stack3.item();
        Console.WriteLine("res= {0}", res);
      }

    В трех первых строках этой процедуры порождаются три экземпляра стеков. Все они имеют общего родителя - абстрактный универсальный класс GenStack, но каждый из них работает с данными своего типа и по-разному реализует методы родителя. На рис. 8.2 показаны результаты работы этой процедуры.

    (рис 8.2) Три стека, порожденных универсальным классом

    Дополним наше рассмотрение еще одним примером работы с вариацией стеков, в том числе - хранящим объекты класса Person:

    public void TestPerson()
     {
        OneLinkStack<int> stack1 = new OneLinkStack<int>();
        OneLinkStack<string> stack2 = new OneLinkStack<string>();
        ArrayUpStack<double> stack3 = new ArrayUpStack<double>(10);
        ArrayUpStack<Person> stack4 = new ArrayUpStack<Person>(7);
    
        stack2.put("Петров"); stack2.put("Васильев"); stack2.put("Шустов");
        stack1.put(27); stack1.put(45); stack1.put(53);
        stack3.put(21550.5); stack3.put(12345.7); stack3.put(32458.8);
    
        stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));
        stack1.remove(); stack2.remove(); stack3.remove();
        stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));
        stack1.remove(); stack2.remove(); stack3.remove();
        stack4.put(new Person(stack2.item(), stack1.item(), stack3.item()));
    
        Person pers = stack4.item(); pers.PrintPerson();
        stack4.remove(); pers = stack4.item(); pers.PrintPerson();
        stack4.remove(); pers = stack4.item(); pers.PrintPerson();
        stack4.remove(); if (stack4.empty()) Console.WriteLine("OK!");
     }

    Результаты работы этой процедуры приведены на рис. 8.3.

    (рис 8.3) Работа со стеком Person

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

    Хорошо, когда есть свобода. Еще лучше, когда свобода ограничена. Аналогичная ситуация существует и с универсальностью. Универсальность следует ограничивать. На типы универсального класса, являющиеся его параметрами, следует накладывать ограничения. Звучит парадоксально, но, наложив ограничения на типы, программист получает гораздо большую свободу в работе с объектами этих типов.

    Если немного подумать, то это совершенно естественная ситуация. Когда имеется неограниченная универсальность, над объектами типов можно выполнять только те операции, которые допускают все типы, - в C# это эквивалентно операциям, разрешенным над объектами типа object, прародителя всех типов. В нашем предыдущем примере, где речь шла о свопинге, над объектами выполнялась единственная операция присваивания. Поскольку присваивание внутри одного типа разрешено для всех типов, неограниченная универсальность приемлема в такой ситуации. Но что произойдет, если попытаться выполнить сложение элементов, сравнение их или даже простую проверку элементов на равенство? Немедленно возникнет ошибка еще на этапе компиляции. Эти операции не разрешены для всех типов, поэтому в случае компиляции такого проекта ошибка могла бы возникнуть на этапе выполнения, когда вместо формального типа появился бы тип конкретный, не допускающий подобную операцию. Нельзя ради универсальности пожертвовать одним из важнейших механизмов C# и Framework .Net - безопасностью типов, поддерживаемой статическим контролем типов. Именно поэтому неограниченная универсальность существенно ограничена. Ее ограничивает статический контроль типов. Бывают, разумеется, ситуации, когда необходимо на типы наложить ограничения, позволяющие ослабить границы статического контроля, что, повторяю, дает большую свободу программисту.

    В языке C# допускаются три вида ограничений накладываемых на родовые параметры.

  • Ограничение наследования. Это основный вид ограничений, указывающий, что тип T является наследником некоторого класса и ряда интерфейсов. Следовательно, над объектами типа T можно выполнять все операции, заданные базовым классом и интерфейсами. Эти операции статический контроль типов будет допускать, и обеспечивать для них интеллектуальную поддержку, показывая список разрешенных операций. Ограничение наследования позволяет выполнять над объектами больше операций, чем в случае неограниченной универсальности. Синтаксически ограничение выглядит так: where T: BaseClass, I1, …Ik.
  • Ограничение конструктора.Это ограничение указывает, что тип T имеет конструктор без аргументов и, следовательно, позволяет создавать объекты типа T. Синтаксически ограничение выглядит так: where T: new().
  • Ограничение value/reference. Это ограничение указывает, к значимым или к ссылочным типам относится тип T. Для указания значимого типа задается слово struct, для ссылочных - class. Так что синтаксически этот тип ограничений выглядит так: where T: struct.
  • Возникает законный вопрос: насколько полна предлагаемая система ограничений? Конечно, речь идет о практической полноте, а не о математически строгих определениях. С позиций практики систему хотелось бы дополнить, в первую очередь, введением ограничений операций, указывающим допустимые знаки операций в выражениях над объектами соответствующего типа. Хотелось бы, например, указать, что к объектам типа T применима операция сложения + или операция сравнения <. Позже я покажу, как можно справиться с этой проблемой, но предлагаемое решение довольно сложно. Наличие ограничения, допускающего операции, намного элегантнее решало бы эту проблему.

    Синтаксис ограничений

    Уточним некоторые синтаксические правила записи ограничений. Если задан универсальный класс с типовыми параметрами T1, … Tn, то на каждый параметр могут быть наложены ограничения всех типов. Ограничения задаются предложением where, начинающегося соответствующим ключевым словом, после которого следует имя параметра, а затем через двоеточие - ограничения первого, второго или третьего типа, разделенные запятыми. Порядок их важен: если присутствует ограничение третьего типа, то оно записывается первым. Заметьте, предложения where для разных параметров отделяются лишь пробелами; как правило, они записываются на отдельных строчках. Предложения where записываются в конце заголовка класса после имени и списка его типовых параметров, после родительских классов и интерфейсов, если они заданы для универсального класса. Вот синтаксически корректные объявления классов с ограничением универсальности:

    public class Father<T1, T2>
       { }
       public class Base
       {
          public void M1() { }
          public void M2() { }
       }
       public class Child<T1,T2> :Father<T1,T2>
          where T1:Base,IEnumerable<T1>, new()
          where T2:struct,IComparable<T2>
       {   }

    Универсальный класс Child задан с ограничениями на родовые параметры. Благодаря этому для данных типа T1 можно вызывать методы M1 и M2 базового класса Base, так же как и методы интерфейса IEnumerable<T1>. Можно создавать объекты типа T1, используя конструктор по умолчанию. Фактический тип, подставляемый вместо формального типа T2, должен быть значимым, и объекты этого типа разрешается сравнивать между собой.

    Список с возможностью поиска элементов по ключу

    Ключевые идеи ограниченной универсальности, надеюсь, понятны. Давайте теперь рассмотрим пример построения подобного класса, где можно будет увидеть все детали. Возьмем классическую и саму по себе интересную задачу построения списка с курсором. Как и всякий контейнер данных, список следует сделать универсальным, допускающим хранение данных разного типа. С другой стороны, мы не хотим, чтобы в одном списке происходило смешение типов, - уж если там хранятся персоны, то чисел int в нем не должно быть. По этим причинам класс должен быть универсальным, имея в качестве параметра тип T, задающий тип хранимых данных. Мы потребуем также, чтобы данные хранились с их ключами. И поскольку не хочется заранее накладывать ограничения на тип ключей - они могут быть строковыми или числовыми, - тип хранимых ключей будет еще одним параметром нашего класса. Мы хотим определить над списком операцию поиска по ключу, значит, нам придется выполнять проверку ключей на равенство, поэтому универсальность типа ключей должна быть ограниченной, проще всего сделать этот тип наследником стандартного интерфейса IComparable.

    Чтобы не затемнять ситуацию сложностью списка, рассмотрим достаточно простой односвязный список с курсором. Элементы этого списка будут принадлежать классу Node, два поля которого будут хранить ключ и сам элемент, а третье поле будет задавать указатель на следующий элемент списка. Очевидно, что этот класс должен быть универсальным классом. Вот как выглядит текст этого класса:

    public class Node<K, T> where K:IComparable<K>
       {
          public Node()
          {
             next = null; key = default(K);
             item = default( T);
          }
          public K key;
          public T item;
          public Node<K, T> next;
       }

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

    Рассмотрим теперь организацию односвязного списка. Начнем с того, как устроены его данные:

    public class OneLinkList<K, T>   where K : IComparable<K>
    {
          protected Node<K, T> first, cursor;
    }

    Являясь клиентом универсального класса Node, наш класс сохраняет родовые параметры клиента и ограничения, накладываемые на них. Два поля класса - first и cursor - задают указатели на первый и текущий элементы списка. Операции над списком связываются с курсором, позволяя перемещать курсор по списку. Рассмотрим вначале набор операций, перемещающих курсор:

    public void start()
          { cursor = first; }
    public void forth()
          { if (cursor.next != null) cursor = cursor.next; }

    Операция start передвигает курсор к началу списка, а forth - к следующему элементу справа от курсора. Операции finish и forth определены только для непустых списков. Конец списка является барьером, и курсор не переходит через барьер. Нарушая принципы ради краткости текста, я не привожу формальных спецификаций методов, записанных в тегах summary.

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

    public void add(K key, T item)
    {
       Node<K, T> newnode = new Node<K, T>();
       if (first == null)
       {
          first = newnode; cursor = newnode;
          newnode.key = key; newnode.item = item;
       }
       else
       {
          newnode.next = cursor.next; cursor.next = newnode;
          newnode.key = key; newnode.item = item;
       }
    }

    Заметьте, аргументы метода имеют соответствующие родовые параметры, чем и обеспечивается универсальный характер списка. При добавлении элемента в список различаются два случая - добавление первого элемента и всех остальных.

    Рассмотрим теперь операцию поиска элемента по ключу, реализация которой потребовала ограничения универсальности типа ключа K:

    public bool findstart(K key)
    {
       Node<K, T> temp = first;
       while (temp != null)
       {
          if (temp.key.CompareTo(key) == 0)
              {cursor = temp; return(true);}
          temp = temp.next;
       }
       return (false);
    }

    Искомые элементы разыскиваются во всем списке. Если элемент найден, то курсор устанавливается на найденном элементе и метод возвращает значение true. Если элемента с заданным ключом нет в списке, то позиция курсора не меняется, а метод возвращает значение false. В процессе поиска для каждого очередного элемента списка вызывается допускаемый ограничением метод CompareTo интерфейса IComparable. При отсутствии ограничений универсальности вызов этого метода или операции эквивалентности приводил бы к ошибке, обнаруживаемой на этапе компиляции.

    Два метода класса являются запросами, позволяющими извлечь ключ и элемент списка, который отмечен курсором:

    public K Key()
       {
          return (cursor.key);
       }
       public T Item()
       {
          return(cursor.item);
      }

    Давайте рассмотрим теперь тестирующую процедуру - клиента нашего списка,- демонстрирующую работу со списками, в которых элементы и ключи имеют разные типы:

    public void TestConstraint()
    {
       OneLinkList<int, string> list1 = new OneLinkList<int, string>();
       list1.add(33, "thirty three"); list1.add(22, "twenty two");
       if(list1.findstart(33)) Console.WriteLine("33 - найдено!");
       else Console.WriteLine("33 - не найдено!");
       if (list1.findstart(22)) Console.WriteLine("22 - найдено!");
       else Console.WriteLine("22 - не найдено!");
       if (list1.findstart(44)) Console.WriteLine("44 - найдено!");
       else Console.WriteLine("44 - не найдено!");
       Person pers1 = new Person("Савлов", 25, 1500);
       Person pers2 = new Person("Павлов", 35, 2100);
       OneLinkList<string, Person> list2 = new OneLinkList< string, Person>();
       list2.add("Савл", pers1); list2.add( "Павел", pers2);
       if (list2.findstart("Павел")) Console.WriteLine("Павел - найдено!");
       else Console.WriteLine("Павел - не найдено!");
       if (list2.findstart("Савл")) Console.WriteLine("Савл - найдено!");
       else Console.WriteLine("Савл - не найдено!");
       if (list2.findstart("Иоанн")) Console.WriteLine("Иоанн - найдено!");
       else Console.WriteLine("Иоанн - не найдено!");
       Person pers3 = new Person("Иванов", 33, 3000);
       list2.add("Иоанн", pers3); list2.start();
       Person pers = list2.Item(); pers.PrintPerson();
       list2.findstart("Иоанн"); pers = list2.Item(); pers.PrintPerson();
    }

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

    OneLinkList<int, string> list1 = new OneLinkList<int, string>();
    OneLinkList<string, Person> list2 = new OneLinkList< string, Person>();

    У списка list1 ключи имеют тип int, у списка list2 - string. Заметьте, оба фактических типа, согласно обязательствам, реализуют интерфейс IComparable. У первого списка тип элементов - string, у второго - Person. Все работает прекрасно. Вот результаты вычислений по этой процедуре.

    (рис 8.4) Поиск в списке с ограниченной универсальностью

    Как справиться с арифметикой

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

    Как уже говорилось, наличие ограничения операции, где можно было бы указать, что над элементами определена операция +, решало бы проблему. Но такого типа ограничений нет. Хуже того, нет и интерфейса INumeric, аналогичного IComparable, определяющего метод сложения Add. Так что нам не может помочь и ограничение наследования.

    Вот один из возможных выходов, предлагаемых в такой ситуации. Стратегия следующая: определим абстрактный универсальный класс Calc с методами, выполняющими вычисления. Затем создадим конкретизированных потомков этого класса. В классе, задающем список с суммированием, введем поле класса Calc. При создании экземпляров класса будем передавать фактические типы ключа и элементов, а также соответствующий калькулятор, но уже не как тип, а как аргумент конструктора класса. Этот калькулятор, согласованный с типом элементов, и будет выполнять нужные вычисления. Давайте приступим к реализации этой стратегии. Начнем с определения класса Calc:

    public abstract class Calc<T>
     {
       public abstract T Add(T a, T b);
       public abstract T Sub(T a, T b);
       public abstract T Mult(T a, T b);
       public abstract T Div(T a, T b);
    }

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

    public class IntCalc : Calc<int>
    {
      public override int Add(int a, int b) { return (a + b); }
      public override int Sub(int a, int b) { return (a - b); }
      public override int Mult(int a, int b) { return (a * b); }
      public override int Div(int a, int b) { return (a / b); }
    }
     public class DoubleCalc : Calc<double>
    {
      public override double Add(double a, double b) { return (a + b); }
      public override double Sub(double a, double b) { return (a - b); }
      public override double Mult(double a, double b) { return (a * b); }
      public override double Div(double a, double b) { return (a / b); }
    }
    public class StringCalc : Calc<string>
    {
       public override string Add(string a, string b) { return (a + b); }
       public override string Sub(string a, string b) { return (a ); }
       public override string Mult(string a, string b) { return (a ); }
       public override string Div(string a, string b) { return (a); }
    }

    Здесь определяются три разных калькулятора: один - над целочисленными данными, другой - над данными с плавающей точкой, третий - над строковыми данными. В последнем случае определена, по сути, только операция сложения строк (конкатенации).

    Теперь нам нужно ввести изменения в ранее созданный класс OneLinkList. Обратите внимание на важный технологический принцип работы с объектными системами. Пусть уже есть нормально работающий класс с нормально работающими клиентами класса. Не следует изменять этот класс.Он закрыт для изменений. Используйте наследование и открывайте класс потомок, в который и вносите изменения, учитывающие добавляемую специфику класса. Принцип "Закрыт - Открыт" является одним из важнейших принципов построения программных систем в объектном стиле.

    В полном соответствии с этим принципом построим класс SumList - потомок класса OneLinkList. То, что родительский класс является универсальным, ничуть не мешает строить потомка класса, сохраняющего универсальный характер родителя.

    public class SumList<K, T> : OneLinkList<K, T> where K : IComparable<K>
    {
       Calc<T> calc;
       T sum;
       public SumList(Calc<T> calc)
       { this.calc = calc; sum = default(T); }
    
       public new void add(K key, T item)
       {
          Node<K, T> newnode = new Node<K, T>();
          if (first == null)
          {
            first = newnode; cursor = newnode;
            newnode.key = key; newnode.item = item;
            sum = calc.Add(sum, item);
          }
          else
          {
            newnode.next = cursor.next; cursor.next = newnode;
            newnode.key = key; newnode.item = item;
            sum = calc.Add(sum, item);
         }
       }
       public T Sum()
       {return (sum);   }
    }//SumList

    У класса добавилось поле sum, задающее сумму хранимых элементов, и поле calc - калькулятор, выполняющий вычисления. Метод add, объявленный в классе с модификатором new, скрывает родительский метод add, задавая собственную реализацию этого метода. Родительский метод можно было бы определить как виртуальный, переопределив его у потомка, но я не стал трогать код родительского класса. К классу добавился еще один запрос, возвращающий значение поля sum.

    Проведем теперь эксперименты с новыми вариантами списков, допускающих суммирование элементов:

    public void TestSum()
      {
          SumList<string, int> list1 = 
            new SumList<string, int>(new IntCalc());
          list1.add("Петр", 33); list1.add("Павел", 44);
          Console.WriteLine("sum= {0}", list1.Sum());
          SumList<string, double> list2 = 
             new SumList<string, double>(new DoubleCalc());
          list2.add("Петр", 33.33); list2.add("Павел", 44.44);
          Console.WriteLine("sum= {0}", list2.Sum());
          SumList<string, string> list3 = 
             new SumList<string, string>(new StringCalc());
          list3.add("Мама", " Мама мыла "); 
          list3.add("Маша", "Машу мылом!");
          Console.WriteLine("sum= {0}", list3.Sum());
      }

    Обратите внимание на создание списков:

    SumList<string, int> list1 = new SumList<string, int>(new IntCalc());
       SumList<string, double> list2 = new SumList<string, double>(new DoubleCalc());
       SumList<string, string> list3 = new SumList<string, string>(new StringCalc());

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

    (рис 8.5) Списки с суммированием

    Наследование и встраивание. Совместное использование

    Прием, использованный для того, чтобы справиться с арифметикой, имеет более широкое методическое применение. Он основан на сочетании встраивания и наследования, и его полезно применять в разных ситуациях. В чем его суть. Мы хотели классу SumList добавить новые возможности - арифметику. Простым наследованием этого сделать нельзя, хотя бы потому, что у класса SumList уже есть родительский класс. Поэтому в класс SumList встроен объект абстрактного класса Calc, обладающий свойствами арифметики, - здесь работает встраивание. У класса Calc есть потомки, каждый из которых по-своему реализует арифметику. Здесь в полной мере используются возможности наследования. Конструктору объектов класса SumList передается нужный потомок класса Calc, и наш объект получает возможность работать с арифметикой.

    Давайте посмотрим, как этот же прием позволяет справляться с проблемой множественного наследования, обходя ограничение одного родителя. На практике часто возникает необходимость построить класс, который был бы потомком двух родителей. Пусть, например, созданы классы " дом " и " автомобиль ", а хочется построить класс " домомобиль ", объединяющий свойства уже построенных классов. Аналогично, классы " дом " и " вагон " могут порождать класс " спальный вагон ", а классы " самолет " и " пароход " - " гидросамолет ".

    Вот схема возможного решения этой проблемы:

    public class Home
        {
            // описывает свойства дома        
        }
        public abstract class Transport
        {
            // описывает свойства транспортных средств 
        }
        public class Car : Transport
        {
            // описывает свойства автомобиля
        }
        public class Carriage : Transport
        {
            // описывает свойства вагона
        }
        public class Spaceship : Transport
        {
            // описывает свойства космического корабля
        }

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

    public class Home_Car : Home
        {
            Transport transport;
            public Home_Car(Transport transport)
            {
                this.transport = transport;
            }
        }
        public class Home_Carriage : Home
        {
            Transport transport;
            public Home_Carriage(Transport transport)
            {
                this.transport = transport;
            }
        }
        public class Home_Spaceship
        {
            Transport transport;
            public Home_Spaceship(Transport transport)
            {
                this.transport = transport;
            }
        }

    Если теперь некоторый клиент захочет создать объект, моделирующий космический корабль и предназначенный для межзвездных путешествий, то он может вначале создать объект класса Spaceship и передать его конструктору, создающему объект класса Home_Spaceship.

    public void TestSpace()
      {
          Spaceship spaceship = new Spaceship();
          Home_Spaceship home_ship = 
              new Home_Spaceship(spaceship);
          Console.WriteLine("И корабль плывет!");
      }

    Предложение using

    До сих пор рассматривалась ситуация родового порождения экземпляров универсального класса. Фактические типы задавались в момент создания экземпляра. Это наглядно показывает преимущества применяемой технологии, поскольку очевидно, что не создается дублирующий код для каждого класса, порожденного универсальным классом. И все-таки остается естественный вопрос: можно ли объявить класс путем подстановки фактических параметров в универсальный класс, а потом спокойно использовать этот класс обычным образом? Такая вещь возможна. Это можно сделать не совсем обычным путем - не в программном коде, а в предложении using, назначение которого и состоит в выполнении подобных подстановок. Предложение using не создает реальный класс. Это лишь форма сокращения записи, но содержательно его можно рассматривать как объявление конкретного класса.

    Давайте вернемся к универсальному классу OneLinkStack<T>, введенному в начале этой лекции, и объявим класс IntStack, заменив формальный параметр T фактическим - int. Для этого достаточно задать следующее предложение using:

    using IntStack = ConsoleGeneric.OneLinkStack<int>;

    Вот тест, в котором создаются несколько объектов этого класса:

    public void TestIntStack()
          {
             IntStack stack1 = new IntStack();
             IntStack stack2 = new IntStack();
             IntStack stack3 = new IntStack();
    
             stack1.put(11); stack1.put(22);
             int x1 = stack1.item(), x2 = stack1.item();
             if ((x1 == x2)  (x1 == 22)) Console.WriteLine("OK!");
             stack1.remove(); x2 = stack1.item();
             if ((x1 != x2)  (x2 == 11)) Console.WriteLine("OK!");
             stack1.remove(); x2 = (stack1.empty()) ? 77 : stack1.item();
             if ((x1 != x2)  (x2 == 77)) Console.WriteLine("OK!");
    
             stack2.put(55); stack2.put(66);
             stack2.remove(); int s = stack2.item();
             if (!stack2.empty()) Console.WriteLine(s);
    
             stack3.put(333); stack3.put((int)Math.Sqrt(Math.PI));
             int res = stack3.item();
             stack3.remove(); res += stack3.item();
             Console.WriteLine("res= {0}", res);
          }

    Все работает заданным образом, можете поверить.

    Универсальность и специальные случаи классов

    Универсальность - это механизм, воздействующий на все элементы языка. Поэтому он применим ко всем частным случаям классов C# .

    Универсальные структуры

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

    public struct Point<T>
       {
           T x, y;//координаты точки, тип которых задан параметром
          // другие свойства и методы структуры
       }

    Универсальные интерфейсы

    Интерфейсы чаще всего следует делать универсальными, предоставляя большую гибкость для позднейших этапов создания системы. Возможно, вы заметили применение в наших примерах универсальных интерфейсов библиотеки FCL - IComparable<T> и других. Введение универсальности, в первую очередь, сказалось на библиотеке FCL - внутренних классов, определяющих поведение системы. В частности, для большинства интерфейсов появились универсальные двойники с параметрами. Если бы в наших примерах мы использовали не универсальный интерфейс, а обычный, то потеряли бы в эффективности, поскольку сравнение объектов потребовало бы создание временных объектов типа object, выполнения операций boxing и unboxing.

    Универсальные делегаты

    Делегаты также могут иметь родовые параметры. Чаще встречается ситуация, когда делегат объявляется в универсальном классе и использует в своем объявлении параметры универсального класса. Давайте рассмотрим ситуацию с делегатами более подробно. Вот объявление универсального класса, названного UniversalDelegates, в котором объявляется функциональный тип:

    class UniversalDelegates<T>
        {
            public delegate T Unidel(T a, T b);
        }

    Как видите, тип аргументов и возвращаемого значения в сигнатуре функционального типа определяется параметром класса Delegate.

    Добавим в класс функцию высшего порядка FunAr, одним из аргументов которой будет функция типа Unidel, заданного делегатом. Эта функция будет применяться к элементам массива, передаваемого также функции FunAr. Приведу описание:

    public T FunAr(T[] arr, T a0, Unidel f)
          {
             T temp = a0;
             for(int i =0; i<arr.Length; i++)
             {
                temp = f(temp, arr[i]);
             }
             return (temp);
          }

    Эта универсальная функция с успехом может применяться для вычисления сумм, произведения, минимума и других подобных характеристик массива.

    Рассмотрим теперь клиентский класс Testing, в котором определен набор функций:

    public int max2(int a, int b)
          { return (a > b) ? a : b; }
    public double min2(double a, double b)
          { return (a < b) ? a : b; }
    public string sum2(string a, string b)
          { return a + b; }
    public float prod2(float a, float b)
          { return a * b; }

    Хотя все функции имеют разные типы, все они соответствуют определению класса Unidel - имеют два аргумента одного типа и возвращают результат того же типа. Посмотрим, как они применяются в тестирующем методе класса Testing:

    public void TestFun()
      {
          int[] ar1 = { 3, 5, 7, 9 };
          double[] ar2 = { 3.5, 5.7, 7.9 };
          string[] ar3 = { "Мама ", "мыла ", "Машу ", "мылом." };
          float[] ar4 = { 5f, 7f, 9f, 11f };
          UniversalDelegates<int> d1 = 
              new UniversalDelegates<int>();
          UniversalDelegates<int>.Unidel del1;
          del1 = this.max2;
          int max = d1.FunAr(ar1, ar1[0], del1);
          Console.WriteLine("max= {0}", max);
    
          UniversalDelegates<double> d2 = 
              new UniversalDelegates<double>();
          UniversalDelegates<double>.Unidel del2;
          del2 = this.min2;
          double min = d2.FunAr(ar2, ar2[0], del2);
          Console.WriteLine("min= {0}", min);
    
          UniversalDelegates<string> d3 = 
              new UniversalDelegates<string>();
          UniversalDelegates<string>.Unidel del3;
          del3 = this.sum2;
          string sum = d3.FunAr(ar3, "", del3);
          Console.WriteLine("concat= {0}", sum);
    
          UniversalDelegates<float> d4 = 
              new UniversalDelegates<float>();
          UniversalDelegates<float>.Unidel del4;
          del4 = this.prod2;
          float prod = d4.FunAr(ar4, 1f, del4);
          Console.WriteLine("prod= {0}", prod);
      }

    Обратите внимание на объявление экземпляра делегата:

    UniversalDelegates<int>.Unidel del1;

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

    del1= this.max2;

    При выполнении этого присваивания проверяется соответствие сигнатуры функции в правой части и экземпляра делегата, в случае успеха экземпляр делегата связывается с функцией.

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

    public void TestLanbdaFun()
    {
        int[] ar1 = { 3, 5, 7, 9 };
        double[] ar2 = { 3.5, 5.7, 7.9 };
        string[] ar3 = 
        { "Мама ", "мыла ", "Машу ", "мылом." };
        float[] ar4 = { 5f, 7f, 9f, 11f };
     
        UniversalDelegates<int> d1 =
            new UniversalDelegates<int>();   
        int max = d1.FunAr(ar1, ar1[0],
          (int a, int b)=> { return (a > b) ? a : b; });
        Console.WriteLine("max= {0}", max);
    
        UniversalDelegates<double> d2 =
            new UniversalDelegates<double>();    
        double min = d2.FunAr(ar2, ar2[0],
         (double a, double b)=> {return (a < b) ? a : b;});       
        Console.WriteLine("min= {0}", min);
    
        UniversalDelegates<string> d3 =
            new UniversalDelegates<string>();   
        string sum = d3.FunAr(ar3, "",
            (string a, string b)=> {return a + b; });
        Console.WriteLine("concat= {0}", sum);
    
        UniversalDelegates<float> d4 =
            new UniversalDelegates<float>();   
        float prod = d4.FunAr(ar4, 1f,
            (float a, float b) => {return a*b;});       
        Console.WriteLine("prod= {0}", prod);
    }

    Покажем, что и сам функциональный тип - делегат - можно объявлять с родовыми параметрами. Вот пример такого объявления:

    public delegate T1 FunOneArg<T1>(T1 a);

    Добавим в наш тестовый пример код, демонстрирующий работу с этим делегатом:

    FunOneArg<double> F = (double x) => 
        {return Math.Sin(x) + Math.Cos(x);};
        Console.WriteLine("x = {0}, Sin(x) + Cos(x) = {1}",
            5.0, F(5.0));

    Вот как выглядят результаты работы тестового примера.

    (рис 8.6) Результаты работы с универсальными делегатами

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

    public void delegate EventHandler<T> (object sender, T args)
                   where T:EventArgs

    Этот делегат может применяться и для событий с собственными аргументами, поскольку вместо параметра T может быть подставлен конкретный тип - потомок класса EventArgs, дополненный нужными аргументами.

    Framework .Net и универсальность

    Универсальность принадлежит к основным механизмам языка. Ее введение в язык C# не могло не сказаться на всех его основных свойствах. Как уже говорилось, классы и все частные случаи стали обладать этим свойством. Введение универсальности не должно было ухудшить уже достигнутые свойства языка - статический контроль типов, динамическое связывание и полиморфизм. Не должна была пострадать и эффективность выполнения программ, использующих универсальные классы.

    Решение этих задач потребовало введения универсальности не только в язык C#, но и поддержки на уровне каркаса Framework .Net и языка IL, включающем теперь параметризованные типы. Универсальный класс C# не является шаблоном, на основе которого строится конкретизированный класс, компилируемый далее в класс (тип) IL. Компилятору языка C# нет необходимости создавать классы для каждой конкретизации типов универсального класса. Вместо этого происходит компиляция универсального класса C# в параметризованный тип IL. Когда же CLR занимается исполнением управляемого кода, то вся необходимая информация о конкретных типах извлекается из метаданных, сопровождающих объекты.

    При этом дублирования кода не происходит и на уровне JIT-компиляторов, которые, однажды сгенерировав код для конкретного типа, сохраняют ссылку на этот участок кода и передают ее, когда такой код понадобится вторично. Это справедливо как для ссылочных, так и для значимых типов.

    Естественно, что универсальность потребовала введения в библиотеку FCL соответствующих классов, интерфейсов, делегатов и методов классов, обладающих этим свойством.

    Так, например, в класс System.Array добавлен ряд универсальных статических методов. Вот один из них:

    public static int BinarySearch<T>(T[] array, T value);

    В табл. 8.1 показаны некоторые универсальные классы и интерфейсы библиотеки FCL 2.0 из пространства имен System.Collections.Generic и их аналоги из пространства System.Collections.

    Соответствие между универсальными классами и их обычными двойниками
    Универсальный класс Обычный класс Универсальный интерфейс Обычный интерфейс
    Comparer<T> Comparer ICollection<T> ICollection
    Dictionary<K,T> HashTable IComparable<T> IComparable
    LinkedList<T> ---- IDictionary<K,T> IDictionary
    List<T> ArrayList IEnumerable<T> IEnumerable
    Queue<T> Queue IEnumerator<T> IEnumerator
    SortedDictionary<K,T> SortedList IList<T> IList
    Stack<T> Stack

    Проекты

  • Постройте DLL с классом Sorting, содержащим различные методы сортировки. Методы должны быть универсальными и позволять сортировать объекты разных типов - персоны, машины, числа, строки. Методы должны быть функциями высших порядков, допускающими сортировать данные типа T по разным критериям, например, сортировать машины по маркам, по номерам, по фамилиям владельцев. В Windows-проекте постройте классы Person, Car и другие классы, демонстрирующие работу с классом Sorting. В клиентском классе предусмотрите построение набора функций, позволяющих сравнивать персон по разным критериям. Предусмотрите возможность задания таких функций лямбда-выражениями. В интерфейсе проекта предусмотрите возможность сравнения методов сортировки по времени. Постройте метод, вычисляющий время сортировки, как функцию высшего порядка, которой передается в качестве аргумента метод сортировки.
  • Постройте DLL, включающую собственные аналоги библиотечных классов Stack<T>, Queue<T>, List<T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую собственные аналоги библиотечных классов List<T>, LinkedList<T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую собственные аналоги библиотечных классов Dictionary<K, T>, SortedDictionary<K, T>. Постройте Windows-проект для работы с этими классами.
  • Постройте DLL, включающую класс BinaryTreeSearch<K, T> - бинарное дерево поиска. Элементы, хранимые в дереве, обладают ключом типа K и информационным полем типа T. Ключи элементов уникальны. Для дерева поиска справедливо свойство: Ключ элемента, хранимого в корне дерева, больше ключей всех элементов, хранимых в левом поддереве, и меньше ключей всех элементов, хранимых в правом поддереве. Постройте Windows-проект для работы с этим классом.
  • (**) Постройте DLL с классами, реализующими сбалансированные бинарные деревья поиска - идеально сбалансированные деревья, АВЛ - деревья, красно-черные деревья. Постройте Windows-проект для работы с этим классом. Проект должен позволять оценить время работы на одних и тех же данных при использовании разных типов деревьев.
  • Вернуться к учебному плану