
ооп теория
.pdf
проектирования системы и заканчивая этапами реализации и сопровождения.
На этапе задания спецификаций появляются абстрактные, универсальные классы, которые в ходе разработки становятся вполне конкретными классами с конкретными типами данных. Механизмы наследования и универсализации позволяют существенно сократить объем кода, описывающего программную систему, поскольку потомки не повторяют наследуемый код своих родителей, а единый код универсального класса используется при каждой конкретизации типов данных. На рис. 22.2 показан схематически процесс разработки программной системы.
Рис. 22.2.1. 1: Этап проектирования: абстрактный класс с абстрактными типами
Рис. 22.2.2. 2: Наследование: уточняется представление данных; задается или уточняется реализация методов родителя
Рис. 22.2.3. 3: Родовое порождение: уточняются типы данных; порождается класс путем подстановки конкретных типов
441
На этапе спецификации, как правило, создается абстрактный, универсальный класс, где задана только сигнатура методов, но не их реализация; где определены имена типов, но не их конкретизация. Здесь же, используя возможности тегов класса, формально или неформально задаются спецификации, описывающие семантику методов класса. Далее в ходе разработки, благодаря механизму наследования, появляются потомки абстрактного класса, каждый из которых задает реализацию методов. На следующем этапе, благодаря механизму универсализации, появляются экземпляры универсального класса, каждый из которых выполняет операции класса над данными соответствующих типов.
Для наполнения этой схемы реальным содержанием давайте рассмотрим некоторый пример с прохождением всех трех этапов.
Стек. От абстрактного, универсального класса к конкретным версиям
Возьмем классическую задачу определения стека. Следуя схеме, определим
абстрактный универсальный класс, описывающий всевозможные
представления стеков:
///<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>
442
///<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 t);
///<summary>
///require: true;
///</summary>
///<returns>true если стек пуст, иначе false </returns> abstract public bool empty();
}// class GenStack
В приведенном примере программного текста чуть-чуть. Это объявление абстрактного универсального класса:
abstract public class GenStack<T>
и четыре строки с объявлением сигнатуры его методов. Основной текст задает описание спецификации класса и его методов. Заметьте, здесь спецификации заданы достаточно формально с использованием аксиом,
характеризующих смысл операций, которые выполняются над стеком.
Не хочется вдаваться в математические подробности, отмечу лишь, что, если задать последовательность операций над стеком, то аксиомы позволяют точно определить состояние стека в результате выполнения этих операций.
Как неоднократно отмечалось с первых лекций курса, XML-отчет,
построенный по этому проекту, будет содержать в читаемой форме все спецификации нашего класса. Отмечу еще, что все потомки класса должны удовлетворять этим спецификациям, хотя могут добавлять и собственные ограничения.
443
Наш класс является универсальным - стек может хранить элементы любого типа, и конкретизация типа будет производиться в момент создания экземпляра стека.
Наш класс является абстрактным - не задана ни реализация методов, ни то,
как стек будет представлен. Эти вопросы будут решать потомки класса.
Перейдем теперь ко второму этапу и построим потомков класса, каждый из которых задает некоторое представление стека и соответствующую этому представлению реализацию методов. Из всех возможных представлений ограничимся двумя. В первом из них стек будет представлен линейной односвязной списковой структурой. Во втором - он строится на массиве фиксированного размера, задавая стек ограниченной емкости. Вот как выглядит первый потомок абстрактного класса:
///<summary>
///Стек, построенный на односвязных элементах списка GenLinkable<T>
///</summary>
public class OneLinkStack<T> : GenStack<T>
{
public OneLinkStack()
{
last = null;
}
GenLinkable<T> last; //ссылка на стек (вершину стека) public override T item()
{
return (last.Item);
}//item
public override bool empty()
{
return (last == null); }//empty
public override void put(T elem)
{
GenLinkable<T> newitem = new GenLinkable<T>(); newitem.Item = elem; newitem.Next = last;
last = newitem;
}//put
public override void remove()
{
last = last.Next; }//remove
}//class OneLinkStack
444
Посмотрите, что происходит при наследовании от универсального класса. Во-первых, сам потомок также является универсальным классом:
public class OneLinkStack<T> : GenStack<T>
Во-вторых, если потомок является клиентом некоторого класса, то и этот класс, возможно, также должен быть универсальным, как в нашем случае происходит с классом
GenLinkable<T>:
GenLinkable<T> last; //ссылка на стек (элемент стека)
В-третьих, тип T встречается в тексте потомка всюду, где речь идет о типе элементов, добавляемых в стек, как, например:
public override void put(T elem)
По ходу дела нам понадобился класс, задающий представление элементов стека в списковом представлении. Объявим его:
public class GenLinkable<T>
{
public |
T |
Item; |
public |
GenLinkable<T> Next; |
|
public |
GenLinkable() |
|
{ Item |
= |
default(T); Next = null; } |
}
Класс устроен достаточно просто, у него два поля: одно для хранения элементов, помещаемых в стек и имеющее тип T, другое - указатель на следующий элемент. Обратите внимание на конструктор класса, в котором для инициализации элемента используется новая конструкция default(T),
которая возвращает значение, устанавливаемое по умолчанию для типа T.
Второй потомок абстрактного класса реализует стек по-другому, используя представление в виде массива. Потомок задает стек ограниченной емкости.
Емкостью стека можно управлять в момент его создания. В ряде ситуаций использование такого стека предпочтительнее по соображениям эффективности, поскольку не требует динамического создания элементов.
Приведу текст этого класса уже без дополнительных комментариев:
public class ArrayUpStack<T> : GenStack<T>
{
int SizeOfStack; T[] stack;
445
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);
}
446

