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

Отношения между классами. Клиенты и наследники

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

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

Отношения между классами

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

Классы программной системы находятся в определенных отношениях друг с другом. Два основных типа отношений между классами определены в ОО-системах. Первое отношение, "клиенты и поставщики", называется часто клиентским отношением или отношением вложенности (встраивания). Второе отношение, "родители и наследники", называется отношением наследования.

Определение 1. Классы A и В находятся в отношении "клиент - поставщик", если одним из полей класса В является объект класса А. Класс А называется поставщиком класса В, класс В называется клиентом класса А.

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

Определение 2. Классы А и В находятся в отношении "родитель - наследник", если при объявлении класса В класс А указан в качестве родительского класса. Класс А называется клиентом класса В, класс В называется наследником класса А.

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

(рис 4.1) Графическое отображение отношений между классами

Оба отношения, наследования и вложенности, являются транзитивными. Если В - клиент А и С - клиент В, то отсюда следует, что С - клиент А. Если В - наследник А и С - наследник В, то отсюда следует, что С - наследник А.

Определения 1 и 2 задают прямых или непосредственных клиентов и поставщиков, прямых родителей и наследников. Вследствие транзитивности необходимо ввести понятие уровня. Прямые клиенты и поставщики, прямые родители и наследники относятся к соответствующему уровню 1 (клиенты уровня 1, поставщики уровня 1 и так далее). Затем следует рекурсивное определение: прямой клиент клиента уровня k относится к уровню k+1.

Для отношения наследования используется терминология, заимствованная из естественного языка. Прямые классы-наследники часто называются сыновними или дочерними классами. Непрямые родители называются предками, а их непрямые наследники - потомками.

Замечу, что цепочки вложенности и наследования могут быть достаточно длинными. На практике вполне могут встречаться цепочки длины 10. Например, библиотечные классы, составляющие систему Microsoft Office, полностью построены на отношении вложенности. При программной работе с объектами Word можно начать с объекта, задающего приложение Word, и добраться до объекта, задающего отдельный символ в некотором слове некоторого предложения одного из открытых документов Word. Для выбора нужного объекта можно задать такую цепочку: приложение Word - коллекция документов - документ - область документа - коллекция абзацев - абзац - коллекция предложений - предложение - коллекция слов - слово - коллекция символов - символ. В этой цепочке каждому понятию соответствует класс библиотеки Microsoft Office, где каждая пара соседствующих классов связана отношением вложенности.

Классы библиотеки FCL связаны как отношением вложенности, так и отношением наследования. Длинные цепочки наследования достаточно характерны для классов этой библиотеки.

Отношения "является" и "имеет"

При проектировании классов часто возникает вопрос, какое же отношение между классами нужно построить. Рассмотрим совсем простой пример двух классов - Square и Rectangle, описывающих квадраты и прямоугольники. Наверное, понятно, что эти классы следует связать скорее отношением наследования, чем вложенности; менее понятным остается вопрос, а какой из этих двух классов следует сделать родительским. Еще один пример двух классов - Car и Person, описывающих автомобиль и персону. Какими отношениями с этими классами должен быть связан класс Person_of_Car, описывающий владельца машины? Может ли он быть наследником обоих классов? Найти правильные ответы на эти вопросы проектирования классов помогает понимание того, что отношение "клиент - поставщик" задает отношение "имеет" ("has"), а отношение наследования задает отношение "является" ("is a"). В случае классов Square и Rectangle понятно, что каждый объект квадрат "является" прямоугольником, поэтому между этими классами существует отношение наследования и родительским классом является класс Rectangle, а класс Square является его потомком.

В случае автомобилей, персон и владельцев авто также понятно, что владелец "имеет" автомобиль и "является" персоной. Поэтому класс Person_of_Car является клиентом класса Car и наследником класса Person.

Диаграмма классов

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

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

В момент создания нового Windows-проекта автоматически строятся классы, отражающие специфику проектов такого типа. Так автоматически создается класс Program с точкой входа в проект - процедурой Main. Автоматически создается и класс Form1, являющийся прямым наследником класса Form из библиотеки FCL.

С первых шагов проектирования классам следует давать содержательные имена, в том числе выполняя переименование и классов, создаваемых автоматически. Переименуем в нашем примере класс Form1, дав ему имя MyForm.

В процедуре Main автоматически создается объект класса MyForm, так что класс Program становится клиентом класса MyForm. При проектировании интерфейса формы и размещения в ней элементов управления соответствующий инструментарий - дизайнер форм - добавляет программный код в класс MyForm. Для каждого элемента управления в класс добавляется поле, представляющее объект класса, определяющего этот элемент управления. Так, класс MyForm становится клиентом многих классов, характеризующих элементы управления, расположенные на форме. Для простоты примера на форме размещена только одна командная кнопка "Пуск", так что в нашем примере класс MyForm является клиентом класса Button из библиотеки FCL.

Добавим теперь в проект собственные классы. Создадим класс Testing и сделаем объект этого класса полем класса MyForm. Класс Testing станет поставщиком для класса MyForm, а последний будет клиентом класса Testing. Объект класса Testing будет создаваться в обработчике события Click командной кнопки "Пуск". Добавим в класс Testing три поля - три объекта классов Student, OwnerOfCar и Square, что делает класс Testing клиентом этих трех классов, которые также добавим в наш проект. Классы Student и OwnerOfCar сделаем прямыми наследниками класса Person, а класс OwnerOfCar - клиентом класса Car. Свяжем отношением наследования три класса, задающие геометрические фигуры - Square, Rectangle и Figure. Квадрат "является" прямоугольником, а прямоугольник "является" геометрической фигурой. Класс Figure имеет поле center класса Point, что делает его клиентом класса Point.

Хотя текстовое описание классов проекта я старался сделать информативным, но графическое представление более наглядно. В Visual Studio 2008 есть инструментарий, позволяющий строить диаграмму классов проекта. Для построения диаграммы в окне Solution Explorer достаточно выбрать имя проекта, нажать правую кнопку мыши и из выпадающего контекстного меню выбрать пункт View Class Diagram. Диаграмма классов проекта, названного Architecture, показана на рис. 4.2.

(рис 4.2) Диаграмма классов проекта Architecture

На диаграмме отображаются все классы проекта. Помимо описанных классов, на диаграмме показаны два класса, автоматически строящиеся для проектов - Resources и Settings, задающие ресурсы и установки проекта.

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

К сожалению, на диаграмме не отображаются отношения встраивания. Причина этому понятна. Достаточно просто понять из описания класса, кто является прямым родителем класса, поскольку он явно выписан в заголовке класса. Определить прямых поставщиков класса значительно сложнее. Определение 1 хотя и справедливо, но оно не описывает все ситуации, когда класс является поставщиком для другого класса. Как будет определено чуть ниже, помимо того, что поле класса является объектом другого класса (поставщика), возможны и другие ситуации, приводящие к возникновению отношения "клиент-поставщик". По этой причине полезно строить диаграмму классов и другими средствами, отображая на ней как отношения наследования, так и встраивания.

На рис. 4.3 показана такая диаграмма.

(рис 4.3) Отношения встраивания и наследования для классов проекта Architecture

Среди классов, изображенных на рис. 4.3 , показан и класс object - прародитель всех классов. Фактически от всех классов системы ведет путь наследования к классу object, так что при рисовании полной картины наследования (отображая не только прямых родителей) из каждого класса выходит стрелка наследования, непосредственно или транзитивно ведущая к классу object. Класс object - это единственный класс, в который ведут несколько стрелок, но ни одна из стрелок не выходит. Этот класс не имеет ни родителей, ни клиентов. Особую роль играет и класс Program. Из него могут выходить несколько стрелок, но ни одна стрелка не входит. У этого класса нет потомков, нет клиентов. Никто не может создать объекты этого класса. Единственный объект этого класса создается автоматически в момент запуска программной системы на исполнение, и создается он вне программной системы высшими силами.

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

Отношение вложенности

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

/// <summary>
    /// Класс поставщик,предоставляет клиентам
    /// статический и зкземплярный методы,
    /// закрывая поля класса
    /// </summary>
    class Provider
    {
        //fields
        string fieldP1;
        int fieldP2;
        static int fieldPS;
        //Конструкторы класса
        /// <summary>
        /// Конструктор с аргументами
        /// </summary>
        /// <param name="p1">аргумент,инициализирующий поле класса</param>
        /// <param name="p2">аргумент,инициализирующий поле класса</param>
        public Provider(string p1, int p2)
        {
            fieldP1 = p1.ToUpper(); fieldP2 = p2*2;
            fieldPS = 0;
        }
        public Provider()
        {
            fieldP1 = ""; fieldP2 = 0; fieldPS = 0;
        }
        //Динамический (Экземплярный) метод
        public string MethodPD()
        {
            fieldPS++;
            string res = "Объект класса Provider" + "\n\r";
            res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                fieldP1, fieldP2);
            return res;
        }
        // Статический (Модульный) метод
        public static string MethodPS()
        {
            string res = "Модуль класса Provider" + "\n\r";
            res += string.Format("Число вызовов метода MethodPD = {0}",
                fieldPS.ToString());
            return res;
        }

Поля класса, как и положено, закрыты для клиентов. У класса, как и положено, есть конструктор без аргументов, инициализирующий поля класса соответствующими константами, и конструктор с аргументами, который преобразует переданные ему значения, прежде чем записать их в поля класса. Методы класса позволяют получить информацию, хранящуюся в полях. Динамический (экземплярный) метод MethodPD, которому доступны поля класса, хранимые экземплярами класса, возвращает строку с информацией о хранимых значениях в полях. Одновременно этот метод увеличивает значение, хранимое в статическом поле, которое можно рассматривать как счетчик общего числа вызовов динамического метода всеми объектами данного класса. Статический метод MethodPS, которому доступно только статическое поле, возвращает в качестве результата строку с информацией о числе вызовов динамического метода.

