СДЕЛАЙТЕ СВОИ УРОКИ ЕЩЁ ЭФФЕКТИВНЕЕ, А ЖИЗНЬ СВОБОДНЕЕ

Благодаря готовым учебным материалам для работы в классе и дистанционно

Скидки до 50 % на комплекты
только до

Готовые ключевые этапы урока всегда будут у вас под рукой

Организационный момент

Проверка знаний

Объяснение материала

Закрепление изученного

Итоги урока

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

Категория: Информатика

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

Просмотр содержимого документа
«Универсальность. Классы с родовыми параметрами»

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

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


public void Swap(ref T x1, ref T x2)

{

T temp;

temp = x1; x1 = x2; x2 = temp;

}


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


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


До недавнего времени Framework .Net и соответственно язык C# не поддерживали универсальность. Так что те, кто работает с языком C#, входящим в состав Visual Studio 2003 и ранних версий, должны смириться с отсутствием универсальных классов. Но в новой версии Visual Studio 2005, носящей кодовое имя Whidbey, проблема решена, и программисты получили наконец долгожданный механизм универсальности. Я использую в примерах этой лекции бета-версию Whidbey.


Замечу, что хотя меня прежде всего интересовала реализация универсальности, но и общее впечатление от Whidbey самое благоприятное.



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


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


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


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


class MyClass Tn {...}


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

формальные имена типов будут заменены фактическими параметрами, представляющими уже конкретные типы - имена встроенных классов, классов библиотеки FCL, классов, определенных пользователем.


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


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


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


class Change

{

static public void Swap(ref T x1, ref T x2)

{

T temp;

temp = x1; x1 = x2; x2 = temp;

}

}


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

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


Рассмотрим тестирующую процедуру из традиционного для наших примеров класса Testing, в которой интенсивно используется вызов метода swap для различных типов переменных:


public void TestSwap()

{

int x1 = 5, x2 = 7;

Console.WriteLine("до обмена: x1={0}, x2={1}",x1, x2); Change.Swap(ref x1, ref x2);

Console.WriteLine("после обмена: x1={0}, x2={1}", x1, x2); string s1 = "Савл", s2 = "Павел";

Console.WriteLine("до обмена: s1={0}, s2={1}", s1, s2); Change.Swap(ref s1, ref s2); Console.WriteLine("после обмена: s1={0}, s2={1}", s1, s2); Person pers1 = new Person("Савлов", 25, 1500);

Person pers2 = new Person("Павлов", 35, 2100); Console.WriteLine("до обмена: "); pers1.PrintPerson(); pers2.PrintPerson(); Change.Swap(ref pers1, ref pers2); Console.WriteLine("после обмена:"); pers1.PrintPerson(); pers2.PrintPerson();

}


Обратите внимание на строки, осуществляющие вызов метода:


Change.Swap(ref x1, ref x2); Change.Swap(ref s1, ref s2); Change.Swap(ref pers1, ref pers2);

В момент вызова метода передаются фактические аргументы и фактические типы. В данном примере в качестве фактических типов использовались встроенные типы int и string и тип Person, определенный пользователем. Общая ситуация такова: если в классе объявлен универсальный метод со списком параметров M ...Tn (...), то метод вызывается следующим образом: M(...), где TYPEi - это конкретные типы.


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


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


В этом примере использовался класс Person, и поскольку он появится и в следующих примерах, то приведу его текст:


class Person

{

public Person(string name, int age, double salary)

{

this.name = name; this.age = age; this.salary = salary;

}

public string name; public int age; public double salary;

public void PrintPerson()

{

Console.WriteLine("name= {0}, age = {1}, salary ={2}", name, age, salary);

}

}


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


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

Универсализация позволяет специализировать данные, уточнить, над какими данными выполняются операции.

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


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

22.2 показан схематически процесс разработки программной системы.


Рис. 22.2.1. 1: Этап проектирования: абстрактный класс с абстрактными типами


Рис. 22.2.2. 2: Наследование: уточняется представление данных; задается или уточняется реализация методов родителя