В трех первых строках этой процедуры порождаются три экземпляра стеков. Все они имеют общего родителя - абстрактный универсальный класс GenStack, но каждый из них работает с данными своего типа и по-разному реализует методы родителя. На рис. 22.3 показаны результаты работы этой процедуры.
Рис. 22.3. Три разных стека, порожденных абстрактным универсальным классом
Дополним наше рассмотрение еще одним примером работы с вариацией стеков, в том числе хранящим объекты класса 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!");
}
Результаты работы этой процедуры приведены на рис. 22.4.
Рис. 22.4. Работа со стеками
447
Ограниченная универсальность
Хорошо, когда есть свобода. Еще лучше, когда свобода ограничена.
Аналогичная ситуация имеет место и с универсальностью. Универсальность следует ограничивать. На типы универсального класса, являющиеся его параметрами, следует накладывать ограничения. Звучит парадоксально, но,
наложив ограничения на типы, программист получает гораздо большую свободу в работе с объектами этих типов.
Если немного подумать, то это совершенно естественная ситуация. Когда имеет место неограниченная универсальность, над объектами типов можно выполнять только те операции, которые допускают все типы, - в C# это эквивалентно операциям, разрешенным над объектами типа object,
прародителя всех типов. В нашем предыдущем примере, где речь шла о свопинге, над объектами выполнялась единственная операция присваивания.
Поскольку присваивание внутри одного типа разрешено для всех типов, то неограниченная универсальность приемлема в такой ситуации. Но что произойдет, если попытаться выполнить сложение элементов, сравнение их или даже простую проверку элементов на равенство? Немедленно возникнет ошибка еще на этапе компиляции. Эти операции не разрешены для всех типов, поэтому в случае компиляции такого проекта ошибка могла бы возникнуть на этапе выполнения, когда вместо формального типа появился бы тип конкретный, не допускающий подобную операцию. Нельзя ради универсальности пожертвовать одним из важнейших механизмов C# и
Framework .Net - безопасностью типов, поддерживаемой статическим контролем типов. Именно поэтому неограниченная универсальность существенно ограничена. Ее ограничивает статический контроль типов.
Бывают, разумеется, ситуации, когда необходимо на типы наложить ограничения, позволяющие ослабить границы статического контроля. На
448

практике универсальность почти всегда ограничивается, что, повторяю, дает большую свободу программисту.
В языке C# допускаются три вида ограничений, накладываемых на родовые параметры.
Ограничение наследования. Это основный вид ограничений,
указывающий, что тип T является наследником некоторого класса и ряда интерфейсов. Следовательно, над объектами типа T можно выполнять все операции, заданные базовым классом и интерфейсами.
Эти операции статический контроль типов будет разрешать и обеспечивать для них интеллектуальную поддержку, показывая список разрешенных операций. Ограничение наследования позволяет выполнять над объектами больше операций, чем в случае неограниченной универсальности. Синтаксически ограничение выглядит так: where T: BaseClass, I1, ...Ik.
Ограничение конструктора. Это ограничение указывает, что тип T
имеет конструктор без аргументов и, следовательно, позволяет создавать объекты типа T. Синтаксически ограничение выглядит так: where T: new().
Ограничение value/reference. Это ограничение указывает, к значимым или к ссылочным типам относится тип T. Для указания значимого типа задается слово struct, для ссылочных - class. Так что синтаксически этот тип ограничений выглядит так: where T: struct.
Возникает законный вопрос: насколько полна предлагаемая система ограничений? Конечно, речь идет о практической полноте, а не о математически строгих определениях. С позиций практики систему хотелось бы дополнить, в первую очередь, введением ограничений операций,
указывающим допустимые знаки операций в выражениях над объектами
449
соответствующего типа. Хотелось бы, например, указать, что к объектам типа 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; так же, как и методы
450