Построим теперь класс Client - клиента класса Provider. Класс будет устроен похожим образом. Существенное дополнение состоит в том, что одним из полей является объект provider класса Provider:

/// <summary>
    /// Клиент класса Provider
    /// </summary>
    class Client
    {
        //fields
        Provider provider;
        string fieldC1;
        int fieldC2;
        
        const string NEWLINE = "\n\r";
        //Конструкторы класса
        public Client(string p1, int p2, string c1, int c2)
        {
            fieldC1 = c1.ToLower(); fieldC2 = c2-2;
            provider = new Provider(p1,p2);
        }
        public Client()
        {
            fieldC1 = ""; fieldC2 = 0; 
            provider = new Provider();
        }
        /// <summary>
        /// Метод, использующий поле класса provider
        /// для работы с методами класса Provider
        /// </summary>
        /// <returns>композиция строк провайдера и клиента </returns>
        public string MethodClient1()
        {
            string res = provider.MethodPD() + NEWLINE;             
            res += "Объект класса Client" + NEWLINE;
            res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                fieldC1, fieldC2);
            return res;
        }
}

Обратите внимание: конструкторы клиента (класса Client ) создают объект поставщика (класса Provider ), вызывая конструктор поставщика. Для создания объектов поставщика могут требоваться аргументы, поэтому они передаются конструктору клиента, как это сделано в нашем примере.

Создавая объект класса Client, конструкторы этого класса создают и объект класса Provider, связывая его ссылкой с полем provider. Все динамические методы клиентского класса могут использовать этот объект, вызывая доступные клиенту методы и поля класса поставщика. Метод класса Client - MethodClient1 начинает свою работу с вызова: provider.MethodPD(), вызывая сервис, поставляемый методом класса Provider.

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

Расширение определения клиента класса

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

Первую возможную ситуацию демонстрирует процедура Main Windows-проекта. В ней "на лету" в момент вызова метода Run создается объект класса, наследуемого от класса Form. Создаваемый объект является результатом вычисления выражения, заданного операцией new. В этом случае у класса клиента нет ни поля, ни локальной переменной, связанной с объектом класса поставщика. Но и в этом случае клиент создает объект поставщика, передавая его затем в качестве аргумента методу Run класса Application.

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

/// <summary>
        /// Метод, использующий локальный объект класса provider
        /// для работы с методами класса Provider
        /// </summary>
        /// <returns>композиция строк провайдера и клиента </returns>
        public string MethodClient3()
        {
            Provider local_provider =
                new Provider("Локальный объект Provider", 77);
            string res = local_provider.MethodPD() + NEWLINE;
            res += "Объект класса Client" + NEWLINE;
            res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                fieldC1, fieldC2);
            return res;
        }

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

/// <summary>
        /// Метод, использующий модуль Provider
        /// для работы со статическим методом класса Provider
        /// </summary>
        /// <returns>композиция строк провайдера и клиента </returns>
        public string MethodClient2()
        {
            string res = Provider.MethodPS() + NEWLINE;
            res += "Объект класса Client" + NEWLINE;
            res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                fieldC1, fieldC2);
            return res;
        }

Дадим теперь расширенное определение клиента.

Определение 3. Класс B называется клиентом класса A, если в классе B создаются объекты класса A или вызываются статические сервисы класса A.

Под сервисом класса понимается метод или поле класса, доступное клиентам, то есть объявленное с модификатором public или internal. Статический сервис - это сервис, объявленный с модификатором static.

Отношения между клиентами и поставщиками

Что могут делать клиенты и что могут делать поставщики? Класс поставщик создает сервисы, предоставляемые своим клиентам. Клиенты создают объекты поставщика. Вызывая доступные им сервисы, клиенты получают возможность выполнить работу, которую сами они выполнить не могут или не хотят выполнять, поскольку класс поставщик эту работу может сделать более квалифицированно. Так, например, арифметические классы - int и другие - не могут вычислять математические функции. При необходимости вычислить sin(x) они обращаются к соответствующему сервису, предоставляемому классом Math.

Клиенты не могут ни повлиять на поведение методов поставщика, ни изменить состав предоставляемых им сервисов, они не могут вызывать закрытые поставщиком поля и методы класса.

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

В заключение построим тест, проверяющий работу с объектами классов Provider и Client:

class Program
    {
        static void Main(string[] args)
        {
            Client client =
                new Client("object of class Provider", 33,
                    "object of class Client", 44);
            string res = client.MethodClient1();
            Console.WriteLine(res);
            res = client.MethodClient2();
            Console.WriteLine(res);
            res = client.MethodClient3();
            Console.WriteLine(res);
        }
    }

Согласно определению, класс Program является клиентом класса Client. В процедуре Main локально создается объект класса Client, в процессе работы конструктора которого создается и объект класса Provider. Вызываемые методы клиентского класса в процессе своей работы вызывают методы класса Provider. Результаты работы показаны на рис. 4.4.

(рис 4.4) Клиенты и поставщики

Сам себе клиент

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

Первая ситуация характерна для динамических структур данных. Элемент односвязного списка имеет поле, представляющее элемент односвязного списка; элемент двусвязного списка имеет два таких поля; узел двоичного дерева имеет два поля, представляющих узлы двоичного дерева. Эта ситуация характерна не только для рекурсивно определяемых структур данных. Вот еще один типичный пример. В классе Person могут быть заданы два поля – Father и Mother, задающие родителей персоны, и массив Children. Понятно, что все эти объекты могут быть того же класса Person.

Не менее часто встречается ситуация, когда классы имеют поля, взаимно ссылающиеся друг на друга. Типичным примером могут служить классы Man и Woman, первый из которых имеет поле wife класса Woman, а второй – поле husband класса Man.

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

Наследование

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

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

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

Построение родительского класса Found

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

/// <summary>
    /// Родительский класс
    /// </summary>
    class Found
    {
        //fields
        protected string name;
        protected int credit;
        static protected int count;
        const string NL = "\r\n";
        //Constructors
        public Found()
        {
            name = "Nemo";
            credit = 0;
            count++;
        }
        public Found(string name, int credit)
        {
            this.name = name;
            this.credit = credit;
            count++;
        }
	}

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

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

Хотя явно для класса Found родительский класс не задан, но родитель есть у каждого класса. Если прямой родитель не задан, то таковым является класс object. Класс Found наследовал от своего родителя – класса object ряд методов. Чаще всего методы, наследуемые от object, потомок должен переопределить, и в первую очередь это касается метода ToString. Как правило, переопределение этого метода сводится к тому, что возвращаемая методом строка содержит информацию о значениях, хранящихся в полях класса. Вот как выглядит переопределяемый метод ToString для класса Found:

public override string ToString()
        {
            string s = "Поля: name = {0}, credit = {1}";
            return String.Format(s, name, credit);
        }

Начнем добавлять в класс Found собственные методы:

public string NonVirtMethod()
        {
            return "Found: " + this.ToString();
        }

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

Добавим в класс еще один похожий метод:

public virtual string VirtMethod()
        {
            return "Found: " + this.ToString(); 
        }

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

Добавим еще один метод класса:

public string Parse()
        {
            return "Выполнен разбор кода!";
        }

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

Рассмотрим теперь чуть более сложный метод:

public string Job()
        {
            string res = "";
            res += "VirtMethod: " + 
                VirtMethod() + NL;
            res += "NonVirtMethod: " +
                NonVirtMethod() + NL;
            res += "Parse: " +
                Parse() + NL;
            return res;
        }

Метод Job поочередно вызывает методы класса – VirtMethod, NonVirtMethod, Parse. Строки, задающие результаты этих методов, соединяются, и полученная строка возвращается в качестве результата метода Job.

Последний метод, который добавим в класс Found, - это статический метод, возвращающий информацию из статического поля класса:

public static string NumberOfObjects()
        {
            return "Объектов создано: " + count;
        }

Не будем ограничиваться консольными проектами и для тестирования наследования создадим Windows-проект. Интерфейс проекта, который назван "WindowsParentsAndChildrenClasses", имеет традиционную для наших примеров архитектуру с главной кнопочной формой и формами, отвечающие за частные задачи. Покажем, как выглядит спроектированная форма для тестирования работы с объектами класса Found. Ее вид приведен на рис. 4.5.

(рис 4.5) Интерфейс для работы с объектами класса Found

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

Построение класса потомка Derived

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

public class Derived:Found
   {
   }

Тело класса Derived пусто, но это вовсе не значит, что объекты этого класса не имеют полей и методов: они наследуют все поля и методы (кроме конструктора) класса Found и поэтому могут делать все, что могут делать объекты родительского класса. Можно даже не создавать собственных конструкторов класса. В этом случае автоматически добавляется конструктор по умолчанию - конструктор без аргументов, который будет вызывать конструктор без аргументов родительского класса. Заметьте, такой конструктор у родителя должен быть, иначе возникнет ошибка. Но в нашем случае такой конструктор предусмотрительно создан.

(рис 4.6) Форма для работы с объектами класса Derived

На рис. 4.6 показана знакомая нам форма, расширенная для работы с объектами класса Derived. Как видите, несмотря на то, что тело класса пусто, можно создать объект и вызывать многочисленные его методы, наследованные от родителя. На рисунке показаны результаты работы метода Job для объектов классов Found и Derived, созданных по умолчанию. Результаты совпадают. Если потомок ничего не делает, то его объекты ведут себя так же, как и объекты родительского класса. Каждый объект потомка "является" объектом родительского класса.

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