Рис. 22.2.3. 3: Родовое порождение: уточняются типы данных; порождается класс путем подстановки конкретных типов


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


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

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


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

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


///

/// Абстрактный класс GenStack задает контейнер с

/// доступом LIFO:

/// Функции:

/// конструктор new: - GenStack

/// запросы:

/// 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

///

abstract public class GenStack

{

///

/// require: not empty();

///

/// элемент вершины(последний пришедший) abstract public T item();

///

/// require: not empty();

/// ensure: удален элемент вершины(последний пришедший)

///

abstract public void remove();

///

/// require: true; ensure: elem находится в вершине стека

///

/// abstract public void put(T t);

///

/// require: true;

///

/// true если стек пуст, иначе false abstract public bool empty();

}// class GenStack


В приведенном примере программного текста чуть-чуть. Это объявление абстрактного

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


abstract public class GenStack


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


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

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


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


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


///

/// Стек, построенный на односвязных элементах списка GenLinkable

///

public class OneLinkStack : GenStack

{

public OneLinkStack()

{

last = null;

}

GenLinkable last; //ссылка на стек (вершину стека) public override T item()

{

return (last.Item);

}//item

public override bool empty()

{

return (last == null);

}//empty

public override void put(T elem)

{

GenLinkable newitem = new GenLinkable(); newitem.Item = elem; newitem.Next = last;

last = newitem;

}//put

public override void remove()

{

last = last.Next;

}//remove

}//class OneLinkStack


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


public class OneLinkStack : GenStack


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


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


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


public override void put(T elem)


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


public class GenLinkable

{

public T Item;

public GenLinkable Next; public GenLinkable()

{ Item = default(T); Next = null; }

}


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


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


public class ArrayUpStack : GenStack