Добавление и скрытие полей потомком

Что может делать потомок с полями? Прежде всего, он может добавить новые свойства - поля класса. Он может скрыть поле родителя, добавив собственное поле с тем же именем, что и поле родителя, возможно, изменив тип этого поля и его модификаторы. Скрытие родительского поля не означает, что потомок удаляет поле, наследованное от родителя. Это поле у потомка остается и доступно для вызова, но скрыто, и чтобы добраться до него, необходимо имя поля уточнить именем родителя, задаваемым ключевым словом base.

Итак, потомок не может ни удалить, ни изменить поля, наследованные от родителя, он может только добавить собственные поля и может скрыть поля родителя.

Модифицируем наш класс Derived. Добавим новое поле класса debit. Будем также полагать, что поля debit и credit должны иметь тип double, а не int. По этой причине скроем родительское поле credit, добавив собственное поле с тем же именем, но изменив его тип:

//добавление и скрытие полей
        protected double debit;
        new protected double credit;

Скрываемые поля следует снабжать модификатором new. Если этого не сделать, то родительское поле все равно будет скрыто, и на этапе компиляции будет выдано предупреждение о возникшей ситуации.

Для обоих полей задан модификатор доступа protected. Напомню, хорошей стратегией является стратегия "ничего не скрывать от потомков". Какой родитель знает, что именно из сделанного им может понадобиться потомкам?

Конструкторы родителей и потомков

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

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

Чтобы подчеркнуть синтаксически такую семантику работы конструктора, вызов конструктора родителя встроен не в тело конструктора, а в его заголовок. Для вызова конструктора используется ключевое слово base, именующее родительский класс. Как это делается, покажу на примере конструкторов класса Derived:

//Конструкторы
        public Derived(): base()
        {
            debit = 0; credit = 0;
        }
        public Derived(string name, double debit,
            double credit)
            : base(name, (int)credit)
        {
            this.debit = debit; 
            this.credit = credit;
        }

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

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

Добавление методов и изменение методов родителя

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

//Методы
        public string MyBaseCredit()
        {
            return base.credit.ToString();
        }

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

  • перегрузка метода. Она возникает, когда сигнатура создаваемого метода отличается от сигнатуры наследуемых методов предков. В этом случае в классе потомка будет несколько перегруженных методов с одним именем, и вызов нужного метода определяется обычными правилами перегрузки методов;
  • переопределение метода. Метод родителя в этом случае должен иметь модификатор virtual, abstract или override. Это наиболее интересная ситуация, и она будет подробно рассмотрена. При переопределении сохраняется сигнатура и модификаторы доступа наследуемого метода;
  • скрытие метода. Если родительский метод не является виртуальным или абстрактным, то потомок может создать новый метод с тем же именем и той же сигнатурой, скрыв родительский метод в данном контексте. Здесь ситуация такая же, как и со скрытием полей. При вызове метода по его имени предпочтение будет отдаваться методу потомка. Это не означает, что метод родителя становится недоступным. Скрытый родительский метод всегда может быть вызван, если при вызове уточнить имя метода родительским именем base.
  • Метод потомка, скрывающий метод родителя, следует сопровождать модификатором new, указывающим на новый метод. Если этот модификатор опущен, но из контекста ясно, что речь идет о новом методе, то выдается предупреждающее сообщение при компиляции проекта.

    Вернемся к нашему примеру. Класс Found имел в своем составе метод Parse. Его потомок класс Derived расширил возможности метода, добавив проверку в метод разбора. Поскольку родительский метод Parse не был ни виртуальным, ни абстрактным, то новый метод Parse, добавленный потомком, скрывает родительский метод:

    new public string Parse()
            {
                string res = base.Parse() + NL;
                res += "Выполнена проверка кода!";
                return res; 
            }

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

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

    Статический контроль типов и динамическое связывание

    Рассмотрим семейство классов A1, A2, ... An, связанных отношением наследования. Класс Ak+1 является прямым потомком класса Ak. Пусть создана последовательность объектов x1, x2, ... xn, где xk - объект класса Ak. Пусть в классе A1 создан метод M с модификатором virtual, переопределяемый всеми потомками, так что в рамках семейства классов метод M существует в n формах, каждая из которых задает реализацию метода, выбранную соответствующим потомком. Рассмотрим основную операцию, инициирующую объектные вычисления - вызов объектом метода класса:

    x1.M(arg1, arg2, … argN)

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

  • в классе A1 объекта x1 действительно имеется метод M ;
  • у метода М действительно N формальных аргументов;
  • список фактических аргументов в точке вызова соответствует по числу и типам списку формальных аргументов метода M, заданного в классе A1.
  • Язык C#, как и большинство других языков программирования, позволяет выполнить эту проверку еще на этапе компиляции и в случае нарушений выдать сообщение об ошибке. Контроль типов, выполняемый на этапе компиляции, называется статическим контролем типов. Языки программирования, называемые динамическими, например, язык Smalltalk, производят этот контроль динамически - в ходе работы программы непосредственно перед выполнением метода. Понятно, что ошибки, обнаруживаемые при динамическом контроле типов, трудно исправимы и потому приводят к более тяжелым последствиям. В таких случаях остается уповать на то, что система тщательно отлажена, иначе непонятно, что будет делать конечный пользователь, получивший сообщение о том, что вызываемого метода вообще нет в классе данного объекта. У динамических языков есть свои преимущества - прежде всего, простота.

    Перейдем к рассмотрению связывания. И снова рассмотрим основную операцию - вызов

    x1.M(arg1, arg2, … argN);

    Предположим, что статический контроль типов для этого вызова успешно выполнен. Рассмотрим еще один аспект, связанный с этим вызовом. Вспомним, что x1 - это ссылка, которая связана с объектом, расположенным в динамической памяти. Всегда ли совпадает тип объекта и тип ссылки? Другими словами, всегда ли объект, созданный в динамической памяти, принадлежит классу A1? Ответ: нет, не всегда.

    Предположим, что вызову метода M объектом x1 предшествует присваивание

    x1 = y;

    Это ссылочное присваивание, поскольку x1 - ссылка. Присваивание является допустимым, если объект y принадлежит классу, являющемуся потомком класса объекта x1. Таким образом, в точке вызова ссылка x1 может быть связана с объектом любого класса нашего семейства A1, …AN. В каждом из этих классов существует метод M с одной и той же сигнатурой. Метод M полиморфен: имея одно и то же имя и сигнатуру, он существует в разных формах - для каждого класса задана собственная реализация метода. Возникает естественный вопрос, какой же метод M следует вызывать - метод класса A1 (метод ссылки) или метод того класса, которому принадлежит реальный объект в динамической памяти (метод объекта). Возможны два решения этого вопроса, и оба варианта используются на практике.

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

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

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

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

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

    В языке C# принята следующая стратегия связывания. По умолчанию предполагается статическое связывание. Для того чтобы выполнялось динамическое связывание, родительский класс, впервые создающий метод, должен снабдить метод модификатором virtual или abstract, в классах потомках такой виртуальный метод будет иметь модификатор override.

    Три механизма, обеспечивающие полиморфизм

    Под полиморфизмом в ООП понимают способность одного и того же программного текста

    x1.M(arg1, arg2, … argN);

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

  • Одностороннее присваивание объектов внутри семейства классов. Сущность, базовым классом которой является класс предка, можно связать с объектом любого из потомков. Другими словами, для введенной нами последовательности объектов xk присваивание xi = xj допустимо для всех j >=i.
  • Переопределение потомком метода, наследованного от родителя. Благодаря переопределению в семействе классов существует совокупность полиморфных методов с одинаковым именем и одинаковой сигнатурой, но разной реализацией.
  • Динамическое связывание, позволяющее в момент выполнения вызывать метод, принадлежащий объекту, с которым связана сущность в момент вызова.
  • В совокупности это и называется полиморфизмом семейства классов. Целевую сущность часто называют полиморфной сущностью, вызываемый метод - полиморфным методом, сам вызов - полиморфным вызовом.

    Полиморфизм семейства классов Found и Derived

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

    public override string ToString()
            {
                string s = "Поля: name = {0}, Basecredit = {1}" + 
                    "credit = {2}, debit = {3}";
                return String.Format(s, name, base.credit, 
                    credit, debit);
            }
            public override string VirtMethod()
            {
                return "Derived: " + this.ToString(); 
            }

    В классе Found определены два не виртуальных метода NonVirtMethod и Job, наследуемые потомком Derived без всяких переопределений. Вы ошибаетесь, если думаете, что работа этих методов полностью определяется базовым классом Found. Полиморфизм делает их работу куда более интересной. Давайте рассмотрим в деталях работу метода Job:

    public string Job()
            {
                string res = "";
                res += "VirtMethod: " + 
                    VirtMethod() + NL;
                res += "NonVirtMethod: " +
                    NonVirtMethod() + NL;
                res += "Parse: " +
                    Parse() + NL;
                return res;
            }

    При компиляции метода Job будет обнаружено, что вызываемый метод VirtMethod является виртуальным, поэтому для него будет применяться динамическое связывание. Это означает, что вопрос о вызове метода откладывается до момента, когда метод Job будет вызван объектом, связанным с x. Объект может принадлежать как классу Found, так и классам Derived и ChildDerived, и в зависимости от класса объекта и будет вызван метод этого класса.

    Для вызываемых методов NonVirtMethod и Parse, не являющихся виртуальными, будет применено статическое связывание, так что метод Job всегда будет вызывать методы, принадлежащие классу Found. Однако и здесь не все просто. Метод NonVirtMethod

    public string NonVirtMethod()
            {
                return "Found: " + this.ToString();
            }

    в процессе своей работы вызывает виртуальный метод ToString. Опять-таки, для метода ToString будет применяться динамическое связывание, и в момент выполнения будет вызываться метод объекта, а не метод ссылки.

    Что же касается метода Parse, определенного в каждом классе, то всегда в процессе работы Job будет вызываться только родительский метод разбора из-за стратегии статического связывания.

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

    Класс Found, создающий метод Job, говорит примерно следующее: "Я предоставляю этот метод своим потомкам. Потомок, вызвавший этот метод, должен иметь VirtMethod, выполняющий специфическую для потомка часть работы; конечно, потомок может воспользоваться и моей реализацией, но допустима и его собственная реализация. Затем часть работы выполняю я сам, но выдача информации об объекте определяется самим объектом. Заключительную часть работы, связанную с анализом, я потомкам не доверяю и делаю ее сам".

    Пример работы с полиморфным семейством классов

    Класс Found и его потомок Derived имеют виртуальные методы и, следовательно, являются полиморфными классами. Они образуют полиморфное семейство классов с полиморфными методами.

    Теперь в статике уже нельзя предсказать, как будут выполняться методы этих классов. На рис. 4.7 показана работа с объектами этих классов.

    (рис 4.7) Полиморфизм семейства классов Found и Derived

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

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

    Абстрактные классы

    С наследованием тесно связан еще один важный механизм проектирования семейства классов - механизм абстрактных классов. Начну с определений.

    Определение. Класс называется абстрактным, если он имеет хотя бы один абстрактный метод.

    Определение. Метод называется абстрактным, если при определении метода задана его сигнатура, но не задана реализация метода.

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

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

    Абстрактный класс Stack

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

    /// <summary>
        /// Абстрактный стек целых чисел
        /// С операциями: Put, Remove, Item, IsEmpty
        /// </summary>
        public abstract class Stack
        {        
            /// <summary>
            /// Втолкнуть элемент item  в стек
            /// </summary>
            /// <param name="item">Целое число</param>
            public abstract void Put(int item);
    
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Удалить элемент в вершине стека
            /// </summary>
            public abstract void Remove();
    
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Прочитать элемент в вершине стека
            /// </summary>
            public abstract int Item();
    
            /// <summary>
            /// Определить, пуст ли стек
            /// </summary>
            /// <returns>true, если пуст, иначе false</returns>
            public abstract bool IsEmpty();
        }

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

    Класс ListStack - потомок абстрактного класса Stack

    Построим теперь класс, который будет являться потомком абстрактного класса Stack и задавать реализацию стека, основанную на списковом представлении. Дополнительно нам понадобится класс Linkable, задающий представление элемента списка.

    class Linkable
        {
            internal int info;
            internal Linkable next;
        }

    У класса лишь два поля и конструктор по умолчанию. Поля снабжены модификатором internal, чтобы они были доступны в дружественном классе ListStack.

    Класс ListStack будет потомком абстрактного класса Stack и клиентом класса Linkable. Теперь, когда принято решение о представлении данных, основанное на списковой структуре, несложно определить реализацию абстрактных методов класса Stack. Вот как выглядит реализация класса ListStack:

    public class ListStack : Stack
        {
            //поля
            Linkable top;
            //конструктор
            public ListStack()
            {
                top = new Linkable();
            }
            //Методы
    
            /// <summary>
            /// Втолкнуть элемент item  в стек
            /// </summary>
            /// <param name="item">Целое число</param>
            public override void Put(int item)
            {
                Linkable newitem = new Linkable();
                newitem.info = item;
                newitem.next = top;
                top = newitem;
            }
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Удалить элемент в вершине стека
            /// </summary>
            public override void Remove()
            {
                 top = top.next;
            }
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Прочитать элемент в вершине стека
            /// </summary>
            public override int Item()
            {
                return (top.info);
            }
            /// <summary>
            /// Определить, пуст ли стек
            /// </summary>
            /// <returns>true, если пуст, иначе false</returns>
            public override bool IsEmpty()
            {
                return (top.next == null);
            }
       }

    Класс имеет одно поле top класса Linkable и методы, наследованные от абстрактного класса Stack. Реализация операций традиционна для спискового представления данных и приводится без пояснений. Добавим в класс еще одну операцию, которая отсутствует у абстрактного класса и не принадлежит к операциям, традиционным для стека. Но для стека, основанного на списковом представлении, операция полезна, она преобразует стек в список.

    public ArrayList list = new ArrayList();
            public ArrayList StackToList()
            {
                list.Clear();
                Linkable temp = top;
                while (temp.next != null)
                {
                    list.Add(temp.info);
                    temp = temp.next;
                }
                return list;
            }

    Для тестирования работы построенного класса добавим в Windows-проект новую форму, реализующую пользовательский интерфейс. Интерфейсный класс, задающий форму, будет клиентом класса ListStack.

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

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

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

    public partial class FormStack : Form
        {
            ListStack stack;
            ArrayList list;
            const string NL = "\r\n";
            public FormStack()
            {
                InitializeComponent();
            }
    
            private void buttonCreate_Click(object sender, EventArgs e)
            {
                stack = new ListStack();
                list = new ArrayList();
                textBoxResult.Text = "Стек создан!";
                ShowStack();
            }
            void ShowStack()
            {
                list = stack.StackToList();
                listBoxStack.Items.Clear();
                foreach (int item in list)
                    listBoxStack.Items.Add(item);
            }
    
            private void buttonPut_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                int item = 0;
                try
                {
                    item = int.Parse(textBoxItem.Text);
                }
                catch (Exception)
                {
                    textBoxResult.Text =
                        "Некорректно задан элемент item!" +
                        NL + "Необходимо целое число!";
                    return;
                }
                stack.Put(item);
                textBoxResult.Text =
                    " Операция Put выполнена успешно!";
                ShowStack();
            }
    
            private void buttonRemove_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (!stack.IsEmpty())
                {
                    textBoxItem.Text = stack.Item().ToString();
                    stack.Remove();
                    textBoxResult.Text =
                        " Операция Remove выполнена успешно!";
                    ShowStack();
                }
                else
                    textBoxResult.Text =
                        "Стек пуст. Удаление невозможно!" +
                    NL + " Операция Remove не выполнена!";
                
            }
    
            private void buttonItem_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (!stack.IsEmpty())
                {
                    int item = stack.Item();
                    textBoxItem.Text = item.ToString();
                    textBoxResult.Text =
                        " Операция Item выполнена успешно!";                
                }
                else
                    textBoxResult.Text =
                        "Стек пуст. Чтение невозможно!" +
                    NL + " Операция Item не выполнена!";
            }
    
            private void buttonIsEmpty_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (stack.IsEmpty())
                    textBoxResult.Text = "Стек пуст!";
                else
                    textBoxResult.Text = "Стек не пуст!";
            }        
        }

    Класс ArrayList - потомок абстрактного класса Stack

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

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

    Классы без потомков

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

    Задачи

  • Постройте семейство классов Person, Car, OwnerOfCar, связанных отношениями наследования и вложенности, моделируя предметную область "Люди и машины". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows- проект для работы с объектами классов.
  • Постройте семейство классов Person, Employee, Firm, связанных отношениями наследования и вложенности, моделируя предметную область "Люди на службе". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows- проект для работы с объектами классов.
  • Постройте семейство классов Person, Library, Book, Author, Reader, связанных отношениями наследования и вложенности, моделируя предметную область "Люди, книги и библиотеки". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте семейство классов Person, Student, Teacher, Faculty, связанных отношениями наследования и вложенности, моделируя предметную область "Студенты и преподаватели". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте классы Home, Car, Carriage моделирующие дом, автомобиль и вагон поезда, а также классы HomeAndCar, HomeAndCarriage (дом в автомобиле, дом в вагоне), обладающие свойствами дома и транспортного средства. Установите правильные отношения между классами. Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте классы Home, Sputnik, моделирующие дом и космический корабль, а также класс HomeSpace (космический дом), предназначенный для космических путешествий. Установите правильные отношения между классами. Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • По аналогии с классом System.Collections.Queue из библиотеки FCL напишите собственную реализацию класса MyQueue, задающего динамическую структуру данных - очередь. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в очереди элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса DEQ (Double Ended Queue), задающего динамическую структуру данных - двустороннюю очередь, где добавление и удаление элементов выполняется на обоих концах очереди. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в очереди элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • По аналогии с классом System.Collections.ArrayList из библиотеки FCL напишите собственную реализацию класса MyArrayList, задающего динамическую структуру данных - список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса OneWayList, задающего динамическую структуру данных - односвязный список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса TwoWayList, задающего динамическую структуру данных - двусвязный список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса ListWithCursor, задающего динамическую структуру данных - список с курсором. Курсор в этой структуре данных задает текущий элемент списка. В списке должны быть определены операции по перемещению курсора. Операция поиска элемента списка устанавливает курсор на найденном элементе списка. Операции добавления и удаления элементов списка выполняются по отношению элемента, на который указывает курсор. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Постройте семейство классов, начиная с абстрактного класса, задающего динамическую структуру данных, потомками которого будут стеки и очереди.
  • Постройте семейство классов, начиная с абстрактного класса, задающего динамическую структуру данных, потомками которого будут списки - односвязные, двусвязные, списки с курсором.
  • Страницы:

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

    Отношения между классами

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

    Классы программной системы находятся в определенных отношениях друг с другом. Два основных типа отношений между классами определены в ОО-системах. Первое отношение, "клиенты и поставщики", называется часто клиентским отношением или отношением вложенности (встраивания). Второе отношение, "родители и наследники", называется отношением наследования.

    Определение 1. Классы A и В находятся в отношении "клиент - поставщик", если одним из полей класса В является объект класса А. Класс А называется поставщиком класса В, класс В называется клиентом класса А.

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

    Определение 2. Классы А и В находятся в отношении "родитель - наследник", если при объявлении класса В класс А указан в качестве родительского класса. Класс А называется клиентом класса В, класс В называется наследником класса А.

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

    (рис 4.1) Графическое отображение отношений между классами

    Оба отношения, наследования и вложенности, являются транзитивными. Если В - клиент А и С - клиент В, то отсюда следует, что С - клиент А. Если В - наследник А и С - наследник В, то отсюда следует, что С - наследник А.

    Определения 1 и 2 задают прямых или непосредственных клиентов и поставщиков, прямых родителей и наследников. Вследствие транзитивности необходимо ввести понятие уровня. Прямые клиенты и поставщики, прямые родители и наследники относятся к соответствующему уровню 1 (клиенты уровня 1, поставщики уровня 1 и так далее). Затем следует рекурсивное определение: прямой клиент клиента уровня k относится к уровню k+1.

    Для отношения наследования используется терминология, заимствованная из естественного языка. Прямые классы-наследники часто называются сыновними или дочерними классами. Непрямые родители называются предками, а их непрямые наследники - потомками.

    Замечу, что цепочки вложенности и наследования могут быть достаточно длинными. На практике вполне могут встречаться цепочки длины 10. Например, библиотечные классы, составляющие систему Microsoft Office, полностью построены на отношении вложенности. При программной работе с объектами Word можно начать с объекта, задающего приложение Word, и добраться до объекта, задающего отдельный символ в некотором слове некоторого предложения одного из открытых документов Word. Для выбора нужного объекта можно задать такую цепочку: приложение Word - коллекция документов - документ - область документа - коллекция абзацев - абзац - коллекция предложений - предложение - коллекция слов - слово - коллекция символов - символ. В этой цепочке каждому понятию соответствует класс библиотеки Microsoft Office, где каждая пара соседствующих классов связана отношением вложенности.

    Классы библиотеки FCL связаны как отношением вложенности, так и отношением наследования. Длинные цепочки наследования достаточно характерны для классов этой библиотеки.

    Отношения "является" и "имеет"

    При проектировании классов часто возникает вопрос, какое же отношение между классами нужно построить. Рассмотрим совсем простой пример двух классов - Square и Rectangle, описывающих квадраты и прямоугольники. Наверное, понятно, что эти классы следует связать скорее отношением наследования, чем вложенности; менее понятным остается вопрос, а какой из этих двух классов следует сделать родительским. Еще один пример двух классов - Car и Person, описывающих автомобиль и персону. Какими отношениями с этими классами должен быть связан класс Person_of_Car, описывающий владельца машины? Может ли он быть наследником обоих классов? Найти правильные ответы на эти вопросы проектирования классов помогает понимание того, что отношение "клиент - поставщик" задает отношение "имеет" ("has"), а отношение наследования задает отношение "является" ("is a"). В случае классов Square и Rectangle понятно, что каждый объект квадрат "является" прямоугольником, поэтому между этими классами существует отношение наследования и родительским классом является класс Rectangle, а класс Square является его потомком.

    В случае автомобилей, персон и владельцев авто также понятно, что владелец "имеет" автомобиль и "является" персоной. Поэтому класс Person_of_Car является клиентом класса Car и наследником класса Person.

    Диаграмма классов

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

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

    В момент создания нового Windows-проекта автоматически строятся классы, отражающие специфику проектов такого типа. Так автоматически создается класс Program с точкой входа в проект - процедурой Main. Автоматически создается и класс Form1, являющийся прямым наследником класса Form из библиотеки FCL.

    С первых шагов проектирования классам следует давать содержательные имена, в том числе выполняя переименование и классов, создаваемых автоматически. Переименуем в нашем примере класс Form1, дав ему имя MyForm.

    В процедуре Main автоматически создается объект класса MyForm, так что класс Program становится клиентом класса MyForm. При проектировании интерфейса формы и размещения в ней элементов управления соответствующий инструментарий - дизайнер форм - добавляет программный код в класс MyForm. Для каждого элемента управления в класс добавляется поле, представляющее объект класса, определяющего этот элемент управления. Так, класс MyForm становится клиентом многих классов, характеризующих элементы управления, расположенные на форме. Для простоты примера на форме размещена только одна командная кнопка "Пуск", так что в нашем примере класс MyForm является клиентом класса Button из библиотеки FCL.

    Добавим теперь в проект собственные классы. Создадим класс Testing и сделаем объект этого класса полем класса MyForm. Класс Testing станет поставщиком для класса MyForm, а последний будет клиентом класса Testing. Объект класса Testing будет создаваться в обработчике события Click командной кнопки "Пуск". Добавим в класс Testing три поля - три объекта классов Student, OwnerOfCar и Square, что делает класс Testing клиентом этих трех классов, которые также добавим в наш проект. Классы Student и OwnerOfCar сделаем прямыми наследниками класса Person, а класс OwnerOfCar - клиентом класса Car. Свяжем отношением наследования три класса, задающие геометрические фигуры - Square, Rectangle и Figure. Квадрат "является" прямоугольником, а прямоугольник "является" геометрической фигурой. Класс Figure имеет поле center класса Point, что делает его клиентом класса Point.

    Хотя текстовое описание классов проекта я старался сделать информативным, но графическое представление более наглядно. В Visual Studio 2008 есть инструментарий, позволяющий строить диаграмму классов проекта. Для построения диаграммы в окне Solution Explorer достаточно выбрать имя проекта, нажать правую кнопку мыши и из выпадающего контекстного меню выбрать пункт View Class Diagram. Диаграмма классов проекта, названного Architecture, показана на рис. 4.2.

    (рис 4.2) Диаграмма классов проекта Architecture

    На диаграмме отображаются все классы проекта. Помимо описанных классов, на диаграмме показаны два класса, автоматически строящиеся для проектов - Resources и Settings, задающие ресурсы и установки проекта.

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

    К сожалению, на диаграмме не отображаются отношения встраивания. Причина этому понятна. Достаточно просто понять из описания класса, кто является прямым родителем класса, поскольку он явно выписан в заголовке класса. Определить прямых поставщиков класса значительно сложнее. Определение 1 хотя и справедливо, но оно не описывает все ситуации, когда класс является поставщиком для другого класса. Как будет определено чуть ниже, помимо того, что поле класса является объектом другого класса (поставщика), возможны и другие ситуации, приводящие к возникновению отношения "клиент-поставщик". По этой причине полезно строить диаграмму классов и другими средствами, отображая на ней как отношения наследования, так и встраивания.

    На рис. 4.3 показана такая диаграмма.

    (рис 4.3) Отношения встраивания и наследования для классов проекта Architecture

    Среди классов, изображенных на рис. 4.3 , показан и класс object - прародитель всех классов. Фактически от всех классов системы ведет путь наследования к классу object, так что при рисовании полной картины наследования (отображая не только прямых родителей) из каждого класса выходит стрелка наследования, непосредственно или транзитивно ведущая к классу object. Класс object - это единственный класс, в который ведут несколько стрелок, но ни одна из стрелок не выходит. Этот класс не имеет ни родителей, ни клиентов. Особую роль играет и класс Program. Из него могут выходить несколько стрелок, но ни одна стрелка не входит. У этого класса нет потомков, нет клиентов. Никто не может создать объекты этого класса. Единственный объект этого класса создается автоматически в момент запуска программной системы на исполнение, и создается он вне программной системы высшими силами.

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

    Отношение вложенности

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

    /// <summary>
        /// Класс поставщик,предоставляет клиентам
        /// статический и зкземплярный методы,
        /// закрывая поля класса
        /// </summary>
        class Provider
        {
            //fields
            string fieldP1;
            int fieldP2;
            static int fieldPS;
            //Конструкторы класса
            /// <summary>
            /// Конструктор с аргументами
            /// </summary>
            /// <param name="p1">аргумент,инициализирующий поле класса</param>
            /// <param name="p2">аргумент,инициализирующий поле класса</param>
            public Provider(string p1, int p2)
            {
                fieldP1 = p1.ToUpper(); fieldP2 = p2*2;
                fieldPS = 0;
            }
            public Provider()
            {
                fieldP1 = ""; fieldP2 = 0; fieldPS = 0;
            }
            //Динамический (Экземплярный) метод
            public string MethodPD()
            {
                fieldPS++;
                string res = "Объект класса Provider" + "\n\r";
                res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                    fieldP1, fieldP2);
                return res;
            }
            // Статический (Модульный) метод
            public static string MethodPS()
            {
                string res = "Модуль класса Provider" + "\n\r";
                res += string.Format("Число вызовов метода MethodPD = {0}",
                    fieldPS.ToString());
                return res;
            }

    Поля класса, как и положено, закрыты для клиентов. У класса, как и положено, есть конструктор без аргументов, инициализирующий поля класса соответствующими константами, и конструктор с аргументами, который преобразует переданные ему значения, прежде чем записать их в поля класса. Методы класса позволяют получить информацию, хранящуюся в полях. Динамический (экземплярный) метод MethodPD, которому доступны поля класса, хранимые экземплярами класса, возвращает строку с информацией о хранимых значениях в полях. Одновременно этот метод увеличивает значение, хранимое в статическом поле, которое можно рассматривать как счетчик общего числа вызовов динамического метода всеми объектами данного класса. Статический метод MethodPS, которому доступно только статическое поле, возвращает в качестве результата строку с информацией о числе вызовов динамического метода.

    Построим теперь класс Client - клиента класса Provider. Класс будет устроен похожим образом. Существенное дополнение состоит в том, что одним из полей является объект provider класса Provider:

    /// <summary>
        /// Клиент класса Provider
        /// </summary>
        class Client
        {
            //fields
            Provider provider;
            string fieldC1;
            int fieldC2;
            
            const string NEWLINE = "\n\r";
            //Конструкторы класса
            public Client(string p1, int p2, string c1, int c2)
            {
                fieldC1 = c1.ToLower(); fieldC2 = c2-2;
                provider = new Provider(p1,p2);
            }
            public Client()
            {
                fieldC1 = ""; fieldC2 = 0; 
                provider = new Provider();
            }
            /// <summary>
            /// Метод, использующий поле класса provider
            /// для работы с методами класса Provider
            /// </summary>
            /// <returns>композиция строк провайдера и клиента </returns>
            public string MethodClient1()
            {
                string res = provider.MethodPD() + NEWLINE;             
                res += "Объект класса Client" + NEWLINE;
                res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                    fieldC1, fieldC2);
                return res;
            }
    }

    Обратите внимание: конструкторы клиента (класса Client ) создают объект поставщика (класса Provider ), вызывая конструктор поставщика. Для создания объектов поставщика могут требоваться аргументы, поэтому они передаются конструктору клиента, как это сделано в нашем примере.

    Создавая объект класса Client, конструкторы этого класса создают и объект класса Provider, связывая его ссылкой с полем provider. Все динамические методы клиентского класса могут использовать этот объект, вызывая доступные клиенту методы и поля класса поставщика. Метод класса Client - MethodClient1 начинает свою работу с вызова: provider.MethodPD(), вызывая сервис, поставляемый методом класса Provider.

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

    Расширение определения клиента класса

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

    Первую возможную ситуацию демонстрирует процедура Main Windows-проекта. В ней "на лету" в момент вызова метода Run создается объект класса, наследуемого от класса Form. Создаваемый объект является результатом вычисления выражения, заданного операцией new. В этом случае у класса клиента нет ни поля, ни локальной переменной, связанной с объектом класса поставщика. Но и в этом случае клиент создает объект поставщика, передавая его затем в качестве аргумента методу Run класса Application.

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

    /// <summary>
            /// Метод, использующий локальный объект класса provider
            /// для работы с методами класса Provider
            /// </summary>
            /// <returns>композиция строк провайдера и клиента </returns>
            public string MethodClient3()
            {
                Provider local_provider =
                    new Provider("Локальный объект Provider", 77);
                string res = local_provider.MethodPD() + NEWLINE;
                res += "Объект класса Client" + NEWLINE;
                res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                    fieldC1, fieldC2);
                return res;
            }

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

    /// <summary>
            /// Метод, использующий модуль Provider
            /// для работы со статическим методом класса Provider
            /// </summary>
            /// <returns>композиция строк провайдера и клиента </returns>
            public string MethodClient2()
            {
                string res = Provider.MethodPS() + NEWLINE;
                res += "Объект класса Client" + NEWLINE;
                res += string.Format("Мои поля: поле1 = {0}, поле2 = {1}",
                    fieldC1, fieldC2);
                return res;
            }

    Дадим теперь расширенное определение клиента.

    Определение 3. Класс B называется клиентом класса A, если в классе B создаются объекты класса A или вызываются статические сервисы класса A.

    Под сервисом класса понимается метод или поле класса, доступное клиентам, то есть объявленное с модификатором public или internal. Статический сервис - это сервис, объявленный с модификатором static.

    Отношения между клиентами и поставщиками

    Что могут делать клиенты и что могут делать поставщики? Класс поставщик создает сервисы, предоставляемые своим клиентам. Клиенты создают объекты поставщика. Вызывая доступные им сервисы, клиенты получают возможность выполнить работу, которую сами они выполнить не могут или не хотят выполнять, поскольку класс поставщик эту работу может сделать более квалифицированно. Так, например, арифметические классы - int и другие - не могут вычислять математические функции. При необходимости вычислить sin(x) они обращаются к соответствующему сервису, предоставляемому классом Math.

    Клиенты не могут ни повлиять на поведение методов поставщика, ни изменить состав предоставляемых им сервисов, они не могут вызывать закрытые поставщиком поля и методы класса.

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

    В заключение построим тест, проверяющий работу с объектами классов Provider и Client:

    class Program
        {
            static void Main(string[] args)
            {
                Client client =
                    new Client("object of class Provider", 33,
                        "object of class Client", 44);
                string res = client.MethodClient1();
                Console.WriteLine(res);
                res = client.MethodClient2();
                Console.WriteLine(res);
                res = client.MethodClient3();
                Console.WriteLine(res);
            }
        }

    Согласно определению, класс Program является клиентом класса Client. В процедуре Main локально создается объект класса Client, в процессе работы конструктора которого создается и объект класса Provider. Вызываемые методы клиентского класса в процессе своей работы вызывают методы класса Provider. Результаты работы показаны на рис. 4.4.

    (рис 4.4) Клиенты и поставщики

    Сам себе клиент

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

    Первая ситуация характерна для динамических структур данных. Элемент односвязного списка имеет поле, представляющее элемент односвязного списка; элемент двусвязного списка имеет два таких поля; узел двоичного дерева имеет два поля, представляющих узлы двоичного дерева. Эта ситуация характерна не только для рекурсивно определяемых структур данных. Вот еще один типичный пример. В классе Person могут быть заданы два поля – Father и Mother, задающие родителей персоны, и массив Children. Понятно, что все эти объекты могут быть того же класса Person.

    Не менее часто встречается ситуация, когда классы имеют поля, взаимно ссылающиеся друг на друга. Типичным примером могут служить классы Man и Woman, первый из которых имеет поле wife класса Woman, а второй – поле husband класса Man.

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

    Наследование

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

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

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

    Построение родительского класса Found

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

    /// <summary>
        /// Родительский класс
        /// </summary>
        class Found
        {
            //fields
            protected string name;
            protected int credit;
            static protected int count;
            const string NL = "\r\n";
            //Constructors
            public Found()
            {
                name = "Nemo";
                credit = 0;
                count++;
            }
            public Found(string name, int credit)
            {
                this.name = name;
                this.credit = credit;
                count++;
            }
    	}

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

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

    Хотя явно для класса Found родительский класс не задан, но родитель есть у каждого класса. Если прямой родитель не задан, то таковым является класс object. Класс Found наследовал от своего родителя – класса object ряд методов. Чаще всего методы, наследуемые от object, потомок должен переопределить, и в первую очередь это касается метода ToString. Как правило, переопределение этого метода сводится к тому, что возвращаемая методом строка содержит информацию о значениях, хранящихся в полях класса. Вот как выглядит переопределяемый метод ToString для класса Found:

    public override string ToString()
            {
                string s = "Поля: name = {0}, credit = {1}";
                return String.Format(s, name, credit);
            }

    Начнем добавлять в класс Found собственные методы:

    public string NonVirtMethod()
            {
                return "Found: " + this.ToString();
            }

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

    Добавим в класс еще один похожий метод:

    public virtual string VirtMethod()
            {
                return "Found: " + this.ToString(); 
            }

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

    Добавим еще один метод класса:

    public string Parse()
            {
                return "Выполнен разбор кода!";
            }

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

    Рассмотрим теперь чуть более сложный метод:

    public string Job()
            {
                string res = "";
                res += "VirtMethod: " + 
                    VirtMethod() + NL;
                res += "NonVirtMethod: " +
                    NonVirtMethod() + NL;
                res += "Parse: " +
                    Parse() + NL;
                return res;
            }

    Метод Job поочередно вызывает методы класса – VirtMethod, NonVirtMethod, Parse. Строки, задающие результаты этих методов, соединяются, и полученная строка возвращается в качестве результата метода Job.

    Последний метод, который добавим в класс Found, - это статический метод, возвращающий информацию из статического поля класса:

    public static string NumberOfObjects()
            {
                return "Объектов создано: " + count;
            }

    Не будем ограничиваться консольными проектами и для тестирования наследования создадим Windows-проект. Интерфейс проекта, который назван "WindowsParentsAndChildrenClasses", имеет традиционную для наших примеров архитектуру с главной кнопочной формой и формами, отвечающие за частные задачи. Покажем, как выглядит спроектированная форма для тестирования работы с объектами класса Found. Ее вид приведен на рис. 4.5.

    (рис 4.5) Интерфейс для работы с объектами класса Found

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

    Построение класса потомка Derived

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

    public class Derived:Found
       {
       }

    Тело класса Derived пусто, но это вовсе не значит, что объекты этого класса не имеют полей и методов: они наследуют все поля и методы (кроме конструктора) класса Found и поэтому могут делать все, что могут делать объекты родительского класса. Можно даже не создавать собственных конструкторов класса. В этом случае автоматически добавляется конструктор по умолчанию - конструктор без аргументов, который будет вызывать конструктор без аргументов родительского класса. Заметьте, такой конструктор у родителя должен быть, иначе возникнет ошибка. Но в нашем случае такой конструктор предусмотрительно создан.

    (рис 4.6) Форма для работы с объектами класса Derived

    На рис. 4.6 показана знакомая нам форма, расширенная для работы с объектами класса Derived. Как видите, несмотря на то, что тело класса пусто, можно создать объект и вызывать многочисленные его методы, наследованные от родителя. На рисунке показаны результаты работы метода Job для объектов классов Found и Derived, созданных по умолчанию. Результаты совпадают. Если потомок ничего не делает, то его объекты ведут себя так же, как и объекты родительского класса. Каждый объект потомка "является" объектом родительского класса.

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

    Добавление и скрытие полей потомком

    Что может делать потомок с полями? Прежде всего, он может добавить новые свойства - поля класса. Он может скрыть поле родителя, добавив собственное поле с тем же именем, что и поле родителя, возможно, изменив тип этого поля и его модификаторы. Скрытие родительского поля не означает, что потомок удаляет поле, наследованное от родителя. Это поле у потомка остается и доступно для вызова, но скрыто, и чтобы добраться до него, необходимо имя поля уточнить именем родителя, задаваемым ключевым словом base.

    Итак, потомок не может ни удалить, ни изменить поля, наследованные от родителя, он может только добавить собственные поля и может скрыть поля родителя.

    Модифицируем наш класс Derived. Добавим новое поле класса debit. Будем также полагать, что поля debit и credit должны иметь тип double, а не int. По этой причине скроем родительское поле credit, добавив собственное поле с тем же именем, но изменив его тип:

    //добавление и скрытие полей
            protected double debit;
            new protected double credit;

    Скрываемые поля следует снабжать модификатором new. Если этого не сделать, то родительское поле все равно будет скрыто, и на этапе компиляции будет выдано предупреждение о возникшей ситуации.

    Для обоих полей задан модификатор доступа protected. Напомню, хорошей стратегией является стратегия "ничего не скрывать от потомков". Какой родитель знает, что именно из сделанного им может понадобиться потомкам?

    Конструкторы родителей и потомков

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

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

    Чтобы подчеркнуть синтаксически такую семантику работы конструктора, вызов конструктора родителя встроен не в тело конструктора, а в его заголовок. Для вызова конструктора используется ключевое слово base, именующее родительский класс. Как это делается, покажу на примере конструкторов класса Derived:

    //Конструкторы
            public Derived(): base()
            {
                debit = 0; credit = 0;
            }
            public Derived(string name, double debit,
                double credit)
                : base(name, (int)credit)
            {
                this.debit = debit; 
                this.credit = credit;
            }

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

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

    Добавление методов и изменение методов родителя

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

    //Методы
            public string MyBaseCredit()
            {
                return base.credit.ToString();
            }

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

  • перегрузка метода. Она возникает, когда сигнатура создаваемого метода отличается от сигнатуры наследуемых методов предков. В этом случае в классе потомка будет несколько перегруженных методов с одним именем, и вызов нужного метода определяется обычными правилами перегрузки методов;
  • переопределение метода. Метод родителя в этом случае должен иметь модификатор virtual, abstract или override. Это наиболее интересная ситуация, и она будет подробно рассмотрена. При переопределении сохраняется сигнатура и модификаторы доступа наследуемого метода;
  • скрытие метода. Если родительский метод не является виртуальным или абстрактным, то потомок может создать новый метод с тем же именем и той же сигнатурой, скрыв родительский метод в данном контексте. Здесь ситуация такая же, как и со скрытием полей. При вызове метода по его имени предпочтение будет отдаваться методу потомка. Это не означает, что метод родителя становится недоступным. Скрытый родительский метод всегда может быть вызван, если при вызове уточнить имя метода родительским именем base.
  • Метод потомка, скрывающий метод родителя, следует сопровождать модификатором new, указывающим на новый метод. Если этот модификатор опущен, но из контекста ясно, что речь идет о новом методе, то выдается предупреждающее сообщение при компиляции проекта.

    Вернемся к нашему примеру. Класс Found имел в своем составе метод Parse. Его потомок класс Derived расширил возможности метода, добавив проверку в метод разбора. Поскольку родительский метод Parse не был ни виртуальным, ни абстрактным, то новый метод Parse, добавленный потомком, скрывает родительский метод:

    new public string Parse()
            {
                string res = base.Parse() + NL;
                res += "Выполнена проверка кода!";
                return res; 
            }

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

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

    Статический контроль типов и динамическое связывание

    Рассмотрим семейство классов A1, A2, ... An, связанных отношением наследования. Класс Ak+1 является прямым потомком класса Ak. Пусть создана последовательность объектов x1, x2, ... xn, где xk - объект класса Ak. Пусть в классе A1 создан метод M с модификатором virtual, переопределяемый всеми потомками, так что в рамках семейства классов метод M существует в n формах, каждая из которых задает реализацию метода, выбранную соответствующим потомком. Рассмотрим основную операцию, инициирующую объектные вычисления - вызов объектом метода класса:

    x1.M(arg1, arg2, … argN)

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

  • в классе A1 объекта x1 действительно имеется метод M ;
  • у метода М действительно N формальных аргументов;
  • список фактических аргументов в точке вызова соответствует по числу и типам списку формальных аргументов метода M, заданного в классе A1.
  • Язык C#, как и большинство других языков программирования, позволяет выполнить эту проверку еще на этапе компиляции и в случае нарушений выдать сообщение об ошибке. Контроль типов, выполняемый на этапе компиляции, называется статическим контролем типов. Языки программирования, называемые динамическими, например, язык Smalltalk, производят этот контроль динамически - в ходе работы программы непосредственно перед выполнением метода. Понятно, что ошибки, обнаруживаемые при динамическом контроле типов, трудно исправимы и потому приводят к более тяжелым последствиям. В таких случаях остается уповать на то, что система тщательно отлажена, иначе непонятно, что будет делать конечный пользователь, получивший сообщение о том, что вызываемого метода вообще нет в классе данного объекта. У динамических языков есть свои преимущества - прежде всего, простота.

    Перейдем к рассмотрению связывания. И снова рассмотрим основную операцию - вызов

    x1.M(arg1, arg2, … argN);

    Предположим, что статический контроль типов для этого вызова успешно выполнен. Рассмотрим еще один аспект, связанный с этим вызовом. Вспомним, что x1 - это ссылка, которая связана с объектом, расположенным в динамической памяти. Всегда ли совпадает тип объекта и тип ссылки? Другими словами, всегда ли объект, созданный в динамической памяти, принадлежит классу A1? Ответ: нет, не всегда.

    Предположим, что вызову метода M объектом x1 предшествует присваивание

    x1 = y;

    Это ссылочное присваивание, поскольку x1 - ссылка. Присваивание является допустимым, если объект y принадлежит классу, являющемуся потомком класса объекта x1. Таким образом, в точке вызова ссылка x1 может быть связана с объектом любого класса нашего семейства A1, …AN. В каждом из этих классов существует метод M с одной и той же сигнатурой. Метод M полиморфен: имея одно и то же имя и сигнатуру, он существует в разных формах - для каждого класса задана собственная реализация метода. Возникает естественный вопрос, какой же метод M следует вызывать - метод класса A1 (метод ссылки) или метод того класса, которому принадлежит реальный объект в динамической памяти (метод объекта). Возможны два решения этого вопроса, и оба варианта используются на практике.

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

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

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

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

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

    В языке C# принята следующая стратегия связывания. По умолчанию предполагается статическое связывание. Для того чтобы выполнялось динамическое связывание, родительский класс, впервые создающий метод, должен снабдить метод модификатором virtual или abstract, в классах потомках такой виртуальный метод будет иметь модификатор override.

    Три механизма, обеспечивающие полиморфизм

    Под полиморфизмом в ООП понимают способность одного и того же программного текста

    x1.M(arg1, arg2, … argN);

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

  • Одностороннее присваивание объектов внутри семейства классов. Сущность, базовым классом которой является класс предка, можно связать с объектом любого из потомков. Другими словами, для введенной нами последовательности объектов xk присваивание xi = xj допустимо для всех j >=i.
  • Переопределение потомком метода, наследованного от родителя. Благодаря переопределению в семействе классов существует совокупность полиморфных методов с одинаковым именем и одинаковой сигнатурой, но разной реализацией.
  • Динамическое связывание, позволяющее в момент выполнения вызывать метод, принадлежащий объекту, с которым связана сущность в момент вызова.
  • В совокупности это и называется полиморфизмом семейства классов. Целевую сущность часто называют полиморфной сущностью, вызываемый метод - полиморфным методом, сам вызов - полиморфным вызовом.

    Полиморфизм семейства классов Found и Derived

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

    public override string ToString()
            {
                string s = "Поля: name = {0}, Basecredit = {1}" + 
                    "credit = {2}, debit = {3}";
                return String.Format(s, name, base.credit, 
                    credit, debit);
            }
            public override string VirtMethod()
            {
                return "Derived: " + this.ToString(); 
            }

    В классе Found определены два не виртуальных метода NonVirtMethod и Job, наследуемые потомком Derived без всяких переопределений. Вы ошибаетесь, если думаете, что работа этих методов полностью определяется базовым классом Found. Полиморфизм делает их работу куда более интересной. Давайте рассмотрим в деталях работу метода Job:

    public string Job()
            {
                string res = "";
                res += "VirtMethod: " + 
                    VirtMethod() + NL;
                res += "NonVirtMethod: " +
                    NonVirtMethod() + NL;
                res += "Parse: " +
                    Parse() + NL;
                return res;
            }

    При компиляции метода Job будет обнаружено, что вызываемый метод VirtMethod является виртуальным, поэтому для него будет применяться динамическое связывание. Это означает, что вопрос о вызове метода откладывается до момента, когда метод Job будет вызван объектом, связанным с x. Объект может принадлежать как классу Found, так и классам Derived и ChildDerived, и в зависимости от класса объекта и будет вызван метод этого класса.

    Для вызываемых методов NonVirtMethod и Parse, не являющихся виртуальными, будет применено статическое связывание, так что метод Job всегда будет вызывать методы, принадлежащие классу Found. Однако и здесь не все просто. Метод NonVirtMethod

    public string NonVirtMethod()
            {
                return "Found: " + this.ToString();
            }

    в процессе своей работы вызывает виртуальный метод ToString. Опять-таки, для метода ToString будет применяться динамическое связывание, и в момент выполнения будет вызываться метод объекта, а не метод ссылки.

    Что же касается метода Parse, определенного в каждом классе, то всегда в процессе работы Job будет вызываться только родительский метод разбора из-за стратегии статического связывания.

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

    Класс Found, создающий метод Job, говорит примерно следующее: "Я предоставляю этот метод своим потомкам. Потомок, вызвавший этот метод, должен иметь VirtMethod, выполняющий специфическую для потомка часть работы; конечно, потомок может воспользоваться и моей реализацией, но допустима и его собственная реализация. Затем часть работы выполняю я сам, но выдача информации об объекте определяется самим объектом. Заключительную часть работы, связанную с анализом, я потомкам не доверяю и делаю ее сам".

    Пример работы с полиморфным семейством классов

    Класс Found и его потомок Derived имеют виртуальные методы и, следовательно, являются полиморфными классами. Они образуют полиморфное семейство классов с полиморфными методами.

    Теперь в статике уже нельзя предсказать, как будут выполняться методы этих классов. На рис. 4.7 показана работа с объектами этих классов.

    (рис 4.7) Полиморфизм семейства классов Found и Derived

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

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

    Абстрактные классы

    С наследованием тесно связан еще один важный механизм проектирования семейства классов - механизм абстрактных классов. Начну с определений.

    Определение. Класс называется абстрактным, если он имеет хотя бы один абстрактный метод.

    Определение. Метод называется абстрактным, если при определении метода задана его сигнатура, но не задана реализация метода.

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

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

    Абстрактный класс Stack

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

    /// <summary>
        /// Абстрактный стек целых чисел
        /// С операциями: Put, Remove, Item, IsEmpty
        /// </summary>
        public abstract class Stack
        {        
            /// <summary>
            /// Втолкнуть элемент item  в стек
            /// </summary>
            /// <param name="item">Целое число</param>
            public abstract void Put(int item);
    
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Удалить элемент в вершине стека
            /// </summary>
            public abstract void Remove();
    
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Прочитать элемент в вершине стека
            /// </summary>
            public abstract int Item();
    
            /// <summary>
            /// Определить, пуст ли стек
            /// </summary>
            /// <returns>true, если пуст, иначе false</returns>
            public abstract bool IsEmpty();
        }

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

    Класс ListStack - потомок абстрактного класса Stack

    Построим теперь класс, который будет являться потомком абстрактного класса Stack и задавать реализацию стека, основанную на списковом представлении. Дополнительно нам понадобится класс Linkable, задающий представление элемента списка.

    class Linkable
        {
            internal int info;
            internal Linkable next;
        }

    У класса лишь два поля и конструктор по умолчанию. Поля снабжены модификатором internal, чтобы они были доступны в дружественном классе ListStack.

    Класс ListStack будет потомком абстрактного класса Stack и клиентом класса Linkable. Теперь, когда принято решение о представлении данных, основанное на списковой структуре, несложно определить реализацию абстрактных методов класса Stack. Вот как выглядит реализация класса ListStack:

    public class ListStack : Stack
        {
            //поля
            Linkable top;
            //конструктор
            public ListStack()
            {
                top = new Linkable();
            }
            //Методы
    
            /// <summary>
            /// Втолкнуть элемент item  в стек
            /// </summary>
            /// <param name="item">Целое число</param>
            public override void Put(int item)
            {
                Linkable newitem = new Linkable();
                newitem.info = item;
                newitem.next = top;
                top = newitem;
            }
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Удалить элемент в вершине стека
            /// </summary>
            public override void Remove()
            {
                 top = top.next;
            }
            /// <summary>
            /// Предусловие: Стек не пуст!
            /// Прочитать элемент в вершине стека
            /// </summary>
            public override int Item()
            {
                return (top.info);
            }
            /// <summary>
            /// Определить, пуст ли стек
            /// </summary>
            /// <returns>true, если пуст, иначе false</returns>
            public override bool IsEmpty()
            {
                return (top.next == null);
            }
       }

    Класс имеет одно поле top класса Linkable и методы, наследованные от абстрактного класса Stack. Реализация операций традиционна для спискового представления данных и приводится без пояснений. Добавим в класс еще одну операцию, которая отсутствует у абстрактного класса и не принадлежит к операциям, традиционным для стека. Но для стека, основанного на списковом представлении, операция полезна, она преобразует стек в список.

    public ArrayList list = new ArrayList();
            public ArrayList StackToList()
            {
                list.Clear();
                Linkable temp = top;
                while (temp.next != null)
                {
                    list.Add(temp.info);
                    temp = temp.next;
                }
                return list;
            }

    Для тестирования работы построенного класса добавим в Windows-проект новую форму, реализующую пользовательский интерфейс. Интерфейсный класс, задающий форму, будет клиентом класса ListStack.

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

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

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

    public partial class FormStack : Form
        {
            ListStack stack;
            ArrayList list;
            const string NL = "\r\n";
            public FormStack()
            {
                InitializeComponent();
            }
    
            private void buttonCreate_Click(object sender, EventArgs e)
            {
                stack = new ListStack();
                list = new ArrayList();
                textBoxResult.Text = "Стек создан!";
                ShowStack();
            }
            void ShowStack()
            {
                list = stack.StackToList();
                listBoxStack.Items.Clear();
                foreach (int item in list)
                    listBoxStack.Items.Add(item);
            }
    
            private void buttonPut_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                int item = 0;
                try
                {
                    item = int.Parse(textBoxItem.Text);
                }
                catch (Exception)
                {
                    textBoxResult.Text =
                        "Некорректно задан элемент item!" +
                        NL + "Необходимо целое число!";
                    return;
                }
                stack.Put(item);
                textBoxResult.Text =
                    " Операция Put выполнена успешно!";
                ShowStack();
            }
    
            private void buttonRemove_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (!stack.IsEmpty())
                {
                    textBoxItem.Text = stack.Item().ToString();
                    stack.Remove();
                    textBoxResult.Text =
                        " Операция Remove выполнена успешно!";
                    ShowStack();
                }
                else
                    textBoxResult.Text =
                        "Стек пуст. Удаление невозможно!" +
                    NL + " Операция Remove не выполнена!";
                
            }
    
            private void buttonItem_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (!stack.IsEmpty())
                {
                    int item = stack.Item();
                    textBoxItem.Text = item.ToString();
                    textBoxResult.Text =
                        " Операция Item выполнена успешно!";                
                }
                else
                    textBoxResult.Text =
                        "Стек пуст. Чтение невозможно!" +
                    NL + " Операция Item не выполнена!";
            }
    
            private void buttonIsEmpty_Click(object sender, EventArgs e)
            {
                if (stack == null)
                {
                    textBoxResult.Text = "Создайте стек!";
                    return;
                }
                if (stack.IsEmpty())
                    textBoxResult.Text = "Стек пуст!";
                else
                    textBoxResult.Text = "Стек не пуст!";
            }        
        }

    Класс ArrayList - потомок абстрактного класса Stack

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

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

    Классы без потомков

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

    Задачи

  • Постройте семейство классов Person, Car, OwnerOfCar, связанных отношениями наследования и вложенности, моделируя предметную область "Люди и машины". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows- проект для работы с объектами классов.
  • Постройте семейство классов Person, Employee, Firm, связанных отношениями наследования и вложенности, моделируя предметную область "Люди на службе". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows- проект для работы с объектами классов.
  • Постройте семейство классов Person, Library, Book, Author, Reader, связанных отношениями наследования и вложенности, моделируя предметную область "Люди, книги и библиотеки". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте семейство классов Person, Student, Teacher, Faculty, связанных отношениями наследования и вложенности, моделируя предметную область "Студенты и преподаватели". Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте классы Home, Car, Carriage моделирующие дом, автомобиль и вагон поезда, а также классы HomeAndCar, HomeAndCarriage (дом в автомобиле, дом в вагоне), обладающие свойствами дома и транспортного средства. Установите правильные отношения между классами. Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • Постройте классы Home, Sputnik, моделирующие дом и космический корабль, а также класс HomeSpace (космический дом), предназначенный для космических путешествий. Установите правильные отношения между классами. Предусмотрите виртуальные методы в проектируемых классах. Постройте DLL и Windows-проект для работы с объектами классов.
  • По аналогии с классом System.Collections.Queue из библиотеки FCL напишите собственную реализацию класса MyQueue, задающего динамическую структуру данных - очередь. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в очереди элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса DEQ (Double Ended Queue), задающего динамическую структуру данных - двустороннюю очередь, где добавление и удаление элементов выполняется на обоих концах очереди. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в очереди элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • По аналогии с классом System.Collections.ArrayList из библиотеки FCL напишите собственную реализацию класса MyArrayList, задающего динамическую структуру данных - список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса OneWayList, задающего динамическую структуру данных - односвязный список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса TwoWayList, задающего динамическую структуру данных - двусвязный список. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Напишите реализацию класса ListWithCursor, задающего динамическую структуру данных - список с курсором. Курсор в этой структуре данных задает текущий элемент списка. В списке должны быть определены операции по перемещению курсора. Операция поиска элемента списка устанавливает курсор на найденном элементе списка. Операции добавления и удаления элементов списка выполняются по отношению элемента, на который указывает курсор. Постройте семейство классов, начиная с абстрактного класса и кончая разными классами потомками. Постройте реализации, основанные на массивах и на списковой структуре данных. Постройте классы потомки, хранящие в списке элементы фиксированного типа. Постройте Windows-проект, демонстрирующий полиморфизм построенного семейства классов.
  • Постройте семейство классов, начиная с абстрактного класса, задающего динамическую структуру данных, потомками которого будут стеки и очереди.
  • Постройте семейство классов, начиная с абстрактного класса, задающего динамическую структуру данных, потомками которого будут списки - односвязные, двусвязные, списки с курсором.
  • Вернуться к учебному плану