{

int SizeOfStack;

T[] stack;

int top;

///

/// конструктор

///

/// размер стека public ArrayUpStack(int size)

{ SizeOfStack = size; stack = new T[SizeOfStack]; top = 0; }

///

/// require: (top

///

/// name="x" элемент, помещаемый в стек 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 stack1 = new OneLinkStack(); OneLinkStack stack2 = new OneLinkStack(); ArrayUpStack stack3 = new ArrayUpStack

(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, но каждый из них работает с данными своего типа и по-разному реализует методы родителя. На рис. 22.3 показаны результаты работы этой процедуры.


Рис. 22.3. Три разных стека, порожденных абстрактным универсальным классом


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


public void TestPerson()

{

OneLinkStack stack1 = new OneLinkStack(); OneLinkStack stack2 = new OneLinkStack(); ArrayUpStack stack3 = new ArrayUpStack

(10);

ArrayUpStack stack4 = new ArrayUpStack(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!");

}


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


Рис. 22.4. Работа со стеками

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

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


Если немного подумать, то это совершенно естественная ситуация. Когда имеет место неограниченная универсальность, над объектами типов можно выполнять только те операции, которые допускают все типы, - в 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

{ }

public class Base

{

public void M1() { } public void M2() { }

}

public class Child :Father where T1:Base,IEnumerable, new() where T2:struct,IComparable

{ }


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


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


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


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


class Node T where K:IComparable

{

public Node()

{

next = null; key = default(K); item = default( T);

}

public K key; public T item;

public Node next;

}

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


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


public class OneLinkList T where K : IComparable

{

Node T first, cursor;

}


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


public void start()

{ cursor = first; } public void finish()

{

while (cursor.next != null) cursor = cursor.next;

}

public void forth()

{ if (cursor.next != null) cursor = cursor.next; }


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


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


public void add(K key, T item)

{

Node newnode = new Node(); 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 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 string list1 = new OneLinkList

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 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();

}

Рис. 22.5. Поиск в списке с ограниченной универсальностью Обратите внимание на строки, где создаются два списка:

OneLinkList list1 = new OneLinkList(); OneLinkList Person list2 = new OneLinkList string, Person();


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


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


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


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


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


public abstract class Calc

{

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

{

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

{

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

{

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 T : OneLinkList T where K : IComparable


{

Calc calc;

T sum;

public SumList(Calc calc)

{ this.calc = calc; sum = default(T); } public new void add(K key, T item)

{

Node T newnode = new Node 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 int list1 =

new SumList(new IntCalc()); list1.add("Петр", 33); list1.add("Павел", 44);

Console.WriteLine("sum= {0}", list1.Sum()); SumList double list2 =

new SumList (new DoubleCalc()); list2.add("Петр", 33.33); list2.add("Павел", 44.44);

Console.WriteLine("sum= {0}", list2.Sum()); SumList string list3 =

new SumList (new StringCalc()); list3.add("Мама", " Мама мыла "); list3.add("Маша",

"Машу мылом!");

Console.WriteLine("sum= {0}", list3.Sum());

}


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


SumList int list1 =

new SumList(new IntCalc()); SumList double list2 =

new SumList(new DoubleCalc()); SumList string list3 =

new SumList string(new StringCalc());


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


Рис. 22.6. Списки с суммированием


Родовое порождение класса. Предложение using


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

программном коде, а в предложении using, назначение которого и состоит в выполнении подобных подстановок.


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


using IntStack = Generic.OneLinkStack;


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


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 x, y;//координаты точки, тип которых задан параметром

// другие свойства и методы структуры

}


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


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

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

использовали не универсальный интерфейс, а обычный, то потеряли бы в эффективности, поскольку сравнение объектов потребовало бы создание временных объектов типа object, выполнения операций boxing и unboxing.


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


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


class Delegate

{

public delegate T Del(T a, T b);

}


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


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


public T FunAr(T[] arr, T a0, Del f)

{

T temp = a0;

for(int i =0; 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 public string sum2(string a, string b)

{ return a + b; }

public float prod2(float a, float b)

{ return a * b; }


Хотя все функции имеют разные типы, все они соответствуют определению класса Del - имеют два аргумента одного типа и возвращают результат того же типа. Посмотрим, как они применяются в тестирующем методе класса 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 };

Delegate d1 = new Delegate(); Delegate.Del del1;

del1= this.max2;

int max = d1.FunAr(ar1, ar1[0], del1);

Console.WriteLine("max= {0}", max); Delegate d2 = new Delegate(); Delegate.Del del2;

del2 = this.min2;

double min = d2.FunAr(ar2, ar2[0], del2); Console.WriteLine("min= {0}", min); Delegate d3 = new Delegate(); Delegate.Del del3;

del3 = this.sum2;

string sum = d3.FunAr(ar3, "", del3); Console.WriteLine("concat= {0}", sum); Delegate d4 = new Delegate(); Delegate.Del del4;

del4 = this.prod2;

float prod = d4.FunAr(ar4, 1f, del4); Console.WriteLine("prod= {0}", prod);

}


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


Delegate.Del del1;


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


del1= this.max2;


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


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


public delegate T FunTwoArg(T a, T b);


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


FunTwoArg mydel;

mydel = max2;

max = mydel(17, 21); Console.WriteLine("max= {0}", max);


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


Рис. 22.7. Результаты работы с универсальными делегатами


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


public void delegate EventHandler (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[] array, T value);


В таблице 22.1 показаны некоторые универсальные классы и интерфейсы библиотеки FCL

    1. из пространства имен System.Collections.Generic и их аналоги из пространства

System.Collections.


Таблица 22.1. Соответствие между универсальными классами и их обычными двойниками


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

Обычный класс

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

Обычный интерфейс

Comparer

Comparer

ICollection

ICollection

Dictionary

HashTable

IComparable

IComparable

LinkedList

----

IDictionary

IDictionary

List

ArrayList

IEnumerable

IEnumerable

Queue

Queue

IEnumerator

IEnumerator

SortedDictionary

SortedList

IList

IList

Stack

Stack




Сериализация и универсализация также согласуются друг с другом, так что можно иметь

универсальный класс, для которого задан атрибут сериализации.