This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
С# 2008: Пер. с англ. — СПб.: БХВ-Петербург, 2009. — 576 е.: ил. — (Самоучитель) ISBN 978-5-9775-0287-0 Книга посвящена основам программирования на языке С# 2008. Материал излагается последовательно на примере решения различных типичных проблем, с которыми сталкиваются программисты. Описаны типы данных языка С#, их достоинства, недостатки и особенности применения. Рассмотрены операторы языка, основы объекгпо-ориептированного, компонентно-ориентированного и функционального программирования. Показаны особенности обработки строк и исключений, а также мноюпогачная обработка информации. Описаны принципы хранения данных, конфигурационные файлы приложения, динамическое выполнение кода. Рассмотрен интерфейс среды разработки Visual С# Express Edition 2008. Материал сопровождается многочисленными примерами разработки приложений: калькулятор, переводчик, простая система искусственного интеллекта, обмен валют, вычисления налогов и др. Для
программистов
УДК 681.3.06 ББК 32.973.26-018.2 Группа подготовки издания: Главный редактор Зам. главного редактора Зав. редакцией Перевод с английского Редактор Компьютерная верстка Корректор Оформление обложки Зав. производством
Екатерина Кондукова Игорь Шишигин Гоигорий Добин Сергея Таранушенко Анна Кузьмина Натальи Караваевой Виктория Пиотровская Елены Беляевой Николай Тверских
Лицензия ИД № 02429 от 24.07.00. Подписано в печать 01.09.08. Формат 70ХЮ0716. Печать офсетная. Усл. печ. л. 46,44. Тираж 2000 экз. Заказ № 555 "БХВ-Петербург", 194354, Санкт-Петербург, ул. Есенина, 5Б. Санитарно-эпидемиологическое заключение на продукцию № 77.99.60.953.Д.003650.04.08 от 14.04.2008 г. выдано Федеральной службой по надзору в сфере защиты прав потребителей и благополучия человека. Отпечатано с готовых диапозитивов в ГУП "Типогрвфия "Нвука" 199034, Санкт-Петербург, 9 линия, 12
ISBN 978-1-59059-869-5 (англ.) ISBN 978-5-9775-0287-0 (рус.)
Пища для ума при написании программного обеспечения: "Распространенной ошибкой, которую люди совершают, когда пытаются создать нечто абсолютно защищенное от дурака, является недооценивание находчивости полных дураков". "Основная разница между чем-то, что может выйти из строя, и чем-то, что просто не может выйти из строя, состоит в том, что когда первое выходит из строя, то обычно к нему невозможно подобраться или отремонтировать". Дуглас Адаме, в основном безвредный
Об авторе
Многие люди говорят, что по собаке можно судить о ее владельце. Ну, на фотографии моя собака Луис, английский бульдог. И действительно, мы с бульдогом имеем много общего. Но как насчет биографии автора, Кристиана Гросса? Она довольно проста: я парень, который провел уйму времени в кресле, отлаживая и разбирая по частям код. В действительности, мне по настоящему нравится этот бизнес, называющийся разработка программного обеспечения. Я полюбил его с тех пор, когда я впервые научился подсматривать и вставлять содержимое байтов. Я написал несколько книг, среди них "Ajax and REST Recipes: A Problem-Solution Approach" ("Рецепты для Ajax и REST: Подход проблемарешение"), "Foundations of Object-Oriented Programming Using .NET 2.0 Patterns" ("Основы объектно-ориентированного программирования с использованием шаблонов .NET 2.0") и "A Programmer's Introduction to Windows DNA" ("Введения для программистов в Windows DNA"). В настоящее время я получаю удовольствие от написания кода для .NET и экспериментирования с этой увлекательной средой. .NET вызывает у меня чувства, подобные чувствам ребенка, открывающего новогодний подарок: какой подарок, в принципе известно, но полной уверенности все-таки нет. А подарки от .NET — это вам не какие-то носки или шарфик от любимой тетушки. Это один непрекращающийся восторг!
О техническом рецензенте
Кристиан Кенйерес (Christian Kenyeres), главный разработчик в компании Collaborative Consulting, является профессионалом новаторских технологий, имеющий свыше 15 лет обширного опыта работы в области информационных технологий. Он предоставлял свои услуги разработчика программного обеспечения для предприятий многочисленным клиентам высокого уровня и может похвастаться обширными техническими и деловыми знаниями. До работы в компании Collaborative Consulting Кристиан предоставлял услуги консультирования различным компаниям, включающим Compaq, EMC, Fidelity Investments, Liberty Mutual Insurance и John Hancock. Он получил дипломы бакалавра Массачусетского университета и магистра в вычислительной технике Бостонского университета.
Введение
Первой книгой по программированию, которую я прочитал, была книга Чарльза Петцольда (Charles Petzold) "Программирование Windows 3.0". Это было приблизительно в то время, когда операционная система Microsoft Windows 3.0 (около 1992 г.) раз и навсегда показала игрокам в области информационных технологий, что у компании Microsoft будет успешное будущее. В те времена написание кода под Windows было сложно по многим причинам: отсутствие документации, 16-битовая архитектура, а также необходимость покупать компилятор отдельно от набора SDK (Software Development Kit, набор разработчика программного обеспечения). Книга Чарльза связала все вместе и решила проблему написания программ под Windows. Теперь у программистов прямо противоположные проблемы: слишком много документации, 64-битовая архитектура, а также масса инструментов и утилит, поставляемых вместе со средой разработки. Все это изобилие создает проблему разобраться, что же нам в самом деле нужно. У нас слишком много опций, слишком много способов решить одну и ту же проблему. Я хочу с помощью этой книги сделать то же самое, что Чарльз сделал с помощью своей для меня, когда я только начинал работать в области программирования, а именно помочь разобраться, что собственно необходимо для написания кода. Целью этой книги является обучение языку программирования С# в контексте решения проблем. Язык С# развился в сложный язык программирования, с помощью которого можно решить многие задачи, но все эти возможности делают трудной задачу выбора необходимых средств из множества доступных. Эта книга призвана дать ответы на ваши вопросы. Это не справочник по всем возможностям языка С#, и в ней не рассматриваются его экзотические возможности. Основное внимание в ней уделяется тем возможностям языка С#, которые вам придется использовать каждый день. Но это не означает, что вы не сможете ознакомиться с определенными конструктивами языка, т. к. я охватил все основные возможности. Чтобы получить наиболее полную пользу от этой книги, я советую делать упражнения, приведенные в конце каждой главы. Ответы на упражнения можно посмотреть на Web-сайте издательства Apress (http://www.apress.com). Но вы можете мухлевать и не делать упражнений, но я бы не советовал этого.
8
Введение
Если вы начинающий программист, который ничего не знает о С#, внимательно прочитаете эту книгу и выполните все упражнения в ней, то я почти полностью уверен, что к концу книги вы будете владеть солидными знаниями программирования на С#. Если это звучит, как будто бы я много обещаю, что ж, так оно и есть. Текст глав предназначен ознакомить вас с определенными возможностями языка С# и их применениями. А упражнения к главам предназначены проверить, что вы действительно поняли материал, изложенный в каждой главе. Упражнения трудные; за пять минут вы их не решите. Между прочим, когда я делал эти упражнения, то у меня ни их выполнение ушло пять рабочих дней! Если у вас возникнут вопросы, типа: "Чего же мы хотим добиться в этом упражнении?", то можно отправить мне вопросы по электронной почте по адресу atchristianhgross @ gmail.com. Спасибо за внимание. Желаю вам успехов.
Глава 1
На старт, внимание, марш!
Эта книга о языке программирования С# и о том, как стать опытным программистом на этом языке. Прочитав ее от корки до корки, вы не станете гениальным программистом, но получите знания, которые помогут вам в написании надежных, стабильных и сопровождаемых приложений. В этой главе мы начнем процесс получения этих знаний и навыков с приобретения инструментов для разработки приложений на языке С# и с испытания возможностей этих инструментов. По ходу дела мы также создадим несколько приложений на языке С#.
Скачивание и установка инструментов Начав работать с С# 3.0, вы, наверное, горите желанием сразу же написать какуюлибо программу на этом языке. В этом отношении .NET позволяет вам удовлетворить ваше желание— вы можете начать писать работающий код срап же после установки или набора разработчика программного обеспечения .NET ( Л Е Т SDK) или интегрированной среды разработки (IDE) Visual Studio. Поэтому первым, критическим, шагом в вашей работе с С# 3.0 является скачивание и установка среды разработки. ПРИМЕЧАНИЕ Для начинающих, да и не только для начинающих, разобраться с номерами версий программ, описаниями продуктов и возможностями технологий может быть нелегкой задачей. На основе своего свыше десятилетнего опыта работы с технологиями корпорации Microsoft я могу утверждать, что присваивание имен технологиям и продуктам никогда не было сильной стороной Microsoft. Сами технологии и продукты были (по большей части) замечательными, но их классификация и идентификация таковыми являлись далеко не всегда. В этой книге рассматривается язык программирования С# 3.0, который применяется для написания приложений для .NET Framework. Для С# 3.0 применяются версии 3.0 и 3.5 .NET Framework. .NET 3.0 предоставляет основные возможности, a .NET 3.5 расширяет эти возможности.
Для написания примеров, рассматриваемых в этой книге, применяется Visual С# 2008 Express Edition, т. к. эта среда разработки является бесплатной и предоставляет все необходимые функциональности для того, чтобы начать работать с С# 3.0.
Глава 10
10
Другие среды разработки Express Edition, предоставляемые Microsoft, предназначены для работы с другими языками — Visual Basic и С++. А функциональность Visual Web Developer Express слишком ограниченная для наших целей. Корпорация Microsoft также предоставляет полные версии среды разработки Visual Studio, такие как выпуски Standard, Professional и Team. Каждый из этих выпусков имеет свои возможности и свою цену. Дополнительную информацию см. на Web-сайте корпорации Microsoft в разделе для Visual Studio по адресу http://msdn2.microsoft.com/en-us/vstudio/default.aspx. Если у вас уже есть Visual Studio 2008 Professional, то для создания примеров из этой книги вы можете пользоваться данной средой. Она позволяет делать все, что можно делать с Visual С# Express, и имеет много других функциональностей. ПРИМЕЧАНИЕ Лично я пользуюсь средой Visual Studio Standard или Professional совместно с другими инструментами, такими как X-develop и JustCode!, предоставляемыми компанией Omnicore (http://www.omnicore.com), TestDriven.NET (http://www.testdriven.net/) и NUnit (http://www.nunit.org). Средства, входящие в Visual Studio, очень хороши, но имеются и другие хорошие инструменты. Разработчик должен знать, какими инструментами лучше всего пользоваться.
Размер установочного пакета Visual С# Express довольно большой, поэтому если у вас нет высокоскоростного Интернета, я бы посоветовал устанавливать среду разработки с CD-ROM.
Скачивание Visual С# Express Далее приводится процедура для скачивания установочного пакета Visual С# Express с Web-сайта Microsoft. К тому времени, когда вы будете читать эту книгу, процедура может быть несколько иной, но в основном она будет достаточно похожей на описанную, чтобы вы смогли с легкостью найти необходимую страницу и скачать с нее установочный пакет. 1. Откройте страницу http://msdn.microsoft.com/vstudio/express/. 2. Выберите на ней ссылку Visual Studio 2008 Express Editions. 3. Выберите Windows Development 1 (т.к. пространство этой книги ограничено, в ней мы будем рассматривать проекты только этого типа). 4. Нажмите ссылку Visual Studio Express Download. 5. Откроется страница со списком сред разработки Visual Studio Express (рис. 1.1). Нажмите ссылку Visual С# 2008 Express Edition. 6. Откроется диалоговое окно для выбора папки для сохранения скачанного файла. Это небольшой файл самозагрузки, с помощью которого будет выполняться настоящая установка среды разработки Visual С# Express. Сохраните этот файл на рабочем столе. ' Разработка программного обеспечения под Windows. — Пер.
На
старт,
внимание,
марш!
11
Все эти действия должны занять очень короткое время, не больше нескольких минут. Не принимайте эту процедуру за скачивание самой среды Visual С# Express, т. к. здесь мы скачиваем только загрузочный файл. Сама же среда разработки будет скачана с помощью этого загрузочного файла.
Рис. 1.1. Выбор Visual С# 2008 Express Edition для скачивания
Установка Visual С# Express Скачав файл установки, можно приступать к установке Visual С# Express. Во время этого процесса загружаются и устанавливаются все составные части среды разработки, общий размер которых составляет около 300 Мбайт. Выполните такую последовательность шагов: 1. Выполните двойной щелчок по скачанному файлу vcssetup.exe. Подождите, пока программа установки не загрузит все необходимые компоненты. 2. Щелкните кнопку Next в первоначальном окне установки. 3. Будет выведена последовательность диалоговых окон. Во всех этих окнах оставьте опции по умолчанию и нажмите кнопку Next для продолжения установки. В последнем диалоговом окне нажмите кнопку Install.
12
Глава 10
4. После скачивании всех элементов среды и ее установки, может, потребуется перезагрузить компьютер. Установленную среду разработки Visual С# Express можно запустить, выбрав ее в меню Пуск | Программы.
Выбор типа приложения Установив Visual С# Express, вы можете написать ваше первое приложение .NET. Но сначала вам нужно решить, какой тип приложения написать. В общих чертах, в .NET можно разрабатывать программы трех основных типов: •
консольные приложения предназначены для исполнения в командной строке и не имеют пользовательского интерфейса;
•
приложения Windows исполняются в окне и оснащены пользовательским интерфейсом;
•
библиотеки кчассов содержат разделяемую функциональность, которую можно использовать в консольных приложениях и приложениях Windows. Самостоятельно исполняться библиотеки классов не могут.
В этой главе мы рассмотрим написание приложений всех трех типов. Это будут разновидности примера "hello, world", который выводит на экран текст "hello, world". Программы типа "hello, world" использовались на протяжении десятилетий для демонстрации возможностей языков программирования.
Создание проектов и решений Независимо от тою. про!рамму какого типа вы решили написать, при использовании инструментов линейки Visual Studio создаются проекты и решения. •
Проект — это классификация, описывающая тип приложения .NET.
•
Решение — это классификация, обозначающая несколько взаимосвязанных приложений .NET.
Представьте себе процесс сборки автомобиля. В данном случае проектом может быть создание рулевого колеса, двигателя или кузова автомобиля. Сборка же всех компонентов-проектов вместе будет законченным решением, которое называется автомобилем. То есть проекты являются частями решения. Для примеров в этой главе решение будет содержать три проекта, по одному для каждого типа приложения. В Visual С# Express создание проекта неявно означает и создание решения, т. к. создание пустого решения, не содержащего проекта, не имеет смысла. Это было бы подобно сборке машины без частей. Когда в этой книге упоминается "проект" или "приложение", с точки зрения организации рабочего пространства эти термины
На
старт,
внимание,
марш!
13
обозначают одно и то же. Решение является явной ссылкой на один или несколько проектов или приложений. Наш план действий относительно проектов и решений в этой главе следующий: •
создать решение .NET, создав приложение Windows с названием Examplei (создание этого приложения также создает и решение);
•
добавить в созданное решение консольное приложение с названием Example2;
•
добавить в созданное решение проект библиотеки класса с названием Example3.
Создание приложения Windows Для создания приложения Windows выполните такую последовательность действий: 1. Выполните последовательность команд меню File | New Project. 2. В диалоговом окне New Project выберите пиктограмму Windows Application. Она представляет тип проекта на основе предопределенного шаблона, называемого Windows Application. 3. Измените название по умолчанию проекта на Examplei. 4. Нажмите кнопку ОК.
Рис. 1.2. Интегрированная среда разработки Visual С# Express с проектом и решением Examplei
14
Глава 10
Исполнение этих шагов одновременно создает новый проект и решение с названиями Examplei соответственно. В результате будут созданы готовый проект и решение (рис. 1.2).
Просмотр исходного кода При создании нового приложения Visual С# Express автоматически генерирует для него определенный исходный код. Этот исходный код можно просмотреть, выполнив двойной щелчок мышью по элементу Program.cs в панели Solution Explorer. В результате в левой, большей, панели среды вид формы будет заменен видом кода (рис. 1.3). ПРИМЕЧАНИЕ Чтобы переключаться между видом формы и видом кода, щелкните правой кнопкой мыши по элементу Form1.cs в панели S o l u t i o n Explorer. Появится контекстное меню, в котором будут команды V i e w C o d e (Вид кода) и V i e w D e s i g n e r (Вид формы).
Рис. 1.3. Исходный код в только что созданном проекте С#
Помеченные элементы на рис 1.3 представляют суть исходного кода С#, который мы будем писать. Мы будем изучать эти элементы на протяжении всей книги. На данный же момент ограничимся дополнительной информацией о двух из показанных на рис. 1.3 элементах исходного кода: • класс— организационная единица, которая группирует связанный код. Эта группировка намного специфичнее, чем решение или проект. Применяя аналогию с автомобилем снова, если проект является двигателем, тогда класс можно рассматривать как один из компонентов двигателя, например карбюратор. Иными словами, проекты состоят из множества классов;
На
старт,
внимание,
марш!
15
• метод — набор инструкций для выполнения определенного задания. Метод является аналогом функции во многих других языках программирования. Метод Main () исполняется при запуске приложения; поэтому он содержит код, который должен исполняться в начале программы.
Переименование решения При создании решения Visual С# Express автоматически присвоил одно и то же имя — Examplei — как проекту, так и решению, что не всегда является желательным. Но это не является проблемой, т. к. решению можно с легкостью присвоить другое имя. Для этого нужно выполнить такую последовательность шагов: 1. Щелкните правой кнопкой мыши по имени решения в Solution Explorer и в открывшемся контекстном меню выберите пункт Rename. 2. Теперь имя решения можно редактировать. Измените его на ThreeExamples. 3. Нажмите клавишу <Enter>, чтобы применить изменение. Таким же образом можно переименовывать проекты или любой другой элемент, отображаемый в Solution Explorer.
Сохранение решения После переименования решения хорошей идеей будет сохранить внесенные изменения. Чтобы сохранить проект, выполните такую последовательность действий: 1. Выделите имя решения в Solution Explorer. 2. Выберите команды File | Save ThreeExamples.sln. 3. Обратите внимание на то, что Visual С# Express хочет сохранить решение под его старым именем Examplei, а не новым именем ThreeExamples. Чтобы сохранить новое имя решения на жесткий диск, необходимо снова изменить Examplei на ThreeExamples. Запомните путь, по которому Visual С# Express сохраняет ваши проекты, т. к. вам время от времени может понадобиться эта информация. 4. Нажмите кнопку Save. При успешном сохранении решения и проекта в строке состояния в левом нижнем углу окна выводится сообщение "Item(s) Saved". В будущем решение и проект можно сохранять с помощью комбинации клавиш +<S>. ПРИМЕЧАНИЕ Если при выходе из Visual С# Express имеются несохраненные изменения, то система выведет диалоговое окно с запросом, следует ли сохранить решение и проект.
Ранее сохраненное решение можно открыть, выполнив последовательность команд меню File | Open Project и указав путь к файлу решения. Решение можно также открыть, выбрав его в панели Recent Projects при запуске Visual С# Express.
16
Глава 10
Панель Recent Projects также всегда присутствует на вкладке Start Page главного окна Visual С# Express.
Выполнение приложения Windows Исходный код, сгенерированный Visual С# Express, представляет базовое приложение, содержащее пустое окно. Этот исходный код предоставляет отправной пункт, в котором можно добавлять дополнительный исходный код, отлаживать имеющийся исходный код и исполнять приложение. Для исполнения приложения выполните последовательность команд меню Debug | Start Without Debugging. Приложение также можно запустить на исполнение с помощью комбинации клавиш +. Работающее приложение в своем текущем виде выведет на* экран пустое окно (рис. 1.4). Это окно имеет точно такие лее свойства, как и главное окно любого другого приложения. В частности, чтобы его закрыть и остановить приложение, нужно нажать стандартную кнопку закрытия окна.
Рис. 1.4. Исполняющееся приложение
Исполнение приложения позволяет увидеть, исполнение из среды разработки идентично способом, например двойным щелчком по приложение Examplei выводит пустое окно,
что оно делает. Запуск приложения на запуску на исполнение любым другим его пиктограмме. В данном примере, имеющее минимальную функциональ-
На
старт,
внимание,
марш!
17
ность и базовый набор элементов управления. На данном этапе вся функциональность, предоставляемая исходным кодом, состоит в выводе пустого окна, которое имеет только кнопки для его сворачивания, восстановления и закрытия. Таким образом, не написав ни единой строчки кода, вы получили исполняемое приложение. Это оказалось возможным благодаря тому, что Visual С# генерирует шаблонный код для каждого приложения, который сразу же можно исполнять. Таким образом, вы уже создали приложение, просмотрели его исходный код и исполнили его. Все эти действия осуществлялись в контексте удобной, выполняющей все ваши требования среды разработки Visual С# Express. Visual С# Express — одновременно и хорошая, и плохая вещь по одной и той же причине: она скрывает все запутанные подробности. Представьте себе, что вы автомобильный механик и вам нужно починить автомобиль. В современных автомобилях проблемы указываются с помощью индикаторов на панели. Этого достаточно для водителя, чтобы знать, что машину нужно починить. Но для этого он пригласит механика, а для механика одних индикаторов далеко не достаточно, чтобы составить полную картину неисправности.
Заставляем приложение сказать "Hello" Наше приложение Windows в его теперешнем виде не делает ничего, кроме вывода пустого окна, которое можно свернуть, восстановить и закрыть. Чтобы приложение делало что-либо полезное, необходимо вставить в форму элементы интерфейса и добавить исходный код. Давайте оснастим приложение кнопкой, при нажатии которой приложение будет выводить в текстовое поле сообщение "hello, world". Для этого нам сначала нужно вставить в форму элемент управления Button. Выполните двойной щелчок мышью по элементу Forml.cs в панели Solution Explorer, чтобы переключиться в вид формы. После этого щелкните по вкладке Toolbox, чтобы открыть панель с элементами управления. На панели Toolbox щелкните по элементу управления Button, после чего щелкните по форме, чтобы поместить кнопку на форму (рис. 1.5). Датее, с помощью такой же процедуры, добавьте в форму элемент управления TextBox. Наконец, выровняйте кнопку и текстовое поле, как показано на рис. 1.6. Для перемещения элемента управления по форме наведите на него указатель мыши, который при этом принимает вид двух скрещенных под прямым углом стрелок. Теперь нажмите левую кнопку мыши и перетащите элемент управления в нужное место на форме, после чего отпустите кнопку мыши. При перетаскивании элемента управления по форме Visual С# Express выравнивает его края с краями близлежащего элемента управления, поэтому выравнивание элементов управления не составляет особого труда. Запустив приложение Examplei теперь, вы увидите окно с кнопкой и текстовым полем на ней. Кнопку можно нажимать, а в поле вводить текст. Но нажатие кнопки не дает никаких результатов, а с текстом в поле ничего нельзя делать, т. к. с этими элементами управления не было ассоциировано никакого кода.
Глава 10
18
Рис. 1.5. Добавление кнопки в форму
Рис. 1.6. Вставка в форму кнопки и текстового поля
На
старт,
внимание,
марш!
19
Чтобы заставить приложение делать что-либо, необходимо думать в терминах событий. Например, если дверь вашего гаража управляется дистанционно, то вы ожидаете, что нажатие кнопки на пульте управления вызовет открытие двери, когда она закрыта, и закрытие, когда она открыта. Изготовитель этой автоматически открываемой гаражной двери ассоциировал событие нажатия кнопки на пульте дистанционного управления с действиями закрытия или открытия двери. В приложении Examplei мы ассоциируем событие нажатия кнопки с действием вывода текста в текстовом поле. Дважды щелкните по кнопке в форме. Откроется исходный код с курсором, находящимся в теле функции button_ciick. Вставьте в это место в функции следующий исходный код (рис. 1.7): textBoxl.Text = "hello world";
Рис. 1.7. Ассоциирование события нажатия кнопки с действием вывода текста в текстовом поле
Обратите внимание на то, что имя вставленного в форму текстового поля — textBoxl. Это имя было сгенерировано Visual С# Express, как и имя для кнопки. Имена, которые Visual С# Express присваивает по умолчанию элементам управления, можно изменить (посредством окна Properties соответствующего элемента управления), но в данном случае были оставлены имена по умолчанию.
20
Глава 1
При следовании инструкций, показанных на рис. 1.7, ассоциирование действия с событием не составляет никакого труда. Но эта легкость достигается благодаря возможностям Visual С# Express, а не потому, что лежащий в основе процесс является простым. Visual С# Express предполагает, что при двойном щелчке по элементу управления вы хотите модифицировать событие по умолчанию данного элемента управления, и поэтому автоматически генерирует код в шаге 3 на рис. 1.7. Для кнопки событием по умолчанию является событие щелчка (click event), т. е. событие, соответствующее нажатию пользователем левой кнопки мыши. Предположение о том, что событие щелчка является событием по умолчанию для левой кнопки мыши, вполне логично. Другие элементы управления имеют иные события по умолчанию. Например, в результате двойного щелчка по элементу управления TextBox будет сгенерирован код для события изменения текста (text-changed event). Запустите приложение на исполнение и нажмите кнопку в форме. В текстовом поле выводится текст "hello world". Поздравляем, вы только что создали ваше первое приложение в С#! Вы ассоциировав событие с действием: щелчок левой кнопкой мыши с выводом текста. Ассоциирование событий с действиями является основой всех приложений Windows.
Вставка в приложение комментариев Имея работающую программу, будет неплохой идей задокументировать то, что она делает прямо по месту, т. е. в исходном коде. Таким образом, если вам придется работать над поддержкой этого приложения в будущем, вы сможете быстро восстановить в памяти, что и как данный код делает. Кроме этого, выполнять поддержку вашей программы может другой программист, поэтому, вставляя комментарии в свой исходный код, вы поможете ему разобраться в работе вашей программы. Но даже если вы знаете, что выполнять поддержку программы будете вы сами, воспринимайте себя как незнакомца. Для вас будет сюрпризом обнаружить, как трудно разобраться в своем же коде, написанном несколько месяцев, не говоря уже о нескольких годах, тому назад. Для вставки комментария длиной в одну строчку используется следующий синтаксис: // Однострочный комментарий
Компилятор игнорирует все, что идет после двойной косой черты ( / / ) , и не включает его в конечное приложение. Давайте задокументируем наше приложение Windows следующим образом: // Когда пользователь нажимает кнопку, выводим текст в текстовом поле. private void buttonl_Click(object sender, EventArgs e) { textBoxl.Text = "hello world"; }
гI На старт, внимание, марш!
21
Всегда полезно оставлять простые комментарии по ходу написания программы, т. к. они значительно облегчают понимание логики приложения. Но что если необходимо вставить более подробное объяснение, которое не помещается в одну строчку? В таком случае применятся многострочный комментарий: /* Первая строка многострочного комментария. * Вторая строка. * Третья строка. */
В этот раз, комментарий начинается символами /* и заканчивается символами */. Как и в случае С однострочными комментариями, компилятор игнорирует все, что находится между этими парами символов. Обратите внимание на то, что звездочки в начале второй и третьей строк комментария добавлены средой Visual С# Express просто для оформления и не являются необходимыми в многострочном комментарии. Давайте вставим многострочный комментарий в наше приложение Windows: namespace Examplei { /* Пример простой формы, которая выводит текст, * когда пользователь нажимает кнопку. * Это наше первое знакомство с событийно-управляемым программированием. */ public partial class Forml : Form { public Forml () { InitializeComponent(); } // Когда пользователь нажимает кнопку, // выводим текст в текстовом поле. private void buttonl_Click(object sender, EventArgs e) { textBoxl.Text = "hello world"; } } }
.Существуют также другие виды комментариев, с помощью которых Visual С# может предоставлять дополнительную информацию в своем графическом интерфейсе пользователя. Эти комментарии рассматриваются в главе 10. 2 Зак. 555
Глава 10
22
Перемещение по пользовательским элементам управления решения При создании кода в среде разработки наиболее важным средством перемещения по решению является окно Solution Explorer. Solution Explorer представляет собой элемент управления, содержащий ссылки на решения и проекты в виде древовидной структуры. Solution Explorer можно рассматривать как приборную панель разработчика, которую можно использовать для тонкой настройки сборки и исполнения приложений .NET. Я советую вам посвятить некоторое время исследованию Solution Explorer. В частности, пощелкайте правой кнопкой мыши по его разным элементам. Контекстнозависимый щелчок является быстрым способом выполнить тонкую настройку определенных аспектов решения и проекта. Но не нажимайте кнопку ОК в любом открывшемся по щелчку диалоговом окне; пока что нажимайте кнопку Cancel, чтобы не применять никаких сделанных в процессе экспериментирования модификаций. Слева от панели Solution Explorer находится рабочая область. Она применяется для написания кода и редактирования пользовательского интерфейса. В рабочей области можно отображать только один информационный аспект, которым может быть код, пользовательский интерфейс или проект. Как мы видели ранее, в результате двойного щелчка по элементу Program.cs в Solution Explorer в рабочей области в правой панели выводится код, связанный с файлом Program.cs. Файл Program.cs является простым файлом исходного кода (plain-vanilla source code file) проекта Examplei. Простые файлы исходного кода не имеют специального представления в Visual С# Express и просто содержат исходный код. Файл Program.cs содержит исходный код для выполнения инициализации приложения и выглядит следующим образом: using
System;
using
System.Collections.Generic;
using
System.Linq;
us ing
Sys tem.Windows.Forms;
namespace Examplei { static class Program { III
<summary>
III Главная точка входа приложения. Ill [STAThread] static void MainO {
Простые файлы исходного кода содержат логику, с помощью которой приложение выполняет требуемые действия. Преимущество простых файлов исходного кода состоит в том, что они предоставляют полный вид логики приложения. Типичное приложение содержит многочисленные простые файлы исходного кода. Solution Explorer также отображает специализированные группирования, являющиеся специфичными элементами, которые Visual С# Express распознает и упорядочивает. Специализированное группирование содержит определенное количество взаимозависящих файлов, которые реализуют специфическую функциональность. Примером специализированного группирования является элемент Forml, который управляет организацией пользовательского интерфейса, элементами пользовательского интерфейса и специализированного кода. Отдельные файлы группирования Forml и их назначение показаны на рис. 1.8.
Рис. 1.8. Специализированное группирование, содержащее три файла
На рис. 1.8 показан элемент высшего уровня Forml.cs, который является файлом исходного кода и содержит определяемые пользователем компоненты Forml.
24
Глава 10
Элемент Formi можно представить графически (форма) и в виде текста тировать файл Forml.cs, с помощью доставим Visual С# Express работать
в рабочей области одним из двух способов: (исходный код). В основном мы будем редакисходного кода и графических средств, и прес файлами Forml.Designer.es и Forml.resx.
Специализированное группирование Formi предназначено для облегчения упорядочивания кода, который представляет пользовательский интерфейс Formi как для разработчика, так и для среды разработки. Но это не означает, что разработчик не может редактировать файлы Formi.Designer.cs и Forml.resx. Двойной щелчок мышью по элементу Forml.Designer.cs в Solution Explorer выведет в рабочей области соответствующий исходный код, который можно редактировать. Но предупреждаю вас заранее: если вы накуролесите с исходным кодом в этом файле, то Visual С# Express может оказаться не в состоянии должным образом редактировать Formi. Зная, что специализированное группирование Formi должно рассматриваться как единое целое, вы можете задаваться вопросом, откуда взялось определение элемента textBoxi. Этот элемент определен и назначен в одном из исходных файлов, сгенерированных средой разработки. На рис. 1.9 показано, что сгенерированный исходный код делает с элементом textBoxi.
Рис. 1.9. Исходный код для textBoxi, сгенерированный средой разработки
Обратите внимание на то, что все аспекты — определение, ассоциирование событий с действиями и размещение элементов управления — управляются средой
На
старт,
внимание,
марш!
25
Visual С# Express. Например, если иначе расположить элемент управления textBoxi, изменив его координаты, то Visual С# Express прочитает и обработает изменения. Но внесение в исходный код данного элемента более объемных изменений, которые Visual С# Express не сможет обработать, нарушит целостность пользовательского интерфейса. Ну вот, теперь вы имеете представление о том, как работает интегрированная среда разработки Visual С# Express, и мы можем перейти к рассмотрению примеров приложений других типов. Следующим на очереди — консольное приложение.
Создание консольного приложения Консольное приложение — это приложение с текстовым интерфейсом. Это означает, что вместо графического интерфейса, взаимодействие с пользователем происходит посредством текстовых указаний, вводимых в командную строку, и вся информация, выдаваемая приложением, также выводится на экран в виде текста. Консоль является очень старым интерфейсом, т. к. она была самым первым способом общения пользователя с компьютером. Консольный интерфейс не очень удобен для пользователя, и работа с ним становится очень трудоемкой для любой более-менее сложной операции. Тем не менее, некоторые личности утверждают, что консоль — это весь интерфейс, который требуется. (Дополнительную информацию о консольном интерфейсе см. в Интернете по адресу http://en.wikipedia.org/wiki/ Command_line_interface.) Чтобы открыть консоль в Windows, выберите Пуск | Стандартные | Командная строка (Start | Accessories | Command Prompt) или выполните Пуск | Выполнить (Start | Run) и введите cmd в текстовое поле. С помощью Visual С# Express можно создавать и компоновать консольные приложения, а также управлять ими.
Добавление консольного приложения в решение Наше консольное приложение будет выполнять то же самое, что и приложение Windows— выводить текст "hello, world", но только не в тестовое поле, а в консоль. Чтобы добавить новый проект, составляющий консольное приложение, в решение ThreeExampies, выполните такую последовательность действий: 1. Щелкните правой кнопкой мыши по имени решения ThreeExampies в Solution Explorer. 2. В открывшемся контекстном меню выберите пункты Add | New Project. 3. В панели Templates открывшегося окна Add New Project выберите компонент Console Application. В поле Name измените имя приложения на Exampie2. По нажатию кнопки ОК новый проект отображается в панели Solution Explorer, а в рабочей области появится исходный код файла Program.cs этого проекта.
Глава 10
26
Обратите внимание на простоту консольного приложения. Оно содержит только один простой файл исходного кода, Program.cs. Консольные приложения обычно не имеют никаких специализированных группирований и никаких событий.
Заставляем консольное приложение сказать "Hello" Чтобы заставить консольное приложение выполнять какую-либо операцию, необходимо в его метод Main о добавить соответствующий исходный код. Например, следующий: namespace Example2 { class Program { static void Main(string[] args) { Console.WriteLine("hello, world"); } } }
Выделенная жирным шрифтом строчка кода выводит в консоль текст "hello, world". Но если вы попытаетесь исполнить консольное приложение из среды разработки одним из способов, с помощью которых мы запускали приложение Windows в предыдущем примере, то ничего не получится: запустится то же приложение Windows. В следующем разделе показано, как запустить консольное приложение на исполнение из среды разработки.
Установка стартового проекта Чтобы исполнить консольное приложение из среды разработки, его необходимо задать в качестве стартового проекта (startup project). Посмотрите на названия проектов в Solution Explorer. Обратите внимание на то, что проект Examplei отображается жирным шрифтом. Это означает, что Examplei является стартовым проектом. Иными словами, при запуске приложения из среды разработки на исполнение или отладку исполняется или выполняется отладка стартового проекта. Чтобы сделать Exampie2 стартовым проектом, щелкните правой кнопкой мыши по элементу Exampie2 в Solution Explorer и в открывшемся контекстном меню выберите пункт Set As Startup Project. Теперь жирным шрифтом отображается проект Exampie2. Это означает, что он является стартовым проектом решения ThreeExamples.
На
старт,
внимание,
27
марш!
Запуск консольного проекта на выполнение Теперь, когда Exampie2 установлен в качестве стартового проекта, консольное приложение можно исполнить, нажав комбинацию клавиш +. Приложение 2
выводит на экран следующий текст : hello, world Press any key to continue
При исполнении консольного приложения не создается окно, как для приложения Windows. Вместо этого в проекте Exampie2 в качестве исполняемого приложения запускается окно командной строки, в котором отображается текст "hello, world". Кроме этого, выводится текст, указывающий, каким образом закрыть окно консольного приложения. Код для вывода этой инструкции и исполнения указанного в ней действия был автоматически сгенерирован Visual С# Express. В общем, возможности консольного приложения довольно ограничены, но оно предоставляет легкий способ для выполнения определенных задач.
Создание библиотеки класса Наш третий пример не является приложением .NET; это разделяемая функциональность, которая обычно называется библиотекой класса (class library). Приложения Windows и консольные приложения можно выполнить в Проводнике Windows или из командной строки. Но библиотеку класса запустить на исполнение пользователь не может; это можно только сделать из приложения этих двух типов. Библиотека класса является удобным хранилищем для кода, который используется в нескольких приложениях.
Добавление библиотеки класса в решение Приступим к созданию библиотеки класса, которая может совместно использоваться в нашем приложении Windows и консольном приложении. Чтобы добавить новый проект, составляющий библиотеку класса, в решение ThreeExamples, выполните такую последовательность действий: 1. Щелкните правой кнопкой мыши по имени решения ThreeExamples в Solution Explorer. 2. В открывшемся контекстном меню выберите пункты Add | New Project. 3. В панели Templates открывшегося окна Add New Project выберите компонент Class Library. В поле Name измените имя приложения на Exampie3. Добавленный проект должен отобразиться в решении (рис. 1.10). 2
В русских версиях Windows сообщение будет "Для продолжения нажмите любую клавишу. ..". — Пер.
28
Глава 10
Рис. 1.10. Структура решения, содержащего все три проекта
Проект Exampie3 содержит единственный файл Classl.cs, который является простым файлом исходного кода.
Перемещение функциональности Теперь мы переместим код, ответственный за вывод текста "hello, world", из Exampie2 в Exampie3. Для этого вставьте в исходный код в файле Classl.cs код, выделенный жирным шрифтом: using System; using System.Collections.Generic; using System.Text; namespace Example3 { public class Classl { public static void HelloWorldO { Console.WriteLine("hello, world"); } } }
Вставленный код содержит метод HeiioWorid*). При вызове этого метода он выводит текст "hello, world". Как было сказано ранее в этой главе, метод представляет собой набор инструкций для выполнения определенной задачи. Более подробно методы рассматриваются в главе 2.
На
старт,
внимание,
марш!
29
Для того чтобы приложения могли совместно использовать код библиотеки класса, необходимо, чтобы проекты знали о существовании друг друга. Это достигается посредством ссылок.
Определение ссылок Чтобы один проект знал об определениях в другом проекте, необходимо определить ссылку. Концепция ссылки заключается в том, чтобы указать, что проект знает о другой функциональности. ПРИМЕЧАНИЕ Проект знает только о функциональности, которая было объявлена открытой (public). Открытая функциональность, или как еще говорят программисты в С# открытая область видимости, получается в результате объявления типа с помощью ключевого слова public. Открытая область видимости и другие типы областей видимости рассматриваются на протяжении всей книги.
Чтобы проект Exampie2 знал о функциональности, содержащейся в файле Classl.cs, необходимо установить физическую ссылку следующим образом: 1. Разверните узел References проекта Exampie2, щелкнув по его значку со знаком "плюс". Обратите внимание, что уже существуют три ссылки. Когда вы ввели текст console.writeLine() в код файла Classl.cs, то использовали функциональность, предоставляемую пространством имен system. 2. Щелкните правой кнопкой мыши по элементу References и выберите опцию Add Reference. 3. Щелкните по вкладке Projects. 4. Выберите Exampie3, после чего нажмите кнопку ОК. В результате этих действий проект Example3 будет добавлен В ССЫЛКИ проекта Example2. После установки ссылки проект Example2 может вызывать функциональность проекта Example3. ПРИМЕЧАНИЕ В файле Class1.cs первые три строчки начинаются с ключевого слова using. Оно сообщает Visual С# Express, что вы хотите использовать функциональность, определенную в ссылке на ресурс после ключевого слова using. В этом примере мы не использовали этот быстрый способ создания ссылки на функциональность, с тем, чтобы показать другой способ ее создания.
Вызов функциональности библиотеки класса Теперь нам необходимо модифицировать проект Exampie2, чтобы он вызывал функцию в проекте Exampie3. Для этого необходимо вставить в исходный код файла Program.cs проекта Exampie2 код, выделенный жирным шрифтом: using System; using System.Collections.Generic;
30
Глава 10
using System.Text; namespace Example2 { class Program { static void Main(string[] args) { Console.WriteLine("hello, world"); Ехапч?1еЗ. Claaal. HelloWorld() ; } } }
Запустите приложение проекта Example2 на исполнение. Должно открыться окно командной строки, в котором дважды выводится текст "hello, world". Первое "hello, world" сгенерировано кодом console. WriteLine о, а в т о р о е — вызовом функции Example3.Classl.HelloWorld().
Быстрый способ указания ссылок В Exampie3 .classl .HelloWorld*) применяется полная ссылка на ресурс. Если бы
такой формат ссылки был использован для вызова метода console .WriteLine (), то его пришлось писать в виде system, console. WriteLine о, т . к . метод console.WriteLine() находится в пространстве имен System. Но так как мы ис-
пользовали строчку кода using system, нам не нужно вызывать этот метод таким способом. Чтобы вызвать методы проекта Exampie3 быстрым способом, нужно вставить дополнительную строчку using в начале исходного кода файла Program.cs проекта Exampie2 и отредактировать вызов метода HelloWorld о класса classl, как указа-
но жирным шрифтом в следующем коде: using System; using System.Collections.Generic; using System.Text; using Example3; namespace Example2; { class Program { static void Main(string[] args) { Console.WriteLine("hello, world");
На
старт,
внимание,
марш!
31
Classl.HelloWorld(); } } }
Но применение такого способа ссылок на ресурсы имеет свои недостатки. Что если у нас имеется несколько ссылок на ресурс, содержащий класс classl? В этом случае, для того чтобы среда Visual С# Express могла знать, какой класс имеется в виду в каждом конкретном случае, необходимо использовать полный формат ссылки. Конечно же, маловероятно, что кто-либо может назвать несколько классов одним именем classl, но в коллекции ссылок существует возможность дублирования даже смысловых имен классов. А если вы ссылаетесь на чей-то другой код, то вероятность существования дубликатов имен повышается.
Использование переменных и констант Одной из основных концепций в программах на С# является использование переменных. Переменную удобно рассматривать как область памяти, в которой можно хранить данные для дальнейшего использования. Это позволят с легкостью перемещать данные внутри программы. Работать с проектом Ехашр1еЗ было бы несколько легче, если бы мы могли определить выводимое сообщение в начале метода. Таким образом, в случае необходимости изменить сообщение, это можно было бы сделать намного легче. В его теперешнем виде, если мы добавим дополнительный код перед вызовом метода Console.WriteLineO, то нам придется прокручивать исходный код для того, чтобы найти текст сообщения, который нужно изменить. Идеальным решением этой проблемы будет использование переменной, т. к. мы можем определить необходимые данные (в данном случае — выводимое сообщение), которые мы можем использовать в программе позже, namespace Example3 ( public class Classl ( public static void HelloWorldO { // Переменная, хранящая выводимое сообщение, string message = "hello, world"; Console.WriteLine(message); } } }
В предыдущем коде мы определили строковую переменную (string variable) message. После этого мы можем в дальнейшем ссылаться на переменную message,
32
Глава 10
когда нам необходимо поместить ее содержимое в код. В примере содержимое переменной message помещается в список параметров при вызове метода Console .WriteLine О, который в данном случае работает таким же образом, как и раньше. Но на этот раз мы определили выводимое сообщение в отдельном операторе. Таким образом, использование переменной может быть очень полезным. Но переменные также имеют и другие аспекты, а именно свойство, называемое областью видимости. Переменная message имеет область видимости на уровне метода. Это означает, что она доступна только внутри метода, в котором она определена. Рассмотрим следующий код: public static void HelloWorld() { // Выводимое сообщение. string message = "hello, world"; Console.WriteLine(message); } public static void DisplayMessageText() { Console.WriteLine("The message text is: "); Console.WriteLine(message);
> Метод DisplayMessageText () сообщает нам о содержимом сообщения, выводя на экран две строчки текста. Но компилятор отказывается компилировать этот код, т. к. он знает, что переменная message недоступна методу DisplayMessageText() по причине ограничения ее области видимости методом HelloWorld (). Чтобы исправить эту проблему, переменной message необходимо присвоить область видимости на уровне класса, для чего ее нужно переместить в начало Определения класса. (Так как эта переменная используется методами, обозначенными static, ее также необходимо обозначить static.) public class Classl { // Выводимое сообщение. static string message = "hello, world"; public static void HelloWorld() ( Console.WriteLine(message); } public static void DisplayMessageText()
На
старт,
внимание,
марш!
33
{ Console.WriteLine("The message text is: "); Console.WriteLine(message); } }
Теперь переменную message могут использовать все методы класса Classl. Вы узнаете намного больше об области видимости на уровне метода и на уровне класса, а также о ключевых словах public и static по мере освоения материала в этой книге. В то время как совместное использование переменной несколькими методами класса может быть полезным, иногда это не является благоразумным. Методы могут изменять значение переменных в процессе обработки, что может вызвать непредсказуемые результаты в дальнейшем. Чтобы предотвратить изменения значения, следует вместо переменной использовать константу. Константы объявляются с помощью ключевого слова const: // Выводимое сообщение. const string MESSAGE = "hello, world"; public static void HelloWorld() { Console.WriteLine(MESSAGE); } public static void DisplayMessageText() ( Console.WriteLine("The message text is: "); Console.WriteLine(MESSAGE); }
Имена констант всегда должны задаваться прописными буквами. Изменить значение константы нельзя ни при каких обстоятельствах. Так, например, следующий код не скомпилируется: // Выводимое сообщение, const string MESSAGE = "hello, world"; public static void HelloWorldO ( MESSAGE = "goodbye, world"; Console.WriteLine(MESSAGE); }
А теперь, получив немного практического опыта работы с С#, рассмотрим, каким образом код С# в Visual С# Express превращается в программу, которая может исполняться под управлением операционной системы Windows.
34
Глава 10
Как работает .NET Framework? Когда вы пишете исходный код на С#, то создаете инструкции для исполнения программой. Инструкции определяются с помощью языка программирования С#, который, в общем, понятен людям, но совсем непонятен компьютерам. Компьютеры не понимают информацию в виде текста, они понимают единицы и нули. Чтобы вводить инструкции в компьютер в понятной для него форме, был разработан высокоуровневый механизм, который преобразовывает текстовые инструкции в формат, понимаемый компьютером. Этот преобразовывающий инструмент называется компилятором. Но особенность .NET, в отличие от традиционных языков программирования, таких как С++ и С, состоит в том, что компилятор генерирует двоичный промежуточный код на языке CIL (Common Intermediate Language). .NET Framework потом преобразовывает инструкции из формата CIL в двоичные инструкции, требуемые для процессора. С первого взгляда может показаться, что преобразование исходного кода в промежуточный код является неэффективным, но в действительности это хороший подход. Рассмотрим этот вопрос с помощью аналогии. Некоторые собаки дрессируются быстро, а некоторым необходимо дополнительное время. Например, немецкие овчарки обычно быстро обучаемы и им не нужно повторение уроков. С другой стороны, с бульмастифами нужно быть особенно терпеливым, т. к. они имеют склонность к упрямству. Теперь представьте себе, что были разработаны инструкции специально для дрессировки бульмастифов. Если по этим инструкциям дрессировать немецкую овчарку, то ей от этой дрессировки очень быстро станет скучно, и в результате вы можете не научить ее тому, чему собирались. Проблема здесь заключается в том, что инструкции были настроены для дрессировки конкретной породы собак. Для дрессировки двух разных пород требуются две разные инструкции. Но можно также применить и одни, общие, инструкции, но с примечаниями для конкретной породы, например: "Если собака упрямится, то повторите упражнение". Применительно к компьютерам, для разных процессоров применяются различные наборы команд, или для процессоров одного типа, применяемых для выполнения задач разных типов, применяются разные подмножества набора команд. Например, требования к серверным компьютерам отличаются от требований к клиентским компьютерам. Для серверных компьютеров требуется скорость при обработке данных, в то время как для клиентских компьютеров скорость нужна при выводе данных на экран. Для разных типов задач существуют свои компиляторы, но было бы неэффективным пытаться создавать отдельные дистрибутивы приложения под разные компиляторы или под разные установки одного компилятора. Выходом из этой ситуация является создание одного набора общих инструкций, но содержащих интерпретационные примечания. .NET Framework выполняет эти инструкции, используя интерпретационные примечания.
На
старт,
внимание,
35
марш!
Исходный код компилируется в инструкции на языке CIL, которые потом преобразуются в специфичные для конкретного процессора инструкции с помощью этих интерпретационных примечаний. Архитектура .NET показана на рис. 1.11.
Рис. 1.11. Архитектура NET
На рис. 1.11 показано, что среда Visual С# Express является ответственной за преобразование исходного кода на языке С# в пакет CIL. Пакет CIL представляет собой двоичный файл, для исполнения которого требуется среда CLR (common language runtime, общеязыковая среда исполнения). Если на компьютере не установлена среда CLR, то пакет C1L исполняться на нем не будет. Среда CLR устанавливается в фоне как отдельный компонент при инсталляции Visual С# Express. Кроме того, что Visual С# Express позволяет разрабатывать приложения для среды, CLR и сама использует эту среду. Среда CLR позволяет преобразовывать инструкции в пакете C1L в формат, понимаемый процессором и операционной системой. Если вы сравните синтаксисы разных языков .NET, таких как Visual Basic, С# или Eiffel.NET, то увидите, что они отличаются друг от друга. Но среда CLR может работать с пакетами CIL, созданными на любом из этих языков, т. к. независимо от языка программирования, компилятор .NET генерирует набор инструкций, общих для среды CLR. Программы, разрабатываемые с помощью .NET Framework, создаются для среды CLR, поэтому все в них должно быть понятным для этой среды. В общем, это
36
Глава 10
требование не является проблемой при создании кода на языке С#. Далее приводится список некоторых преимуществ кода, предназначенного для исполнения в среде CLR. •
Управление памятью и сборка мусора. Программы используют ресурсы, такие как память, файлы и т. п. В традиционных языках программирования, таких как С и С++, задачи открытия и закрытия файлов, выделения и освобождения памяти являются ответственностью программиста. В .NET программисту нет надобности беспокоиться о закрытии файлов или освобождении памяти. Среда CLR знает, когда файл или память больше не используется, и автоматически закрывает файл или освобождает память. ПРИМЕЧАНИЕ Некоторые опытные программисты думают, что среда CLR способствует неряшливому программированию, т. к. с ней программисту не нужно убирать за собой. Но практика показывает, что для любого сложного приложения, поиски причин проблем, вызываемых неосвобожденной памятью, ведут к потере времени и ресурсов.
•
Оптимизация под специфические требования. Одним программам нужно обрабатывать большие объемы данных, например, записи в базе данных, а другим — предоставлять сложный пользовательский интерфейс. В каждом случае производительность фокусируется на разный тип кода. Среда CLR может оптимизировать пакет CIL и решить, какой способ исполнения будет для него наиболее быстрым и эффективным.
•
Система общих типов (common type system, CTS). Строка в Visual Basic такая же, как и строка в С#. Таким образом, обеспечивается правильное взаимодействие пакета CIL, сгенерированного в С#, с пакетом CIL, сгенерированным в Visual Basic, и избежание неправильного представления типов данных.
•
Безопасный код. Если программа взаимодействует с файлами или памятью, существует вероятность, что ошибка в программе может вызвать проблемы безопасности. Злоумышленники могут воспользоваться этой ошибкой, чтобы исполнить свои программы, что может вызвать серьезные отрицательные последствия. Среда CLR не может предотвратить ошибок, создаваемых приложением впоследствии, например, неправильного обращения к файлу или памяти, но она может остановить и взять под контроль программу, сгенерировавшую эту ошибку.
Преимущество среды CLR состоит в том, что она позволяет разработчикам фокусироваться на проблемах, связанных с приложением, т. к. им не нужно беспокоиться об аспектах, связанных с инфраструктурой. Со средой CLR разработчик может фокусироваться на коде для считывания и обработки содержимого файла. Без среды CLR разработчику нужно было бы также создавать код открытия, считывания и закрытия файла.
На
старт,
внимание,
марш!
37
Советы разработчику В этой главе мы начали работать с языком программирования С#. используя интегрированную среду разработки. Приведем ключевые аспекты главы, которые следует запомнить. •
В С# имеются три основные типа программ: приложения Windows, консольные приложения и библиотеки классов.
•
Приложение Windows имеет пользовательский интерфейс и работает, как любое другое приложение Windows (например, Блокнот или Калькулятор). Основным свойством приложений Windows является ассоциирование событий с действиями.
•
Консольное приложение проще, чем приложение Windows, и в нем не используются события. Эти приложения применяются для обработки данных. Консольные приложения принимают данные из командной строки и выводят данные в окно командной строки.
•
Для управления кодирования, отладки и исполнения приложения следует пользоваться интегрированной средой разработки.
•
Среди всего прочего, интегрированная среда разработки упорядочивает исходный код с помощью решений и проектов.
•
В ней также можно применять комбинации клавиш для упрощения выполнения повторяющихся операций. Например, в Visual С# Express изменения в проекте можно сохранить с помощью комбинации клавиш +<S>, а с помощью комбинации клавиш + приложение можно запустить на исполнение без отладки.
•
Проекты Visual С# Express содержат простые файлы исходного кода и специализированные группирования. При работе со специализированными группированиями убедитесь в том, что вы понимаете функционирование этих группирований, и модифицируйте только те файлы, которые предназначены для изменения программистом.
Вопросы и задания для самопроверки Далее приводится несколько вопросов по материалу, представленному в этой главе. Попробуйте ответить на эти вопросы. Это поможет вам начать разрабатывать проекты в интегрированной среде разработки. ПРИМЕЧАНИЕ Ответы/решения на вопросы/упражнения в конце каждой главы можно загрузить в разделе Source Code | Download Web-сайта издательства Apress (http://www.apress.com). Кроме этого, можно послать автору сообщение электронной почты по адресу [email protected].
1. В интегрированной среде разработки решения и проекты применяются для классификации родственных компонентов функциональности. Взаимосвязь
38
Глава 10
между решениями и проектами аналогична взаимосвязи, например, между автомобилем и его компонентами. Создали бы вы решение, содержащее несвязанные компоненты? Например, попытались бы вы создать авиационное решение, содержащее компоненты автомобиля? 2. Проекты основываются на шаблонах, предоставляемых Microsoft. Можете ли вы назвать ситуацию, для которой вы бы создали свой шаблон и добавили бы его в набор шаблонов Visual С# Express? 3. В Solution Explorer каждый узел дерева представляет один элемент (такой как файл, элемент управления пользовательского интерфейса и т. п.). Двойной щелчок мышью по cs-файлу открывает для манипулирования файл, содержащий исходный код на С#. Должен один файл С# содержать обращение только к одному классу С# илй к одному пространству имен? Если не должен, то каким образом вы организовали бы свой код С# по отношению к файлам С#? 4. Вы теперь знаете, каким образом приложение .NET генерирует исполняемый файл. Допустим, что вы попробуете исполнить сгенерированное приложение на другом компьютере под управлением Windows. Будет ли эта попытка успешной? Предположим, что вы попробуете исполнить это же приложение на компьютере под управлением операционной системы OS X или Linux. Будет ли эта попытка успешной в этом случае? Почему? 5. Вам не нравится имя textBoxl элемента управления, и вы хотите переименовать его в txtoutput. Каким образом вы это сделаете? 6. Проект Ехашр1еЗ содержит встроенную логику, которая предполагает, что метод вызывается консольным приложением. Полезно ли в случае с библиотекой предполагать специфический тип или логику вызывающего эту библиотеку приложения? Почему?
Глава 2
Типы данных в .NET
В предыдущей главе мы рассмотрели использование Visual С# Express для создания приложений трех типов, а также основные компоненты .NET Framework — язык CIL и среду CLR. В этой главе мы засучим рукава и приступим к написанию настоящего кода на языке С#. В частности, мы напишем программу Калькулятор. Калькулятор является идеальным примером, с которого удобно начать писать настоящие программы, т. к. он позволяет программисту фокусироваться на аспектах приложения и не беспокоиться обо всех малоприятных деталях взаимодействия программы с системой. В таком языке программирования, как С#, сложение двух чисел — тривиальная задача. Но воплощение операции сложения двух чисел в программу тривиальным не является. В этой главе мы сосредоточимся на механике написания программ на С#, а именно каким образом идея воплощается в программу на С#, которая выполняет задуманные программистом действия. Мы рассмотрим, каким образом организовать процесс разработки и как реализовать библиотеку класса С#, а также как среда CLR управляет типами данных.
Постановка задачи и организация процесса разработки При разработке программного обеспечения работа обычно разбивается на две основные задачи: организацию и реализацию. Организация разработки заключается в выяснении, какие возможности и библиотеки необходимо определить, сколько людей будет заниматься разработкой возможностей и т. д. Организация процесса разработки является одной из наиболее важных задач при написании кода, и обычно это наиболее непонятная задача для начинающих разработчиков. Когда разработчикам ставится задача создания программы, от них требуется написать программное обеспечение, в котором реализуется определенный набор возможностей. Требуемыми возможностями может быть вычисление суточных процентов по вкладам, автоматическое создание писем, сообщающих об удовлетворении
40
Глава 10
или отказе в запросе о займе и т. п. Возможность всегда связана с выполнением какой-то задачи, определенной неким процессом. Можно сказать, что реализация возможности является прямой реализацией задачи. Процесс определение возможностей состоит из двух главных шагов. •
Осознание требуемых возможностей. Нельзя реализовать то, чего вы не понимаете. Поэтому, чтобы написать код для реализации возможности, необходимо понимать все, что касается этой возможности.
•
Описание возможностей с помощью методов структурного проектирования. Если вы являетесь единственным разработчиком программы, может быть достаточным просто организовать свои мысли; но в большинстве случаев вы будете работать в команде. Методы структурного проектирования необходимо применять с тем, чтобы вы и другие члены команды могли обмениваться своими соображениями по разработке программы.
Одним из распространенных методов структурного проектирования является язык UML (Unified Modeling Language, унифицированный язык моделирования). Язык UML применяется для представления возможностей в элементах, соответствующих структурам языка программирования, таким как, например, классам. Язык UML можно рассматривать как жаргон разработчиков программного обеспечения, с помощью которого различные аспекты среды программирования описываются на высоком уровне абстракции. Язык UML позволяет получить общее представление об архитектуре приложения, не прибегая к изучению исходного кода. Вы можете рассматривать язык UML как структурные наброски на салфетке программирования приложений. Кроме языка UML существуют и другие средства для организации процесса разработки. Одним из таких средств является метод, называемый гибким программированием (agile software development). Суть гибкого программирования состоит в разработке собственного структурного механизма обмена информацией. Разработчик или команда разработчиков может выбрать любой структурированный метод проектирования — язык UML, гибкое программирование или какой-либо иной метод. Но вам нужно будет выразить свои соображения и иметь структурированный метод обмена информацией. Если этого не сделать, то вы не уложитесь в сроки разработки вашего программного обеспечения, а само оно будет содержать ошибки, стоить слишком дорого или окажется незавершенным. Не будет преувеличением сказать, что должным образом организованный процесс разработки программного обеспечения — выполнение половины работы по его созданию. В данной главе демонстрируется упрощенный метод структурированной разработки, чтобы дать вам, по крайней мере, общее понятие о протекании этого процесса.
Организация разработки программы Калькулятор Чтобы приступить к примеру, рассматриваемому в этой главе, возьмите лист бумаги и карандаш, или, если у вас есть карманный ПК, можно пользоваться им. Потом в центре листа (физического или виртуального) нарисуйте круг и напишите в нем
Типы данных в .NET
41
слово "Калькулятор". Теперь остановитесь и подумайте о том, что означает калькулятор по отношению к программе, которую вы хотите написать. Запишите свои возникшие соображения на бумаге вокруг первоначального круга. Идеи, пришедшие в голову мне, показаны на рис. 2.1.
42
Глава 10
Ваши идеи могут не совпадать с моими, но их общей чертой будет то, что они будут разбросаны в беспорядке вокруг центрального вопроса. Таким образом, рис. 2.1 показывает, что одной из самых больших проблем, с которой сталкиваются разработчики программного обеспечения, является отсутствие фокуса и организации. Дело не в том, что разработчики не могут сфокусироваться или организовать процесс разработки, а в том, что они завалены разнообразной информацией, отслеживать которую, не говоря уже об ее организации, является геркулесовым трудом. Но для успеха проекта разработки программного обеспечения он должен быть конкретизирован и организован. Поэтому следующим шагом будет конкретизация и упорядочивание первоначальных беспорядочных идей (рис. 2.2)., На рис. 2.2 идеи упорядочены по определенным классам. Так как это книга о программировании, то единственными идеями, имеющими отношение к данному предмету, являются идеи, связанные с функциональностью исходного кода. Грубо говоря, каждая идея в категории исходного кода соответствует возможности, которую следует реализовать.
Конкретизация процесса разработки программы Калькулятор Чтобы реализовать какую-либо возможность, требуется исходный код: файл, проект, решение и прочие аспекты программирования. Прежде чем реализовывать возможности, необходимо подумать о том, каким образом организовать исходный код. Существуют два уровня организации исходного кода: •
на уровне файлов организовываются создаваемые проекты и решения;
•
на уровне исходного кода организовываются пространства имен, имена классов и прочие идентификаторы, к которым выполняется обращение в исходном коде.
Реализация программы Калькулятор на файловом уровне начинается с принятия решения о том, каким из трех типов проектов она должна быть. Как рассматривалось в главе 1, у нас есть выбор трех типов приложений: приложение Windows, консольное приложение и библиотека класса. Если Калькулятор реализовывать как приложение Windows, то он может выглядеть, как показано на рис. 2.3. Калькулятор, реализованный как приложение Windows, дает возможность пользователям выполнять вычисления нажатием соответствующих кнопок. Чтобы сложить два числа, пользователь нажимает соответствующие кнопки для первого числа, символа операции сложения, второго числа и, наконец, нажимает кнопку со знаком равенства, чтобы вывести результат операции. Знак равенства указывает Калькулятору, что ввод данных окончен и нужно обработать введенные данные и вывести в текстовом поле результат. Калькулятор можно также реализовать как консольное приложение (рис. 2.4).
Типы
данных
в
.NET
43
Рис. 2.4. Калькулятор, реализованный как консольное приложение
В данном случае числа и операции вводятся с помощью соответствующих клавишей. Обычно нажатие клавиши <Enter> применятся для указания консольному приложению о завершении ввода и необходимости выполнить требуемые операции над введенными данными и вывести результат в окне командной строки. По завершению одного вычисления цикл повторяется. Пользователь взаимодействует с каждым из этих двух типов приложений поразному. Это означает две разные программы, хотя обе они и реализуют одинаковые возможности. Фокус делается на создании программы определенного типа, а не на общую структуру программирования. При необходимости делать выбор между реализацией Калькулятора как приложения Windows и как консольного приложения, скорее всего, вы остановитесь
Глава 10
44
на приложении Windows, т. к. оно и выглядит лучше, и его легче использовать. В конкретизированных идеях, показанных на рис. 2.2, удобство использования не было определено как возможность. Нужно ли было включить тип пользовательского интерфейса в рассматриваемые возможности программы? Обычно да, но для целей этой главы — нет. Рассмотрим этот вопрос в абстрактных терминах. Вам было дано задание реализовать Калькулятор с обоими вариантами пользовательского интерфейса — графическим и командной строки. Каким образом вы подошли бы к решению этой задачи — реализовали бы всю функциональность дважды или сначала попытались определить, какие аспекты Калькулятора можно использовать для обоих пользовательских интерфейсов? Скорее всего, вы бы хотели использовать как можно больше одинаковых компонентов Калькулятора для обоих приложений, чтобы уменьшить объем работы по его реализации. Кроме этого, совместное использование компонентов поможет избежать проблем с поддержкой программы и расширением ее возможностей. Таким образом, разработчику программного обеспечения необходимо думать о нем в терминах компонентов, которые собираются в программу. Некоторые компоненты можно использовать для разных программ, в то время как другие — нет. Поэтому думайте о приложении Калькулятор, как о состоящем из двух частей (пользовательского интерфейса для ввода данных и блока для обработки данных), поставляемых пользовательским интерфейсом. С организационной точки зрения, или как говорят разработчики, с точки зрения архитектуры, компоненты приложения Калькулятор можно упорядочить, как показано на рис. 2.5.
Рис. 2.5. Организация компонентов приложения калькулятора
Некоторые программисты применяют вместо термина "компоненты" термин "модули", но я предпочитаю первый термин. Компоненты организованы таким
Типы данных в .NET
45
образом, что функциональность низкого уровня находится внизу блок-схемы, а функциональность высокого уровня — вверху. Каждый компонент выполняет определенную задачу, а компоненты высшего уровня используют решения, реализованные на низшем уровне. Идея заключается в том, что каждый уровень является ответственным за. определенную функциональность, и другие уровни не дублируют работу, выполняя свою реализацию функциональностей низших уровней. Функциональности высших уровней зависят от функциональностей низших уровней, но обратная зависимость отсутствует. Приложения реализуются с применением либо нисходящей (top-down), либо восходящей (bottom-up) архитектуры. Нисходящая архитектура означает создание в первую очередь компонентов высших уровней, при этом компоненты низших уровней создаются по мере надобности. В противоположность, применение восходящей технологи означает, что сперва создаются компоненты низших уровней. Восходящий подход полезен, когда ясно, какие функциональности необходимо реализовать. Когда же вы имеете только общее представление о том, какие возможности нужно реализовать, и не хотите отклоняться слишком далеко в сторону от цели приложения, лучше применить нисходящий подход. В этой главе рассматривается разработка библиотеки класса calculator. Таким образом, мы применим восходящий подход.
Реализация библиотеки класса Создание библиотеки класса представляет собой вид организации файлов. Следующим шагом является создание для этой библиотеки класса определенного исходного кода. Задача создания исходного кода реализуется в два этапа: •
определяется класс и его методы;
•
реализуются методы класса.
Одной из самых больших проблем при изучении нового языка программирования является понимание, что можно осуществить с помощью этого языка, а что нет. Нельзя писать исходный код, который в данном языке не имеет смысла. Поэтому чрезвычайно важно знать свойства данного языка программирования, т. к. они определяют, каким образом будут структурированы ваши мысли. Мы будем писать исходный код двух типов: исходный код для организации приложения и исходный код для выполнения действий. Организационный исходный код подобен файловой системе с ее папками. Исполнительный исходный код подобен папке с ее содержимым. Создавая файловую систему, нам не важно содержимое папок, а при заполнении папки, нас обычно не интересует файловая система. Для организации исходного кода применяются такие концепции, как классы, пространства имен и методы. Метод заполняется исходным кодом и выполняет какую-либо операцию, например, осуществляет сложение чисел или создает строку текста.
Глава 10
46
При заполнении метода исходным кодом, наиболее часто приходится ссылаться на другие фрагменты организованного исходного кода. Ссылки можно рассматривать как записки-наклейки на папки с надписями типа "Дополнительная информация находится в папке В". Вот фрагмент кода, упорядоченного на все 100%, но который ничего не делает: namespace MyMainTypes { static class AType { public static void DoSomething() { } } } namespace MyOtherMainTypes { static class AnotherType { public static void DoSomething() { } } }
Исходный код имеет три организационных уровня. Пространство имен (MyMainTypes и MyOtherMainTypes) инкапсулирует типы (в примере — классы дтуре и AnotherType). Классы инкапсулируют методы (в примере — метод DoSomething) и свойства. Все имена в пределах пространства имен должны быть уникальными. Но в разных пространствах имен можно использовать одинаковые идентификаторы классов. В пределах типа запрещаются идентичные идентификаторы и идентичные параметры. (Этот аспект станет более понятным по мере нашего изучения С# в последующих главах.) А вот тот же самый код, что и в предыдущем примере, но в который был добавлен код для выполнения определенного действия (выделен жирным шрифтом): namespace MyMainTypes { static class AType { public static void DoSomething() { } } } namespace MyOtherMainTypes { static class AnotherType { public static void DoSomething() { MyMainTypes. AType. DoSomething (); } } }
Код, выделенный жирным шрифтом, ссылается на другое пространство имен, тип и метод с открывающей и закрывающей скобками. Это называется вызовом метода на статическом классе, или статическим методом." Это означает, что метод реализуется посредством вызова другого метода.
Типы данных в .NET
47
Обратите внимание на то, как используются идентификаторы пространства имен и типа при обращении к другому методу. Ссылки на типы и методы всегда осуществляются таким образом. Идентификатор пространства имен необходим только в том случае, если тип (например, класс) не определен в текущем пространстве имен. В случае пространств имен с длинными идентификаторами этот метод обращения может стать трудоемким. Данная проблема решается добавлением директивы using для ссылки на соответствующее пространство имен, как показано в следующем коде: using MyMainTypes; namespace MyOtherMainTypes { static class AnotherType { public static void DoSomething() { AType.DoSomething(); } } }
Директива using указывает, что если код ссылается на какой-либо тип, который не определен в локальном пространстве имен, то его нужно искать в пространстве имен, указанном в данном операторе (в примере — в пространстве имен MyMainTypes). Обратите внимание, что если в двух пространствах имен существуют типы с идентичными именами, то короткая ссылка на какой-либо из этих типов вызовет ошибку компилятора, т. к. он не будет знать, какой именно из этих типов, т. е. из какого именно пространства имен, имеется в виду. Теперь у нас имеются основные знания, требуемые для написания кода, и мы можем приступить к написанию программы, которая что-то делает.
Метод Add() Что именно мы будем писать, так это код для выполнения сложения двух чисел. Начнем с создания нового проекта Visual С#, для чего выполните такую последовательность шагов: 1. Запустите Visual С# (если среда уже запущена, то выберите последовательность команд меню File | Close Solution, чтобы начать с нового решения). 2. Выберите последовательность команд меню File | New Project или на вкладке Start Page выберите Create: Project. 3. Выберите тип проекта Class Library, назовите его calculator и нажмите кнопку ОК. 4. Переименуйте Classl.cs в Calculator.cs. 5. Сохраните решение.
48
Глава 10
Теперь мы можем приступить к написанию метода Add(). Добавьте выделенный жирным шрифтом код в исходный код в файле Calculator.cs. , using System; using System.Collections.Generic; us ing Sys tem.Text; namespace Calculator { public class Calculator { } public class Operations { public static int Add(int number1, int number2) { return numberl + number2; } }
Значения различных частей метода Add () объяснены на рис. 2.6. В данном коде вводимые данные указываются с помощью входных параметров. Каждый параметр представляет одно из чисел, которые нужно сложить.
Рис. 2.6. Объяснение компонентов операции сложения
Типы
данных в
.NET
49
В объявлении метода Add о тип возвращаемого значения указан как i n t , т. е. как целое число. Методы и параметры необходимо ассоциировать с каким-либо типом данных, т. к. язык С# является языком программирования, обеспечивающим типовую безопасность. Типовая безопасность означает, что при написании кода мы знаем, с какими типами данных работаем. Допустим, что при написании программы вы сталкиваетесь с числами 1, 1.0 и "1.0". Для вас, как для человека, эти три числа одинаковы. Но в контексте исходного кода они не являются идентичными. Число 1 является целым числом, i. о — действительное число двойной точности, а " 1. о" — вообще не число, а строка. При выполнении сложения, вычитания или иного манипулирования данными эти данные должны быть одного типа; в противном случае возможно возникновение ошибок из-за несовместимости типов. Языки программирования, обеспечивающие типовую безопасность, позволяют избежать проблем этого рода. Типы данных .NET рассматриваются более подробно в разд. "Типы числовых данных среды CLR" далее в этой главе. В объявлении метода Add о указывается, что в метод передаются два целых числа, и метод также возвращает целое число. Комбинация типов передаваемых параметров и возвращаемого значения называется сигнатурой метода. Сигнатура метода становится важной, когда метод Add () вызывается другим фрагментом кода. В этом фрагменте кода должны использоваться такие же типы, как и в объявлении метода. На рис. 2.7 показан фрагмент кода, который вызывает метод Add(), что мы сделаем из другого приложения в следующем разделе.
Рис. 2.7. Метод Add () вызывается посредством ссылки на пространство имен и класс, содержащие метод. Идентификаторы разделяются точкой
Вызывающий код должен выполнять две задачи: •
ссылаться на правильную комбинацию идентификаторов пространства имен, класса и метода;
•
передавать правильные типы для сигнатуры метода.
В примере результатом сложения чисел 1 и 2 является число 3, поэтому переменная t o t a l должна содержать значение 3 (знак равенства присваивает значение, возвращаемое методом, переменной слева от него). Я говорю "должна содержать значение", потому что при написании кода не всегда можно быть уверенным, что в действительности она будет содержать. Иногда в коде допускаются ошибки, потому что программист что-то не предусмотрел или забыл выполнить ссылку на что-то.
Глава 10
50
Посмотрите на код, вызывающий метод Add (), и спросите себя, имеется ли гарантия, что результатом вызова метода Add О с числами 1 и 2 будет число 3. Ответ на этом вопрос будет таким: "Вызывающая сторона не может быть уверенной на 100%, что переменная total будет содержать число 3". (По аналогии: сам факт, что на сейфе написано "Деньги", еще не означает, что в сейфе действительно деньги. Можно полагать с большей или меньшей степенью уверенности, что это именно так, но полностью убедиться можно, лишь открыв сейф.) Так и в программировании: чтобы быть уверенным в содержимом переменной total, необходимо посмотреть, каким образом реализован метод Add (). Но если это возможно в данном частном случае, то визуальная проверка промышленного исходного кода не является практическим решением, т. к. это заняло бы слишком много времени, и к тому же результаты такой проверки были бы абсолютно ненадежными. Единственным практическим решением является применение тестового кода.
Код для тестирования метода AddQ Тестовый код вызывает метод, передавая ему параметры с целевыми значениям, и ожидает целевой ответ. Если вызывающий код не получает целевого ответа, тогда метод реализован неправильно. На рис. 2.8 показан пример вызывающего кода для тестирования метода Add() (этот код будет добавлен к проекту следующим). Целевое тестирование переменной total на равенство значению 3
Целевой вызывающий код, который складывает числа 1 и 2 и присваивает результат переменной total
I int total = Calculator.Operations.Add(l, 2); ^ ^ i f (total 1=3) { Console.WriteLine("Oops 1 and 2 does not equal 3"); } В случае успешного целевого тестирования генерируется текст "Oops...", указывая на наличие ошибки Рис. 2.8. Тестирование метода Add ()
Вызывающий тестовый код поразительно похож на код, который мы рассматривали в предыдущем разделе. Разница заключается в том, что тестовый код использует целевые переменные и значения, в то время как другой код может содержать любые переменные и значения. Также от тестового кода требуется подтверждение, что ответы, возвращаемые методом, соответствуют целевым ответам. Для проверки, равняется ли значение переменной total числу 3, применяется оператор if. При написании тестового кода метод Add() должен использоваться точно таким же образом, каким он используется приложением Windows или консольным приложе-
Типы данных в .NET
51
нием. В противном случае наше тестирование будет подобно тестированию зимних шин в Сахаре — довольно занимательное занятие, но не имеющее никакого отношения к стоящей задаче. Часть тестового кода для верификации ответа на соответствие целевому немного специфична. Нужно или верифицировать ответ в промышленном коде? Может быть, нужно, а может быть, и нет. В тестовом варианте верификационный код выполняет проверку на 100%, но для промышленного варианта выполняется только общее тестирование. Например, можно выполнять проверку на разумность или на существование данных. Другим, связанным с тестированием, вопросом является время тестирования. Когда нужно создавать тестовый код, до или после реализации метода Add () ? Для рассмотрения этого вопроса снова воспользуемся аналогией. Допустим, что мы разрабатываем шину. Когда нам нужно определить тесты для проверки шины, до или после разработки шины? Скорее всего, их нужно определять до, на протяжении и после разработки. Этот аспект является очень важным при разработке программного обеспечения. Тесты пишутся до, во время и после реализации, в следующем порядке: 1. До реализации метода Add () необходимо разработать тест, чтобы получить представление о том, какие пространства имен, классы и методы будут определяться. Определения разных элементов дает разработчику представление о том, как эти элементы будут использоваться. 2. Во время реализации метода Add() тесты разрабатываются для того, чтобы удостовериться в том, что процесс реализации движется в правильном направлении. 3. А после реализации метода тесты разрабатываются с целью всестороннего тестирования реализации.
Добавление тестового проекта в решение При написании тестовых процедур необходимо организовывать исходный код, а это означает выработку решения, к каким проектам добавлять тесты. Для приложения Калькулятор, тестовые процедуры можно было бы разместить в библиотеке класса Calculator. Но это было бы неправильным подходом по причине распределения библиотеки класса и правильного контекста тестирования. Вспомните, что процедуры тестирования должны быть идентичны целевому назначению кода. Поэтому правильным местом для расположения тестовых процедур будет их собственное приложение. Идеальным подходом будет создание другого приложения, представляющего тесты. На рис. 2.5 было показано, каким образом приложение Windows и консольное приложение могут использовать библиотеку класса Calculator. А на рис. 2.9 добавлено тестовое консольное приложение, которое также использует данную библиотеку класса.
Глава 10
52
Рис. 2.9. Добавление тестового консольного приложения. Это приложение с ограниченной функциональностью, используемое для проверки функциональности, предоставляемой библиотекой класса Calculator
Тестовое консольное приложение подобно консольному приложению, созданному в главе 1, которое обращается к библиотеке класса calculator. Оба эти проекта должны принадлежать решению Calculator. Перейдем от слов к делу и добавим в решение Calculator проект TestCalculator. Не забудьте добавить ссылку на библиотеку класса Calculator (щелкните правой кнопкой мыши по элементу References добавленного проекта, после чего выберите последовательность команд Add Reference | Project | Calculator). Также не забудьте установить TestCalculator в качестве стартового проекта для исполнения. Наше решение, состоящее из тестового приложения TestCalculator и библиотеки класса Calculator, должно выглядеть в Solution Explorer, как показано на рис. 2.10.
Рис. 2.10. Отображение библиотеки класса Calculator и тестового консольного приложения TestCalculator в Solution Explorer
Типы данных в .NET
53
Тестирование операции простого сложения Для верификации правильности сложения чисел 1 и 2 добавьте выделенный жирным шрифтом код в исходный код тестового консольного приложения, как показано в следующем фрагменте кода: namespace TestCalculator { class Program { public static void TestSiiqpleAddition() { int total = Calculator.Operations.Add(1, 2); if (total !=3 ) { Console.WriteLine("Oops 1 and 2 does not equal 3"); } } static void Main(string[] args){ TestSiiqpleAddition(); } } }
Протестируйте выполнение операции сложения, запустив консольное приложение на исполнение нажатием комбинации клавиш +. При исполнении тестовое консольное приложение вызывает тестовый метод TestsimpleAddition (), который вызывает и верифицирует функциональность библиотеки класса Calculator. ПРИМЕЧАНИЕ Вспомните, что исполнение проекта начинается в методе Main (). Поэтому, чтобы приложение делало что-нибудь, необходимо добавить соответствующий код в метод Main().
Чтобы проверить вариант неуспешного тестирования, измените метод Add(), как показано жирным шрифтом в следующем фрагменте кода: public static int Add(int number1, int number2) { return number1 * number2; }
Тестовая программа теперь должна вывести сообщение о неуспешном исполнении метода. До сих пор в этой главе мы имели дело со следующими тремя фрагментами кода: •
сегмент кода, реализующий метод Add(), является компонентом, исполняющим операцию вычисления;
•
код вызывающего компонента, который может быть либо приложением Windows, либо консольным приложением, считается промышленным кодом;
3 Зак. 555
54
Глава 10
• код, содержащий промышленный код и процедуры верификации, представляет тестовый код. Важность тестового кода заключается в том, что в случае изменения реализации компонентов, чтобы убедиться в том, что все продолжает работать должным образом, необходимо только выполнить этот тестовый код. Эти три фрагмента кода демонстрируют полный цикл разработки.
Проверка операции сложения двух очень больших чисел Теперь наш код и проекты организованы, но нам не хватает некоторых тестов. Наш тест проверяет сложения двух простых чисел. Давайте теперь добавим к нашему проекту тест для проверки операции сложения двух очень больших чисел, например, 2 млрд и 2 млрд. Исходный код для такого теста показан на рис. 2.11. Добавьте его к исходному коду в файле Program.cs проекта TestCalculator.
Рис. 2.11. Проверка операции сложения двух очень больших чисел
За исключением самих чисел, тест для операции сложения двух больших чисел практически такой же, как и тест для проверки операции сложения двух простых чисел. Также несколько по-иному обрабатывается сообщение об ошибке — здесь оно составляется, конкатенируя строку с целым числом с другой строкой. С# автоматически преобразует целое число в строку. Часто при написании тестов единственная существенная разница состоит в самих данных, а не в результате операций над ними. Как вы думаете, операции сложения двух очень больших чисел (например, 2 млрд и 2 млрд) и двух простых чисел (например, 2 и 2) чем-либо отличаются друг от друга? Для людей — нет, т. к. для нас основная разница между числами 2 млрд и 2 состоит в куче нулей; результат будет или 4 млрд или просто 4, что кажется очень тривиальным. Но для компьютеров, как мы позже увидим, 4 млрд значительно отличается от 4.
Типы данных в .NET
55
Прежде чем исполнять тест, его вызов необходимо добавить в код метода Main (), как показано жирным шрифтом в следующем фрагменте кода: static void Main(string[] args) { Tes tS impleAddi t ion(); TestReallyBigNumbers(); }
Результат исполнение теста теперь будет следующим 1 : Error found (-294967296) should have been (4869586958695)
Программа сообщает нам, что при сложении произошла ошибка, и что результатом сложения 2 млрд. с 2 млрд. является значение -294 967 296, что не имеет никакого смысла. Что здесь происходит? Проблема здесь заключается в типе данных ( i n t ) , которым был объявлен метод Add ().
Проблемы с числами Наше понимание концепции числа и то, что считают числом компьютеры, являются абсолютными разными вещами. В детстве мы учились считать, начиная с 1 и заканчивая 100, что тогда для нас казалось громаднейшим числом. По мере того как мы взрослели, мы узнали о числе 0 и о числах меньше нуля. Еще позже мы продвинулись к изучению простых и десятичных дробей. На протяжении всего этого процесса нашего познавания чисел, число 1 и число 1,5 для нас были одинаковыми, т. е. они были просто числами. Но для компьютера это два разных типа чисел. Причиной этому является стремление разработчиков компьютеров к большей эффективности в их работе, для чего применяются разные способы хранения чисел. Как мы знаем, в нашей десятичной системе мы начинаем считать с 0, доходим до 9, после чего следующим будет число 10. Изобретателями десятичной системы считаются вавилоняне, но они применяли шестидесятеричную систему счисления, в которой было 60 уникальных составных идентификаторов чисел, по сравнению с 10 простыми в нашей десятичной системе. В компьютерах также применяется позиционная система счисления, но только в ней существуют лишь два уникальных идентификатора — 1 и 0. Такая система счисления называется двоичной, или бинарной. Применение в компьютерах двух уникальных числовых идентификаторов обусловлено тем, что они представляют два уникальных состояния: включено и выключено. Базовым компонентом компьютера является транзистор, который может различать два состояния — включено и выключено. На рис. 2.12 приведен пример выполнения счета компьютером в двоичной системе счисления. В данном случае счет идет до 7.
1
Ошибочный результат: -294 967 296. Результат должен бы быть: 4 869 586 958 695. — Пер.
Глава 10
56 Компьютеры считают, используя цифры 0 и 1 Когда компьютер посчитает О, потом 1, увеличивается значение следующей . позиции (для людей это происходит после счета 9)
Люди считают с помощью цифр 0, 1 9 0=0 1 = 1
10 = 2
11 =3 100 = 4
Не думайте об этом числе, как об одной сотне. Это один, ноль, ноль по основанию 2
101 = 5
110 = 6
111 =7
Рис. 2.12. Компьютер считает до 7 в двоичной системе счисления
Теоретически, человек может считать до какого угодно числа. Единственное ограничение здесь — это продолжительность его жизни. Но для компьютера существуют ограничения на величину числа, до которого он может считать, накладываемые такими компонентами его реализации, как память произвольного доступа, жесткий диск и т. п. Также существуют ограничения на размер чисел конкретных типов. Например, используя целочисленный тип (int), можно считать только до определенной величины и только целыми числами. Различные типы числовых данных можно рассматривать, как автомобильный одометр. Верхний предел одометров большинства автомобилей составляет 1 млн км. Представьте теперь, что на одометре нужно сложить 900 000 и 200 000. Результат будет 100 000, а не ожидаемый 1 100 000. То же самое происходит при сложении на компьютере двух целочисленных значений по 2 млн каждое. Но в случае с одометром, особенно при покупке подержанного автомобиля, мы не знаем, прошел ли одометр свой предел или сколько раз он его прошел. Вы может купить автомобиль с 100 000 км на одометре, который в действительности проехал 1 100 000 км. К счастью, .NET знает, когда число переходит через верхнюю границу, установленную для его типа. Эта ситуация называется арифметическим переполнением или антипереполнением. Переполнение — это ситуация, когда одометр переходит через верхнюю границу (от 999 999 до 1 100 000), а антипереполнение — это ситуация, когда одометр переходит через нижнюю границу (от 0 до -100 ООО). Возможность выявления любой из этих ситуаций можно активировать в качестве свойства проекта. Чтобы включить обнаружение переполнения/антипереполнения, выполните такую последовательность действий для библиотеки класса Calculator: 1. Щелкните правой кнопкой мыши по проекту Calculator в Solution Explorer и в открывшемся контекстном меню выберите команду Properties. 2. В панели свойств активизируйте вкладку Build, а на ней нажмите кнопку Advanced.
Типы данных в .NET
57
3. В открывшемся диалоговом окне Advanced Build Settings установите флаг Check for arithmetic overflow/underflow. 4. Щелкните кнопку OK, чтобы сохранить установки. Теперь при исполнении тестового консольного приложения будет выдано следующее системное сообщение об ошибке переполнения (выберите опцию не выполнять отладку): Unhandled Exception: in an overflow.
Ошибку можно найти, выполнив приложение в режиме отладки (с помощью клавиши ). Результат должен быть подобен показанному на рис. 2.13. Чтобы остановить отладку, нажмите комбинацию клавиш <Shift>+.
Ситуация с переполнением представляет собой проблему, и то, что .NET может уловить эту проблему, уже неплохо. Но окончательным желательным решением этой проблемы было бы получение возможности все-таки сложить 2 млрд с 2 млрд. В конце концов, Биллу Гейтсу было бы больше по душе иметь 4 млрд на своем банковском счете, чем какое-то вычисленное отрицательное значение или сообщение из банка, извещающее его о том, что банк не может принять его 4 млрд.
58
Глава 10
Типы данных Тип данных представляет собой способ для описания единицы данных с помощью метаописания. Существует несколько типов данных: int, long, short, single, double, string, enum, struct и т. д. В С# можно даже определять собственные типы данных. Типы данных лежат в основе среды CLR и обеспечивают типовую безопасность программирования.
Обычные и ссылочные типы данных Среда CLR поддерживает две категории типов данных: обычные (простые) типы и ссылочные типы. Основная разница между этими двумя категориями типов данных состоит в способе хранения информации каждого типа. Но деление на простые и ссылочные типы данных было введено сравнительно недавно, и для некоторых может быть проблемой разобраться с ними. При исполнении приложения средой CLR поток исполняет инфраструктуру CLI (Common Language Infrastructure, инфраструктура универсального языка). Рассмотрим понятие потока с помощью аналогии похода в торговый центр за покупками. Как отдельная личность вы можете покупать вещи независимо от других покупателей. Но каждый из этих покупателей, так же как и вы, является отдельной личностью и делает свои покупки независимо от всех других покупателей. Подобным образом в компьютере (торговый центр) исполняется множество потоков (покупатели), каждый из которых выполняет свои операции независимо от других потоков. В магазине вы можете по неосторожности столкнуться с другим покупателем, и тот может уронить свои вещи. В то время как CLR старается предотвратить такие проблемы, приложив достаточно усилий в своем коде, можно заставить другие потоки "уронить" свои вещи. При исполнении потоку выделяется локальный пул памяти, называемый стеком, что можно сравнить с вашим бумажником, содержащим наличные и кредитные карточки. Как покупатель носит свой бумажник из одного магазина в другой, так и поток носит с собой свой стек при вызове разных методов. Вы можете оплатить свою покупку одним из двух основных способов: наличными или с помощью кредитной карточки. Но при расплате кредитной карточкой приходится пройти через дополнительные процедуры. Чтобы удостовериться в подлинности кредитной карточки или наличии на ней достаточных средств, терминал на месте продаж должен связаться с центральным сервером. Оплата наличными выполняется намного быстрее, чем кредитной карточкой, т. к. здесь процесс проверки отсутствует. Теперь допустим, что вы отправились за покупками вместе со своей женой. У вас один кредитный счет на двоих, но у каждого своя кредитная карточка, которая ссылается на этот счет. С наличными этого делать нельзя: разделить 500-рублевую купюру у вас не получится, и каждый из вас должен имеет часть общей суммы отдельными купюрами.
Типы
данных
в
.NET
59
Способ оплаты наличными и с помощью кредитных карточек аналогичен обычным и ссылочным типам. Наличные — это обычный тип, а кредитная карточка — это ссылочный тип. При исполнении среды CLR от одного вызова метода к другому переносится код, который является стеком, содержащим некое число переменных обычного типа. Переменные этого типа хранятся непосредственно в стеке подобно наличным в бумажнике. А переменные ссылочного типа хранятся в стеке в виде указателей на область памяти, содержащую требуемы данные, как кредитные карточки указывают на наличные, которые хранятся в каком-то другом месте. Область памяти, хранящей переменные, на которые ссылаются указатели, называется кучей (heap). Все эти понятия иллюстрируются на рис. 2.14.
Рис. 2.14. Стеки и их взаимодействие с кучей во время исполнения среды CLR
При использовании переменных обычных типов, когда значение одной переменной назначается другой, содержимое первой переменной копируется во вторую переменную. Если одна из копий модифицируется, то эта модификация не затрагивает другую копию. В противоположность, переменные ссылочного типа, которые ссылаются на одно значение, разделяют это значение. При модификации этого значения все эти переменные будут ссылаться на новое, модифицированное, значение. Возвратимся к нашей аналогии с наличными и кредитными карточками: если вы и ваша жена имеете по 500 рублей каждый и вы потратите, скажем, 300 рублей, то это никоим образом не отразится на тех 500 рублях, которые имеет ваша жена, как и подобает модели обычных типов. Но если у вас с женой имеется 500 рублей на общем счету ваших кредитных карточек и если один из вас потратит те же 300 рублей, то тогда для каждого из вас будут доступными только оставшиеся 200 рублей.
60
Глава 10
Иногда мы применяем обычные типы, а иногда — ссылочные, так же как и иногда расплачиваемся за покупки наличными, а иногда— кредитными карточками. Обычно мы расплачиваемся кредитными карточками за дорогостоящие вещи, т. к. предпочитаем не носить с собой большие суммы наличными". Этот же принцип относится к обычным и ссылочным типам в том смысле, что мы не хотим хранить данные большого объема в стеке. Зная разницу между стеком и кучей, вы автоматически поймете разницу между обычными и ссылочными типами, т. к. между ними существует прямая взаимосвязь. Обычные типы, как правило, хранятся в стеке, а содержимое ссылочных типов всегда находится в куче.
Типы числовых данных среды CLR Среда CLR поддерживает два основных типа чисел: целые числа и дробные числа. Оба эти типа являются обычными. В методе Add() используется обычный целочисленный тип i n t . Как обсуждалось ранее, целые числа имеют верхний предел, который устанавливается размером доступной памяти. Возьмем, к примеру, число 123456. Для выражения этого числа требуется шесть позиций. Допустим, например, что на данной странице можно представлять только числа, выражаемые шестью цифрами. На основе этой информации мы можем сделать вывод, что самым большим числом, которое можно выразить на этой странице, является 999 999, а самым маленьким — 0. Подобным образом специфичный числовой тип заставляет среду CLR накладывать ограничения на количество цифр, применяемых для выражения чисел данного типа. Каждая цифра, называемая битом, может быть 1 или 0, т. к. среда CLR выражает числа в двоичной системе счисления. Чтобы узнать самое большое число, которое можно выразить данный тип данных, необходимо 2 возвести в степень, равную количеству цифр, и вычесть из результата 1 (т. е. 2" - 1, где п — количество цифр). Для выражения чисел типа i n t применяются 32 бита. Но прежде чем мы станем возводить 2 в 32-ю степень, нам необходимо принять во внимание отрицательные значения этого типа. В действительности верхний предел типа i n t не 4 294 967 295 (232 - 1), т. к. тип i n t также выражает отрицательные числа. Иными словами, переменные этого типа могут содержать отрицательные значения, такие как, например, - 2 . Для выражения знака двоичного числа используется его первый (самый старший) бит. Таким образом, для выражения собственно числа типа i n t применяется только 31 бит из 32, что делает максимальное число, которое можно выразить этим типом, равным 2 147 483 647, а самое меньшее равным -2 147 483 648. Возвращаясь теперь к нашей проблеме со сложением двух чисел по 2 млрд каждое, мы видим, 2 Г1о крайней мере, такова практика в США и странах Западной Европы. В США расплата наличными за дорогостоящий и даже не очень дорогостоящий товар может вызвать подозрение персонала, что вы являетесь наркоторговцем. Пер.
Типы
данных
в
.NET
61
что тип i n t не имеет достаточного количества битов для выражения результата в 4 млрд, для которого требуется 32 бита. В табл. 2.1 перечислены предоставляемые в .NET типы данных и их описание. При описании числовых типов данных применяется следующая терминология: •
бит — двоичная цифра; 8 битов составляют один байт-,
•
ijenoe число;
•
типы с плавающей точкой представляют дробные числа;
•
знаковый (signed) означает, что самый старший бит числа используется для выражения его знака. Таблица
2.1.
Типы
числовых
данных
.NET
Тип
Описание
byte
8-битовое целое число без знака; самое меньшее выражаемое число равно 0, а самое большее — 255
sbyte
Знаковое 8-битовое целое число; самое меньшее выражаемое число равно - 1 2 8 , а самое большее — 127
ushort
16-битовое целое число без знака; самое меньшее выражаемое число равно 0, а самое большее — 65 535
short
Знаковое 16-битовое целое число; самое меньшее выражаемое число равно - 3 2 768, а самое большее — 32 767
uint
32-битовое целое число без знака; самое меньшее выражаемое число равно 0, а самое большее — 4 294 967 295
int
Знаковое 32-битовое целое число; самое меньшее выражаемое число равно -2 147 483 648, а самое большее — 2 147 483 647
ulong
64-битовое целое число без знака; самое меньшее выражаемое число равно 0, а самое большее — 18 446 744 073 709 551 615
long
Знаковое 64-битовое целое число; самое меньшее выражаемое число равно -9 223 372 036 854 775 808, а самое большее — 9 223 372 036 854 775 807
float
32-битовое число с плавающей запятой; самое меньшее выражаемое число равно 1,5x10" 4 5 , а самое большее — 3,4x10 3 8 , с точностью до 7 знаков
3
double
64-битовое число с плавающей запятой; самое меньшее выражаемое число 324 308 равно 5,0x10 , а самое б о л ь ш е е — 1 , 7 x 1 0 , с точностью от 15 до 17 знаков
decimal
28 Специальный тип данных; самое меньшее выражаемое число равно 1,0x10 , а самое большее — 1,0x10 28 , с точностью, по крайней мере, до 28 значащих цифр 3
Тип decimal часто применяется для работы с финансовыми данными, т. к. по причине ошибок округления результаты вычислений здесь иногда получаются па копенку меньше, чем правильный результат (например, 14,9999 вместо 15,00). — Пер.
62
Глава 10
Имея такое множество числовых данных, естественным будет задать себе вопрос: какие из них использовать и когда? Ответ прост: все зависит от ваших надобностей. Для научных вычислений, скорее всего, нужно будет использовать тип double или float. А для расчетов ипотечного кредита, вероятно, потребуется применение типа decimal. Для выполнения же вычислений с множествами подойдет тип int или long. Все зависит от того, насколько точный или до какой разрядности результат вы хотите получить. Точность числа является важной темой, которую никогда не следует рассматривать поверхностно. Рассмотрим это на примере переписи населения, из которой, кроме общего количества населения, мы узнаем другую интересную информацию. Например, в Канаде количество разведенных людей составляет 31% от всего населения. Также, в Канаде частота рождаемости составляет один новорожденный каждую минуту и 32 секунды. На момент написания данной книги, население Канады составляет 32 899 736 человек. Таким образом, на момент написания этой книги в Канаде было 10 164 818 разведенных людей. Подумайте немного о том, что было только что написано. А было написано, что существует прямая взаимосвязь между количеством разведенных людей и количеством новорожденных. Разве это не изумительно, что рождаемость и разводы измеряются с такой точностью и что именно 10 164 818 людей — не 10 164 819 или 10 164 820 — будут разведенными. Конечно же, цитирование этих данных с такой точностью — всего лишь способ привлечь внимание к тому, что мы все постоянно, возможно, не обращая на это сознательного внимания, обычно применяем в таких ситуациях — округление чисел. Нельзя сказать, что в именно 10 164 818 человек разведутся, т. к. для этого необходимо было бы пересчитать данную категорию населения в данный момент. Но можно сказать, 10 164 818 плюс-минус 100 000 будут разведенными. Таким образом, диапазон разведенных будет от 10 064 818 до 10 264 818, или, грубо говоря, 10,2 млн человек. В действительности, именно число в формате 10,2 и употребилось бы в газетной статье, научной литературе или большинством людей в разговоре на эту тему. Таким образом, если сложить 10,2 млн и 1000, можно сказать, что мы получим 10 201 000? Число 10,2 является округлением к ближайшей десятой миллиона, а добавляемое к нему число 1000 меньше, чем округленное число. Поэтому число 1000 нельзя добавить к числу 10,2, т. к. число 1000 не является обычным по отношению к числу 10,2. Но число 1000 можно добавить к числу 10 164 818, получив 10 165 818, т. к. наиболее значимая величина является одним целым числом. Результатом сложения чисел 1.5 и 1.5 как целых будет число 2 (рис. 2.15). Распространим концепцию на тип с плавающей точкой, float, и рассмотрим пример, показанный на рис. 2.16. Как показано на рис. 2.16, если мы хотим сохранить точность при сложении маленького числа с большим, нам необходимо использовать тип double. Но даже этот тип имеет свои ограничения и может представлять числа только с точностью от 15 до 16 цифр.
Типы
данных
в
.NET
63
Рис. 2.16. Сложение дробных-чисел как чисел типа f l o a t
Если требуется еще более высокая точность, используется тип d e c i m a l , но этот тип более подходит для выполнения финансовых вычислений. В финансовых вычислениях часто приходится выполнять сложение очень больших чисел с очень маленькими. Представьте себе, что вы Билл Гейтс и у вас несколько миллиардов на банковском счету. Когда банк насчитывает проценты, вы хотите знать, сколько центов у вас накопилось, т. к. из этих центов в течение многих лет накапливаются значительные суммы. (Между прочим, были случаи, когда программисты "грабили" банки на довольно крупные суммы, снимая доли цента с многих счетов и переводя их на свой счет.) Теперь, когда мы в курсе некоторых сложностей, сопутствующих работе с числами, завершим наш Калькулятор.
64
Глава 10
Завершение разработки Калькулятора В то время как первоначальное объявление метода Add() работает, возможности метода серьезно ограничены, т. к. он может выполнять сложение только определенных типов чисел. Чтобы завершить разработку Калькулятора, нам нужно объявить метод Add (), используя другой тип, а также реализовать оставшиеся операции. Для объявления метода Add() можно использовать один из следующих трех типов: П long— позволяет сложение очень больших чисел, порядка миллионов, но не способен складывать дробные числа, например 1.5 + 1. 5; П double— позволяет складывать как очень большие и маленькие числа, так и дробные числа. В общем, тип double является хорошим выбором, но могут возникнуть проблемы со значимостью, когда очень большое число складывается с очень маленьким числом; • decimal — хороший общий подход и пригодный для всех типов точности, но также самый медленный при выполнении математических операций. Самым простым общим решением будет использование типа double, т. к. он предоставляет достаточно хорошую точность и сравнительно быстрый. Полный исходный код реализации Калькулятора будет выглядеть так: public class Operations { public static double Add(double numberl, double numer2) { return numberl + number2; } public static double Subtract(double numberl, double number2) { return numberl - number2; } public static double Divide(double numberl, double number2) { return numberl / number2; } public static double Multiply(double numberl, double number2) { return numberl * number2; } }
Для выполнения четырех математических операций применяются методы с разными идентификаторами, но одинаковыми сигнатурами, что позволяет с легкостью запомнить, как использовать каждый метод. Для каждой из четырех операций имеется соответствующий набор тестов, проверяющих правильность реализации. Тесты не рассматриваются в книге, но содержатся в исходном коде. Я бы порекомендовал взглянуть на тесты, чтобы убедиться в том, что вы понимаете, как они работают.
Типы данных в .NET
65
Советы разработчику В этой главе мы рассмотрели разработку библиотеки класса для выполнения определенных вычислений. Из этого материала рекомендуется запомнить следующие ключевые аспекты. П При разработке программного обеспечения организация мыслительного процесса разработчика, проектов и возможностей приложения является большим способствующим успеху фактором. •
Всегда концентрируйтесь на главном аспекте разрабатываемого приложения, не распыляя внимание на вопросы, не имеющие прямого отношения к решаемой задаче. Для успешной разработки программного обеспечения необходимо быть организованным и сконцентрированным.
•
Программное обеспечение разрабатывается на основе нисходящей или восходящей архитектуры.
•
Отдельные фрагменты архитектуры называются компонентами, которые складываются вместе для создания цельного приложения.
•
Тесты необходимы по той причине, что мы не можем удостовериться в правильности функционирования компонента на основе его идентификатора, параметров или возвращаемого значения.
•
При реализации компонентов мы разрабатываем тесты до, во время и после написания исходного кода.
•
Тест представляет собой исходный код, который вызывает тестируемый компонент, используя целевые входные данные, а результаты, выдаваемые компонентом, верифицируются на основе ожидаемых реакций. Если результаты не соответствуют ожидаемому реагированию, то это означает, что компонент не работает должным образом.
•
Среда CLR предоставляет много разных типов данных, разница между которыми заключается в том, что одни типы являются ссылочными, а другие — обычными.
•
Все числовые типы являются обычными типами.
•
При обработке чисел возможно переполнение (overflow) или антипереполнение (underflow). Чтобы среда CLR могла уловить подобные ситуации, необходимо активировать соответствующие установки компилятора.
•
Решение, какой конкретный числовой тип использовать, в большой мере основывается на том, насколько точным должен быть желаемый результат.
66
Глава 10
Вопросы и задания для самопроверки С помощью следующих упражнений можете проверить, как хорошо вы усвоили материал этой главы: 1. При написании кода каким образом следует его организовывать? Например, является ли обязательным использование идентификаторов определенного формата? Является ли обязательной определенная схема кодирования? Является ли обязательным использование комментариев? 2. Среди разработчиков программного обеспечения ведется дискуссия, должно ли программное обеспечение быть организовано на основе формальных структур или же его организация должна быть основана на конкретной решаемой задаче. Каково ваше мнение по этому поводу? 3. В общем, каким образом вы бы протестировали, работает ли компонент, использующий базу данных, должным образом? Дайте короткое описание процесса тестирования в форме исполняемых шагов. 4. В общих чертах, как бы вы протестировали правильность записи данных в файл? Чтобы понять природу проблемы, как мы узнаем, что операционная система манипулирует файлами должным образом? 5. Если бы среда CLR не предоставляла механизма для улавливания ситуаций с переполнением и антипереполнением, каким бы образом бы вы реализовали такой механизм? 6. Для 32-битового процессора Pentium какой тип данных обеспечит наиболее быстрое выполнение вычислений? 7. В примере для этой главы класс O p e r a t i o n s предназначен для работы с типом d o u b l e . Каким образом можно изменить данный класс для выполнения общих вычислений, т. е. вычислений с любыми типами?
Глава 3
Работа со строками
В предыдущей главе мы изучили основы хранения и управления данными в .NET, в том числе различия между обычными и ссылочными типами. В .NET поддерживаются три основных типа данных: числовые, строковые и определяемые пользователем. В предыдущей главе мы фокусировались на числовых типах. В этой главе основное внимание уделяется строковому типу s t r i n g . Как мы увидим далее в этой главе, тип s t r i n g обладает определенными особыми характеристиками. Посмотрев на биты и байты, составляющие строковые значения, ничего, напоминающего буквы, вы в них не увидите. В абстрактных терминах, тип s t r i n g является числовым типом, но имеющим особую грамматику. Для соотношения набора букв с набором чисел компьютер использует таблицы преобразований. Для примера в этой главе мы рассмотрим программу для перевода между несколькими языками. Хотя это будет простенькая программа, не обладающая богатыми возможностями, с ее помощью мы продемонстрируем многие проблемы, сопутствующие работе со строковыми данными.
Организация приложения перевода Как было подчеркнуто в предыдущей главе, первым шагом в разработке приложения является организация процесса разработки. Разработчик должен иметь четкое представление и определить возможности приложения, которое он собирается создавать. В нашей программе для перевода между несколькими языками будут реализованы следующие возможности: •
перевод приветствия между тремя языками: французским, немецким и английским;
•
преобразование чисел между этими тремя языками;
П преобразование дат между этими тремя языками. С точки зрения возможностей, первая является сама собой разумеющейся, но остальные две не так очевидны. Обычно мы представляем себе процесс перевода, как перевод слов с одного языка на другой. Но в разных языках числа и даты также
68
Глава 10
могут иметь различное представление. Поэтому термин "перевод" может означать два понятия: перевод слов с одного языка на другой и перевод числа или даты из формата, применяемого в одном языке, в формат, применяемый в другом. Как и в главе 2, мы создадим решение в виде компонентов, а именно: приложение Windows, тестовое консольное приложение и библиотеку класса. Решение, содержащее все три проекта, должно выглядеть, как показано на рис. 3.1. В приложении Windows и консольном приложении не забудьте добавить ссылку на библиотеку класса LanguageTranslator (щелкните правой кнопкой мыши по элементу References соответствующего проекта и выберите Add Reference | Project | LanguageTranslator). Также не забудьте установить стартовым проектом консольное приложение TestLanguageTranslator.
Рис. 3.1. Структура проектов приложения перевода в Solution Explorer
Создание приложения перевода Подобно приложению Калькулятор в предыдущей главе, приложение перевода составляется из нескольких компонентов: библиотеки класса, которая выполняет перевод на основе данных, доставленных пользовательским интерфейсом, компонента тестирования и пользовательского интерфейса. Отдельные компоненты решения подобны частям мозаики, которые вместе составляют рисунок. ПРИМЕЧАНИЕ Компоненты являются основной частью вашего набора инструментов разработчика. Как мы увидим на протяжении всей книги, применение компонентов позволяет реализовать функциональность в виде общего модуля. Построенные из компонентов
Работа
со
строками
69
приложения с легкостью поддаются сопровождению и расширению. Конечно же, существуют и ограничения на применение компонентов, и только сам факт использования компонентов еще не означает автоматическое преимущество. Чтобы применение компонентов при реализации приложения принесло пользу, приложение должно быть спроектировано должным образом.
Создание класса Translator При работе с Visual С# Express или каким-либо другим продуктом Visual Studio в результате применение стандартных шаблонов для создания библиотеки класса создается файл Classl.cs. Хорошо, что для библиотеки класса создается файл по умолчанию, но идентификатор Classl .cs не говорит нам о многом. Поэтому удалите этот файл из проекта, а вместо него создайте класс Translator следующим образом: 1. Щелкните правой кнопкой мыши по названию проекта LanguageTranslator в Solution Explorer. 2. Выберите команды Add | New Item. 3. Выберите опцию Class. 4. Переименуйте файл на Translator.cs. 5. Щелкните кнопку Add, чтобы создать файл и добавить его в проект. Заметьте, как с помощью среды разработки Visual Studio мы быстро создали класс в С#. Легкость, с которой мы можем создавать файлы классов, позволяет нам концентрироваться на написании кода этого класса. Но не заблуждайтесь, что, создав некое количество файлов класса, вы автоматически получите работающее приложение. Для этого вам еще нужно поразмыслить, какие файлы, проекты, классы и тесты нужно создать.
Перевод слова "hello" Первым делом в нашем приложении перевода мы реализуем возможность перевода слова "hello". Так как это английское слово, то сначала мы переведем его с английского на немецкий. Скопируйте следующий код и вставьте в файл Translator.cs проекта LanguageTranslator. public class Translator { public static string TranslateHello(string input) { if (input.CompareTo("hello") = = 0 ) { return "hallo"; } else if (input.CompareTo("alio") == 0) { return "hallo"; } return ""; } }
Глава 10
70
Класс Translator является основным классом, который предоставляется другим компонентам или фрагментам исходного кода приложения. Его можно рассматривать как идентификатор "черного ящика". Этот черный ящик имеет всего лишь один метод: TranslateHello (), который переводит французское "alio" и английское "hello" в немецкое "hallo". В качестве входного параметра метод принимает строковую переменную ссылочного объектного типа. В реализации метода TranslateHello () мы используем метод CompareTo (), чтобы сравнить содержимое буфера ввода с параметром "hello". Если строки одинаковые, то метод возвращает значение 0. Как будет рассмотрено более подробно в разд. "Исследование строкового типа" далее в этой главе, строка является объектом, а объекты имеют методы. Одним из методов строкового типа является метод CompareTo (). Компонент, вызывающий наш метод TranslateHello (), не знает, каким образом мы переводим слово с одного языка на другой. Более того, этот аспект ему абсолютно безразличен; единственное, что его волнует, — чтобы этот метод работал, как от него ожидается. С абстрактной точки зрения, целью метода TranslateHello() является принятие определенного текста, и, если этот текст совпадает с заданным образцом, возвращение немецкого слова "hallo".
Создание тестового приложения Не задаваясь вопросами об абстрактном замысле, нам необходимо протестировать его реализацию в исходном коде. Для этого вставьте следующий тестовый код В тестовое Приложение, которое ЯВЛЯеТСЯ Проектом TestLanguageTranslator, а именно в файл Program.cs. static void TestTranslateHello() { string verifyValue; verifyValue = LanguageTranslator.Translator.TranslateHello("hello"); if (verifyValue.CompareTo("hallo") != 0) { Console.WriteLine("Test failed of hello to hallo"); } verifyValue = LanguageTranslator.Translator.TranslateHello("alio"); if (verifyValue.CompareTo("hallo") != 0) { Console.WriteLine("Test failed of alio to hallo"); } verifyValue = LanguageTranslator.Translator.TranslateHello("allosss"); if (verifyValue.CompareTo("") != 0) { Console.WriteLine("Test to verify nontranslated word failed"); } verifyValue = LanguageTranslator.Translator.TranslateHello(" if (verifyValue.CompareTo("hallo") != 0) {
alio");
Работа
со
строками
71
Console.WriteLineC'Test failed of extra whitespaces alio to hallo"); } }
Данный тест в действительности состоит из четырех отдельных тестов. Каждый из этих тестов вызывает метод TranslateHello О, передавая ему входные данные и получая от него результаты. Тестирование происходит, когда возвращаемый методом результат сверяется с ожидаемым результатом. Тестирование на правильность перевода выполняется с помощью метода CompareTo (). Обратите внимание на третий тест: verifyValue = LanguageTranslator.Translator.TranslateHello("allosss"); if (verifyValue.CompareTo("") != 0) { Console.WriteLineC'Test to verify nontranslated word failed"); }
В этом тесте ожидается явная неудача. Успешно выполняющиеся тесты на неуспешное выполнение операции являются необходимыми. С помощью тестов, целью которых является успешное невыполнение, мы удостоверяемся в том, что наш код не выдаст ложноположительных результатов. Ложноположителъным называется положительный результат, выдаваемый кодом в ситуации, когда должен быть получен отрицательный, или неуспешный, результат. Все эти тесты находятся в методе, который нужно вызывать из метода Main(), как показано в следующем примере: static void Main(string!] args) { TestTranslateHello(); }
Скомпилировав тесты и запустив их на исполнение, вы увидите, что один из них завершится неудачей. Это будет четвертый тест, который пытается перевести слово, в котором переводимое слово имеет несколько начальных пробельных символов. Пробельными называются символы, которые сами не выводятся на экран или печать, но используются в качестве служебных символов для указания слов, предложений, табуляций и т. п. Прежде чем приступать к решению проблемы с пробельными символами, необходимо выяснить, какая часть приложения не работает должным образом.
Вопрос разумного использования Проблема с пробельными символами является интересной. Вызывающий компонент явно вставил в переданные данные дополнительные пробелы, но как должны эти пробелы рассматриваться? Как ошибка или как неправильно переданные методу данные? Ответ на этот вопрос можно сформулировать на основе понятия разумного использования. Представьте себе, что ваш недавно купленный автомобиль поломался,
72
Глава 10
когда вы ехали на нем самым нормальным образом в самых нормальных условиях. Срок гарантии еще не истек, и ремонт автомобиля будет выполнен по гарантии, т. е. без расходов с вашей стороны. А теперь представьте себе, что, заполучив свой давно желаемый автомобиль, вы решили попробовать некоторые из тех каскадерских трюков, которые вы видели в кино. Например, попробовать выполнить на нем прыжок с разгона. Возможно, вам и удастся поднять машину в воздух, и те несколько мгновений полета вызовут у вас непередаваемые ощущения, но каждый полет заканчивается приземлением. Так что машина приземлятся, подвеска вся искорежена, двигатель сорван с креплений и т.д. Но вы не очень переживаете — у вас гарантия. Но когда вы пытаетесь воспользоваться этой гарантией, то слышите в ответ смешок механика станции техобслуживания. Иными словами— ваша гарантия распространяется только на поломки, случившиеся при нормальной эксплуатации в нормальных условиях. Каскадерство сюда не входит. Возвращаясь к нашему компоненту для перевода, можно сказать, что он предоставляет метод TranslateHello () и имеет определенные ответственности. А от компонента, вызывающего метод TransiateHeiio (), ожидаются разумные тесты с данными для перевода. Таким образом, является ли разумным со стороны вызывающего компонента посылать компоненту перевода пробельные символы? Если наличие пробельных символов во входных данных является нормальным обстоятельством, то тогда неуспешное тестирование является ошибкой в компоненте перевода. В противном же случае, т. е. когда наличие пробельных символов во входных данных не является нормальным, поведение вызывающего компонента не является разумным, и его необходимо исправить. Ответ на ранее поставленный вопрос состоит в том, что вызывающий компонент ведет себя разумным образом, а проблема связана с вызываемым компонентом, который не обрабатывает переданные ему данные должным образом. Это ошибка в компоненте перевода, которую необходимо исправить. Откуда я знаю, что вызывающий компонент ведет себя разумным образом? Я так решил, потому что я хочу, чтобы контракт между вызывающим и вызываемым компонентами был реализован именно таким образом. Хорошее определение контракта является ключевым аспектом. Ошибка в компоненте перевода связана со способом перевода слова. Для перевода применяется метод compareToO, который сравнивает по одному символу во введенном слове и шаблоне. Тест завершился неудачей потому, что вызывающий компонент передал компоненту перевода строку, содержащую пробельные символы, которых последний не ожидал. Эта ошибка не является чем-то необычным, т. к. люди игнорируют пробельные символы, но компьютеры не могут этого делать. Но прежде чем объяснять, каким образом исправить эту ошибку, нам необходимо получить дополнительную информацию о строках и о том, что они могут делать.
Исследование строкового типа Строка является объектом и, поэтому, ссылочным типом. Строковый тип string имеет методы и свойства. Обычные типы, такие как double и int, также имеют
Работа
со
73
строками
методы и свойства, но строковый тип является первым действительным объектом, который нам нужно рассмотреть. Тип можно изучить, читая соответствующую документацию или воспользовавшись возможностью IntelliSense среды разработки. Чтение документации имеет свои достоинства, но это наиболее медленный и наиболее тягостный способ. Более удобным является использование IntelliSense, когда методы и свойства определенного типа выводятся на экран в легко поддающемся пониманию формате. При первом использовании IntelliSense может показаться, что вместо помощи он просто лезет под руку в самое неподходящее время, но со временем его помощь трудно будет переоценить. Процедура применения IntelliSense проиллюстрирована на рис. 3.2. Я бы порекомендовал вам потратить несколько минут на экспериментирование с использованием этой возможности. Я также рекомендую постоянно держать IntelliSense включенным в Visual С# Express.
Рис. 3.2. Демонстрация использования IntelliSense на примере со строковой переменной
IntelliSense работает только с переменными определенного типа. Принцип его работы основан на синтаксическом анализе кода средой разработки и чтении мета-
Глава 10
74
данных, ассоциированных с данным типом. Метаданные— это данные, которые описывают исходный код. Всякий раз при определении класса с ним ассоциируются методы и свойства. Описания методов и свойств и являются элементами метаданных, выводимых IntelliSense. Обстоятельство, что все типы имеют метаданные, является одним из достоинств .NET. КОГДА
ИНФОРМАЦИИ
ОТ
INTELLISENSE
НЕДОСТАТОЧНО
IntelliSense является хорошим гидом и даже показывает объяснения о том, что данный метод делает (см. рис. 3.2). Но иногда этих объяснений недостаточно; в таком случае требующуюся информацию можно найти в документации Microsoft, к которой можно получить доступ, выбрав последовательность команд меню Help | Index. Информацию о конкретном типе можно найти, введя его имя в поле Look for. Например, если ввести в это поле текст "String class", то будет выведена подробная информация о классе string, которую потом можно будет отфильтровать в поле Filtered by. Документация Microsoft в Visual С# Express является частью библиотеки Microsoft Developer Network (MSDN) (http://msdn.microsoft.com). Web-страница MSDN содержит документацию, которая поможет вам разобраться с интерфейсом API стандартного набора SDK .NET. Существует буквально тысячи типов и неимоверное количество их методов и свойств. Хотя вы вряд ли используете даже малую толику этих классов и их методов и свойств в одном приложении, вы всегда будете использовать набор SDK .NET. В большинстве случаев MSDN будет достаточно, чтобы получить необходимую информацию о типе. Но если вам захочется узнать побольше о концепциях, то можете поискать требуемую информацию в Интернете. Одним из таких дополнительных источников информации может быть Web-страница Code Project (http://www.codeproject.com). Данная Web-страница содержит множество примеров на практически любую тему разработки, которая может прийти вам в голову.
Основа всех типов: объект По умолчанию, все элементы в .NET являются объектами, имеющими несколько основных свойств и методов. С каждым объектом ассоциируются четыре базовых метода: •
Equals () — удостоверяет равенство двух объектов (рис. 3.3);
•
GetHashCode () — получает уникальное число, описывающее объект (рис. 3.4). Объекты, имеющие одинаковое содержимое, возвращают одинаковый хэш-код;
•
GetType ( ) — получает метаданные, ассоциированные с объектом (рис. 3.5). Позволяет программе динамически определить методы и свойства объекта. Этот метод применяется в IntelliSense для вывода на экран списка;
•
Tostring () — преобразует содержимое переменной типа в строку (рис. 3.6). Обратите внимание, что стандартная реализация метода Tostring () в среде CLR применима только с обычными типами.
Данные четырех базовых методов можно вызывать для любой объявленной переменной. Мы будем применять метод Tostring () при отладке и исследовании состояний экземпляров объектов во время исполнения программы. Метод Tostring () возвращает строку в читаемом формате, содержащую состояние экземпляра объекта.
Работа
со
строками
Рис. 3.3. Метод Equals () применяется для проверки двух объектов на равенство
Рис. 3.4. Метод GetHashCode () получает уникальное число, описывающее объект
Рис. 3.5. Метод GetType () получает метаданные, ассоциированные с объектом
75
76
Глава 10
Рис. 3.6. Метод T o s t r i n g () преобразует числовое содержимое переменной в строку
Программисты могут изредка прибегать к использованию метода GetTypeO, но среда разработки и другие инструменты применяют этот метод постоянно. С помощью метода GetType () можно узнать возможности переменной во время исполнения программы. В технических терминах метод GetTypeO возвращает формальное описание на основе метаданных этого типа. При чтении описания методов Equals!) и GetHashCode!) у вас может сложиться впечатление, что эти две функции имеют одинаковое назначение. Но это не так. Допустим, что вы переезжаете и упаковали кухонную утварь в два ящика. В каждом из этих ящиков находятся одинаковые предметы: пять красных тарелок, две серебряные вилки, два ножа с медными рукоятками и два бокала для вина. Сравнивая ящики, методы Equals () и GetHashCode!) возвратят положительный результат, означающий, что оба ящика содержат одинаковое количество одинаковых предметов. Важно понимать, что даже хотя каждый из этих двух ящиков является уникальным экземпляров своего класса, содержащим уникальные предметы, их содержимое одинаково. При сравнении экземпляров объектов с помощью метода Equals () или GetHashCode () мы сравниваем атрибуты метаданных и значений, а не уникальные экземпляры. Теперь представьте, что винные бокалы в одном ящике хрустальные от известного производителя, а в другом — стеклянные подделки типа "Made in China". Сравнивая ящики с помощью метода Equals (), мы получим отрицательный результат, т. к. свойства содержимого ящиков отличаются. Разница заключается в наших фужерах для вина. А вот метод GetHashCode () будет продолжать указывать, что содержимое ящиков идентично. Причиной этому является то обстоятельство, что метод GetHashCode () выполняет идентификацию содержимого по-быстрому. Разница между методами Equals!) и GetHashCode () заключается в точке зрения. С точки зрения перевозчика, ящики одинаковые, т. к. ему безразлично, какого про-
Работа
со
строками
77
изводства фужеры, но вам — нет, т. к. за хрусталь вы переживаете больше, чем за стекло. То что метод GetHashCode () может возвратить идентичные значения для объектов с, казалось бы, разным содержимым, может приводить разработчиков в замешательство. Одним из способов избежать такого замешательства может быть рассмотрение назначения метода GetHashCode() не как удостоверения идентичности двух объектов, а как удостоверения их различия. Если объекты возвращают разные хэш-коды, то мы знаем, что их содержимое не одинаковое. Целью получения хэшкода является быстрая идентификация содержимого объекта. Это не стопроцентно надежный способ, но в большинстве случаев он работает.
Проблема: посимвольное сравнение Возвратимся к проблеме пробельных символов. Источником этой проблемы является метод CompareTo (). Информацию об этом методе можно найти в документации MSDN, прокрутив страницу справки для класса string до части, содержащей элемент CompareTo, и щелкнув по этому элементу. В результате будет выведена страница, содержащая следующее описание: Compares this instance with a specified object'. Это не говорит нам о многом, поэтому попробуем найти нужную нам информацию в объяснении родственного метода. Подобные методы часто ссылаются друг на друга и объясняют общие принципы. Возвратитесь обратно на страницу справки для класса string и щелкните по ссылке Compare!), а в открывшейся странице щелкните по ссылке Compare (string, string). Прокрутите страницу до раздела Remarks, и в объяснении метода Compare () будет содержаться следующий текст: "Процесс сравнивания прекращается при обнаружении разницы между строками или по окончании сравнения двух строк. Но если процесс сравнения доходит до конца одной строки, в то время как в другой строке остаются символы, тогда строка с оставшимися символами считается большей. Возвращаемое значение является результатом последней выполненной операции сравнения". ПРИМЕЧАНИЕ Хотя из предыдущего описания может показаться, что просмотр назначения метода является довольно длительным процессом, в действительности это не так. Набравшись немного опыта, вы будете делать это автоматически, даже не замечая этих нескольких щелчков мышью.
Выполнение метода CompareTo () завершилось неудачей потому, что он сравнивает строки посимвольно (рис. 3.7).
1
Сравнивает данный экземпляр с указанным объектом. - Пер.
Глава 10
78
Рис. 3.7. Посимвольное сравнение строк методом CompareTo ()
Строки хранятся в буферах, по одному символу в одной ячейке буфера. Один пробельный символ занимает одну ячейку буфера. Как будет показано в следующем разделе, этим обстоятельством иногда можно воспользоваться, но в данном случае оно вызывает ошибку сравнения строк. Выяснив, в чем заключается проблема, мы можем приступить к поиску ее решения.
Решение проблемы пробельных символов Проблему пробельных символов можно решить несколькими способами. Какой из них выбрать, зависит от ваших требований. Рассмотрим несколько решений, чтобы выяснить, какое из них подходит лучше всего для нашей программы перевода.
Удаление пробельных символов Первым способом для удаления пробельных символов, который мы рассмотрим, будет метод, специально предназначенный для этой цели. Проблема пробельных символов не является чем-то необычным, она хорошо известна. Строковый тип имеет метод для удаления пробельных символов из буфера. С его помощью можно удалять пробельные символы в начале, конце или с обеих сторон буфера. Несмотря на то, как заманчиво может выглядеть изменение первоначальной реализации метода T r a n s l a t e H e l l o f ) , делать это не следует, т. к. наши исправления могут внести другие проблемы. Проблемы, возникающие в процессе разработки кода, обычно можно решить несколькими способами. Но если вы станете поочередно испытывать эти способы на первоначальном исходном коде, к тому времени, когда вы дойдете до третьего или четвертого способа, первоначальный исходный код может оказаться в полнейшем беспорядке. Ваши попытки исправить первоначальные ошибку могут внести в код другие ошибки, и возвращение исходного кода
Работа
со
строками
79
в его начальное состояние может оказаться весьма проблематичным, а то и вовсе не возможным. ПРИМЕЧАНИЕ Для управления исходным кодом следует использовать управление версиями. Но даже применяя управление версиями, при удалении старого кода теряются идеи, реализованные в нем. Поэтому ваш исходный код может быть упорядоченным и аккуратным, но вы не должны ничего забывать из того, что делали всего лишь несколько часов тому назад. Поверьте мне, такое случается, т. к. разработка исходного кода является интенсивным процессом мышления.
Поэтому предпочтительным подходом к исправлению ошибки будет создание вставки, которая вызывает метод TranslateHello (), и исправление ошибки в этой вставке. Код вставки, которая является временным решением проблемы пробельных символов, выглядит таким образом: public static string TrimmingWhitespace(string buffer) { string trimmed = buffer.Trim(); return LanguageTranslator.Translator.TranslateHello(trimmed); }
Временный метод TrimmingWhitespace () удаляет пробельные символы из строки, которую нужно перевести. В нем применяется метод buffer .Trim(), который предоставляет новую функциональность предварительной обработки буфера. После этого метода вызывается первоначальный метод TranslateHello О, чтобы выполнить перевод. Конечно же, новый метод необходимо протестировать, чтобы убедиться в том, что он должным образом удаляет пробельные символы из строк, которые нужно перевести. Соответствующий код будет таким: • verifyValue = TrimmingWhitespace("alio"); if (verifyValue.CompareTo("hallo") != 0) { Console.WriteLineC'Test failed of extra white spaces alio to hallo"); }
Код тестирования вызывает рабочий метод TrimmingWhitespace(), чтобы проверить, что все работает должным образом. Код собственно верификации не меняется. Чтобы получить предсказуемые результаты, не забудьте вызвать вставку, а не первоначальный метод. Исполнив тестовый код, вы увидите, что вставка работает, таким образом, являясь решением проблемы пробельных символов.
Обнаружение подстроки Другим способом решения проблемы пробельных символов является поиск определенной подстроки в буфере. Этот подход можно рассматривать как поисковое решение, где метод в цикле последовательно сравнивает элементы буфера с образцом текста. Рабочий код для этого способа показан на рис. 3.8.
Глава 10
80
Рис. 3.8. Решение проблемы пробельных символов способом нахохедения подстроки
Код тестирования для этого метода не приводится, т. к. он похож на код тестирования для предыдущего решения, с одной только разницей, что тестируется другой метод.
Какое решение лучшее? Подумайте минутку, какое из представленных решений лучшее: удаление пробельных символов или нахождение подстроки? Прямого ответа на этот вопрос нет, т. к. каждое решение имеет свои проблемы. При разработке программного обеспечения такая ситуация является довольно обычной. Вы думаете, что просчитали все возможные варианты, когда неожиданно обнаруживается еще один, который опрокидывает все ваши расчеты. Что я хочу этим сказать? Необходимо создать дополнительные тесты, чтобы вычислить, какие сценарии могут вызвать проблемы в вашей программе. Решение, состоящее в удалении пробельных символов, не является идеальным и в определенных ситуациях не работает, как доказывается следующим тестовым кодом. Сколько бы вы ни пытались исправить решение, чтобы оно успешно прошло этот тест, сделать это вам не удастся. verifyValue = TrimmingWhitespace ("a alio"); if (verifyValue.CompareTo("hallo") != 0) { Console.WriteLine("Test failed: cannot parse multiple words"); }
В данном тесте начальная буква "а" считается законным символом и не удаляется, а идущие за ней пробелы уже не стоят в начале строки. Верификация будет неус-
Работа
со
81
строками
пешной, т. к. метод CompareTo () сбивает с толку смещение буфера, вызванное начальной буквой "а". Но второе решение, нахождение подстроки, успешно проходит испытание новым тестом, который находит слово "alio". Так как первое решение не выдерживает новый тест, то можно сказать, что данное решение неприемлемо. В то же самое время успешное прохождение вторым решением второго теста вдобавок к первому повышает наше доверие этому решению. Но не стоит торопиться делать заключение касательно его надежности, т. к. оно не выдерживает испытания следующим тестовым кодом: verifyValue = FindSubstring("allodium"); if (verifyValue.CompareTo("hallo") != 0) { Console.WriteLineC'Test failed: cannot parse multiple words");
}
Проверяемое слово, "allodium", содержит символы "alio". Верификация будет успешной, когда она не должна быть таковой, что является примером ложноположительного результата. ПРИМЕЧАНИЕ Важно иметь большое количество тестов, чтобы можно было проверить как можно больше возможных сценариев. Эти тесты должны содержать такие, которые должны вызывать успешное выполнение, и такие, которые должны вызывать неудачное выполнение.
Конечный вывод таков, что ни одно из решений не работает должным образом. Подвергнув оба решения расширенному тестированию, мы обнаружили сценарии, вызывающие неуспешное исполнение каждого из них. Таким образом, нам необходимо найти новое решение проблемы пробельных символов. САМОИСТЯЗАНИЕ
ПРИ
РАЗРАБОТКЕ
С первого взгляда может показаться, что создание решений, следуемое созданием тестов, которые сводят на нет эти решения, является упражнением в самоистязании. Еще бы — вы намеренно стараетесь завалить код, в создание которого вложили столько усилий. Но вы должны осознавать, что этот процесс является частью общего процесса разработки программного обеспечения, и обязаны относиться к нему со всей серьезностью. Некоторые программисты не заботятся о создании тестов для своего кода, но это те программисты, которые создают профессии разработчика программного обеспечения дурную славу. Вы должны стремиться быть разработчиком, которому можно доверять, а для этого необходимо тестировать свой код. Я работал, а моя жена и сейчас работает, руководителем проектов по разработке прогрвммного обеспечения. В ее словах: "Я могу терпеть медленных разработчиков, но я не выношу разработчиков, которым я не могу доверять в написании стабильного и надежного кода. Проблема с ненадежными разработчиками заключается в том, что я не могу позволить им отправить код в промышленный выпуск, и мне всегда нужно иметь кого-либо, чтобы присматривать за их работой".
82
Глава 10
Пишем тест, прежде чем писать код Предыдущие решения были неудачными, потому что каждое из них было своего рода коленным рефлексом. В программировании коленный рефлекс — это создание решения, направленного на исправление обнаруженной ошибки, и не более того. Вместо этого подхода следует вычислить, о чем говорит нам данная ошибка. Первоначальная проблема с ведущими пробельными символами не заключалась лишь в наличии пробельных символов, а была своего рода вопросом: "А что, если текст не выровнен или является частью предложения и т. п.?" Чтобы исправить ошибку, мы не пишем код, но продумываем все тесты, которые наш код должен выдержать. Необходимо распределить ответственность и определить контексты успешного и неудачного исполнения кода. В примере с переводом, правильным подходом к реализации этого приложения было бы создание тестов до создания кода. Контексты успешного и неудачного исполнения для этого приложения представлены в табл. 3.1. Таблица
3.1.
Ситуации
Ситуация
Результат верификации
alio
Успех
" alio word
"
alio
для
тестирования
программы
перевода
Успех Неудача: нельзя переводить одно слово без перевода всех других слов
word a l i o word
Неудача: по той же само причине, что и в случае с текстом w o r d alio
prefixallo
Неудача: не то слово
alloappend
Неудача: не то слово
prefixalloappend
Неудача: не то слово
Как можно видеть, исполнение большинства тестов заканчивается неудачей, т. к. компонент перевода способен переводить только отдельные слова. Тесты в табл. 3.1 вроде бы охватывают все возможные ситуации, но в действительности это не так. Имеются еще две возможные ситуации, которые показаны в табл. 3.2. Таблица
3.2.
Дополнительные
ситуации
Ситуация
Результат верификации
Alio
Успех
" alio "
Успех
для
тестирования
Текст может быть в смешанном регистре, и с точки зрения людей такое слово равнозначно слову в одном регистре. Но компьютеры рассматривают текст в другом
83
Работа со с т р о к а м и
регистре как совсем иной текст, поэтому мы должны быть в состоянии обрабатывать такую ситуацию. На рис. 3.9 показано рабочее решение проблемы.
Рис. 3.9. Конечное приложение перевода
Рассматривая решение, можно видеть, что оно содержит элементы из первого решения, которое было отброшено, потому что оно не работало должным образом. Это хороший пример того, как можно ошибиться, исправив по-быстрому ошибку и увидев, что это решение не работает, отбросить это решение. Поэтому необходимо сначала основательно подумать, почему код не работает должным образом, а не просто исправлять на скорую руку ошибку, лишь бы избавиться от нее. ПРИМЕЧАНИЕ Во всех рассмотренных решениях применялись методы типа s t r i n g . Это очень сложный тип, который поддерживает многие операции, часто применяющиеся на текстовых данных.
На этом мы закончим рассмотрение примера перевода приветствия. Далее мы обсудим пару других аспектов, которые вам нужно знать при работе со строковыми данными.
Заключение строк в кавычки Вы, наверное, обратили внимание на использование двойных и одинарных кавычек в вызове метода CompareTo (). Эти два типа кавычек существенно отличаются друг от друга. Двойные кавычки определяют строковый тип, как показано в следующем примере: "using double quotes"
Одинарные же кавычки применяются для определения символа, например ' а ' .
84
Глава 10
Соответственно, одинарные кавычки можно использовать только с одиночным символом. Одиночный символ можно рассматривать как букву, но это не всегда так, поскольку не во всех языках имеются буквы. При попытке определить строку с помощью одинарных кавычек компилятор С# сгенерирует ошибку, которая в .NET обычно называется исключением (exception).
Кодовые таблицы символов Для хранения одного символа требуется 16 бит памяти, а объем памяти, занимаемый строкой, зависит от количества символов в строке. Например, для хранения строки длиной в 10 символов требуется 160 бит памяти. Тип string является ссылочным типом. Так как для хранения одного символа отводится 16 бит, то текст можно хранить в огромном разнообразии форматов. В данном случае применяется стандартный формат, называемый Unicode. Возьмем, к примеру, букву а. По-философски, каким образом мы знаем, что а — это а? Для нас это не составляет особого труда, т. к. наш мозг натренирован взаимосвязывать очертания и весь внешний вид данной фигуры с концепцией буквы а. Теперь посмотрим на английскую букву, показанную на рис. 3.10.
Р Рис. 3.10. Буква английского алфавита
Какая буква показана на рис. 3.10? Выглядит, как будто бы буква Р, не так ли? Но английская Р— это русская П. В каждом из этих двух языков применяется свой набор символов для обозначения букв, и английской букве Р соответствует русская П. Соответствие всех букв русского алфавита английскому показано в табл. 3.3. Для начинающих изучать английский язык таблица соотношений пришлась бы кстати. С ее помощью можно быстро сориентироваться, какая буква или комбинация букв английского алфавита соответствует определенной русской букве. Компьютеры также нуждаются в шпаргалке такого вида, т. к. они не понимают букв, а только числа. Поэтому в компьютерах применяются таблицы преобразований, с помощью которых набор букв соотносится с набором чисел. Существует несколько типов таблиц преобразований, одной из них является код ASCII (American Standard Code for Information Interchange, Американский стандартный код обмена информацией). Так, например, в ASCII английская буква а соотносится с числом 97. Но с ASCII имеется проблема — в то время как этот код прекрасно работает с английским алфавитом, с другими алфавитами он работает отвратительно. Код ASCII был расширен для работы с западноевропейскими языками, но с такими языками как китайский, русский или арабский у него имеются проблемы.
Работа
со
85
строками Таблица
3.3.
Соотношение
русских
букв
английским
Русский
Английская транслитерация
Русский
Английская транслитерация
а
а
с
s
б
b
т
t
в
V
У
U
г
g
ф
f
д
d
X
kh
е/ё
е
М
ts
ж
zh
ч
ch
3
z
ш
sh
и/й
1
щ
к
k
ъ
shch ii
л
1
ы
м
m
ь
н
n
э
e
0
У 1
0
ю
iu
п
P
я
ia
Р
r
По этой причине в .NET применяется кодировка Unicode. Эта кодировка определяет набор таблиц преобразования, которые соотносят все алфавиты мира с определенным набором чисел. В большинстве случаев вам не придется иметь дело с Unicode, т. к. все, связанное с кодировками, .NET выполняет прозрачно для программиста. Дело обстояло совсем по-другому много лет тому назад, когда программистам приходилось выполнять самостоятельно всю связанную с таблицами преобразований работу. Так что вы можете считать, что вам повезло: вам не придется познать всю радость такой работы при разработке многоязычных приложений.
Языки и региональные стандарты При работе со строками в .NET применяется не только Unicode. Среда .NET очень инновационная в том смысле, что она понимает такие концепции, как региональные стандарты и язык, которые являются отображением того, как люди живут и разговаривают. Концепция региональных стандартов и языка не существует в других средах программирования. Возьмем, к примеру, Швейцарию, страну размером чуть больше Московской области, расположенную в центре Европе. Швейцария — горная страна, население которой разделено горными хребтами на четыре лингвистические группы: немецкую. 4 Зак. 555
86
Глава 10
итальянскую, ретороманскую и французскую. Но, несмотря на то, что в стране четыре языка, швейцарцы используют одну валюту и пишут цифры одинаковым образом. В предыдущих средах программирования, язык был привязан к определенной стране. Такое решение прекрасно подходит для Франции, Германии и Соединенных Штатов, но никуда не годится для Канады, Швейцарии, Бельгии и Индии. Язык необходимо рассматривать отдельно от страны, т. к. на одном и том же языке могут разговаривать в разных странах. Например, на итальянском разговаривают в Швейцарии и Италии, на французском — во Франции, Швейцарии, Люксембурге и Канаде, а на немецком — в Германии, Швейцарии и Австрии.
Установка региональных стандартов и языка в Windows Операционная система Windows позволяет установить региональные стандарты и язык вашего компьютера, независимо от языка самой Windows. Пример установки региональных стандартов и языка показан на рис. 3.11.
Рис. 3.11. Установка региональных стандартов и языка в Windows
На рис. 3.11 показана Панель управления немецкой версии Windows на компьютере в Швейцарии. В системе установлен английский язык и швейцарские регио-
Работа
со
строками
87
нальные стандарты. Может показаться, что такое сочетание могло бы сбить Windows с толку, но поддержка, казалось бы, разных языков и региональных стандартов не представляет в Windows никаких проблем. Если приложение написано должным образом, то поддержка нескольких языков и региональных стандартов не составляет никакого труда.
Анализ и обработка чисел Региональные стандарты и страна играют важную роль при обработке чисел и дат, которые хранятся в виде строк. Представьте себе выполнение сложения чисел, хранящихся в виде строк. Пример такого сложения продемонстрирован на рис. 3.12.
Рис. 3.12. Результаты арифметических операций с числами, хранящимися в виде строк, могут быть неожиданными
Сложение чисел представляет собой арифметическую операцию. Но при сложении строк выполняется операция конкатенации, т. е. сращивание одной строки с другой. Использование операции сложения со строками представляет удобный способ для конкатенации строк. Но в данном примере конкатенация не была целью сложения. Чтобы получить желаемый результат, нам нужно обращаться со строками, как с числами, над которыми потом и выполняется операция сложения, а результат — 3 ( 1 + 2 = 3) — сохраняется в переменной с. Исправленный код для получения желаемого результата показан на рис. 3.13. Этот код использует метод .NET, чтобы сопоставить строковому представлению цифры соответствующее числовое представление. Тип int имеет метод Parse о, с помощью которого можно преобразовать строковое представление числа в его числовой эквивалент. Метод работает должным образом, только если строка содержит допустимое представление числа. Иными словами, строка может содержать только цифры, но не буквы или, за исключением знака "плюс" или "минус", другие символы. В последнем случае метод Parse о выдает ошибку.
88
Глава 10
Рис. 3.13. Сопоставление строк числам
Если код не может справиться с неудачно завершившейся операцией преобразования строки, в процедурах анализа обычно генерируется исключение, которое может быть обработано программой. Другим способом безопасного преобразования строки в число без применения исключения является метод TryParse (), как показано в следующем примере: int value; i f(int.TryParse("1", out value)) С }
Метод TryParse () возвращает не соответствующее целочисленное значение, а булев флаг, указывающий, поддается ли строка преобразованию. Если возвращается значение true, то тогда строка преобразуется, а результат сохраняется в параметре value, обозначенном идентификатором out. Идентификатор out указывает, что результаты проверки сохраняются в следующем за ним параметре: преобразованное значение в случае успеха или 0 в противном случае. С помощью метода TryParse () можно преобразовывать другие числовые типы, например float .TryParse (). Можно также преобразовывать числа других систем счисления, скажем, шестнадцатеричной. Например, в следующем коде показано преобразование шестнадцатеричного числа 100 из строкового представления в числовое: using System.Globalization; public void ParseHexadecimal() { int value = int. Parse (" 100" , NumberStyles .HexNumber) ; }
В коде используется версия метода Parseo, которая имеет дополнительный параметр, указывающий формат преобразуемого числа. В данном случае второй параметр— NumberStyles.HexNumber ИЗ пространства имен System.Globalization— указывает, что число в шестнадцатеричном формате.
Работа
со
89
строками
ПРИМЕЧАНИЕ Убедиться в том, что 100 в шестнадцатеричном счислении соотносится с 256 в десятичной, можно с помощью программы Калькулятор, поставляемой с операционной системой Windows. Переключите калькулятор в инженерный вид, установите переключатель Hex, введите число 100 и установите переключатель обратно в Dec.
Перечисление NumberStyles содержит другие члены, которые можно использовать для преобразования чисел в соответствии с другими правилами. Например, член AiiowParentheses применяется для обработки скобок, обозначающих, что число отрицательное, а члены AiiowLeadingwhite и AiiowTraiiingwhite обрабатывают начальные и конечные пробельные символы соответственно. Пример использования этих членов перечисления NumberStyles приводится в следующем коде: public void TestParseNegativeValue(){ int value = int.Parse("
В данном примере строковое представление числа 10 усложнено скобками, начальными и конечными пробельными символами. Применение только метода Parse () для преобразования этой строки в числовое представление не даст желаемых результатов, поэтому необходимо использовать члены перечисления NumberStyles. Член перечисления AllowParentheses обрабатывает скобки, AllowLeadingWhite — начальные пробельные символы, a AiiowTraiiingwhite— конечные пробельные символы. После обработки строки этими членами перечисления NumberStyles и методом Parse () в переменной value сохраняется числовое значение - 1 0 . Другие члены перечисления NumberStyles применяются для обработки десятичной точки в дробных числах, положительных и отрицательных чисел и т. п. Это приводит нас к теме обработки чисел иных типов, чем целые ( i n t ) . Каждый из базов ы х ТИПОВ д а н н ы х , т а к и х как boolean, byte и double, имеет СВОИ м е т о д ы Parse о
и TryParse)). Кроме этого, метод TryParse о может использовать перечисление NumberStyles. (Подробную информацию о членах перечисления см. в документации MSDN.) Преобразование целочисленных значений выполняется одинаковым образом, независимо от страны. Но с преобразованиями действительных чисел и дат дело обстоит иначе. Рассмотрим следующий пример кода, в котором делается попытка преобразовать строку, содержащую представление десятичных значений: public void TestDoubleValue() { double value = double.Parse("1234.56"); value = double.Parse("1, 234. 56") ; }
90
Глава 10
В данном примере оба случая применения метода Parse О обрабатывают число 1234.56. В первом случае метод Parse() преобразовывает простое число, т. к. оно содержит только десятичную точку, отделяющую целую часть от дробной. Во втором случае метод Parse о преобразует более сложное число, содержащее кроме десятичной точки разделитель тысяч. В обоих случаях процедуры Parse () исполнялись успешно. Но если вы протестируете этот код, возможно, что будет сгенерировано исключение. В таком случае виновником будут региональные стандарты.
Культурная среда В .NET информация о культурной среде указывается с помощью двух идентификаторов: языка и региональных стандартов. Как было упомянуто ранее, в Швейцарии разговаривают на четырех языках. Это означает, что дата, время и денежная единица выражаются в четырех разных способах. Это не означает, что формат даты разный в немецком и французском языках. Но при одинаковом формате слова для обозначения марта — Maerz или Mars — будут разными. С другой стороны, слова для обозначения дат одинаковые в Австрии, Швейцарии и Германии, но формат даты разный. Это означает, что для стран с несколькими языками, например Канады (французский и английский) или Люксембурга (французский и немецкий), необходимо применение нескольких кодировок, отсюда и надобность в двух идентификаторах. Информацию о культурной среде можно извлечь с помощью следующего кода: Culturelnfo info = Thread.CurrentThread.CurrentCulture(); Console.WriteLine("Culture (" + info.EnglishName + ")");
В данном примере информация о культурной среде, ассоциированной с текущим потоком, извлекается С помощью метода Thread.CurrentThread.CurrentCulture(). Как видно из этого примера, отдельные потоки можно ассоциировать с разными культурными средами. Свойство EngiishName генерирует английскую версию информации о культурной среде, которая, в случае установок, приведенных на рис. 3.11, была бы следующей: Culture (English (Canada))
Теперь рассмотрим число 1,234. В американских или канадских региональных стандартах это будет число тысяча двести тридцать четыре. Один из способов изменить региональные стандарты заключается в использовании диалогового окна языка и региональных стандартов (см. рис. 3.11). Но это можно также сделать программным образом, как показано в следующем коде: Thread.CurrentThread.CurrentCulture = new Culturelnfo("en-CA");
В данном примере создается новый экземпляр класса Culturelnfo, содержащий региональный стандарт еп-СА.
Работа
со
строками
91
Далее, в следующем коде приводится пример обработки действительного числа, отформатированного согласно немецким правилам форматирования: public void TestGermanParseNumber() { Thread.CurrentThread.CurrentCulture = new Culturelnfo("de-DE"); double value = Double.Parse("1,234"); }
В примере текущему потоку назначается культурная среда de-DE. Впоследствии в любой процедуре преобразования в качестве базы для правил форматирования применяется немецкий язык, употребляемый в Германии. Изменение культурной среды не влияет на правила форматирования языка программирования. С помощью методов Parse () и TryParse () также можно преобразовывать даты и время: public void TestGermanParseDate() { DateTime datetime = DateTime.Parse("May 10, 2005"); Assert.AreEqual(5, datetime.Month); Thread.CurrentThread.CurrentCulture = new Culturelnfo("de-DE"); datetime = DateTime. Parse ("10 Mai, 2005"),Assert.AreEqual(5, datetime.Month),}
Обратите внимание на то, как в первом применении метод DateTime. Parse () обработал текст, отформатированный по англо-канадским правилам, и узнал, что идентификатор мау равняется пятому месяцу года. Для второго вызова метода DateTime. Parse о культурная среда была изменена на немецкую, что позволило обработать строку ю Mai, 2005. В обоих случаях, при условии, что мы знали, что обрабатываемая строка представляет немецкую или англо-канадскую дату, ее обработка не представляла особых проблем. Но обработка немецкой даты при установленной английской культурной среде вызовет проблемы. Преобразование данных в строку является относительно нетрудной задачей, т. к. для этого в .NET для индивидуальных типов имеется метод Tostring о, который и предоставляет желаемый результат. В следующем коде демонстрируется использование этого метода для преобразования целочисленного значения в строку: public void TestGenerateString() { String buffer = 123.ToString(); Assert.AreEqual ("123", buffer) ,}
Значение 123 неявно преобразуется в переменную, для которой вызывается метод ToStringO, переводящий значение в его строковое представление и сохраняющий его в строковой переменной buffer. С помощью метода ToStringO также можно преобразовать в строки действительные числа, как показано в следующем примере: double number = 123.5678; String buffer = number.ToString("0.00");
92
Глава 10
В этом примере метод ToStringO имеет параметр, указывающий формат строкового представления преобразованного действительного числа. В данном случае указывается, что строковое представление числа должно иметь самое большее два знака после десятичной точки. Так как третья цифра после десятичной точки — 7, то результат округляется с повышением до 123.57. Теперь рассмотрим, каким образом аспект культурной среды применим к преобразованию чисел в строки. В следующем коде приводится пример преобразования числа в его строковое представление в формате определенной культурной среды, public void TestGenerateGermanNumber() { double number = 123.5678; Thread.CurrentThread.CurrentCulture = new Culturelnfo("de-DE"); String buffer = number.ToString("0.00"); Assert.AreEqual("123,57", buffer); }
Как и в предыдущих примерах, желаемая культура присваивается свойству CurrentCulture текущего потока. После этого вызывается метод ToStringO действительного типа, который выполняет преобразование и сохраняет результат в строковой переменной buffer.
Советы разработчику В этой главе мы рассмотрели строки и некоторые их применения. Далее приводятся ключевые аспекты главы, которые следует запомнить. •
Создание тестов для разрабатываемого приложения является важной частью процесса разработки. Тест — это не только механизм для улавливания ошибок в коде, но также и механизм для понимания динамики разрабатываемого кода.
•
Тип string — это специальный ссылочный тип, имеющий многочисленные методы и свойства. Рекомендуется изучить возможности типа string в документации MS DM.
•
Лучшими источниками информации о конкретных методах, свойствах или типах являются IntelliSense и документация MSDN. Хорошими ресурсами для изучения концепции являются книги и Web-страницы, такие как Code Project.
•
Все переменные и типы являются объектами.
П При разработке кода необходимо определить ответственности и контексты. Создание кода в целом и исправление ошибок в частности должно быть целостным процессом, а не реакцией на разрозненные требования и ошибки. •
Все строки основаны на кодировке Unicode. Длина символов Unicode — 16 битов.
•
Преобразование числового представления чисел и дат в строковое и обратное преобразование являются распространенными операциями.
•
.NET предоставляет высокого уровня технологию для таких операций, включая преобразования дат и чисел в комбинациях форматов разных культурных сред и разных языков.
Работа
со
строками
93
Вопросы и задания для самопроверки В следующих упражнениях вы можете проверить, как хорошо вы усвоили материал этой главы: 1. Закончите приложение для перевода с одного языка на другой, предоставляя пользователю возможность выбора направления перевода. 2. Расширьте компонент LanguageTranslator для перевода слов au revoir и ям/ wiedersehen в good bye. 3. Строки можно конкатенировать с помощью знака "плюс", но большое количество таких операций понизит скорость исполнения вашего кода. Поэтому для конкатенации строк вместо знака "плюс" используйте класс stringBuilder. Подсказка: для конкатенации строковых переменных а и ь нужно вместо строки кода с = а + ь использовать класс stringBuilder. Результат работы метода stringBuilder присваивается переменной с. 4. Создайте тест для демонстрации того, что происходит при попытке сложить числовое значение со строковым значением. Создайте соответствующий тест для подтверждения своих заключений. 5. Расширьте компонент LanguageTranslator, добавив методы для преобразования чисел из американского формата в немецкий. 6. Расширьте компонент LanguageTranslator, добавив методы для преобразования дат из американского или канадского формата в немецкий. Заметьте дополнительный аспект ввода дат в американском или канадском формате. 7. Создайте приложение Windows, которое вызывает компонент LanguageTranslator.
Глава 4
Структуры данных, принятие решений и циклы В исходном коде приложений постоянно требуется принимать всякого рода решения. Например, файл нужно открыть или сохранить? Если открыть, то какого типа итеративный код можно применить для считывания содержимого файла? Для получения ответов на такие вопросы применяются структуры данных и операторы принятия решений и циклов. Самым легким способом продемонстрировать процесс принятия решения будет написание миниатюрной системы искусственного интеллекта (ИИ). Это будет крайне примитивная система ИИ, но, тем не менее, интересно увидеть ее в действии, т. к. в ней широко применяются конструкции принятия решений и выполнения циклов. Система ИИ выполняет итерации цикла и принимает решения на основе данных, определенных в аккуратной и упорядоченной специальной структуре данных. На примере создания алгоритма системы ИИ в данной главе будут рассмотрены следующие вопросы: D структуры данных, включая пользовательские типы; D ограничения обычных типов; П принципы разработки алгоритмов; D конструкторы класса, которые применяются для инициализации объекта; • оператор цикла for, который используется для последовательной обработки элементов набора данных; П оператор принятия решения i f , который в зависимости от результатов логической операции позволяет выполнять ту или иную ветвь кода.
Алгоритм поиска в глубину В системах ИИ требуется выполнять поиск данных, поэтому базовым алгоритмом для системы ИИ является алгоритм поиска. В этой главе мы разработаем алгоритм поиска в глубину. В ИИ также применяются другие поиски, например А* или поиск в ширину, но все они основаны на той же самой идее, что и алгоритм поиска
Глава 10
96
в глубину. Эта общая идея заключается в выполнении поиска данных, организованных в древовидную структуру. ПРИМЕЧАНИЕ Алгоритм— это логический набор конечных, повторяемых шагов для выполнения определенной задачи. Этот термин обычно применяется по отношению к формальным задачам, таким, как поиск, но в большинстве, если не во всех, компьютерных программах используются алгоритмы того или иного рода.
Прежде чем приступить к написанию кода, нам необходимо иметь представление о том, что именно делает алгоритм поиска в глубину и почему мы хотим его использовать. Нам нужно решить задачу, как добраться из пункта А в пункт Б наиболее эффективным способом. В общих терминах эту задачу можно сформулировать так: каким образом решить задачу А, если имеется А'опций ее решения? Представьте, что на пути на работу у входной двери вы осознали, что не взяли ключи от машины. Хуже того, вы не помните, куда вы их положили, и вам теперь нужно искать их по всему дому. Конечно же, вы пытаетесь вспомнить, где вы их оставили, но рано утром память у вас работает туговато. Вы пытаетесь мысленно воссоздать картину своих вчерашний действий, а также думаете о возможных местах, в которых вы могли бы оставить их. Когда вы мысленно воссоздаете картину ваших прошлых действий, то следуете логике, по которой работает ваша память. Иными словами, ваш алгоритм поиска основан на предположениях, делаемых вашей памятью о том, где могут быть ваши ключи. А комнаты вашего дома являются структурой данных, по которой вы мысленно проходите в вашем поиске. Одной из схем поиска, созданной вашим умственным алгоритмом поиска, может быть схема, показанная на рис. 4.1.
Рис. 4.1. Возможный порядок поиска ключей
Структуры
данных,
принятие
решений
и
циклы
97
Итак, вы нашли свои ключи в коридоре, но ваш поисковый алгоритм вел вас в ложном направлении некоторое время, т. к. коридор был последним местом, в котором вы искали. Циник мог бы сказать, что вы просто ходили вокруг ключей, не осознавая, что они были так близко. Но в этом и заключается проблема, т. к. вы не знали, что поиск по разработанному вами алгоритму будет таким долгим. Но в следующий раз поиск по этому же алгоритму может быть намного короче. ПРИМЕЧАНИЕ Поиск с применением разных стратегий очень похож на написание компьютерных алгоритмов. Не существует одного самого лучшего алгоритма; есть только хорошие алгоритмы, в которых делаются определенные компромиссы. При разработке алгоритма необходимо подумать о применении такого, который лучше всего подходит для решения поставленной задачи при наименьшем числе компромиссов, которые могли бы вызвать проблемы.
Как можно видеть на рис. 4.1, ваш поиск проходил против часовой стрелки. Применив другую стратегию, вы могли бы обходить комнаты по часовой стрелке или зигзагами, или даже обыскивать одну и ту же комнату по несколько раз. Теперь преобразуем рис. 4.1 в программу, которая имеет поисковый алгоритм и структуру данных. Поисковый алгоритм будет поиском в глубину, а структура данных будет основана на пути между соответствующими комнатами. Структура данных, представляющая планировку дома из рис. 4.1, и алгоритм поиска по ней показаны на рис. 4.2.
Рис. 4.2. Древовидная структура иллюстрирует каждое возможное действие. Толстые стрелки представляют шаги при поиске в глубину, а каждый черный кружочек — комнату в доме
98
Глава 10
В древовидной структуре на рис. 4.2 каждый узел, обозначенный черным кружочком, представляет пункт, в который можно попасть из определенного места в доме. Из каждой комнаты мы можем попасть в любую другую комнату. Но эта структура рекурсивная. Например, из детской можно попасть в гостиную, а оттуда обратно в детскую. Хотя мы прошлись вниз по дереву, мы перешли из одной комнаты в другую и обратно в первую комнату. Это вполне приемлемо с точки зрения структуры данных, хотя вы, возможно, думаете: "Но ведь это неправильно, т. к. комнаты будут пройдены по несколько раз". ПРИМЕЧАНИЕ Древовидное представление планировки дома на рис. 4.2 ни в коем случае не является полным, т. к. из каждой комнаты можно попасть в любую другую комнату. Полное представление было бы комбинаторным взрывом.
Данная структура показана таким образом, каким она есть, потому что это реальное представление вещей. Могли бы вы в поиске своих ключей возвращаться по несколько раз в одну и ту же комнату? Конечно же, могли бы. Но возвращались бы? Нет, т. к. ваш умственный алгоритм поиска сказал бы вам: "Слушай, парень, мы уже здесь были". Вот в этом и заключается трюк при написании приложений: мы применяем разумную структуру данных и алгоритм, который, оперирует на этой структуре данных. Я называю этот процесс созданием приложения послойно. Нижний уровень — это разумная структура данных, а высший уровень использует функциональность этой разумной структуры данных. Под разумной структурой данных я имею в виду всегда единообразную структуру, которая не нарушает сама себя. В данном примере, направление движения из комнаты не будет обратным в эту же комнату, комната будет представлена в структуре, только если она есть в доме, и т. п. Ответственным за вычисление способа нахождения информации в дереве будет алгоритм высшего уровня. Он должен быть довольно разумным, чтобы понимать, что постоянное перемещение между двумя комнатами ничего, кроме потери времени, не даст. Логика поиска состоит в том, куда мы идем вниз по дереву, проходя комнату за комнатой. Алгоритм называется поиском в глубину, потому что мы проходим вниз по дереву уровень за уровнем. Прохождение вниз по дереву прекращается при достижении комнаты, в которой мы уже побывали. Здесь мы возвращаемся на один уровень вверх и проходим комнату, находящуюся рядом с комнатой, в которой мы уже побывали. Это означает, что путь поиска, составленный компьютером, может быть подобным пути поиска, показанному на рис. 4.1. Это потому, что компьютер такой же глупый, как и мы спозаранку, хотя компьютер не говорит сам себе: "Если бы я только начал поиск с коридора". Необходимо осознать, что волшебной палочки, которая бы нашла ключи для нас, не существует. Метод последовательного перебора всех возможностей, который применили вы и компьютер для поиска ключей, называется поиском методом грубой силы (brute force). Этот метод требует выполнения больших объемов вычислений, и его применения обычно избегают. Но в данном случае применение перебора всех возможных решений является единственным решением, т. к. мы не знаем, где
Структуры
данных,
принятие
решений
и
циклы
99
в доме находятся ключи, а они могут находиться в любой комнате. Вам просто не повезло, что вы осмотрели помещение, в котором нашли ключи, последним. Попробуем улучшить ситуацию. Представьте себе на минуту, что ваши ключи висят на брелоке с бипером, который запускается громким хлопком в ладоши. Теперь, хлопнув в ладоши, вы услышите от ваших ключей "бип, бип, бип" и сможете сразу же найти их, не обыскивая все комнаты. Но допустим, что ключи находятся в правом верхнем углу детской. В таком случае от входной двери вы не сможете с уверенностью сказать, откуда звучит бипер, то ли из кухни, то ли из детской. Куда вы пойдете сначала? Проблема избрания первого, или наиболее эффективного пути, является широко распространенной, которою можно наблюдать каждый день, если у вас в машине установлена система GPS (Global Positioning System, глобальная система навигации и определения положения). В устройствах GPS обычно применяется какой-либо поисковый алгоритм. Вы вводите в устройство с клавиатуры набор координат желаемой точки назначения, а оно будет пытаться найти наиболее быстрый или короткий путь к этой точке. В абстрактном смысле, поисковый алгоритм, применяемый производителями устройств GPS, идентичен алгоритму, который мы собираемся разработать в этой главе.
Реализация пользовательских типов Структура данных, с которой будет работать наш алгоритм, будет иметь тип, определяемый пользователем. В приводимых до этого примерах мы использовали такие типы данных, как double и string, которые предоставляются средой CLR. Для примера в этой главе мы определим свой тип, который впоследствии используем для представления узлов в древовидной структуре.
Объявление структур и классов Пользовательский тип можно определить двумя способами: как структуру или как класс. Пример каждого объявления показан на рис. 4.3.
Рис. 4.3. Два способа объявления пользовательского типа
100
Глава 10
Как показано на рис. 4.3, обычные пользовательские типы определяются с помощью ключевого слова struct, а ссылочные — с помощью ключевого слова class. В большинстве случаев разработчики используют ссылочные типы, т. к. у них меньше ограничений и с ними проще работать в общих случаях. Обычные типы имеют определенные ограничения, вследствие того обстоятельства, что в них все данные сохраняются в стеке.
Ограничения обычных типов Ограничения при использовании обычных типов порождаются тем обстоятельством, что при операциях присваивания значения одной переменной другой данные копируются. Это оказывает влияние на то, что происходит при внедрении ссылочных типов в обычные типы и при использовании обычных типов в качестве параметров для методов.
Эффекты, вызываемые копированием данных Когда одна переменная пользовательского обычного типа присваивается другой переменной этого типа, содержимое первой переменной копируется во вторую. Чтобы увидеть этот процесс в действии, рассмотрим сначала, как объявляются пользовательские типы. Этот процесс показан на рис. 4.4.
Рис. 4.4. Объявление пользовательских типов
При объявлении пользовательских типов элементы данных и методы объявляются между фигурными скобками ({}). Объявление можно рассматривать как надпись на ящике, фигурные скобки — как ящик, а все, что в фигурных скобках, — как содержимое ящика. Все, что находится в фигурных скобках, является телом типа. Идентификатор перед первой фигурной скобкой называется именем типа. В том виде, в котором типы объявлены на рис. 4.4, они не имеют идентификатора области видимости. Область видимости можно рассматривать как определение,
Структуры
данных,
принятие
решений
и
циклы
101
кто имеет доступ к вашим карманам и бумажнику. Область видимости типов в примере подобна ситуации, когда только ваша жена, и никто другой, имеет право проверять содержимое вашего бумажника. Но если бы перед идентификатором типов было поставлено ключевое слово p u b l i c , тогда пользовательский тип был бы доступен всем компонентам программы, как будто бы кто угодно имел право проверять содержимое вашего бумажника. В случае бумажника, устанавливать общую область видимости — не очень хорошая идея, но когда доступ к области видимости можно контролировать, то для типов такая область видимости иногда может быть желаемой. Это можно сравнить с оплатой кредитной карточкой, когда вы отдаете ее кассиру для осуществления транзакции. В этом случае вы предоставляете доступ к части своего бумажника, но под вашим контролем. Теперь рассмотрим код на рис. 4.5. В нем создается и присваивается переменной экземпляр типа MyVaiueType, после чего значение первой переменной присваивается другой переменной такого же типа.
Рис. 4.5. Применение пользовательского обычного типа
Пример на рис. 4.5 иллюстрирует, что происходит с двумя переменными, когда создается экземпляр одной из них, который присваивается другой переменной, после чего вторая переменная модифицируется. Необходимо понимать, каким образом модифицируется каждый тип при взаимодействии с другим типом.
102
Глава 10
Для сравнения, те же самые операции можно выполнить со ссылочным типом, как показано в следующем коде: MyReferenceType val = new MyReferenceType(); MyReferenceType copiedVal = val; Console.WriteLine("val value=" + val.value + " copiedVal value=" + copiedVal.value); val.value = 10; Console.WriteLine("val value=" + val.value + " copiedVal value=" + copiedVal.value);
Значит, если два фрагмента кода функционально идентичны, с единственной разницей, заключающейся в виде типов (обычный по сравнению со ссылочным), то они выдадут одинаковые результаты? Выполнив оба фрагмента кода, мы получим следующие результаты: var value=0
copiedVar value=0
var value=10 copiedVar value=0 val value=0
copiedVal value=0
val value=10 copiedVal value=10
Как можно видеть из этих результатов, два функционально одинаковых фрагмента кода, отличающиеся только видом типа переменной, выдают совершенно разные результаты: •
когда присваивается и модифицируется значение переменной обычного типа, то изменяется только содержимое модифицируемой переменной;
•
когда присваивается и модифицируется значение переменной ссылочного типа, изменяется содержимое как первоначальной, так и присваиваемой переменной.
Этот пример демонстрирует, что при определении пользовательских типов данных необходимо быть осторожным при обращении с обычными и ссылочными типами. Как мы узнали в главе 2, обычные типы сохраняются в стеке. Таким образом, объявление переменной пользовательского обычного типа означает, что все содержимое данной переменной сохраняется в стеке, и после присваивания значения одной переменной обычного типа другой переменной обычного типа полностью копируется все содержимое первой переменной. Происходящее было очевидно в нашем примере, когда были использованы простые числовые типы (такие как double), но результат копирования структур со всех их содержимым может быть далек от того, который вы ожидали.
Обычные типы, содержащие ссылочные типы Правило, что при присваивании переменных обычного типа другим переменным, значения копируются, не распространяется на ситуацию, когда обычный тип
Структуры
данных,
принятие решений
и
циклы
103
содержит в качестве члена данных ссылочный тип. Возьмем, к примеру, следующее объявление: struct MyValueTypeWithReferenceType { public int value; public MyReferenceType reference; }
В ЭТОМ коде объявляется обычный тип MyValueTypeWithReferenceType, который содержит один член данных обычного типа ( i n t ) и один член данных ссылочного типа (MyReferenceType). Это объявление нёявно указывает, что обычный тип сохраняется в стеке, а ссылочный тип — в куче. Обычным типом, содержащим ссылочный тип, можно манипулировать с помощью следующего кода: MyValueTypeWithReferenceType var = new MyValueTypeWithReferenceType(); var.reference = new MyReferenceType(); MyValueTypeWithReferenceType copiedVar = var; Console.WriteLine("var value=" + var.reference.value + " copiedVar value=" + copiedVar.reference.value); var.reference.value = 10; Console.WriteLine("var value=" + var.reference.value + " copiedVar value=" + copiedVar.reference.value;
Важно понимать, что назначение MyValueTypeWithReferenceType не означает назначение внедренного пользовательского типа. В тестовом коде переменная типа MyValueTypeWithReferenceType назначается таким же образом, как и в предыдущих примерах, но переменную типа MyReferenceType требуется назначать снова, Т. К. т и п MyReferenceType я в л я е т с я ССЫЛОЧНЫМ. Е с л и бы т и п MyReferenceType
был обычным, то второе назначение не было бы необходимым. Но если выделить обычный тип подобно ссылочному типу, то компилятор проигнорирует эту директиву. Результаты выполнения предыдущего кода будут следующими: var value=0
copiedVar value=0
var value=10 copiedVar value=10
Когда выделяется и модифицируется внедренный ссылочный тип, то экземпляр ссылочного типа модифицируется для обеих переменных. В данном случае при присваивании обычного типа было скопировано содержимое, включая указатель на ссылочный тип. Поведение типов при присваивании выделенной переменной другой переменной с последующей модификацией элемента данных в первоначальной переменной изложено в табл. 4.1. Например, если выполнить код custom2 = customi; customi .member = [новое значение], каким будет значение члена custom2 .member?
104
Глава 10 Таблица 4.1. Поведение типов при присваивании выделенной переменной другой переменной с последующей модификацией элемента данных в первоначальной переменной
Тип
Поведение
Обычный тип
Присвоенный элемент данных не модифицируется
Ссылочный тип
Присвоенный элемент данных модифицируется
Обычный тип с внедренным обычным типом
Присвоенный внедренный элемент данных не модифицируется
Обычный тип с внедренным ссылочным типом
Присвоенный внедренный элемент данных модифицируется
Ссылочный тип с внедренным обычным типом
Присвоенный внедренный элемент данных модифицируется
Ссылочный тип с внедренным ссылочным типом
Присвоенный внедренный элемент данных модифицируется
Параметры обычных типов Другое ограничение обычных типов связано с особенностями хранения и манипулирования переменными, когда они передаются методом. Допустим, что вы создали метод с параметрами обычного и ссылочного типов. Если в методе параметры модифицируются, то какие модификации будут видны вызывающему компоненту? Рассмотрим следующий код: static void Method(MyValueType value, MyReferenceType reference) { value, value = 10; reference.value = 10; }
Вызывающий компонент может передать методу экземпляры обычного и ссылочного типов, которые внутри метода подвергаются манипуляциям. Теперь вызовем метод Method () следующим кодом: MyValueType value = new MyValueType(); MyReferenceType reference = new MyReferenceType(); Method(value, reference); Console.WriteLine("value value=" + value.value + " reference value=" + reference.value);
Вызывающий код создает экземпляры типов MyValueType И MyReferenceType, вызывает метод Method (), после чего исследует значение элементов данных обычного и ссылочного типов. Результат выполнения кода будет следующим: value value=0 reference value=10
Структуры
данных,
принятие решений
и
циклы
105
Как можно видеть, элемент данных обычного типа (MyVaiueType) не был изменен, в то время как элемент данных ссылочного типа (MyReferenceType) был изменен. Это правильный результат, который демонстрирует, что при вызове метода его параметры назначаются переменным в вызываемом методе. Обратясь к табл. 4.1, можно видеть, что при присвоении значения обычному типу последующее манипулирование присвоенным экземпляром не влияет на первоначальный экземпляр. Это означает, что модифицирование обычных типов в методе не отражается вне метода. Поэтому на практике в большинстве случаев нужно использовать ссылочные типы. Но среда CLR предлагает решение данной проблемы в виде ключевого слова out, которое применяется при вызове метода (рис. 4.6). Оно указывает, что переменная назначается по возвращению метода, а не при его вызове.
Рис. 4.6. Использование ключевого слова out при передаче параметров
Позитивный аспект использования ключевого слова out состоит в том, что в методе переменной можно задать значение, и вызывающий код будет видеть эти изменения. Недостатком же является то обстоятельство, что ключевое слово out игнорирует назначения параметров вызывающим методом. Параметры обычного типа можно переслать в метод и получить их модифицированные значения из метода подобно параметрам ссылочного типа с помощью ключевого слова ref, как показано в следующем коде: static void Method(ref MyValueType value, MyReferenceType reference) { value.value = 10; reference.value = 10; }
106
Глава 10
MyValueType value = new MyValueType(); MyReferenceType reference = new MyReferenceType(); Method(ref value, reference);
При использовании ключевого слова ref обычный тип преобразуется в ссылочный, поэтому, чтобы вызвать метод MethodO, переменной должен быть присвоен действительный экземпляр класса обычного типа. ПРИМЕЧАНИЕ По тому, как используются ключевые слова out и ref, можно видеть, что С# является явно определенным языком. Ключевые слова out и ref указываются как при объявлении метода, так и при его вызове. Программируя в С#, вы всегда знаете, что делает любой параметр, метод, переменная или класс и как они это делают. Благодаря этой определенности, разработчики могут читать и понимать код, созданный другими разработчиками.
Теперь, когда мы понимаем принцип работы алгоритма поиска в глубину и знаем, как определить структуру данных в виде пользовательского обычного типа, мы можем приступить к реализации самого поискового алгоритма.
Организация алгоритма поиска Алгоритм поиска, который мы собираемся реализовать в этой главе, предназначен для решения проблемы планирования авиарейса из точки А в точку В. На первом шагом реализации нужно решить, какие возможности следует предоставить нашему алгоритму. Вот краткое изложение этих возможностей: •
узел, представляющий город с пересадкой в другой город, реализуется с помощью структуры данных;
•
узел может ссылаться на другие узлы;
•
каждый узел имеет описание и уникальный идентификатор, чтобы отличить его от других узлов;
•
все узлы содержат информацию об авиарейсе;
•
алгоритм проходит по узлам и запоминает пройденный путь;
•
найденный путь выдается в виде списка узлов.
Структура данных основана на проблеме планирования рейса между двумя городами (рис. 4.7). Отдельный узел маршрута рейса описывается тремя основными атрибутами: П название города— описание, которое будет использоваться в качестве ключа при определении пользователем начальной и конечной точек маршрута;
Структуры
данных,
принятие решений
и
циклы
107
Рис. 4.7. Планирование авиарейсов
•
координаты — иллюстрационный подход, описывающий расположение городов по отношению друг к другу;
•
пересадки— пересадка между двумя городами. Как и в реальной жизни, не в каждом городе можно делать пересадку в требуемый город. Например, в Хьюстоне нет пересадки на Торонто.
В рамках данной главы имеются только два проекта: библиотека класса, реализующая алгоритм поиска в глубину, и тестовое приложение. Структура проекта показана на рис. 4.8. Не забудьте добавить ссылку на библиотеку класса (searchsolution) и установить тестовое приложение (Testsearchsoiution) в качестве стартового проекта.
Рис. 4.8. Структура проектов решения
Глава 10
108
Код для алгоритма поиска в глубину Алгоритм поиска в глубину будет реализован в три основных этапа. На первом этапе определяется и реализуется структура данных. На втором этапе реализуются алгоритм и тесты. На последнем этапе алгоритм запускается в рабочем режиме, чтобы проверить, какой маршрут он сгенерирует.
Определение и реализация структуры данных Как было упомянуто ранее, программисты чаще используют ссылочные типы по причине ограничений, присущих обычным типам. Но для этого примера мы определим узел Node с помощью ключевого слова struct, т. е. как обычный тип. Алгоритм поиска в глубину реализуется как два отдельных компонента: структура данных и собственно алгоритм. Поэтому определение Node как обычного типа кажется приемлемым. По крайней мере, попробуем и посмотрим, что из этого получится. Согласно атрибутам, показанным на рис. 4.7, требуемая для проекта searchsoiution структура данных реализована, как показано на рис. 4.9.
Рис. 4.9. Структура данных для поиска в глубину
Структура данных объявляется с помощью ключевого слова struct, а пересадки в этой структуре представлены массивом элементов Node. Массив элементов Node создается, когда один узел Node содержит список ссылок на другие элементы Node. Массив можно рассматривать, как набор заметок, содержащих ссылки наточки А, В и С. Когда один узел ссылается на другой узел, создается что-то вроде бесконечного дерева, т. к. имеется возможность бесконечно путешествовать туда и обратно из одного города в другой. Таким образом, алгоритму поиска в глубину необходимо будет избегать повторения самого себя. Элемент данных connections представляет собой массив, определяющий города, в которые имеются пересадки в текущем городе. Города пересадок можно создать в виде массива элементов Node, как объявление, показанное на рис. 4.9. В качестве альтернативы можно применить массив строк, содержащих название города: public struct Node { public string CityName; public double X;
Структуры
данных,
принятие
решений
и
циклы
109
public double Y; public String[] Connections; }
В данном объявлении connections является строкой, содержащей названия городов, которые выводились бы на табло, показывающее все пересадки в другие города из данного города. Но использование строк неэффективно в вычислительном аспекте. Со строками, чтобы обойти дерево городов, сначала нужно обойти названия городов, сопоставить название города с типом Node и только потом можно обходить узел. Таким образом, подход с использованием массива строк требует выполнения дополнительной необязательной операции. Поэтому более эффективным и прагматичным подходом будет применение массива экземпляров Node. Использование объявления, в котором Connections является массивом элементов Node, позволяет размещать имя города и города пересадок в одном элементе. Это позволяет избежать разработки алгоритма для поиска города и связанных с ним городов пересадок. Вместо этого можно применить алгоритм для прохода по структуре, не прибегая к операции поиска и сопоставления.
Самоссылающиеся структуры Интересная информация, которую следует помнить: если объявить структуру Node, в которой член connections ссылается только на один экземпляр Node, компилятор С# выдаст ошибку об узле, ссылающемся на самого себя. Пример объявления самоссылающейся структуры приводится в следующем фрагменте кода: public struct Node { public string CityName; public double X; public double Y; public Node Connections;
> Проблема с данным объявлением заключается в том, что обычные типы имеют фиксированный размер, но поскольку член структуры типа Node объявляется в самой структуре Node, то компилятор не может определить размер объявленной структуры. Объявление структуры Node, содержащей массив членов типа Node, не представляет для компилятора проблем, т. к. явно объявляется массив неизвестного размера, в результате чего объявление массива считается ссылочным типом.
Создание экземпляра узла и его инициализация В предыдущих примерах кода мы видели, как можно создавать экземпляры объектов с помощью ключевого слова new. Экземпляры типов всегда создаются с помощью ключевого слова new. После него следует имя типа, экземпляр которого создается, и открывающая и закрывающая скобки. Для создания экземпляра типа Node применяется следующий код: Node city = new Node();
110
Глава 10
Если из этого кода рассматривать только идентификатор Node со скобками, то может создаться впечатление, что вызывается метод без параметров. Это будет правильное впечатление, но это особый вид вызова метода, что обозначается применением ключевого слова new. Вызываемый метод называется конструктором. Каждый тип имеет свой конструктор, который инициализирует состояние объекта, прежде чем возвратить его вызывающему коду. ПРИМЕЧАНИЕ Термины "класс" и "структура" означают объявление типа. А термин "объект" означает экземпляр объявленного типа.
В объявлении типа Node конструктор не объявляется, поэтому среда CLR предоставляет для него стандартный конструктор. Стандартный конструктор ничего не делает и не имеет параметров. После создания объекта узла его членам данных можно присваивать значения: city.CityName = "Montreal"; city.X = 0.0; city.Y = 0.0;
В результате присваивается наименование города Монреаль (Montreal), с координатами (0, 0). Все это хорошо, но разве не следовало бы задать значения членам данных узла города при его создании? Какой смысл создавать экземпляр узла города, не определяя при этом имя и координаты данного города? С технической точки зрения членам данных узла не обязательно присваивать значения, но с логической точки зрения, узел, членам данных которого не были присвоены значения, является совершенно бесполезным. Также следует помнить о том, что мы работаем над определением разумной структуры данных, поэтому логически экземпляр типа Node, не имеющий названия города и его координат, не является допустимым узлом. Надлежащее верифицируемое начальное состояние экземпляра можно устанавливать принудительно, определив конструктор с параметрами вместо использования стандартного конструктора. Когда конструктор предоставляется кодом пользователя, то независимо от объявления, стандартный конструктор не генерируется и является недоступным. Далее приводится пример определения пользовательского конструктора, public struct Node { public static Node[] RootNodes; public string CityName; public double X; public double Y; public Node[] Connections; public Node(string city, double x, double y) {
Структуры
данных,
принятие
решений
и
циклы
111
CityName = city;
X = х; Y = у; Connections = null; } } ПРИМЕЧАНИЕ В предыдущем коде используется тип null. Это предопределенный специальный тип, который означает, что данные ни на что не указывают или программно определены как null.
Пользовательский конструктор создается, определяя метод, идентификатор которого такой же, как и тип, и который не имеет возвращаемого типа. В большинстве случаев применяется общая область видимости. Параметры конструктора представляют три члена данных, требуемые для создания экземпляра в действительном состоянии. В конструкторе этим членам данных присваиваются значения параметров. То обстоятельство, что пользовательский конструктор имеет параметры, означает, что для создания экземпляра типа Node необходимо предоставить три элемента данных. Таким образом, чтобы узел имел смысл, для создания экземпляра типа Node необходимо предоставить достаточно данных. Первоначальный код для создания экземпляра класса в данном случае не скомпилируется; для этого его необходимо модифицировать следующим образом: Node city = new Node("Montreal", 0.0, 0.0);
При объявлении узла данные, к которым выполняется обращение, могут быть неправильными, но это уже не входит в круг ответственности разумной структуры данных. Подобным образом, текстовый редактор не отвечает за то, чтобы обрабатываемый в нем текст имел какой-либо смысл. Создание смыслового текста является ответственностью пользователя, а роль текстового редактора ограничивается предоставлением возможности создания такого текста.
Проблема с обращением к обычным типам Как рассматривалось ранее, значения переменных обычных типов хранятся в стеке, и при присваивании переменной значения другой переменной первой переменной присваивается копия значения второй переменной, а не ссылка на общее для обеих переменных значение. При создании древовидной структуры с применением обычных типов возникает проблема, состоящая в том, что присвоенные значения не обновляются, т. к. значения копируются. Этот эффект можно продемонстрировать с помощью более обширного примера создания структуры данных городов, на которые есть пересадка в другом городе. Начнем со следующего объявления всех городов и их координат: Node montreal
= new Node("Montreal", 0, 0);
Node newyork
= new Node("New York", 0, -3);
Глава 10
112 Node miami
= new Node("Miami",
-1, -11);
Node toronto
= new Node("Toronto", -4, -1) ;
Node houston
= new Node("Houston", -10, -9) ;
Node losangeies = new Node("Los Angeles", -17, -6); Node Seattle
= new Node("Seattle", -16, -1) ;
В данном коде создаются переменные, представляющие все города, показанные на рис. 4.7. Для каждой из этих переменных создается и присваивается экземпляр типа Node, инициализированный названием города и его координатами. Отдельные переменные представляют города без информации о пересадках, поэтому следующим шагом мы свяжем города между собой. Для этого нам необходимо выделить член данных Connections и присвоить ему значение. Соответствующий код для инициализации члена данных Connections узла Montreal данными о пересадках в другие города будет выглядеть следующим образом: montreal.Connections = new Node[3J; montreal.Connections[0i = newyork; montreal.Connections[1] = toronto; montreal.Connections[2] = losangeies;
При присвоении члену данных Connections массива экземпляров типа Node просто выделяется память под данный массив, экземпляры же типа не создаются. Это можно сравнить с покупкой пустого бумажника, чтобы было, куда класть наличные и кредитные карточки. Поэтому конструктор объектов не вызывается, т. к. никакие объекты не создаются. После того как было выделено место под массив, элементам массива можно присваивать экземпляры типа. Альтернативным способом было бы создать экземпляр массива и присвоить экземпляры узлов его элементам. Обратите внимание на то, что индексы массива указываются в квадратных скобках. Не забывайте, что в С# счет элементов массива начинается с 0. Поэтому в трехэлементном массиве индекс первого элемента будет 0, а последнего — 2. Рассмотрим, что же происходит в данном коде. Мы объявили массив, т. е. выделили память под него, после чего присвоили переменные, представляющие города, каждому элементу массива. Но т. к. массив Connections является массивом обычного типа, ТО значения членов данных Connections В членах данных Connections элементов массива не устанавливаются. Это обстоятельство для элемента New York массива (члена данных) Connections узла Montreal показано на рис. 4.10. Конечно же, это обстоятельство можно логически объяснить тем фактом, что член данных Connections для узла New York еще не был определен. Подумайте о том, каким образом происходит обращение к данным, и освежите в памяти информацию, приведенную в табл. 4.1. Node является обычным типом, а при присваивании переменной обычного типа другой переменной данного типа, значение первой переменной копируется во вторую.
Структуры
данных,
принятие решений
и
циклы
113
Рис. 4.10. Проблема отсутствующей информации о пересадках
Так как элементам массива (членам данных) Connections для узла New York еще не были присвоены значения, то член данных Connections узла New York для узла Montreal не будет иметь никаких значений для пересадок. И если модифицировать первоначальную переменную узла New York, присвоив значения его элементам массива (членам данных) Connections, то эти модификации не отразятся в члене данных Connections узла New York ДЛЯ узла Montreal или для любого другого узла. На данной стадии можно подумать, что это не представляет никакой проблемы, но посмотрите на следующий код, назначающий пересадки для узла New York. newyork.Connections = new Node[3]; newyork.Connections[0] = montreal; newyork.Connec t i ons[1] = hous ton; newyork.Connections[2] = miami; Мы ВИДИМ, ЧТО New York и м е е т п е р е с а д к у на Montreal, а МЫ з н а е м , ЧТО Montreal
имеет пересадку на New York. Пассажиры, совершающие регулярные поездки между этими двумя городами, хотели бы иметь возможность постоянно летать туда и обратно между Нью-Йорком и Монреалем. Но использование переменных обычного типа не позволяет этого (рис. 4.11). Как можно видеть на рис. 4.11, обычные типы нельзя использовать рекурсивно. Видно, что из Нью-Йорка есть рейсы на Монреаль. Но, прилетев в Монреаль, мы видим, что если мы полетим обратно в Нью-Йорк, то снова лететь в Монреаль мы не сможем. Это очевидная ложь, т. к. мы только что прилетели из Нью-Йорка в Монреаль. Этот пример демонстрирует на практике, что при присваивании значений переменных обычного типа, по сути, является присваиванием значения, каким оно есть только в некоторый определенный момент времени. Если после этого значение первой переменной изменится, то это изменение не отразится на второй переменной. В сущности, этот код иллюстрирует, что определение рейсов на конкретный
114
Глава 10
город и последующее их присваивание представляет проблему курицы и яйца. С обычными типами нельзя присвоить рейс из одного города в другой, если рейс, который нужно присвоить, не существует. В принципе, это возможно, но для этого потребовалось бы выполнять бесконечный цикл, что не представляет практической пользы.
Рис. 4.11. Пропавшие рейсы (пересадки) из Нью-Йорка
Замена struct на class для определения узла Чтобы решить проблему курицы и яйца, необходимо вместо обычных типов применять ссылочные. Код для объявления типа Node с помощью ключевого слова class будет выглядеть таким образом (изменения выделены жирным шрифтом): public class Node { public string CityName; public double X; public double Y; public Node[] Connections; public Node(string city, double x, double y) { CityName = city; X = x; Y = y; Connections = null; } }
Как видно, модификация затронула всего лишь одну строчку кода. После этой модификации выполнение кода из предыдущего раздела, присваивающего значения массиву Connections, создаст такую структуру данных (рис. 4.12).
Структуры
данных,
принятие
решений
и
циклы
115
Рис. 4.12. Правильное состояние массива Connections для узла New York
Теперь у нас есть рейсы между Нью-Йорком и Монреалем. Бесконечная цепочка членов данных Connections не. означает, что мы используем бесконечные ресурсы для ее представления. В действительности, просто одна ссылка указывает на другую, а та обратно на первую (рис. 4.13).
Рис. 4.13. Рекурсивное присваивание создает впечатление необходимости бесконечных ресурсов
Кажущаяся бесконечная цепочка назначений в действительности является рекурсивной перекрестной ссылкой двух областей кучи. Это вполне нормальный подход, и эта возможность является одной из причин, по которой программисты предпочитают использовать ссылочные типы вместо обычных. При использовании обычных
116
Глава 10
типов программисты довольно часто заблуждаются, полагая, что определенным переменным или членам данных были присвоены значения, когда в действительности этого не произошло.
Статические члены данных и методы Ранее мы видели, как конкретный экземпляр типа можно инициализировать с помощью конструктора. Теперь нам нужно определить конструктор для древовидной структуры на рис. 4.2. Древовидная структура подразумевает начальную точку для ее прохождения, но схема пересадок не имеет одной начальной точки. Что у нас имеется, так это несколько объявленных переменных, чьи идентификаторы представляют города. Проблема с таким объявлением состоит в том, что для обхода древовидной структуры необходимо знать имена каждой переменной и обходить дерево для каждой из них. Это решение не является приемлемым. Нам нужно создать одну общую начальную точку, из которой можно обращаться ко всем городам. Эту задачу можно решить, добавив в объявление класса Node массив, подобный массиву для члена данных Connections. Соответствующая модификация выделена жирным шрифтом в следующем коде: public class Node { public static Node[] RootNodes; public string CityName; public double X; public double Y; public Nodefi Connections; public Node(string city, double x, double y) { CityName = city; X = x; Y = y; Connections = null; } }
Добавленный член данных имеет модификатор static. Разберемся, что это означает в данном контексте. Допустим, что ваша семья состоит из вашей половинки и двух наследников. Однажды вы увидели, что магазин, продающий мобильные телефоны, приводит так называемую семейную акцию, и решили воспользоваться ею, купив четыре абсолютно одинаковых телефона. Когда телефоны активируются, то каждый из них будет иметь уникальное состояние. Каждый член вашей семьи будет иметь отдельный номер, адресную книгу и т. д. По аналогии с объектами, мобильный телефон представляет собой тип; каждый член вашей семьи имеет копию этого типа, но с индивидуальными настройками, что является аналогией экземпляра типа.
Структуры
данных,
принятие
решений
и
циклы
117
Купленные вами мобильные телефоны также имеют возможность прямой связи. В сущности, эта возможность позволяет использовать мобильный телефон как радиостанцию. Все члены вашей семьи активируют эту возможность. Это означает, что когда кто-то из вас разговаривает, используя эту возможность, слышать его может не только его непосредственный собеседник, но и все остальные члены семьи. Более того, все члены семьи могут говорить (в смысле не слушать) одновременно. Таким образом, прямая связь является общим ресурсом, не связанным ни с каким конкретным мобильным телефоном. В случае с классами, словом static обозначаются общие ресурсы класса, не связанные с каким-либо определенным экземпляром типа. Обозначая член данных класса ключевым словом static, мы говорим, что, несмотря на то, сколько экземпляров класса Node мы создаем, для них всех в любой момент имеется только один экземпляр члена данных RootNodes. Более того, чтобы обратиться к члену данных RootNodes, не обязательно создавать экземпляр класса Node. Статические методы подобны статическим членам данных в том, что они являются общим ресурсом и не ассоциируются с конкретным объектом (как демонстрируется методом Main (), который запускает консольное приложение на исполнение). На рис. 4.14 показано, что можно и что нельзя делать со статическими и нестатическими членами данных.
Рис. 4.14. Разрешенные и запрещенные операции со статическими и нестатическими членами данных 5 Зак 555
Глава 10
118
Согласно общему правилу для статических членов данных и методов класса с целью обращения к ним создание экземпляра класса не является необходимым. Статическим методам нельзя обращаться к нестатическим членам данных или вызывать нестатические методы. Возвратимся к нашему объявлению класса Node. Для определения единого корня для дерева поиска используется статический член данных RootNodes. Для создания экземпляра типа применяется конструктор для статического типа, который вызывается при каждом обращении к статическому методу или члену данных. Статический конструктор такой же, как и ранее определенный конструктор, только вместо ключевого слова public в нем используется ключевое слово static. В случае с деревом поиска этот конструктор применяется для инициализации дерева и его состояния. Теперь мы имеем полное определение класса Node, которое показано в следующем коде. Не премините исследовать его и разобраться, каким образом отдельные фрагменты взаимодействуют друг с другом. public class Node { public static Node[] RootNodes; public string CityName; public double X; public double Y; public Node[] Connections; public Node(string city, double x, double y) { CityName = city; X = x; Y = y; Connections = null; } static Node() { Node montreal
= new Node("Montreal", 0, 0) ;
Node newyork
= new Node("New York", 0, -3) ;
Node miami
= new Node("Miami", -1, -11);
Node toronto
= new Node("Toronto", -4, -1) ;
Node houston
= new Node("Houston", -10, -9);
Node losangeles = new Node("Los Angeles", -17, -6); Node Seattle
= new Node("Seattle", -16, -1);
montreal.Connections = new Node[3]; montreal.Connections[0] = newyork; montreal.Connections[1] = toronto;
Определение теста для алгоритма Тип Node является автономным типом. Это означает, что алгоритму не требуется создавать экземпляр древовидной структуры. Это пример хорошего проектирования, т. к. в случае необходимости добавить новые города нужно будет изменить только сам тип Node. Любой алгоритм поиска, использующий тип Node, изменять не потребуется. ПРИМЕЧАНИЕ Создание кода, который локализирует изменения, не затрагивая другие фрагменты кода, называется развязыванием (decoupling) кода, по аналогии с развязыванием электрических цепей. Изолированный таким образом код позволяет экспериментировать с ним, не затрагивая работоспособность других фрагментов кода. Как вы увидите, в процессе разработки развязывание кода является ежедневным испытанием ваших знаний и способностей как программиста.
Теперь разработаем первую версию алгоритма поиска и посмотрим, что у нас из этою получится. Мы можем начать с определения класса поиска или с определения теста для проверки класса поиска. Сначала определим тест, т. к. это позволит нам выяснить, каким должен быть класс поиска. Начнем со следующего определения: public static void TestSearchO { SearchSolution.SearchAlgorithm.DepthFirstFindRoute("Montreal",
"Seattle");
}
В коде теста алгоритм поиска вызывается непосредственным образом с помощью метода SearchAlgorithm.DepthFirstFindRoute(). Идентификатор SearchAIgorithm является именем класса, а идентификатор DepthFirstFindRoute() — именем метода этого класса. Такой способ именования подразумевает, что данный класс будет содержать реализации всех компонентов поискового алгоритма. Но это неправильно, т. к. весь алгоритм поиска не может содержаться в одном методе. Скорее всего, для него потребуется несколько методов. Но если для каждого алгоритма поиска нужно несколько методов, то поддержка класса searchAigorithm станет кошмаром для программиста. Лучшим решением было бы реализовать каждый вариант алгоритма поиска в отдельном классе. Тогда для каждого класса мы сможем определить общий идентификатор метода для нахождения маршрута между двумя точками. Соответственно модифицированный код теста будет выглядеть так: public static void TestSearchO { SearchSolution.DepthFirstSearch.FindRoute("Montreal", "Seattle"); }
Теперь тест подразумевает, что класс DepthFirstSearch имеет статический метод FindRoute (). Это приемлемо, и при реализации класса BreadthFirstsearch ссылка на ЭТОТ метод будет В виде SearchSolution.BreadthFirstsearch.FindRoute. Но здесь имеется другая проблема, связанная с возможностью использования алгоритма несколькими пользователями при выполнении программы. Как мы выяснили при
Структуры
данных,
принятие
решений
и
циклы
121
обсуждении возможности прямой связи мобильного телефона, метод FindRouteO является статическим, что делает его общим ресурсом. Если несколько пользователей будет использовать этот алгоритм одновременно, они станут разделять этот ресурс. Это может вызвать проблемы, если временные данные сохраняются в членах данных класса DepthFirstseach. Использование статического метода может вылиться в неправильном найденном маршруте. Более подходящим решением будет определение метода FindRouteO нестатическим, подразумевая этим, что прежде чем вызывать метод FindRouteO, необходимо создать экземпляр класса DepthFirstsearch. Соответственно, модифицированный код теста будет таким: public static void TestSearch() { SearchSolution.DepthFirstsearch els = new SearchSolution.DepthFirstsearch(); els.FindRoute("Montreal", "Seattle"); }
Чтобы выполнить метод FindRouteO, нам необходимо сначала создать экземпляр класса DepthFirstsearch, что позволит нескольким пользователям выполнять поиск, не смешивая состояния поиска разных пользователей. На данном этапе мы можем похвалить себя за сообразительность и считать, что мы написали хороший тест, для которого требуется создать класс.
Проблема "волшебных" данных Наш тест еще не окончен, т. к. у нас еще нет доступа к маршруту, найденному алгоритмом, но мы с этим разберемся чуть позже. На данный момент будем считать, что найденный маршрут просто упал нам с неба. В реализации класса DepthFirstsearch необходимо обращаться к структуре данных. Также алгоритм поиска должен знать, какое дерево обходить. Одним из способов обращения к дереву будет прямое обращение к статическим данным Node.RootNodes. Код для соответствующей реализации класса DepthFirstsearch() таков: public class DepthFirstsearch { public DepthFirstsearch() { } public void FindRoute(string start, string end) { Node[] startNodes = Node.RootNodes; } }
В данном фрагменте кода объявляется переменная startNodes, которая представляет начальную точку и корень дерева (см. рис. 4.2). Корень дерева основан на члене данных Node.RootNodes, и этот тип присваивания называется присваиванием волшебного типа. Волшебный тип создается, когда вызываемый метод волшебным
122
Глава 4
образом знает, каким образом обращаться к данным, несмотря на то, что вы никогда не давали типу инструкций об этом. В случае метода DepthFirstsearch() волшебным является его способность обращаться к правильному члену данных RootNodes.
Это плохое предположение, т. к. оно связывает член данных RootNodes с методом FindRoute (). Представьте, что будет, если в будущем разработчик класса Node решит добавить функциональную возможность загрузки дерева с жесткого диска. Чтобы не нарушить метод FindRoute о, разработчику придется явным образом копировать загружаемое с диска дерево в член данных RootNodes. Или что произойдет, если два пользователя захотят создать разные деревья маршрутов? Член данных Nodes .RootNodes является общим ресурсом и поэтому может обрабатывать только одно дерево маршрутов. Разработчик класса Node может изменить член данных RootNodes, что вызовет ошибки в работе метода FindRoute (). Когда мы работаем с "волшебными" данными, то какие бы данные у нас не были, типу необходимо передать волшебство. Поэтому тест для проверки метода нахождения маршрута полета будет изменен следующим образом: public static void TestSearchO { SearchSolution.DepthFirstSearch els = new SearchSolution.DepthFirstSearch(SearchSolution.Node.RootNodes); els.FindRoute("Montreal", "Seattle"); }
Так как нам необходим корневой узел дерева, мы изменяем конструктор, чтобы в нем требовалось, чтобы вызывающий компонент передавал вызываемому методу корневой узел дерева. В тестовом коде продолжается использоваться статический член данных RootNodes, но методу DepthFirstSearch () не обязательно знать, где найти дерево. Если теперь разработчик класса Node изменит поведение члена данных RootNodes, то будет необходимо изменить лишь код конструктора для метода DepthFirstSearch (), а не сам метод. Таким образом, классы Node И DepthFirstSearch изолированы (развязаны) друг от друга.
Получение найденного маршрута Вызвав метод FindRoute (), мы вправе ожидать от него ответа. Так как найденный маршрут может содержать несколько городов, то он сохраняется в массиве элементов Node. Программно массив элементов Nodes можно получить двумя способами. Первый способ состоит в применении значения возвращаемого параметра: public static void TestSearchO { SearchSolution.DepthFirstSearch els = new SearchSolution.DepthFirstSearch(SearchSolution.Node.RootNodes); Node[] foundRoute = els.FindRoute("Montreal", "Seattle"); }
Структуры
данных,
принятие решений
и
циклы
123
Код для присваивания возвращаемого значения переменной foundRoute выделен жирным шрифтом. Второй способ заключается в использовании члена данных: public static void TestSearchO { SearchSolution.DepthFirstsearch els = new SearchSolution.DepthFirstsearch(SearchSolution.Node.RootNodes); els.FindRoute("Montreal", "Seattle"); Node[] foundRoute = els.FoundRoute; }
В этом подходе найденный маршрут сохраняется в члене данных FoundRoute. Соответствующий код выделен жирным шрифтом. Каждый подход кажется нормальным, и трудно решить, какой из них применить. Наиболее безопасным способом принятия решения в таком случае будет разработка тестов, чтобы выяснить, имеются ли какие-либо проблемы с каждым подходом. В случае с нахождением одного маршрута каждый подход является приемлемым. Но посмотрим на код, когда необходимо найти несколько маршрутов. Сначала рассмотрим код, в котором найденный маршрут возвращается в значении параметра: public static void TestSearchO { SearchSolution.DepthFirstsearch els = new SearchSolution.DepthFirstsearch(SearchSolution.Node.RootNodes); Node[] fcrundRoutel = els.FindRoute("Montreal", "Seattle"); Node[] £oundRoute2 = els.FindRoute("New York", "Seattle"); }
А теперь посмотрим на код, в котором найденный путь возвращается как член данных: public static void TestSearchO { SearchSolution.DepthFirstsearch els = new SearchSolution.DepthFirstsearch(SearchSolution.Node.RootNodes); els.FindRoute^"Montreal", "Seattle"); Node[] foundRoute1 = els.FoundRoute; els.FindRoute("New York", "Seattle"); Node[] foundRoute2 = els.FoundRoute; }
И снова оба решения выглядят достаточными. Но в данном случае есть тонкая, но весьма важная разница. В реализации теста, в котором найденный маршрут возвращается в значении параметра, переменные foundRoutel и foundRoute2 представляют маршруты, прямым образом связанные с маршрутом, для которого выполняется поиск. Переменные foundRoutel никоим образом не могут представлять маршрут "Нью-Йорк — Сиэтл". А в случае кода с членом данных, может случить-
Глава 10
124
ся, что переменная f oundRoutel будет указывать на маршрут "Нью-Йорк — Сиэтл", как показано в следующем коде, public static void TestSearchO { SearchSolution.DepthFirstSearch els = new SearchSolution.DepthFirstSearch(SearchSolution.Node.RootNodes); els.FindRoute("Montreal", "Seattle"); els.FindRoute("New York", "Seattle"); Node[]
foundRoute1 = els.FoundRoute;
Node[]
£oundRoute2 = e l s . F o u n d R o u t e ;
}
Если поменять порядок вызовов метода FindRoute() и ссылки на член данных FoundRoute, то переменные foundRoutel и foundRoute2 будут ссылаться на один и тот же найденный маршрут, в частности, на маршрут "Нью-Йорк — Сиэтл". Это не хорошо. Пример демонстрирует, как члены данных не связаны непосредственно с методами и могут независимо меняться. Поэтому подход, в котором найденный маршрут представляется в возвращаемом методом значении, является лучшим и более надежным. ПРИМЕЧАНИЕ Члены данных полезны, когда нужно сохранить или извлечь данные, которые многократно вызываются методами или которые не зависят от порядка вызова методов. В случае данных, зависимых от порядка вызова методов, необходимо применять ключевое слово return или выходные параметры.
Далее приводится полный контрольный пример, включая код верификации, который ищет маршрут из Монреаля в Сиэтл. public static void TestSearchO { SearchSolution.DepthFirstSearch els = new SearchSolution.DepthFirstSearch(SearchSolution.Node.RootNodes); SearchSolution.Node[] foundRoute = els.FindRoute("Montreal", "Seattle"); if (foundRoute.Length != 2) { Console.WriteLine("Incorrect route as route has two legs"); }
if (foundRoute[OJ.CityName.CompareTo("Los Angeles") != 0) { Console.WriteLinet"Incorrect as first leg is Los Angeles"); } } ПРИМЕЧАНИЕ Мы уже применяли конструкцию if в предыдущих главах. В ней проверяется условие, и в случае положительного результата проверки выполнятся код в фигурных скобках. Комбинация символов != означает "не равно". Оператор if более подробно рассматривается в разд. "Оператор if" далее в этой главе.
Структуры
данных,
принятие
решений
и
циклы
125
Реализация алгоритма поиска в глубину Реализация алгоритма поиска в глубину включает создание алгоритма для прохождения узлов дерева. В этом алгоритме интенсивно применяются операторы принятия решения и операторы цикла для обработки данных массива в цикле. Эти операторы широко используются в программах, включая программы на языке С. Тестовый код был реализован в предыдущем примере, поэтому следующим шагом будет реализация варианта метода DepthFirstsearch о, который является оболочкой, чтобы можно было скомпилировать весь код и выполнить программу. Оболочка имеет структурную организацию и содержит все приложение. Код для ее определения показан на рис 4.15.
Рис. 4.15. Первоначальный вариант оболочки для алгоритма поиска в глубину
Теперь, когда наша оболочка готова, мы можем запустить приложение и проверить, как все работает. Однако выполнение кода тестирования на данном этапе будет неудачным, т. к. вызов метода FindRoute () генерирует исключение. Это указывает, что метод еще не был реализован. (Исключения подробно рассматриваются в следующей главе.) Тем не менее, оболочка полностью готова, и мы можем приступить к реализации алгоритма. Это, возможно, один из самых трудных этапов разработки, т. к. нам необходимо продумать логику операций, которые мы хотим выполнять в алгоритме. Лично я, когда неуверен, с чего начать реализацию алгоритма, просто пишу код, основанный на точке входа и точке выхода.
Проблема замочной скважины Точкой входа в нашем алгоритме является метод FindRouteO. В свою очередь, входом метода FindRouteO являются два параметра: start, указывающий начальный город маршрута, и end, указывающий город назначения. Выходом метода FindRoute () является массив элементов типа Node.
126
Глава 10
Массиву элементов типа Node необходимо заранее выделить память, чтобы можно было добавить все найденные города. На данном этапе мы можем предположить, что количество заранее выделенных узлов должно быть равным длине члена данных DepthFirstSearch() ,_root плюс один. Это предположение основано на том, что самый длинный маршрут не может содержать больше городов, чем их общее количество. Мы знаем, что корневой узел является массивом всех городов, используемых в качестве начальных точек маршрута, поэтому превысить допустимый объем при выделении массива невозможно. Модифицированный метод FindFout () будет выглядеть таким образом (код модификации выделен жирным шрифтом): public Node[] FindRoute(string start, string end) { Node[] retumArray = new Node [_root. Length + 1]; return retumArray; }
Код, в котором выделяется массив, представляет собой классическую проблему замочной скважины (эта концепция была впервые выдвинута Скоттом Мэйерсом (Scott Meyers), см. http://www.aristeia.com/TKP/). Данная проблема заключается в том, что алгоритм, реализованный на ваших предположениях, работает для данного конкретного контекста, но не работает в каком-либо другом контексте. То есть ваш алгоритм (ключ) разработан под конкретный контекст (замочная скважина), в то время когда нам требуется универсальный ключ, подходящий ко всем замкам. Данный код выделяет память под массив в объеме, равном длине маршрута от корня древовидной структуры, что является необоснованным предположением. Что если разработчик класса Node решит добавить города пересадки, в которые можно попасть из города, который не включен в корневые узлы? В таком случае существует возможность выйти за пределы доступной памяти в массиве. Другим решением могло бы быть выделение массива произвольного р а з м е р а х Но и тогда, в случае Х+ 1 уникальных городов, пределы этого массива могут быть нарушены. Самым простым решением было бы вычисление, сколько элементов потребуется для найденного маршрута. Но и это не подойдет, т. к. тогда мы бы не знали, какой город мы уже прошли. Еще одним вариантом решения (которое подробно рассматривается в главе 9) может быть использование коллекции. . В данном случае мы умываем руки, и заставляем разработчиков Node модифицировать свой класс. Для этого им нужно будет добавить статический метод, который предоставляет алгоритму поиска информацию о необходимом размере массива. Соответствующим образом модифицированный метод FindRoute о выглядит так (код модификации выделен жирным шрифтом): public Node[] FindRoute(string start, string end) { Node [ ] retumArray =
Структуры
данных,
принятие
решений
и
127
циклы
new Nbde[Node.GetMaxPossibleDestinationsArraySize()]; return returnArray; }
Теперь с точки зрения метода DepthFirstsearch () проблема замочной скважины устранена из кода, т. к. необходимый размер массива будет указываться классом Node. Если и теперь размер массива окажется недостаточным, то источником проблемы будет класс Node, а не наш алгоритм поиска. Это не совсем идеальное решение, но иногда приходится жениться не на королевах.
Цикл for Корневой узел (_root) предоставляет список городов, которые можно использовать в качестве начальной точки маршрута. Для начала поиска надо пройтись по городам в списке, чтобы найти начальный город, указанный в соответствующем параметре. Эта задача выполняется с помощью цикла for. Соответственно модифицированный метод будет выглядеть таким образом (код модификации выделен жирным шрифтом): public Node[] FindRoute(string start, string end) { Node[] returnArray = new Node[Node.GetMaxPossibleDestinationsArraySize()]; for (int cl = 0; cl < _root.Length; cl++) { if (_root[cl].CityName.CompareTo(start) == 0) { returnArray[0] = _root[cl]; FindNextLeg(returnArray, 1, end, _root[cl]); } } return returnArray; }
Поиск начинается с нулевого индекса массива (т. е. первого элемента); конечный элемент поиска (т. е. конечный элемент массива) указывается свойством _root. Length. В каждом проходе цикла проверяется, не является ли элемент _root [cl] .CityName начальным городом маршрута. При обнаружении начального города он присваивается первому элементу массива, который представляет найденный маршрут (returnArray[о] = _root[ci];). После этого в действие подключается метод FindNextLeg (), который и находит возможный маршрут к городу назначения. Основным рабочим элементом данного метода является цикл for, который выполняет последовательность операций, следуя определенной логике. По большому счету, эта последовательность операций заключается в увеличении или уменьшении чисел, но может применяться и другая логика. Оператор цикла for имеет следующий синтаксис: for ([начальное условие]; [выполнение
}
действий]
[конечное условие];
[модификация])
{
128
Глава10
Элементы оператора имеют следующее назначение: •
[начальное условие] — определяет начальную инициализацию цикла. Его можно рассматривать как конструктор цикла, который устанавливает состояние для итерирования. По большому счету, здесь происходит инициализация счетчика предопределенным значением;
•
— определяет условия для завершения цикла. В качестве примера завершения цикла можно привести достижение счетчиком максимального индекса массива, таким образом, прекращая его дальнейшую обработку;
•
[модификация] — реализует модификацию временного ряда. Этот элемент можно рассматривать, как действие, которое нужно выполнить, чтобы перевести состояние из текущего в следующее. В случае, когда состоянием временного ряда является счетчик, это означает увеличение или уменьшение счетчика на определенную величину.
[конечное
условие]
Оба условия и модификация отделяются друг от друга точкой с запятой. В С# имеются и другие операторы цикла, но оператор цикла for является единственным, который явно предназначен для генерирования индексов. В случае применения его для обработки нашего массива _root он сгенерировал последовательность значений (О, I, 2, 3 и т.д.), каждое из которых было использовано для последовательного обращения к отдельному элементу данного массива. ПРИМЕЧАНИЕ Практическое правило для оператора цикла for гласит, что он используется для генерации последовательности индексов с целью обращения к элементам информации. Данная последовательность индексов может непосредственно указывать элементы массива, или же с ее помощью можно выполнять вычисления, результаты которых потом применяются для генерации ссылки на элемент данных. Сгенерированная последовательность индексов не обязательно должна быть возрастающей или убывающей. Также она не обязательно должна быть логической.
Оператор if Когда начальный город маршрута определен, алгоритм начинает поиск промежуточных городов маршрута вниз по дереву вплоть до конечного города маршрута. Поиск в глубину означает, что алгоритм будет идти вниз по дереву до тех пор, пока это возможно, после чего возвратится назад и попытается найти другие маршруты. Рекурсивное обхождение дерева управляется методом для нахождения следующего города маршрута FindNextLeg(), определение которого показано на рис. 4.16. Идея заключается в создании маршрута авиарейса при обходе дерева городов пересадок в надежде, что один из этих городов окажется конечным городом вашего маршрута. Обратите внимание, что для каждого отрезка маршрута увеличивается значение параметра count, поэтому при переходе на следующий уровень дерева город этого уровня попадает в массив, содержащий найденный маршрут.
Структуры
данных,
принятие
решений
и
циклы
129
Рис. 4.16. Метод FindNextLeg () ищет следующий отрезок маршрута
Данная функция приводится в действие кодом принятия решений, реализованным как блок кода оператора if. Суть оператора if можно описать следующим образом: в случае положительного результата проверки условия исполняется код, заключенный в фигурные скобки; в противном случае исполняется код, следующий за блоком кода оператора if. Оператор if имеет такой синтаксис: if(
[условие]
)
{
[действие]
} else if ([условие]) { [действие]
} else { [действие]
}
Операторы if, else if и else совместно представляют один логический блок (т. е. если условие в if неверно, тогда следует проверить условие в else if; если и это
Глава 10
130
условие неверно, тогда выполняются действия, указанные в части else). Операторы после первого if являются необязательными. Проверка условия [условие] должна возвратить значение true (истина) или false (ложь). Значение true позволяет выполнить действия, указанные в блоке; значение false вызывает переход к следующему оператору кода. Часть e l s e обеспечивает обработку случаев, которые не попадают ни в одну из if-частей. В качестве примера логики оператора if можно привести следующий код: if
( проверка1}
{
/ / код1 } else if (проверка2) {
// код2 } else { // кодЗ } // код4
Исполнение данного кода происходит таким образом: •
если результат проверки! положительный, тогда исполняется код1. После исполнения кода1 исполняется код4;
•
если результат проверкиг отрицательный, выполняется переход к e l s e i f и выполняется проверка2\
•
если результат проверкиг положительный, тогда исполняется код2. После исполнения кода2, исполняется код4;
•
если результат
•
исполняется кодЗ. После исполнения кодаЗ, исполняется код4.
проверкиг
отрицательный, выполняется переход к части e l s e ;
Вот еще один пример: if
(проверка 1)
{
// код1 } else {
// код2 } // кодЗ
Поток исполнения этого кода таков: •
если результат проверки! положительный, тогда исполняется код1. После исполнения кода1, исполняется кодЗ;
Структуры
данных,
принятие
решений
и
циклы
проверки!
отрицательный, выполняется переход к части else;
•
если результат
•
исполняется код2. После исполнения кода2, исполняется кодЗ.
131
И еще один пример: if
( п р о в е р к а 1)
{
// код1 } if
(проверка2)
{
// код2 > else { // кодЗ
> // код4
Поток исполнения этого кода таков: •
если результат проверки1 положительный, тогда исполняется код1. После исполнения кода1 выполняется переход к части i f , где выполняется проверка2;
О если результат С
проверки1
отрицательный, выполняется переход к части if
проверкой2\
•
если результат проверкиг положительный, тогда исполняется код2. После исполнения кода2 исполняется код4;
•
если результат
•
исполняется кодЗ. После исполнения кодаЗ исполняется код4.
проверки2
отрицательный, выполняется переход к части else;
А вот это пример неправильного применения оператора i f : else {
// код2 } // кодЗ
Этот код тоже неправильный: else if (test2) { // код2
> else { // кодЗ
> В оператор if можно вставлять другие операторы i f , а также операторы else и else i f , таким образом, создавая более сложное многоуровневое дерево принятия решений. Булевы переменные условие или проверкам могут принимать значение true или false. Мы уже видели примеры применения таких переменных, как в следующем коде: if (CanContinueSearch(returnArray, currNode.Connections[cl]))
Глава 10
132
В данном операторе if если метод cancontinuesearcho возвращает true, то исполняется код, заключенный в фигурные скобки. Вот еще один пример условия: »
if (returnArray[cl] != null)
В этом операторе if если элемент массива returnArray [cl] не содержит значение null, то исполняется код в фигурных скобках. В обоих примерах либо метод, либо операция сравнения должны возвратить булево значение. Если возвращается небулево значение, то компилятор С# сгенерирует ошибку, указывающую на это обстоятельство. Понять, каким образом метод может возвратить значение true или false, можно без проблем, но оператор сравнения элемента массива со значением null немного посложнее. В табл. 4.2 приведен список операторов сравнения и их описание. Таблица
4.2.
Операторы
Выражение
Описание
а == b
Проверка на равенство значения а значению b
а != b
Проверка на неравенство значения а значению b
а > b
Проверка, является ли значение а больше, чем значение b
а < b
Проверка, является ли значение а меньше, чем значение b
а >= b
Проверка, является пи значение а больше или равным значению b
а <= b
Проверка, является ли значение а меньше или равным значению b
!а
Оператор инверсии, преобразующий true в false и наоборот
сравнения
Кроме этого, совместно с операторами принятия решения можно использовать следующие логические операторы: • AND (&&) — возвращает true, если оба операнда сравнения возвращают true, и false в противном случае; • OR (| | ) — возвращает false, если оба операнда сравнения возвращают false, и true в противном случае. Логические операторы применяются для составления сложных выражений сравнения из простых, например, как это: if ((а == Ь) && (Ь == с))
Данное составное выражение состоит из двух простых выражений проверки значений на равенство. Сначала выполняются операции сравнения во внутренних скобках, их результаты временно сохраняются, а потом с помощью логического оператора AND (&&) сравниваются между собой. Если оба первые сравнения возвратили true, то оператор AND также возвратит true. В данном случае оператор проверяет: а равно ь и равно с?
Структуры
данных,
принятие
решений
и
циклы
133
Предотвращение повторений в маршруте Метод для нахождения следующего города FindNextLeg () вызывает метод CanContinue (), назначением которого является прекращение поиска. Другими словами, мы не хотим, чтобы по маршруту, найденному нашим алгоритмом поиска в глубину, мы посещали один и тот же город дважды. Код этого метода подобный коду самого метода FindNextLeg () : private bool CanContinueSearch(Node[] returnArray, Node city) { for (int cl = 0; cl < returnArray.Length; cl++) { if (returnArray[cl] != null) { if (returnArray[cl].CityName.CompareTo(city.CityName) == 0) { return false;
> } } return true;
> Логика метода CanContinueSearch о заключается в том, что он проверяет в цикле массив returnArray (содержащий найденный путь) на предмет содержания в одном из его элементов города, который в данный момент рассматривается в качестве следующей точки маршрута (переменная city). Если массив содержит данный город, то поиск в этой ветви дерева прекращается; в противном случае поиск продолжается.
Выполнение алгоритма поиска в глубину Все необходимые компоненты алгоритма поиска в глубину, включая тесты, были реализованы, и теперь мы готовы приступить к его тестированию. Для первого теста попробуем найти маршрут между Монреалем и Сиэтлом. На рис. 4.7 можно видеть, что существуют два варианта этого маршрута: через Лос-Анджелес и через Торонто. Но наш алгоритм выдает следующий, довольно странный, результат (мы не рассматривали, как выводить результаты на экран, но это довольно легко сделать, применив оператор цикла for для обработки массива foundRoute, который содержит города найденного маршрута): Montreal New York Houston Miami Toronto Seattle
Первое, что можно подумать, увидев этот результат, что алгоритм не работает, т. к. за исключением Лос-Анджелеса, данный маршрут содержит все возможные города.
134
Глава 10
Тем не менее, несмотря на такие странные результаты, алгоритм работает должным образом. Проблема же лежит в другой плоскости — метод cancontinuesearcht) не содержит функциональности для оптимизации маршрута. На данном этапе алгоритм настроен на выполнение поиска в глубину, т. е. на следование вниз по дереву, перед тем, как возвращаться обратно. Давайте-ка мы сами пройдемся по структуре в статическом конструкторе класса Node. Наш маршрут начинается в Монреале, откуда можно лететь в следующие города (определенные в connections): montreal.Connections = new Node[3]; montreal.Connections[0] = newyork; montreal.Connections[1] = toronto; montreal.Connections[2] = losangeles;
Согласно принципу работы нашего алгоритма первый элемент дерева считается следующим городом в маршруте, и из Монреаля мы летим в Нью-Йорк. Прилетев в Нью-Йорк, мы получаем следующий выбор городов для продолжения нашего полета: newyork.Connections = new Node[3]; newyork.Connections[0] = montreal; newyork.Connections[1] = houston; newyork.Connections[2] = miami;
Здесь первым городом продолжения полета является Монреаль, но мы уже там были и он занесен в найденный маршрут. Поэтому выбирается второй элемент массива, которым является Хьюстон. Летим в Хьюстон. Из Хьюстона мы можем лететь в следующие города: houston.Connections = new Node[3]; Houston.Connections[0] = miami; houston.Connections[1]
= Seattle;
houston.Connections[2]
= newyork;
Здесь первым выбором является Майями, где мы еще не были, поэтому летим в Майями. В Майями у нас следующий выбор городов для продолжения нашего полета: miami.Connections = new Node[3]; miami.Connections[0] = toronto; miami.Connections[1] = houston; miami.Connections[2] = newyork;
В Майями первым выбором опять является город, в котором мы еще не были — Торонто — поэтому летим в Торонто. В Торонто выбор городов для продолжения нашего полета таков: toronto.Connections = new Node[3]; toronto.Connections[0] = miami; toronto.Connections[1] = Seattle; toronto.Connections[2]
= montreal;
Структуры
данных,
принятие
решений
и
циклы
135
В Торонто первым выбором следующего города маршрута является Майями, но мы уже там были. Вторым выбором является Сиэтл, который и есть конечный город нашего маршрута. Так что, с точки зрения алгоритма, к нему не может быть никаких претензий — он сработал точно так, как был запрограммирован. Но у пассажира, которому был бы предложен этот маршрут, претензии, скорее всего, имелись бы. Это еще раз демонстрирует важность тестирования вашего кода, т. к. он может быть технически правильным, но выдавать совсем не те результаты, которые вы ожидали от него. Выполнить оптимизацию алгоритма предоставляется вам в качестве одного из упражнений, заданных в конце главы.
Советы разработчику В этой главе мы рассмотрели структуры данных и алгоритмы. Из этого материала рекомендуется запомнить следующие аспекты. •
При разработке программы первым делом необходимо продумать структуры данных и алгоритмы, которые можно применить в ней.
•
Для большинства задач не существует одной лучшей структуры данных и одного лучшего алгоритма. Любая возможная структура данных и любой алгоритм содержит компромиссы. Из всех возможных структур данных и алгоритмов необходимо выбрать такие, которые подходят лучше всего для решения данной задачи и содержат наименьшее число критических компромиссов.
•
Структуры данных и алгоритмы не обязательно должны быть в одном классе. Они могут быть разных типов и часто таковыми и являются.
•
Структуры данных можно реализовать в виде обычных (struct) или ссылочных (class) типов.
•
Структуры данных обычных типов имеют три ограничения, о которых вы должны быть осведомлены. Эти ограничения связаны с тем, что при присваивании переменных другим переменным их данные копируются, с тем, что происходит при внедрении ссылочного типа в обычный тип, и с тем, что происходит, когда обычные типы применяются в качестве параметров методов.
•
На практике в большинстве случаев применяются ссылочные типы, но обычные типы также имеют свое применение. При использовании обычных типов необходимо понимать, каким образом ведут себя соответствующие значения; в противном случае можно получить нежелательные взаимодействия.
•
Конструктор представляет собой специальный тип метода, который вызывается для создания экземпляра типа. При необходимости принудительного присваивания объекту правильного состояния, которое можно верифицировать, с конструктором применяются параметры.
•
При выборе ссылочного или обычного типа решение должно приниматься на основе контекста, в котором будет применяться структура данных. Если созда-
136
Глава 10
ется сложная структура данных, скажем, дерево для поиска, тогда нужно использовать ссылочный тип; для простых же структур будет вполне приемлемым обычный тип. •
При создании экземпляров типов каждый объект имеет собственный экземпляр набора методов и членов данных. Методы и члены данных типа, объявленные с использованием ключевого слова static, существуют в единичном экземпляре и не ассоциируются с экземпляром типа.
О Практика создания тестов, прежде чем реализовывать тип, позволяет разработчику почувствовать, как должен выглядеть и вести себя тип, а также предоставляет определенные ориентиры для последующей реализации типа. •
При написании методов не следует слишком полагаться на "волшебные" данные для обеспечения работоспособности всех аспектов метода. При создании классов нужно следовать принципу модулярности, т. к. это будет способствовать реализации более гибкого кода, который также можно будет применять в других компонентах приложения или даже в совсем иных приложениях.
•
При создании кода с применением циклов рассматривайте операторы в фигурных скобках как код, который генерирует индексы для последовательного обращения к настоящей информации, обрабатываемой в цикле.
•
Для принятия решений используются комбинации операторов if, else if и else.
Вопросы и задания для самопроверки Для закрепления материала, изложенного в этой главе, выполните следующие упражнения: 1. Класс Node был объявлен ссылочным типом. Можете ли вы сказать, какие из членов данных класса Node было бы более подходяще объявить обычным типом? Перепишите определение класса Node согласно вашему мнению по этому вопросу. 2. Статический член данных Node.RootNodes предоставляется для использования всем классам. Возможно ли изолировать RootNodes таким образом, чтобы пользователь класса Node не знал о местонахождении дерева? 3. Мы обсуждали проблему замочной скважины касательно размещения массива. Но кроме этого также существует проблема взаимосвязи между Node и DepthFirstsearch. Попробуйте объяснить, почему эта проблема существует, и дайте короткое описание альтернативного алгоритма, не имеющего проблемы взаимосвязи. 4. Исправьте метод CanContinueSearch о таким образом, чтобы оптимизировать найденный маршрут. Обратите внимание, что необходимо расширить набор тестов для проверки различных сценариев. 5. Реализуйте алгоритм поиска в ширину. Алгоритм поиска в ширину, перед тем как опускаться вниз по дереву, выполняет поиск в каждом узле. Подсказка: НуЖНО Модифицировать р о б о т у Метода FindNextLeg ().
Глава 5
Обработка исключений в С#
Программы могут содержать тысячи, сотни тысяч или даже миллионы строчек исходного кода, поэтому одному человеку невозможно уследить за всеми функциями, реализованными в коде. Для этого необходима команда разработчиков. Это означает, что код, написанный одним разработчиком, будет использоваться и модифицироваться другими. Так как разработчики не могут слить свои умы в один, они должны иметь понятный и продуктивный способ взаимодействия друг с другом. Но это будет всего лишь частью решения. Сам код должен с легкостью поддаваться пониманию. Проблемой при написании программного обеспечения является не создание идеального исходного кода, а создание кода, который смогут понимать другие разработчики и который можно будет использовать в другом программном обеспечении. Иными словами, целью не является продемонстрировать, какой вы умный, и написать код, который может делать все, но написать простое, надежное и легко поддающееся пониманию программное обеспечение. В этом отношении лучшим является подход по принципу "чем проще, тем лучше". Важность иметь код, работа которого легко поддается пониманию, особенно проявляется, когда что-то идет не так. Одним из подходов к предоставлению информации о работе кода будет снабжение его способностью генерировать сообщения об ошибках. Например, допустим, что ваш код рассчитывает на наличие определенного файла. В случае если он не находит этот файл, код должен выдать четкое и понятное сообщение об ошибке, например следующего вида: "Файл XYZ отсутствует, и поэтому дальнейшее исполнение невозможно". Когда другой разработчик увидит такое сообщение, он будет знать, что необходимо проверить наличие требуемого файла. В этой главе мы рассмотрим исключения (как называются ошибки программного обеспечения на техническом жаргоне) и их обработку. Начнем с рассмотрения расположения исключений в общей структуре программы.
Глава 10
138
Ошибки, исключения и обработка исключений Ошибка возникает, когда программа по каким-либо причинам, например в результате ввода неправильных данных или неправильно указанных вычислений, не выполняется должным образом. Но среда CLR .NET не понимает ошибок, а только исключения. Например, если ошибка вызвана умножением двух чисел вместо их сложения, то программа будет продолжать работать, но выдаст неправильные результаты. Подобная ошибка происходит при вводе пользователем неверных д а н н ы х — результат будет неправильным, но программа будет продолжать работать. В случае серьезной проблемы, которая не может быть разрешена пользователем или может вызвать сбой программы, к обработке этой ошибки подключается среда CLR. Такие ошибки называются исключениями. Вместо того чтобы позволить произойти программному сбою, среда CLR останавливает штатное исполнение программы и предоставляет возможность обработать исключение самой программой. (Некоторые придирчивые к подробностям программисты могут спорить, что останавливается исполнение не всей программы, но только одного из ее потоков. Хотя технически они будут правы, для данного обсуждения исключений это не является существенной разницей.) Это называется обработкой исключений. Чтобы получить представление о том, каким образом организация кода влияет на обработку исключений, представьте себе программу, как крупную корпорацию. Корпорация имеет генерального директора, менеджеров первого уровня, менеджеров среднего уровня и т. д. до простых работников. Руководство корпорации понимает, что для того чтобы осуществить поставленные перед корпорацией задачи, нужно разработать план действий и следовать этому плану. Генеральный директор и руководители высшего уровня будут знать весь план действий. А выполнение отдельных пунктов плана возлагается на руководителей более низкого уровня и подчиненных им рабочих. Результатом выполнения всех отдельных положений плана этими организационными единицами будет реализация всего плана. Применив эту концепцию к области разработки программного обеспечения, можно выделить два типа методов: методы для организации функциональности и методы для реализации этой функциональности. Организационный код создается для того, чтобы можно было разбить всю задачу на отдельные индивидуальные рабочие единицы. Исполнение одной такой рабочей единицы кода не влияет на исполнение другой, и таким образом мы получаем модульную программу. ПРИМЕЧАНИЕ Как корпорации подвергаются периодической реорганизации, код управления программой также необходимо реорганизовывать с тем, чтобы исправить ошибки и реализовать новые функциональные возможности. Например, вы можете решить реорганизовать свой код, чтобы сделать его более налаженным и эффективным.
Обработка
исключений
в
С#
139
Теперь посмотрим, какое место в этой организационной схеме занимают исключения. Если происходит что-то непредвиденное планом, то мы имеем ошибку. В управленческой иерархии генеральному директору обычно не докладывают обо всех происшедших нештатных ситуациях. Например, вряд ли ему было бы интересно узнать о том, что в офисе кончились скобки для степлеров. Но ему определенно захотелось бы узнать о выходе из строя главной производственной линии. Иными словами, информация об ошибках передается вверх по инстанциям до такого уровня, для какого определенный тип ошибок представляет интерес. Применительно к разным типам методов, в иерархически организованном приложении организационный код либо исправляет ошибку, либо передает ее вверх по инстанции. Так и высший в иерархии модуль либо исправляет проблему, либо передает ее еще высшему модулю. В оставшемся материале данной главы мы будем рассматривать способы обработки исключений. Целью является предоставить практические решения, которые можно применить, не рискуя застрять в трясине теоретических "а что, если". При разработке средств обработки исключений часто полезно исполнять разрабатываемое приложение в отладчике Visual С# Express, поэтому мы начнем с ознакомления с данным отладчиком.
Работа с отладчиком Отладчик Visual С# Express позволяет наблюдать за процессом выполнения приложения. Запустить его можно, выбрав последовательность команд меню Debug | Start Debugging или нажав клавишу .
Рис. 5.1. Установка контрольной точки и отладка приложения
140
Глава 10
Приложение в отладчике выполняется как обычно, но панель Solution Explorer убирается, и выводятся панели Locals и Call Stack, в которых можно наблюдать состояние переменных и стека. Чтобы прекратить отладку, достаточно просто закрыть приложение обычным способом. Отладчик можно также запустить в определенной точке кода, для этого необходимо установить контрольную точку (рис. 5.1). Когда исполнение достигает этой точки, Visual С# Express перейдет из режима исполнения в режим отладки. Выйти из
режима отладки можно, нажав клавишу , что переведет приложение в режим исполнения, или нажав комбинацию клавиш <Shift>+, что остановит как отладку, так и исполнение приложения. В следующем разделе мы рассмотрим применение отладчика для обнаружения причин исключений.
Обработка исключений Те из вас, кто помнят "добрые старые деньки" Windows 3.0 с ее 16 битами, несомненно, также помнят наводящий ужас салют из трех пальцев, когда нужно было нажать комбинацию клавиш -t-+, чтобы перезагрузить Windows, повисшую после сбоя какого-либо приложения. Никакой возможности сохранить текущую работу не предоставлялось, и все, что можно было делать в такой ситуации, — это только сидеть и смотреть, как все идет коту под хвост. Если вам не пришлось испытать все прелести такой работы с компьютером, то вам здорово повезло. Сегодня же существуют механизмы для перехвата неожиданных ошибок и, в подавляющем большинстве случаев, продолжения работы программы или операционной системы. Одной из наиболее важных особенностей современных операционных систем и сред программирования наподобие среды CLR является их способность остановить исполнение любой отдельной задачи, не нарушая при этом работу центрального процессора.
Перехват исключений На рис. 2.13 показано, как среда Visual С# Express прервала поток исполнения программы, перехватив исключение, сгенерированное арифметическим переполнением. Это подобно ситуации, когда на уроке практического вождения инструктор перехватывает управление от ученика, чтобы избежать аварийной ситуации, предпосылки к которой были созданы неправильными действиями ученика. Подобным образом, механизм среды CLR для перехвата и обработки исключений можно рассматривать как перехват инструктором управления, чтобы избежать отрицательных последствий на исполнении операционной системы и других приложений, которые может вызвать неадекватное поведение какого-либо приложения. В зависимости от конкретной внештатной ситуации перехват инструктором управления может заключаться в торможении, выворачивании руля, устном указании и т. п.
Обработка
исключений
в
141
С#
То же самое происходит и при перехвате исключений — последующие действия могут иметь разный характер. В примере на рис. 2.13 перехват был выполнен средой IDE, которая предоставила дружественный, легко понимаемый интерфейс для дальнейшей обработки исключения. Теперь взглянем на пример исходного кода, генерирующего исключение (рис. 5.2). На техническом жаргоне это называется выбрасыванием исключения (throwing an exception).
Рис. 5.2. Выбрасывание исключения
Если выполнить метод RunAHO, то Visual С# Express сгенерирует исключение (рис. 5.3).
Рис. 5.3. Исключение, вызванное обращением к null-данным
Глава 10
142
Но это исключение не вызовет сбоя ни Visual С# Express, ни операционной системы, т. к. оно было перехвачено обработчиком исключений, встроенным в Visual Studio. Возвращаясь к аналогии с инструктором вождения, Visual Studio перехватила управление и вывела из нормального потока исполнения только программу, создавшую аварийную ситуацию. Теперь представим себе, что эта программа исполняется сама по себе, а не в Visual Studio. В этом случае сгенерированное исключение заставит программу остановиться на полном бегу, выведя на экран пространственное сообщение об ошибке, перечисляющее ссылки на объекты, строки кода и стек. Большинство пользователей ничего не поняло бы, что произошло, и было бы вынуждено прекратить работу с программой. Чтобы избежать такого развития событий, необходимо в исходный код программы вставить средство для перехвата исключения, как это сделала Visual Studio. Например, если-имеются основания полагать, что исключение может быть сгенерировано в методе RunAii (), то код можно модифицировать таким образом: class МуТуре { public int DataMember; } class Tests { public void GeneratesException() { MyType els = null; cls.DataMember = 10;
Код, выделенный жирным шрифтом, называется блоком исключения. Этот код перехватывает исключение и предоставляет средства для его обработки. В этом примере после перехвата исключения ничего не происходит. При ее исполнении Visual С# Express не сгенерирует сообщения об исключении, и программа исполнится без проблем. С точки зрения Visual С# Express с программой все в порядке. Но если подумать, то в самом ли деле с программой все в порядке? Иными словами, хоть программа и выполняется, не вызывая сообщений об ошибке, является ли она логически правильной? Конечно же, нет, т. к. она проглотила исключение, не предприняв никаких действий по устранению причин, вызвавших проблему.
Обработка
исключений
в
С#
143
Никогда не выполняйте обработку перехваченного исключения таким образом, т. к. это будет неряшливым программированием. ПРИМЕЧАНИЕ На практике в некоторых случаях может быть необходимым оставить перехваченные исключения без последующей обработки, т. к. это может быть единственным способом обработки данных. Такое может произойти при работе с сетевыми соединениями, операциями баз данных и т. п. Но в большинстве случаев перехваченное исключение не следует оставлять без последующей обработки.
Примером реальной ситуации, в которой необходимо выбросить исключение, может быть недействительный параметр. Как будет рассмотрено в разд. "Фильтрация исключений" далее в этой главе, для таких случаев имеется специальный тип исключения— Argumen t Except ion о. Разработчики, получив это исключение, могут потом с легкостью вычислить, что им необходимо исправить параметр. Это позволит им сэкономить время на отладке и сократить общее время разработки. Реальная работа в обработке исключений состоит во вставке кода для перехвата и обработки всех возможных исключений. Но что лучше, потерять несколько часов в поисках причины ошибки или потратить несколько минут на вставку кода, который поможет найти потенциальную ошибку без особых трудностей? Вопрос риторический, т. к., в общем, добавление кода для указания причин ошибки сэкономит вам время и сбережет нервы.
Реализация обработчиков исключений Обработчик исключения реализуется с помощью ключевых слов try, catch и finally. Суть реализации обработчика исключения в том, что исключение, сгенерированное в определенном блоке кода, будет перехвачено и обработано. Блок обработчика исключения имеет такую структуру: [действие 1] try {
[действие 2] } catch (Exception exception) {
[действие 3] }
[действие 4]
Ключевое слово try и фигурные скобки определяют защищенную область кода, или блок. Защищенная область кода в данном контексте означает, что любое возникшее исключение должно будет пройти через данный обработчик исключений. Если защищенный код генерирует исключение, то исполняется код в блоке catch, позволяя обработать исключение. Если код в блоке try ( действие 2 в примере) вызывает другой метод, то на код вызываемого метода также распространяется защита блока, даже если код вызы-
144
Глава 10
ваемого метода не защищен с точки зрения этого метода. На рис. 5.4 показан процесс перехвата и обработки исключений в такой ситуации.
Рис. 5.4. Перехват и обработка исключений при вызове метода защищенным блоком кода
Итак, защищенный блок кода в действии 1.2 вызывает метод, который имеет действие 2.1, действие 2.2 И действие 2.4. Действие 2.2 в ы п о л н я е т с я В к о н т е к с т е
защищенного блока вызываемого метода, поэтому если оно сгенерирует исключение, то это исключение будет перехвачено и обработано действием 2.3. Блок catch вызывающего кода, содержащий действие 1.3, не будет знать о происшедшем исключении. С точки зрения вызываемого метода, действие 2 . 1 и действие 2.4 не являются защищенными, но т . к . данный метод вызывается из действия 1.2, которое Защищено блоком catch, содержащим действие 1.3, то действие 2 .1 и действие 2.4, по сути, защищены блоком catch вызывающего метода. Если действие 2 ..1 или действие 2.4 сгенерирует исключение, то это исключение будет перехвачено и обработано блоком catch вызывающего метода. Данный пример иллюстрирует следующее: •
процесс перехвата и обработки исключений может охватывать несколько уровней вызовов методов;
•
сгенерированное исключение будет перехвачено как можно ближе к месту, где оно произошло.
Не все исключения обязательно обрабатываются обработчиком высокого уровня; большинство из них перехватываются и обрабатываются обработчиками низкого уровня. Но иногда исключение с самого низкого уровня обрабатываются на самом высоком уровне, как показано на рис. 5.3, где исключение, сгенерированное на глубине нескольких вызовов, было перехвачено на самом высоком уровне.
Обработка
исключений в
145
С#
В предыдущих примерах исключения вызывались неправильными действиями кода. Но исключение можно также сгенерировать преднамеренно с помощью следующей команды: throw new Exception();
Это действие также называется выбрасыванием исключения. При выбрасывании исключения создается экземпляр типа, связанный с базовым типом Exception. Применение ключевого слова throw совместно с объектом создает исключение, которое может быть перехвачено и обработано блоком catch высшего уровня. В большинстве случаев выбрасывания исключения экземпляр типа exception создается при выбрасывании. В предыдущем примере был использован конструктор Exception () без параметров, но имеются также и другие варианты конструктора: try { throw new Exception("Exception in action 2.4.");
> catch
(Exception thrown) {
throw new Exception("Exception in action 2 has been caught. ", thrown); } В п е р в о м в а р и а н т е конструктора — Exception("Exception
in action 2 . 4 ) — ИС-
пользуется строковый параметр, который передает текст, описывающий причины возникновения исключения. Это описание должно быть понятно людям, поэтому не используйте описаний типа "Ошибка 168: что-то не так". Второй вариант конструктора—Exception("Exception in action 2 has been caught.",
thrown) —
содержит первоначальное исключение в качестве дополнительного параметра в новом выброшенном исключении. Таким образом, можно предоставить еще более подробную информацию о причинах исключения. Данный код генерирует вывод, подобный следующему: Unhandled Exception: System.Exception: Exception in action 2 has been caught. —> System.Exception: Exception in action 2 . 4 .
Эти сообщения предоставляют ясную информацию о том, где произошли исключения и где они были обработаны. Таким образом, у нас имеется полное представление о потоке действий. ПРИМЕЧАНИЕ Опытный программист может заметить, что информацию о потоке выполнения программы также можно получить из дампа стека программы, и необязательно для этого выбрасывать исключения. Хотя в принципе это верно, на практике расшифровка дампа стека 10 или 15 вызовов методов доставляет мало удовольствия.
Теперь рассмотрим предыдущий код, модифицированный для предоставления уменьшенного объема информации: try {
146
Глава 10
throw new Exception("Exception in action 2.4."); } catch (Exception thrown) { throw new Exception("Exception in action 2 has been caught"); }
Предоставляемый этим кодом вывод не очень информативный: Unhandled Exception: caught.
System.Exception:
Exception in action 2 has been
Доступ к тексту сообщения об ошибке можно получить с помощью свойства Message и с к л ю ч е н и я : try { throw new Exception("Exception in action 2.4."); } catch (Exception thrown) { Console.WriteLine(thrown.Message); throw new Exception("Exception in action 2 has been caught."); }
В результате будет выведено более специфичное сообщение, но не как часть потока исключений: Exception in action 2.4. Unhandled Exception: System.Exception: Exception in action 2 has been caught.
HE
ИСПОЛЬЗУЙТЕ
ПОВТОРЯЮЩИХСЯ
СООБЩЕНИЙ
ОБ
ОШИБКАХ
При выбрасывании исключений не следует употреблять одно и то же сообщение об ошибке дважды. Представьте себе ситуацию, когда' разработанная вами программа постоянно сообщает клиенту, что "файл не найден". Если это сообщение выводится в нескольких ситуациях, то, когда пользователь позвонит в службу технической поддержи, ее персонал не будут знать, какой именно файл не был найден. Поэтому в сообщении об ошибке необходимо указать, какой именно файл не был найден и почему. Чем больше подробностей об ошибке вы предоставите, тем легче будет персоналу технической поддержки помочь пользователям решить проблему. Если по каким-либо причинам один и тот же текст необходимо использовать в разных местах, добавьте к нему идентификатор контекста. Например, сообщение об ошибке загрузки файла может выдаваться при загрузке файла с помощью графического диалогового окна или при загрузке из командной строки. Каждый из этих контекстов следует указать с помощью дополнительной информации, добавленной к самому сообщению об ошибке загрузки файла, подобно тому, как было проиллюстрировано в предыдущем коде при перехвате исключения в действии 2.
Обработка
исключений
в
С#
147
Предотвращение раскрутки стека Обработка исключений позволяет предотвратить сбой программы, но не помогает удостовериться в том, что состояние приложения не было изменено. Рассмотрим пример (рис. 5.5), иллюстрирующий, как состояние программы может быть искажено перехваченным, но необработанным исключением.
Рис. 5.5. Исключения могут исказить состояние программы
При перехвате исключения выполняется раскрутка стека. На рис. 5.6 показан пример побочного эффекта раскрутки стека — перепрыгивание через вызов метода. В примере, показанном на рис. 5.6, методы вызываются последовательно. Первым вызывается метод RunAiio, а после выбрасывания исключения немедленно выполняется блок catch метода RunAii (). Поэтому по завершении исполнения, значение переменной depth будет 2 вместо ожидаемого 0, каким бы оно было, если бы не было сгенерировано исключение. Можно видеть, что раскрутка стека была выполнена слишком быстро, вызвав непредсказуемые результаты выполнения программы. Таким образом, проблема преждевременной раскрутки стека вызывает искажение состояния программы, порой катастрофическое. Кажущаяся рабочей программа может исказить саму себя и медленно перейти в нерабочее состояние или начать выдавать неправильные результаты. К счастью, существует пара способов для предотвращения слишком быстрой раскрутки стека.
Глава 10
148
Рис. 5.6. Раскрутка стека может вызвать пропуск вызова метода
Обработка незавершенных задач с помощью finally Проблему излишней раскрутки стека проще всего решить с помощью ключевого слова finally, которое гарантирует выполнение определенного фрагмента кода, независимо от того, было ли выброшено исключение. В следующем фрагменте показан код из рис. 5.6, модифицированный с применением ключевого слова finally. Этот код присваивает члену данных depth правильное значение, class CallingExample { int depth; public void CalledCalledMethodO { depth = 2; throw new Exception О;
> public int GetDepthO { return depth; } } class Tests { void TestCallingExample() { CallingExample els = null; try { els = new CallingExample(); els.Method(); } catch (Exception) { ;} Console.WriteLine("Depth is (" + els.GetDepth() + ")");
> public void RunAll() { TestCallingExample(); } }
В данном примере каждое ключевое слово finally связано с блоком try. При использовании ключевого слова finally ассоциировать блок catch с блоком try нет необходимости. Если выполнение программы переходит в блок try, по выходу из блока, независимо от того, было ли его исполнение успешным или произошло исключение, выполняется код в блоке finally. Таким образом, если происходит раскрутка стека, то прежде чем исключение обрабатывается в каком-либо другом месте, 6 Зак 555
Глава 10
150
можно либо выполнить сброс состояния, либо присвоить ему непротиворечивое значение. ПРИМЕЧАНИЕ При вызове блока finally мы не знаем, вызывается ли он вследствие исключения или после успешного исполнения кода. Поэтому нельзя предполагать, что блок finally вызывается единственно вследствие исключения.
Помещение кода в песочницу Метод песочницы похож на использование грифельной доски — как и с грифельной доски можно все стереть начисто, неудачную попытку создать состояние можно просто выбросить. Для этого код необходимо разбить на три отдельных стадии: объявление, манипуляция и интеграция (рис. 5.7).
Рис. 5.7. Помещение кода в песочницу
Способ помещение кода в песочницу является лишь одним из нескольких вариантов достижения данной цели; существует много других возможных реализаций. Но при любой реализации цель остается одной и той же: обособить операции, которые могут вызвать исключение, от главного кода. Тогда, если исключение и произойдет, оно будет локализировано в обособленном коде, и при раскрутке стека остальной код не будет искажен. ПРИМЕЧАНИЕ Практическим правилом при применении метода песочницы является изолирование кода, который может сгенерировать исключение, от любого существующего состояния, которое может быть искажено. По завершении выполнения манипуляций объекты можно интегрировать в глобальное состояние, вызывая такие методы, которые крайне
Обработка
исключений
в
С#
151
маловероятно могут вызвать исключение. Для ситуаций, когда необходимо манипулировать имеющимся состоянием, используйте обработчик finally, с тем чтобы в случае необходимости можно было воссоздать существующее состояние.
Фильтрация исключений Во всех приведенных примерах исключений в операторе catch применялся тип Exception: catch (ExcepNullReferenceExceptiontion) { ;}
Данный тип перехватывает все исключения. На рис. 5.3 среда IDE перехватила исключение, применяя специальный тип NuiiReferenceException. Использование этого типа в операторе catch ограничивает перехват исключений исключениями обращения к null-данным. Указывая специальный тип исключения, можно отфильтровать исключения, которые мы хотим перехватывать. Например, тип NotSupportedException перехватывает только экземпляры исключений NotSupportedException. Далее приводится пример использования этого типа: try { throw new NotSupportedException("There is no code"); } catch (NotSupportedException ex) { }
В предыдущем коде, если код в блоке t r y выдаст экземпляр исключения типа Exception, то блок catch не будет активирован, т. к. он настроен на перехват только исключений определенного типа. Типы исключений можно комбинировать, чтобы отфильтровать специфические исключения. Специальный тип исключения должен быть первым исключением после блока try: try {
Комбинирование нескольких фильтров исключений позволяет избежать необходимости вычисления типа сгенерированного исключения. Например, без применения
Глава 10
152 возможностей
фильтрования
блока
catch
для
перехвата
исключений
типа
NotsupportedException н у ж н о б ы л о б ы п р и м е н и т ь с л е д у ю щ и й к о д : try { throw new NotsupportedException("There is no code"); } catch (Exception ex) { if (ex is NotsupportedException) { 11
...
} else { throw ex; } } В т а б л . 5.1 п р и в о д и т с я с п и с о к р а с п р о с т р а н е н н ы х т и п о в и с к л ю ч е н и й и з п р о с т р а н с т в а и м е н System, к о т о р ы е м о г у т б ы т ь с г е н е р и р о в а н ы и л и в ы б р о ш е н ы . Э т о д а л е к о н е в е с ь список, т. к. существуют многие другие исключения, и м о ж н о д а ж е создавать собств е н н ы е и с к л ю ч е н и я с п о м о щ ь ю п р о и з в о д н ы х к л а с с о в о т к л а с с а Exception. Таблица
5.1.
Наиболее
распространенные
типы
исключений
Исключение
Описание
Exception
Обычное исключение; общий контейнер для всех исключений. В случае одного из таких исключений, его подробности можно узнать в свойстве Message. При выбрасывании исключения этого типа важно предоставить конструктору исключения легко поддающийся пониманию текст сообщения
ArgumentException
Генерируется при вызове метода с недействительным аргументом. Обычно точную причину можно узнать в свойстве Message. Причиной этого исключения является непрааильное содержимое аргумента
Argumen tNu11Exc ер t i on
Генерируется при вызове метода с null-аргументом. Причиной может быть передача null-значения методу или null-значение одного из аргументов
ArgumentOutOfRangeException
Генерируется при вызове метода с аргументом вне пределов ожидаемого диапазона. Хотя это исключение выглядит похожим на исключение ArgumentException, оно более специализировано и направлено на выявление выхода значения аргумента за пределы допустимого диапазона. Информацию о допустимом диапазоне см. в документации метода или документации реализации метода. При выбрасывании этого исключения в сообщении об ошибке следует указывать допустимый диапазон
ArithmeticException
Генерируется при возникновении арифметической ошибки
Обработка
исключений
в
С#
153 Таблица
5.1
(окончание)
Исключение
Описание
DivideByZeroException
Генерируется при попытке деления на ноль
FormatExcept ion
Генерируется при неправильном формате параметра. Например, если метод ожидает число, отформатированное точкой, а используется запятая
IndexOutOfRangeException
Генерируется при попытке обратиться к элементу массива вне пределов массива. Например, при попытке обратиться к массиву, который не был выделен, или при попытке обратиться к элементу массива с отрицательным индексом
InsufficientMemoryException
Генерируется при недостаточном объеме памяти. Хотя это исключение встречается нечасто, оно может возникнуть при попытке выделить массив размером порядка 5 триллионов элементов (что может случиться, если переменной, указывающей размер массива, не было присвоено правильное значение)
InvalideastException
Генерируется при попытке преобразовать тип в неподдерживаемый тип. Это исключение очень часто возникает во время преобразования, в котором используется наследование
No t Imp 1 emen t edExc ep t i on
Генерируется при попытке использования методов или свойств, которые не были реализованы. Часто у вас не будет времени реализовать аесь код класса за один раз. В таких случаях не оставляйте нереализованных свойста или методов, а выбросите вместо них исключение. Таким образом, вы будете знать, не забыли ли вы реализовать что-то
NotSupportedException
Генерируется при попытке использования экземпляра интерфейса и метода, неприменимого в данной ситуации. Например, при попытке записи в открытый буфер чтения/записи привода CD-ROM только для чтения. Попытка чтения из экземпляра интерфейса исключения не аызывает
NullReferenceException
Генерируется при попытке вызова метода или свойства переменной, которой не был присвоен действительный экземпляр типа
OutOfMemoryException
Подобно
Over f1owExc ep t i on
Генерируется при попытке выполнения неподдерживаемых операций с числами, например сложение 2 миллиарда и 2 миллиарда при помощи 32-битового целого числа
SystemException
Генерируется операционной системой. Создавать производные классы из этого класса нельзя
InsufficientMemoryException
154
Глава 10
Код, не вызывающий исключений Теперь, когда мы знаем, как реализовывать обработчики исключений, рассмотрим еще лучший подход к проблеме исключений: не вызывать их. Мы будем фокусироваться на том, как можно сделать код более безопасным и менее склонным к генерированию исключений.
Защитный код К сожалению слишком часто исключения, такие как NuliReferenceException, возникают потому, что разработчики не принимают меры, чтобы удостовериться в действительности состояния во фрагменте кода. А недействительное состояние вызовет исключение. Более того, один из примеров в этой главе содержит как раз такую ситуацию, которую вы, наверное, заметили. Вот этот код с его маленьким глупым упущением, которое может вылиться в исключение: void TestCallingExample() { CallingExample els = null; try { els - new CallingExample(); els.Method(); } catch (Exception) { ;} Console.WriteLine("Depth is (" + cls.GetDepthO + n)"); }
Проблемный участок кода выделен жирным шрифтом, а проблема состоит в том, что в нем делается предположение, что переменная els будет всегда обращаться к действительному экземпляру CallingExample. Данное предположение относится к классу предположений, которые мы не можем позволить себе допускать. Если исключение произойдет при создании экземпляра CallingExample, то значение переменной els останется null, а блок catch перехватит это исключение, таким образом, предотвращая зависание программы. Но использование метода cls.GetDepthO сразу же после этого сводит всю нашу защиту на нет, т. к. переменная els содержит null, что вызовет исключение NuliReferenceException. Лучше написать этот код так: void TestCallingExample() { CallingExample els = null; try { els = new CallingExample(); els.Method(); } catch (Exception) { ;} if (els != null) {
Обработка
исключений в
С#
155
Console.WriteLine("Depth is (" + els.GetDepth() + ")"); } }
В строке, выделенной жирным шрифтом, иллюстрируется защитный код, который проверяет, не содержит ли переменная cis значение null, и если не содержит, то позволяет обращение к методу cis.GetDepthO. Написание кода таким образом делает его защищенным от исключений. Это не означает, что исключения не могут возникнуть совсем, т. к. они могут произойти в методе GetDeptho, но по отношению к методу TestCaliingExample () мы обезопасились, насколько могли, и предполагаем малую вероятность возникновения исключений в методе GetDepth (). Но в методе TestCaliingExample () отсутствует способ индикации, была ли обработка успешной. Код, вызывающий метод TestCaliingExample(), предполагает, что в результате этого вызова всегда будет что-то сделано. Кроме как возникновения исключения в вызываемом методе, вызывающий код не имеет никакой возможности узнать, если что-то в методе TestCaliingExample () не сработало. Код, который сообщает о проблемах с помощью исключения, является одновременно и добром, и злом. Добром потому, что код сообщает о возникших проблемах. Злом потому, что иногда вы знаете, что может возникнуть какая-то некритическая ошибка, и вы не хотите, чтобы это исключение поднималось наверх иерархии исполнения программы. В таких случаях исключение необходимо перехватить, что вызывает усложнение кода. Скажем, необходимо преобразовать строковое представление числа собственно в число. Предоставляемые .NET процедуры преобразования обычно выдают результат в случае успешного выполнения операции, но генерируют исключение, если что-то идет не так. И только исключение, а не возвращаемое значение или параметр. Но при преобразовании чисел мы знаем, что с такими операциями всегда существует возможность какой-либо ошибки, поэтому для такого случая нам необходимо предоставить обработчик исключения. Рассмотрим следующий код для преобразования числа: int TestGetValue(string buffer) { int retval = 0; try { retval = int.Parse(buffer); } catch (FormatException ex) { Console.WriteLine("Exception (" + ex.Message + ")"); } return retval; }
В данном примере код осознает, что если в вызванном методе Parse () строковый параметр buffer неправильного формата, например, содержит недопустимые
156
Глава 4
символы, то будет сгенерировано исключение. Исключение будет перехвачено, обработано (проблема установлена с помощью свойства Message исключения), после чего значение переменной retval будет возвращено вызывающему коду. Но как исключение повлияет на результаты выполнения последующего кода? Мы видим, что возвращаемая методом parsed переменная retval была инициализирована значением по умолчанию 0. Это действительное число и может быть интерпретировано как результат успешною преобразования. Это обстоятельство ставит разработчика в затруднительное положение. 11ерехватывая исключение, метод TestGetvaiueo как бы говорит: "Я всегда возвращу вызывающему коду действительное значение". Тем не менее, некоторые якобы действительные возвращаемые значения на деле не являются таковыми. Это значения, возвращаемые, когда при преобразовании числа возникает исключение. Таким образом, перехватывая исключение, мы поступаем совсем неправильно, т. к. здесь нужно предоставить задачу перехвата исключения вызывающему коду высшего уровня. Но и здесь не все так просто. Действительно ли мы хотим извещать вызывающий код, что выполнение преобразования является невозможным? Возможно, вызывающий код более заинтересован в том, действительно ли возвращенное значение. В таком случае извещение его о внутренних проблемах метода будет сродни докладыванию генеральному директору о том, что в офисе окончились скобки для степлеров. Конечно же, в соответствующем контексте скобки для степлеров также важны, и без них производительность компании может понизиться на сотую долю процента, но действительно ли мы будем докладывать об этой проблеме генеральному директору? Проблема с преобразованием известна разработчикам Microsoft, и для ее решения они применяют подход, который может пригодиться и нам. Как мы узнали в главе 3, строковое представление число можно преобразовать в собственно число с помощью двух методов: • метод Parse () возвращает действительное число, если ему передается действительное строковое представление числа; в противном случае этот метод выдает исключение; П метод TryParse (), кроме преобразованного значения в случае успешного преобразования, также возвращает значение true или false, указывающее на результат преобразования. Метод TestGetvalue <) можно модифицировать для использования в нем метода TryParse () таким образом: bool TestGetvalue(string buffer, out int val) { bool retval = false; if (int.TryParse(buffer, out val)) { retval = true; } return retval; }
Обработка
исключений
в
С#
157
В модифицированном примере метод TestGetvalue() возвращает true или false, таким образом указывая успех или неудачу операции преобразования строки в число. Если возвращается true, параметр val будет указывать на допустимое число; в противном случае этот параметр не используется. Некоторые из читателей могли заметить, что способ применения методов Parse () и TryParse () не блещет изобретательностью. А именно метод TestGetvalue () можно свести к одному оператору: bool TestGetvalue(string buffer, out int val) { return int.TryParse(buffer, out val); }
Использование состояния по умолчанию Использование состояния по умолчанию является полезным методом защиты от исключений, которые часто игнорируются разработчиками. Нередко, когда код не работает должным образом, разработчики подавляют проблему, возвращая в результатах работы кода null. Использование null является неплохой идеей, но оно связано с добавлением лишнего кода. Рассмотрим, например, следующий код: class DefaultStateWrong { string[] Tokenize(string buffer) { return null; } public void IterateBuffers(string buffer) { string[] found = Tokenize(buffer); if (found != null) { for (int cl =0; cl < found.Length; cl++) { Console.WriteLine("Found (" + found[cl] + ")"); } } } }
В данном примере проблемой является метод Tokenize о, который преобразует содержимое параметра buffer в последовательность строковых маркеров. Применяя методы кодирования, предотвращающие исключения, если данные не поддаются преобразованию, можно выбросить исключение или же возвратить нулевое значение, указывающее, что строку нельзя преобразовать. Вызывающий код знает, что при вызове метода Tokenize () существует возможность возвращения этим методом нулевого значения, и поэтому реализует блок if для проверки на нулевое значение. Реализация блока if является кодом защитного типа, но это усложняет код, т. к. необходимо выполнять проверку на возвращаемое нулевое значение.
158
Глава 10
Но что если бы метод TokenizeO был немного поумней и мог возвращать пустой массив, таким образом, указывая пустой набор результатов? Такая логика кажется правильной, т. к. вызывающий код ожидает либо массив, содержащий преобразованные элементы, либо пустой массив. В случае же серьезной ошибки преобразования единственным выходом будет выброс исключения. Модифицированный код будет выглядеть так: class DefaultStateRight { string[] Tokenize(string buffer) { return new string[0]; } public void IterateBuffers(string buffer) { string[] found = Tokenize(buffer); for (int cl =0; cl < found.Length; cl++) { Console.WriteLine("Found
(" + found[cl] + ")");
} } }
В модифицированном коде метод TokenizeO возвращает пустой массив; при обработке этого массива в цикле for будет выполнено нулевое число итераций. Данный код не вызывает исключений и является более удобочитаемым. Но что будет, если метод Tokenize () все-таки вызовет исключение? В таком случае отсутствие блока try/catch в методе IterateBuffers() может дать повод для предположения, что этот метод реализован неправильно. Но в действительности с методом IterateBuf fers о все в порядке, т. к. метод TokenizeO сгенерирует исключение только в случае по-настоящему серьезной ошибки. Большая проблема находится вне области метода IterateBuf fers о, и поэтому ее нужно решать на более высоком уровне. Данную ситуацию можно сравнить с конституционным иском, который автоматически направляется в Конституционный суд, т. к. суды общей юрисдикции не могут его рассматривать.
Обработка некритических ошибок Одной из наиболее глупых реакций программ на внештатные ситуации является прекращение исполнения, когда они могли бы спокойно продолжать работать. Такое поведение вызвано боязнью разработчиков, что программа может натворить что-то серьезное даже при незначительной ошибке. Допустим, что для выполнения программы требуется конфигурационный файл. Но какой должна быть реакция программы, если этот файл отсутствует? Одним подходом будет не рассуждать много, а попросту прекратить выполнение. Этот подход, конечно, сработает, но что, если проблема отсутствующего конфигурационного файла вызовет цепную реакцию других ошибок? Тогда вместо одной ошибки придется разбираться со многими. Другим подходом к решению проблемы
Обработка
исключений
в
С#
159
отсутствия конфигурационного файла может быть использование действия по умолчанию. В данном примере таким действием может быть вывод диалогового окна, в котором пользователю предлагается выбрать необходимый конфигурационный файл, или же программа могла бы сама создать такой файл с установками по умолчанию. Образец кода для создания такого файла показан в следующем фрагменте кода: try { LoadConfiguration(); } catch (ConfigurationException ex) { CreateDefaultConfiguration(); }
В данном коде метод LoadConf iguration () защищен операторами try/catch, но блок catch перехватывает ТОЛЬКО исключения типа ConfigurationException (встроенное исключение С#). Таким образом, если происходит исключение типа ConfigurationException, то создается конфигурационный файл по умолчанию, что позволит программе продолжить исполнение. Если в методе LoadConf iguration () произойдет исключение другого типа, то оно будет отфильтровано для обработки каким-либо обработчиком более высокого уровня. При обработке некритических ошибок важно отфильтровать для обработки конкретное исключение и реализовать для него должным образом оттестированный обработчик. Не пытайтесь реализовать обработчик для исправления всех внештатных ситуаций, т. к. вы никогда не сможете этого сделать, а только вызовете дополнительные проблемы. Чтобы обработчик мог исправить проблему, примите меры к тому, чтобы в нем самом не могло произойти исключение. Если такое случиться, то оно будет передано для обработки вызывающему коду высшего уровня.
Советы разработчику В этой главе мы рассмотрели ошибки и исключения. Из этого материала рекомендуется запомнить следующие аспекты. •
В программах всегда происходят ошибки и исключения.
•
Код программ организован подобно управленческой иерархии. Иерархия кода содержит два типа кода: организационный код и реализующий код.
•
Исключения перехватываются с помощью блоков кода try и catch.
•
Блок кода finally выполняется независимо от того, было ли сгенерировано исключение. Назначением блока finally является выполнение сброса к первоначальному состоянию.
•
Ответственность за выбрасывание исключений лежит на реализующем, коде. Реализующий код не пытается обработать или подавить исключение. Это означает, что реализующий код содержит блок finally для сбрасывания состояния в первоначальное, но обычно не содержит блока catch.
160
Глава 10
П Организационный код должен иметь в виду возможность возникновения исключений. Это означает, что организационный код реализует блоки catch для перехвата и обработки исключений» Обычно этот код не содержит блока finally, но может фильтровать исключения. •
Исключения фильтруются, чтобы определить, какие из них перехватывать, а какие нет.
•
Код можно защитить от исключений с помощью песочницы.
•
Реализуйте для вашего кода состояние по умолчанию, чтобы сделать его удобочитаемым и легко обслуживаемым.
•
Код, в котором обрабатываются некритические ошибки, обычно является организационным кодом и используется для исправления исключений.
Вопросы и задания для самопроверки Для закрепления пройденного материала перепишите все примеры из главы 4, чтобы обезопасить их от исключений.
Глава 6
Основы объектно-ориентированного программирования На данном этапе вы должны чувствовать себе уверенно с написанием базового кода на С#, но, скорее всего, ваш подход к написанию кода заключается в непосредственном решении проблем без принятия во внимание возможного повторного использования разрабатываемого кода или других продвинутых концепций. При таком подходе вы, может быть, и будете в силах писать циклы, классы и методы, но вряд ли сможете интегрировать их должным образом в одно целостное, надежное и эффективное приложение. Данная глава — еще один шаг в направлении к этой цели. В ней мы сосредоточимся на повторном использовании базовой функциональности, когда два класса разделяют общие методы и свойства с целью решения определенной задачи. Для демонстрации основных принципов мы создадим простое приложение для обмена валют. В приложении применяется объектно-ориентированное программирование — набор мощных средств программирования, широко употребляемых в современных языках программирования, таких как С#. В этой главе мы рассмотрим следующие вопросы. •
Объектно-ориентированное программирование (ООП) — способ создания приложений с помощью экземпляров типов. Сначала определяется тип и его поведение. Созданием экземпляра типа, который также называется объектом, типу присваивается состояние. При разработке состояние объектов нам неизвестно, и мы можем только предполагать, каким оно может быть.
•
Области видимости членов данных. Типы имеют методы, которые могут вызываться другими типами. Но возможность вызывать методы типа всеми другими типами не всегда является желательной. Подобно тому, как мы позволяем различный уровень доступа, скажем, к комнатам и шкафам в нашем доме разным людям, мы контролируем уровень доступа к методам наших типов.
•
Свойства. Кроме методов, типы имеют свойства. Методы применяются для выполнения операций над типом, а свойства предоставляют состояние типа.
•
Базовые классы. Термин "базовый класс" обозначает общую функциональность. Применение слова "базовый" обусловлено тем, что в объектно-ориентированном
Глава 10
162
программировании иерархия определяется от основания — базы — кверху. А слово "класс" применяется потому, что класс является базовым типом, содержащим функциональность. Для демонстрации концепций объектно-ориентированного программирования мы рассмотрим в этой главе приложение для преобразования валют. Для этого нам необходимо знать основные принципы процесса обмена валют, с рассмотрения которых мы и начнем.
Что такое спрэд? Знаете ли вы, что при обмене одной валюты на другую официальная плата за проведение транзакции никогда не взимается? При обмене валюты в аэропорту или в обменном пункте, с вас не должны удерживать плату за услугу обмена. Вы, наверное, удивляетесь, как же люди зарабатывают деньги, выдавая одну валюту взамен другой. Ответ на этот вопрос состоит в специфике работы обмена валюты. При обмене валют, мы всегда имеем дело с парой валют, что отличается от, например, покупки/продажи акций, когда мы имеем дело с акциями только одной компании. При обмене валют мы определяем стоимость обмениваемой валюты в единицах валюты, на которую выполняется обмен, т. е. курс, и выдаем клиенту другую валюту в сумме, эквивалентной стоимости первой валюты. Но не совсем эквивалентную. При коммерческом обмене валют также применяется спрэд — разница в стоимости одной валюты в единицах другой в зависимости, покупаем мы валюту или продаем. Спрэд и есть неофициальная плата за услугу обмена, на которой обменные пункты и менялы зарабатывают свои деньги. Спрэд — довольно мудреная штука, т. к. при разном определении стоимости одной валюты в другой, меняется и спрэд. Например, в отелях всегда предлагают грабительский курс, и многие думают, что они пользуются безвыходным положением постояльцев, чтобы немного подзаработать. В некоторой мере такое мнение, может быть, и имеет основания, но главной причиной такого курса в отелях является тот факт, что предоставление услуг обмена валют не является основным бизнесом отелей, и они просто перестраховываются с запасом. Возьмем, например, следующий спрэд курса: USD.EUR 0,7609 0,7610 Термин USD.EUR означает, что мы обмениваем доллары США на евро. Первое число — это покупная цена, или бид (bid); иными словами, за каждый покупаемый у вас доллар США маклер готов дать вам 0,7609 евро. А второе число — это цена продажи доллара, или аск (ask), т. е. за каждый проданный вам доллар США маклер запрашивает 0,7610 евро. В данном случае спрэд составляет 0,0001 евро, что и является неофициальной комиссией маклера. Применение спрэда является вполне нормальной практикой в обмене валют. То же самое можно сказать и о его колебаниях, т. к. некоторые маклеры могут предложить вам более высокую цену при покупке у вас валюты, а другие — более низкую при продаже ее вам.
Основы
объектно-ориентированного
программирования
163
Допустим, что вы прилетели из США в Европу и вам нужно обменять доллары на евро. Прежде чем делать это, вы узнаете в Интернете текущий курс доллара к евро, и он оказывается таким, как мы только что рассмотрели. С этой информацией вы идете к конторке консьержа и спрашиваете текущий курс доллара США к евро. Консьерж выдает вам следующий курс: USD.EUR
0,75120
EUR.USD
1,29340
Сразу же видно, что с этим курсом что-то не так. Отель дает вам 751,20 евро за ваши 1000 долларов, тогда как трейдеры форекса готовы дать вам 760,90 евро. Отель обдирает вас почти на 10 евро. Но в действительности отель не обдирает вас, а просто страхует себя от возможных потерь. На протяжении дня спотовый валютный курс падает и поднимается, а отель не занимается бизнесом обмена валюты. Их бизнес — предоставить вам кров и питание. Поэтому менеджер отеля должен быть уверен в том, что независимо от колебания спотового курса обмена, когда при обмене ваших долларов на евро отель не потеряют на этой сделке деньги. Достигают этого предоставлением вам меньшей суммы евро за ваши доллары. Таким образом, если вы занимаетесь обменом валют, то вам всегда нужно быть, так сказать, в курсе спрэда. Уловив хороший спрэд, например, когда бид одного маклера выше за аск другого, что иногда случается, вы можете сорвать приличный куш. А теперь, когда мы знаем основы обмена валют, давайте перейдем к реализации нашего приложения.
Организация приложения для обмена валют Приложение для обмена валют берет определенное количество одних валютных единиц и преобразует их в соответствующе число других валютных единиц. В приложении будут реализованы возможности для выполнения таких операций: •
ввод и хранение курса обмена;
•
хранение идентификаторов валют;
•
преобразование одной валюты в другую и обратно;
•
отличия между активными валютными маклерами и обменными пунктами отелей;
•
реализация спрэда для обменных пунктов отелей.
Как и в примерах в предыдущих главах, структура приложения для обмена валют будет состоять из двух проектов: тестового консольного приложения TestcurrencyTrader и библиотеки класса currencyTrader, содержащего функциональности для обмена валют коммерческими маклерами и обменными пунктами отелей.
164
Глава 10
Тесты для приложения обмена валют Так как мы пока не знаем точно, как будет выглядеть конечная реализация нашего приложения обмена валют, начнем с написания тестов, использующих эту реализацию. Мы хотим создавать приложение шаг за шагом, поэтому напишем кое-какие тесты и кое-какой код, протестируем код, и если все нормально, то продолжим процесс в этом же порядке. Наша общая задача состоит в том, чтобы получить возможность преобразования одной валюты в другую.
Введение в структурный код Наше приложение создается на основе курса обмена, валютных единиц и вычисления с применением курса обмена и валютных единиц. Поэтому было бы логичным на первом шаге написать тестовый код, в котором совместно применяются все эти элементы. Например, как в следующем коде: CurrencyTrader els = new CurrencyTraderО; els.ExchangeRate = 1.31; double haveUSD = 100.0; double getEUR = els.Convert(haveUSD); Console.WriteLine("Converted " + haveUSD + " USD to " + getEUR);
Данный код отличается тем, что на нем лежит вся ответственность за то, чтобы структуре были присвоены правильные данные. Чтобы прояснить это, посмотрите на этот же код, но с произвольными именами и значениями переменных валют вместо логических идентификаторов: CurrencyTrader els = new CurrencyTrader(); els.ExchangeRate = dddddedfasffsdf; double ukfkisd = 100.0; double didkfdbnfd = els.Convert(ukfkisd); Console.WriteLine("Converted " + ukfkisd + " USD to " + didkfdbnfd);
Данный код структурного, а не архитектурного типа. Структурный код требует интеллектуального программиста, т. е. программиста, который знает значение отдельных элементов. С другой стороны, архитектурный код является более защищенным от неквалифицированного использования и требует меньших знаний при работе с ним, т. к. многие из его частей инкапсулированы. С архитектурным кодом пользователь должен знать только, как использовать классы. Иными словами, структурный код требует знаний, как сложить два числа. А с архитектурным кодом все, что требуется знать, — это как ввести два числа в калькулятор и нажать кнопки "плюс" и "равно". Можно утверждать, что, не зная, как выполняется операция сложения, и полагаясь на калькулятор для ее выполнения, мы не имеем никакой уверенности в том, что калькулятор выполнит эту задачу должным образом. Такое утверждение будет справедливо, и именно поэтому тесты являются важными для удостоверения правильности работы калькулятора.
Основы
объектно-ориентированного
программирования
165
Базовые классы Первый тестовый структурный код в предыдущем разделе составлен правильно. Структурный код формирует основу компонента, который называется базовым классом. Базовый класс предоставляет определенную функциональность, которая может быть использована в других классах. В данном случае такой функциональностью является функциональность преобразования валютных единиц, которую мы намереваемся использовать как в модуле коммерческого валютного маклера, так и в модуле обменного пункта отеля. Базовые классы необходимо определять таким образом, чтобы выполнение идентичных операций было единообразным. Если бы не было базовых классов, то для повторного использования функциональности пришлось бы копировать и вставлять реализующий ее код. Базовые классы имеют некоторые очень важные характеристики. •
Разработчикам следует применять базовый класс, только если они понимают, какую работу данный класс выполняет. Для управления доступом применяется область видимости.
•
Базовые классы описывают свойства и методы. Для использования базового класса эти описания компонуются для выполнения вычислений. Например, чтобы выполнить преобразование валюты, необходимо вручную определить валютный курс и валютные единицы и выполнить метод преобразования. Пошаговый ручной подход предоставляет разработчикам гибкость в работе.
•
Базовые классы необходимо всесторонне протестировать, т. к. предоставляемая ими функциональность будет использоваться повсеместно в разрабатываемом коде. Если базовые классы содержат ошибки, то велика вероятность ошибочного функционирования крупного фрагмента, применяющего данные классы. ПРИМЕЧАНИЕ Базовые классы являются общим концептом, который полностью понимают только разработчики. Общий концепт называется шаблоном проекта. В процессе использования шаблонов разработки создается терминология разработчиков, в которой такие слова, как фабрика, состояние и посетитель, обозначают конкретные концепции программирования, которые разработчики понимают без всяких объяснений. Я бы посоветовал вам узнать больше о шаблонах проекта. Хорошую коллекцию превосходных примеров кода основных шаблонов, используемых разработчиками, можно найти на Web-странице Data & Object Factory (http://www.dofactory.com/Patterns/ Patterns.aspx).
В данном примере класс currencyTrader необходимо преобразовать в базовый класс, который могут использовать только опытные разработчики. Это требование порождено необходимостью предотвратить использование разрабатываемого кода в неправильном контексте. Одним из способов предотвратить использование класса CurrencyTrader в неправильном контексте будет объявление его как abstract. Ключевое слово abstract в объявлении класса указывает на то, что из этого класса нельзя создавать экземп-
Глава 10
166
ляры. К этому классу можно обращаться, но создавать его экземпляры нельзя. Посмотрим на следующий код, в котором класс currencyTrader объявляется абстрактным: abstract class CurrencyTrader { }
Для создания экземпляра класса, объявленного с помощью ключевого слова abstract, применяется механизм, называющийся наследованием (inheritance). С точки зрения разработчика, идеей в основе объявления класса абстрактным является создание логики многократного использования для применения в других классах.
Что такое наследование? Механизм наследования похож на генеалогическое дерево в том, что мы имеем древовидную структуру с родителем и его наследниками. Подобно генеалогическому дереву, данная структура может содержать несколько уровней. Но механизм наследования не совсем такой, т. к. для каждого узла генеалогического дерева требуется пара людей. Механизм наследования используется классом для получения функциональности базового класса, а сам класс становится подклассом базового класса. В древовидной структуре механизма наследования, особенно в .NET, имеется только один корневой родитель. При использовании наследования мы получаем функциональность, но мы также можем подменять (override) функциональность (рис. 6.1).
Рис. 6.1. Демонстрация двухуровневой структуры наследования на примере автомобилей BMW 530i и BMW 530xi
На первый взгляд, на рис. 6.1 показаны два одинаковых автомобиля, но в действительности это две разные модели, чьи трансмиссии отличаются существенным
Основы
объектно-ориентированного
программирования
167
образом. С точки зрения наследования, модель BMW 530i можно считать родителем модели BMW 530xi. ПРИМЕЧАНИЕ В данном случае определение, кто родитель, а кто наследник, является моим собственным мнением, с которым некоторые люди могут не согласиться. Такое расхождение во мнениях является и частью объектно-ориентированного процесса разработки.
Будучи заднеприводной, что означает более легкую разработку, она бы была разработана первой. А полноприводная модель разрабатывалась бы на базе этой первой модели и была бы лишь ее модификацией, а не полностью новым автомобилем. В обеих моделях применяются одинаковые шины, двигатель, рулевое колесо, отделка кузова и т. д. Но в модели 530xi применяется полностью другая трансмиссия, что оказывает существенное влияние на то, каким образом данная модель ведет себя при вождении. При езде в дождь или снег в обеих моделях применятся одинаковое рулевое колесо, указатели поворотов, педаль газа и т. д. Но реакция каждой модели на дорожные условия была бы разной; так заднеприводная модель может быть подвержена заносам в большей степени, чем полноприводная модель. В этом отношении можно сказать, что другая трансмиссия на производной модели подменила поведение первой модели. Применительно к классам, подмена поведения означает, что потребитель иерархии видит одинаковый интерфейс (например, методы и свойства), но получает разную функциональность. Кроме подмены функциональности, механизм наследия можно использовать для улучшения, или на техническом жаргоне перегрузки (overloading), функциональности. Продемонстрируем эту концепцию опять на примере разных моделей одной марки автомобиля (рис. 6.2).
Рис. 6.2. Демонстрация расширения функциональности
168
Глава 10
Все три автомобиля, показанные на рис. 6.2, относятся к линейке 530. С точки зрения наследования функциональность новой модели BMW 530xi Sports Wagon основана на функциональности модели BMW 530xi. Но здесь имеется одна особенность — функциональность 530xi Sports Wagon требует выработать к ней привычку. Например, в обеих моделях багажник открывается нажатием кнопки, но для каждой модели кнопка находится в разных местах, да и багажник открывается поразному. Можно сказать, что каждая модель предоставляет водителю свои интерфейс и поведение. При использовании механизма наследования для перегрузки функциональности мы добавляем функциональность, которая вызывается таким же образом, но используется и ведет себя по-другому. При данной форме наследования мы не просто меняем поведение, но также меняем взаимодействие с пользователем. В нашем примере мы используем наследование для расширения функциональности, а не для ее подмены или перегрузки.
Использование свойств С# До сих пор тестовый код обращался к члену данных, как в следующей строке кода: els.ExchangeRate - 123.45;
А члены данных реализовывались следующим образом: public abstract class CurrencyTrader { public double ExchangeRate; }
Предоставление члена данных в общей области видимости было приемлемым в предыдущих примерах, но, по правде говоря, мы не хотим делать этого, т. к. этим мы открываем внутреннее состояние объекта. А в объектно-ориентированном программировании предоставление внутреннего состояния является плохой идеей (почему, будет объяснено более подробно немного позже).' Поэтому, вместо предоставления члена данных открытым образом, мы изменим тестовый код для того, чтобы делать это с помощью свойств. Хотя использование свойств также открывает внутреннее состояние объекта, но они предоставляют дополнительный уровень абстракции. Как мы узнаем далее, некоторые свойства предоставляют как внутреннее, так и внешнее состояние. К таким свойствам относится свойство ExchangeRate, которое мы используем для обращения к курсу валют и изменения его. Если не использовать свойство ExchangeRate, то нам придется создать по методу для присвоения и получения значения курса валют. Эти методы работают подобно свойствам, но их не так удобно применять.
Модификация тестового кода для применения свойств Интересной особенностью свойств языка С# является то обстоятельство, что они выглядят и ведут себя, как члены данных. Это означает, что тестовый код не нужно модифицировать, т. к. в нем также предполагается прямой доступ к переменной.
Основы
объектно-ориентированного
программирования
169
Модифицированный класс CurrencyTrader, предоставляющий ExchangeRate В качестве свойства С#, показан на рис. 6.3.
Рис. 6.3. Предоставление ExchangeRate в качестве свойства С#
Чтобы полностью ввести в заблуждение тест, имя свойства должно быть идентичным имени ранее объявленного члена данных. Чтобы избежать коллизии идентификаторов, член данных ExchangeRate переименован В _exchangeRate, а область видимости изменена с общей (public) на частную (private). Свойства выглядят как методы без параметров, но возвращают значение. Кроме этого, каждое свойство должно иметь, по крайней мере, блок get или блок set (могут присутствовать оба блока), которые называются getter (получатель) и setter (установщик) соответственно. Свойства, имеющие лишь блок get, доступны только для чтения. А свойства, имеющие только блок set, можно только устанавливать. В контексте свойства имеются лишь ключевые слова get или set. Никакого другого кода добавить нельзя. Следующий оператор выполняет код, определенный в блоке get, и получает состояние из класса, присваивая его значение переменной: value = els.ExchangeRate;
А следующая строчка выполняет код, определенный в блоке set, и присваивает состояние из переменной классу: els.Exchange = value;
Блок каждого из этих двух типов имеет свои особенности. Блок get должен всегда возвращать данные вызывающему коду, для чего требуется применение ключевого слова return. Блок set не обязан делать ничего. Но если необходимо знать, какие
Глава 10
170
данные передаются свойству, можно использовать переменную value, которая не объявляется в коде, а просто подразумевается. Эту ситуацию можно рассматривать, как будто бы язык С# предоставляет нам значение переменной каким-то волшебством.
Проблемы свойств Многие программисты считают, что использование свойств способствует распространению плохих навыков программирования, т. к. свойства делают возможным доступ к внутреннему состоянию объекта. В предыдущем исходном коде можно видеть, что член данных _exchangeRate имеет взаимно-однозначное соответствие со свойством ExchangeRate. Если вызывающий код присваивает значение свойству ExchangeRate, то сразу же присваивается значение и частному члену данных _exchangeRate. Таким образом, обнажается, хотя и непрямым образом, внутреннее состояние. В качестве примера рассмотрим следующую ситуацию. Скажем, вы решили испечь что-то в электродуховке, и вам нужно прогреть ее до определенной температуры. Легче всего сделать это с помощью свойства Temperature следующим образом: class Oven { private int _temperature; public int Temperature { get { return _temperature; } set { _temperature = value; } } }
Класс oven предоставляет температуру, как свойство, которое является прямой ссылкой на переменную „temperature. Код, вызывающий класс oven, периодически запрашивает значение температуры и решает, когда духовка разогрелась до необходимой температуры. Итак, является ли класс oven в его текущей реализации структурным классом? На пользователя класса oven возлагается довольно значительная ответственность в том, что он должен периодически запрашивать значение температуры и на основе полученного ответа решать, прогрелась ли духовка до требуемой температуры. Лучшим подходом было бы определить класс, который выполняет эту работу. Тогда вызывающему коду нужно было бы только спросить у этого класса: "Ты готов?" Такой класс может выглядеть следующим образом: class Oven { private int _temperature;
Основы
объектно-ориентированного
программирования
171
public void SetTemperature(int temperature) { _temperature = temperature; } public bool AreYouPreHeated() { // Проверяем, соответствует ли температура требуемой. return false; } }
Модифицированная реализация класса oven не предоставляет члена данных _temperature внешнему коду. Также в данной ситуации член данных .temperature не представляет температуру духовки, но указывает верхний предел температуры разогрева. Данный верхний предел устанавливается с помощью метода SetTemperature (). Проверка готовности духовки осуществляется не получением ее температуры, а вызовом метода AreYouPreheated(). Вызывающий код получает ответ в виде значения true или false, указывающего состояние нагрева духовки. Таким образом, ответственностью кода, вызывающего класс oven, является только установка верхнего предела температуры и опрашивание о готовности духовки. Из этого примера может показаться, что можно обойтись без свойств. Но это будет неверное заключение, т. к. нам все еще нужны свойства, потому что в своей текущей реализации класс oven является удобным классом, который можно интегрировать на архитектурном уровне рабочей логики. Задачей разработчика является поиск способа связать между собой сырой структурный класс и предоставляемые возможности архитектурного класса уровня рабочей логики. Эта проблема будет решена при реализации модулей коммерческого валютного маклера и обменного пункта отеля. Даже с этими аргументами и различием между базовым классом и архитектурным классом уровня рабочей логики некоторые программисты продолжают критиковать использование свойств. Причиной этому является управление доступом. Допустим, вы расплачиваетесь за покупки в магазине. Как вы это делаете? Открываете свой бумажник и позволяете кассиру взять из него необходимую сумму наличными или кредитную карточку? Или наоборот, кладете требуемую сумму в кассовый аппарат или сами обрабатываете транзакцию с кредитной карточкой? Ответ на эти вопросы заключается в доверии. Насколько бы не доверяли вы и кассир друг другу, каждый из вас хочет контролировать свою область собственности или ответственности. По аналогии с ранее приведенным примером с доступом к разным комнатам и объектам в вашем доме, свойство Temperature можно сравнить с разрешением доступа. Например, вы бы не разрешили доступ к своему шкафу любому посетителю, но ваша жена или муж такой доступ имели бы. Любому посетителю вашего дома нечего делать в вашем шкафу, но своей жене вы, скорее всего, доверяете. Часто состояние и его представление являются вопросом доверия и применения правильной области видимости.
Глава 10
172 ПРИМЕЧАНИЕ
В данном обсуждении свойств и объектно-ориентированного проектирования моей целью является объяснить, что применимо и то и другое, и у вас не должно возникать предубеждения против одного или другого подхода. Разрабатывая тип, который не открывает свое состояние, вы разрабатываете тип, который реализует абстрактное намерение. А разрабатывая тип, который позволяет доступ к своему состоянию (до определенной степени), вы разрабатываете тип для использования на нижнем техническом уровне. Также следует помнить, что иногда внутреннее состояние является внешним состоянием, как в примере с курсом валют. Состояние курса валют нельзя абстрагировать, т. к. оно является числом, используемым в вычислениях.
Наследование и модификаторы области видимости На данном этапе свойство ExchangeRate является механическим свойством, которое будет использоваться любым производным классом класса CurrencyTrader. Поэтому сейчас нам нужно решить, следует ли ограничивать доступ к этому свойству. Правильным решением будет позволить доступ к нему только тем разработчикам, которые действительно понимают механизм преобразования валют. Доступ должен быть ограничен классами, производными от класса CurrencyTrader. Модифицированный класс CurrencyTrader будет выглядеть таким образом: public abstract class CurrencyTrader { private double _exchangeRate; protected double ExchangeRate { get { return _exchangeRate; } set { _exchangeRate = value; }
> }
Жирным шрифтом в этом коде выделены определения областей видимости. • public (общая)— к данному типу, методу или свойству может обращаться и иметь доступ любой другой тип. По аналогии с квартирой и посетителями, это будет сродни гостиной, в которую практически любой ваш посетитель может иметь доступ. • private (частная) — к данному методу или свойству может обращаться и иметь доступ только тип, который его объявил. По аналогии с квартирой и посетителями, это будет сродни вашей спальне, в которую доступ имеете только вы. • protected (защищенная) — к методу или свойству может обращаться и иметь доступ тип, который его объявил, или производные типы. По аналогии с квартирой и посетителями, это будет сродни вашей столовой, в которую доступ имеете вы и приглашенные вами гости.
Основы
объектно-ориентированного
программирования
173
Типу, методу или свойству, объявленному без указания области видимости, по умолчанию присваивается частная область видимости. Модификаторы private и protected нельзя присваивать типам. Дополнительная информация о других модификаторах и подробности об объявлениях области видимости типа будет рассмотрена в следующей главе.
Использование наследования для создания другого типа Модифицированная версия класса CurrencyTrader выведет из строя тестовый код, т. к. в нем используется ключевое слово abstract, а из абстрактных типов создавать экземпляры непосредственно нельзя. Вот код, который не будет работать: CurrencyTrader els = new CurrencyTrader(); els.ExchangeRate = 123.44;
Код не будет работать по двум причинам: •
класс CurrencyTrader является абстрактным и поэтому не допускает непосредственного создания экземпляров;
•
свойство ExchangeRate является защищенным и поэтому не допускает обращения извне.
Это ставит нас в трудное положение. До настоящего момента при тестировании кода мы полагали, что все фрагменты, подлежащие тестированию, были доступными. Одним из решений этой проблемы было бы изменить объявления областей видимости и удалить ключевые слова abstract и protected. Но, в то время как такой подход решил бы проблему, это было бы компромиссом. Лучшим подходом будет протестировать класс CurrencyTrader в том виде, в каком его подразумевалось использовать, т. е. как наследуемый класс. Таким образом, решением будет применить механизм наследования и создать производный от класса CurrencyTrader тестовый класс TestCurrencyTrader следующим образом: class TestCurrencyTrader:CurrencyTrader { public void InitializeExchangeRate() { ExchangeRate = 100.0; } }
Тестовый класс TestCurrencyTrader добавляется к исходному коду. Идентификатор наследуемого класса (TestCurrencyTrader) отделяется от идентификатора класса, от которого ОН наследует (CurrencyTrader) с помощью двоеточия. Чтобы предоставить доступ к методу внешнему коду, применяется модификатор public, хотя сам класс public не объявлен. К идентификаторам базового класса, область видимости которых объявлена protected или public, можно обращаться из производного класса. Например, обратите внимание на то, что переменная ExcahngeRate используется самостоятельно, без ссылок на объект. Такое обращение к переменной ExcahngeRate вполне
Глава 10
174
нормально, т. к. в базовом классе currencyTrader имеется идентификатор с таким именем. К свойству ExchangeRate можно обращаться локально, т. к. оно имеет область видимости protected. Идентификатор ExchangeRate подразумевает ссылку this (т. е. this .ExchangeRate), поэтому нет надобности добавлять эту ссылку явно,
если только не имеется несколько одинаковых идентификаторов или вы не хотите явно обратиться к определенному идентификатору. Теперь наши тесты не смогут тестировать класс CurrencyTrader, но смогут тестировать класс TestcurrencyTrader. Последний должен содержать код для верификации, что все работает должным образом.
Области видимости private, protected и public Теперь рассмотрим более подробно работу этих трех типов области видимости. Для начала, обсудим реализацию класса CurrencyTrader: public abstract class CurrencyTrader { private double _exchangeRate; protected double ExchangeRate { get { return _exchangeRate; } set { _exchangeRate = value; } } protected double ConvertValue(double input) { return _exchangeRate * input; } protected double ConvertValuelnverse(double input) { return input / _exchangeRate; } }
От класса CurrencyTrade наследуется НОВЫЙ класс ActiveCurrencyTrader таким
образом: public class ActiveCurrencyTrader : CurrencyTrader { }
Так как член данных CurrencyTrader,_exchangeRate объявлен private, ТО обра-
щаться к нему можно только из класса CurrencyTrader. Если бы для него не была явно объявлена область видимости, то область видимости private подразумевав лась бы неявно. Например, следующий код не скомпилировался бы: public class ActiveCurrencyTrader : CurrencyTrader { public void Method() {
Основы
объектно-ориентированного
программирования
175
_exchangeRate = 100.0; } }
Класс ActiveCurrencyTrader не является частью класса CurrencyTrader, И поэтому обращение к члену данных _exchangeRate невозможно. В классе ActiveCurrencyTrader К члену данных ExchangeRate, который был объявлен protected, можно обращаться следующим образом: public class ActiveCurrencyTrader : CurrencyTrader { public void Method() { ExchangeRate = 100.0; } }
Область видимости protected означает, что только классы, наследуемые от базового класса, могут обращаться к методам, свойствам или членам данных с данной областью видимости. Количество наследований и уровень наследования производного класса роли не играют. Изо всех областей видимости public является наиболее дозволяющей и простой. Она применяется, когда нужно предоставить функциональность, к которой требуют доступа другие классы или производные классы данного класса. Далее приводятся некоторые советы по использованию каждой области видимости. •
Область видимости private (частная). Применяется для большинства членов данных, т. к. члены данных неявно выражают состояние объекта. Иногда рабочую логику разрабатываемого алгоритма необходимо разбить на несколько методов. Так как отдельные методы применяются для решения одной и той же задачи, то их необходимо использовать только в контексте класса, что означает, что их область видимости необходимо объявить private.
•
Область видимости protected (защищенная). Применяется, когда необходимо обеспечить соблюдение архитектуры наследования. Очень часто ключевые слова protected и abstract применяются совместно, т. к. оба предназначены для использования с механизмом наследования. Основной целью применения области видимости protected является предоставление производному классу доступа к частному состоянию родительского класса или предоставление функциональности многократного использования, пользоваться которой могут только опытные разработчики.
•
Область видимости public (общая). Подумайте хорошенько, прежде чем применять эту область видимости. Данная область видимости является наиболее используемой, но в то же самое время может быть причиной большинства проблем. Например, после объявления какого-либо элемента public последующая попытка изменить область видимости может привести в полный беспорядок код, использующий данный элемент. Применение других областей видимости может несколько затруднить процесс разработки, но позволит получить более
176
Глава 10
надежный код с точки зрения поддержки. В конечном счете, решение, какую область видимости применить, принимается на основе того, какие методы и свойства необходимо предоставить внешнему коду.
Верификация Для выполнения тестового класса TestcurrencyTrader применяется следующий код: TestCurrencyTrader els = new TestCurrencyTrader(); els.IntializeExchangeRate() ;
В модифицированном тестовом коде создается экземпляр класса TestcurrencyTester, после чего вызывается метод InitializeExchangeRate(). Но является ли данный код тестом? Ведь метод InitializeExchangeRate () не возвращает значение и не имеет выходного параметра. Такой тест можно сравнить с отправлением письма по почте. Хотя, скорее всего, письмо дойдет до адресата, полной уверенности в этом нет. Использование тестов, об успешном завершении которых можно судить только с определенной вероятностью, является очень плохой идеей. Нам необходимо переместить верификационный код из процедуры тестирования в класс TestCurrencyTrader следующим образом: class TestCurrencyTrader : CurrencyTrader { public void InitializeExchangeRate() { ExchangeRate = 100.0; if (ExchangeRate ! = 100.0) { throw new Exception("100.0 verification failed");
}
} }
Код для верификации, что переменной ExchangeRate было присвоено то же самое значение, которое было возвращено, выделен жирным шрифтом. ПРИМЕЧАНИЕ Применяемые нами тесты становятся все более сложными, и вы можете задаться вопросом: "Зачем делать это таким образом?" Для целей данной книги мы пишем тесты и создаем нашу собственную инфраструктуру тестирования. Обычно мы бы этого не сделали, а воспользовались бы для создания тестов готовой инфраструктурой тестирования, например NUnit (http://www.nunit.org), или инструментами, входящими в состав Microsoft Visual Studio Professional. Но здесь я хочу продемонстрировать, как использовать не инструмент тестирования, а язык С#. В процессе изучения написания тестов с самого начала вы поймете, что можно ожидать от инфраструктур тестирования.
Использование условных операторов Размещение верификационного кода в классе является приемлемым в контексте тестового класса TestCurrencyTrader. Но все еще остается проблема тестируемости классов, которые не предоставляют свое состояние.
Основы
объектно-ориентированного
программирования
177
Чтобы понять эту проблему, давайте возвратимся к коду для предварительного подогрева духовки. Представьте себе, что мы переписали класс oven, включив в него верификационный тест, следующим образом: class Oven { private int _temperature; public void SetTemperature(int temperature) { _temperature = temperature; if (_tenperature != 100.0) { throw new Exception("100.0 verification failed"); } } public bool AreYouPreHeated() { // Проверяем, соответствует ли температура требуемой, return false; } }
Жирным шрифтом выделен код, в большой мере, для такой же верификации, как и верификация в классе CurrencyTrader, которая проверяет параметр temperature на соответствие определенному значению. В то время как данная проверка полезна для конкретного теста, в большом плане она бесполезна. В его теперешнем виде тестовый код допускает единственное действительное значение параметра temperature— ю о . о ; любое другое значении сгенерирует исключение. Такое решение явно не является приемлемым. Данную проблему можно решить с помощью условных операторов. Условные операторы — это специальные ключевые слова, с помощью которых разработчик может определить, скомпилировался ли определенный фрагмент исходного кода. Далее приводится пример исходного кода, содержащего условные операторы: class TestCurrencyTrader : CurrencyTrader { public void InitializeExchangeRate() { ExchangeRate = 100.0; #if XNTEGRA.TE TESTS if (ExchangeRate != 100.0) { throw new Exception("100.0 verification failed"); } #endif } }
Условный оператор всегда начинается с символа #, за которым сразу же, без пробела, идет ключевое слово, в данном случае ключевое слово if. Код, заключенный
Глава 10
178
между операторами #if и #endif, компилируется при соблюдении определенного условия. Операторы этого типа называются директивами препроцессора (preprocessor directive). В данном примере условием компиляции кода, заключенного в блок #if/#endi£, является значение true идентификатора INTEGRATE_TESTS. Компиляционные идентификаторы, как INTEGRATE_TESTS, можно определить в исходном коде или в используемой вами интегрированной среде разработки. В Visual С# Express идентификатор INTEGRATE_TESTS можно определить следующим образом: 1. Щелкните правой кнопкой мыши название проекта и выберите пункт меню Properties.
2. Откройте вкладку Build. 3. В в е д и т е INTEGRATE_TESTS В т е к с т о в о е поле Conditional Compilation Symbols.
Верификация с использованием частичных классов Условная компиляция приходится кстати, когда, в зависимости от конфигурации, нужно включить или исключить код. Но некоторые программисты недолюбливают включать условно компилируемый код в функции, т. к. такой код очень трудно сопровождать. Другим решением будет применить ключевое слово partial class совместно с операторами условной компиляции. До сих пор во всех примерах при определении класса все его методы и другие элементы объявлялись в одном месте — между фигурными скобками кода класса. С помощью частичных классов разные элементы класса можно объявлять в разных местах. При компиляции отдельные фрагменты класса будут собраны в одно целое определение класса. Для нашего тестового кода мы можем создать частичный класс для реализации теста и условно компилируемую тестовую реализацию частичного класса. Далее приводится м о д и ф и ц и р о в а н н ы й код класса TestCurrencyTrader, который МОЖНО ИСПОЛЬ-
зовать для тестирования состояния без предоставления его внешнему коду: partial class TestCurrencyTrader : CurrencyTrader { public void InitializeExchangeRate() { ExchangeRate = 100.0; } } #if !XNTEGRATE„TESTS partial class TestCurrencyTrader : CurrencyTrader { public void VerifyExchangeRate(double value)
{
if (ExchangeRate != value) { throw new Exception("ExchangeRate verification failed"); } } } #endif
Основы
объектно-ориентированного
программирования
179
В данном объявлении класса ключевому слову class предшествует ключевое слово partial. Первая реализация класса Testcurrencyrrader демонстрирует пример, когда состояние не предоставляется внешнему коду. Вторая реализация класса TestcurrencyTrader, которая объявлена в контексте блока условной компиляции, содержит метод verifyExchangeRate (). Этот верификационный метод тестирует свойство ExchangeRate на наличие определенного значения. ПРИМЕЧАНИЕ Частичные классы можно применять только в контексте одной сборки. В данном контексте сборка означает скомпилированные фрагменты исходного кода .NET, рассмотренные в главе 1. Иными словами, если вы определите частичный класс в библиотеке класса, тогда все фрагменты данного частичного класса нужно определить в этой же библиотеке класса.
С помощью частичных классов можно с легкостью разбить функциональность на отдельные файлы исходного кода; таким образом, модификация, исходного кода в одном файле не затронет исходный код в другом файле. В данном примере демонстрируется использование частичных классов для манипулирования внутренним состоянием класса без нарушения при этом правила, запрещающего предоставление внутреннего состояния. Частичные классы также применяются в генераторах кода, где один файл исходного кода содержит специальный код, а другой содержит генератор кода.
Завершение создания базового класса Свойство ExchangeProperty является одной из разделяемых функциональностей. Другой разделяемой функциональностью, которую нам нужно реализовать, является обменный курс. Мы это сделаем с помощью методов convertvalue () и Convertvalueinverse (), которые преобразуют стоимость одной валюты в другую с помощью операции умножения. Завершенная реализация базового класса CurrencyTrader, содержащая эти два метода, будет выглядеть таким образом: public abstract class CurrencyTrader { private double _exchangeRate; protected double ExchangeRate { get { return _exchangeRate; } set { _exchangeRate = value; } } protected double Convertvalue(double input) { return _exchangeRate * input;
Код методов для преобразования одной валюты в другую выделен жирным шрифтом. Обратите внимание на отсутствие объявления валютных единиц. Это объясняется тем, что базовый класс является вспомогательным классом, который помогает нам реализовать модули коммерческого валютного маклера или обменный пункт отеля. ПРИМЕЧАНИЕ Даже когда функциональность базового класса кажется тривиальной, она определяется с целью обеспечения постоянства реализации. В результате отсутствия постоянства может возникнуть ситуация, когда одна реализация делает одно, а другая — чтото совсем иное.
На этом разработка тестового кода завершена. Далее мы перейдем к реализации компонентов коммерческого валютного маклера и обменного пункта отеля для приложения обмена валют.
Модули коммерческого валютного маклера и обменного пункта отеля Как уже упоминалось несколько раз ранее, приложение обмена валют CurrencyTrader состоит из модуля коммерческого валютного маклера и модуля обменного пункта отеля. В процессе реализации этих двух модулей мы сможем более детально изучить процесс использования наследования.
Реализация класса ActiveCurrencyTrader Класс ActiveCurrencyTrader реализует логику модуля коммерческого валютного маклера. Для начала, добавим к этому классу конструктор.
Добавление конструктора к ActiveCurrencyTrader Конструктор применяется, чтобы присвоить экземпляру класса ActiveCurrencyTrader определенное состояние по умолчанию. Будем считать этот экземпляр неизменяемым (immutable). Это означает, что присвоенные экземпляру данные впоследствии изменить нельзя. ПРИМЕЧАНИЕ Примером неизменяемого типа может служить строковый тип string. Значение, присвоенное переменной этого типа, в дальнейшем изменить нельзя. Ни один из методов строкового типа не применяется для модификации содержимого строковой переменной.
Основы
объектно-ориентированного
программирования
181
Неизменяемый тип полезен тем, что он позволяет реализовать объекты, которые после начальной установки не требуют к себе никакого внимания. Кроме этого, содержимое неизменяемого объекта не подвержено случайному изменению. В целом, неизменяемый тип является надежным и предсказуемым.
Код конструктора выглядит таким образом: public class ActiveCurrencyTrader : CurrencyTrader { string _fromCurrency; string _toCurrency; public ActiveCurrencyTrader(double currExchange, string fromCurrency, string toCurrency) { ExchangeRate = currExchange; _fromCurrency = fromCurrency; _toCurrency = toCurrency; } }
Конструктор имеет три параметра. Параметр currExchange представляет текущий обменный курс, параметр fromCurrency указывает исходную валюту (например, доллары США), а параметр toCurrency указывает конечную валюту (например, евро). Значения этих трех параметров присваиваются членам данных, вследствие чего только текущий обменный курс ExchangeRate присваивается базовому классу CurrencyTrader.
Определение информационных свойств только для чтения Строки, представляющие исходную и конечную валюты, служат исключительно информационным целям. Например, мы можем создать некоторое число валютных пар, чтобы маклер мог разобраться с валютами, представляемыми значениями переменных _fromCurrency и _toCurrency. Это означает, что значения названий валют
являются свойствами только для чтения и программируются следующим образом: public class ActiveCurrencyTrader : CurrencyTrader { string _fromCurrency; string _toCurrency; public ActiveCurrencyTrader(double currExchange, string fromCurrency, string toCurrency) { ExchangeRate = currExchange; _fromCurrency = fromCurrency; _toCurrency = toCurrency; } public string FromCurrency { get { 7 Зак. 555
Глава 10
182 return _fromCurrency;
> > public string ToCurrency { get { return _toCurrency;
> } }
Свойства именуются подобно соответствующим членам данных (FromCurrency, ToCurrency), чтобы мы с легкостью могли определить, что означает каждый фрагмент кода. Лично я применяю начальный символ подчеркивания в именах частных членов данных, но можно применять любую другую нотацию.
Добавление методов, выполняющих преобразование Последним шагом к завершению класса ActiveCurrencyTrader будет добавление функциональности преобразования стоимости одной валюты в стоимость в другой валюте. В классе ActiveCurrencyTrader используется точный курс обмена. Методы Convertvalue () И ConvertValuelnverse () и м е ю т о б л а с т ь ВИДИМОСТИ protected
и поэтому не предоставляются внешнему коду. Поэтому в классе ActiveCurrencyTrader необходимо определить два метода с областью видимости public, которые будут вызывать эти защищенные методы. Соответственно, модифицированный класс ActiveCurrencyTrader будет выглядеть таким образом (добавленные методы доступа к защищенным методам выделены жирным шрифтом): public class ActiveCurrencyTrader : CurrencyTrader { string _fromCurrency; string _toCurrency; public ActiveCurrencyTrader(double currExchange, string fromCurrency, string toCurrency) { ExchangeRate = currExchange; _£romCurrency = fromCurrency; _toCurrency = toCurrency; } public string FromCurrency { get { return _fromCurrency; } } public string ToCurrency {
Основы
объектно-ориентированного
программирования
183
get { return _toCurrency; } } public double ConvertTo(double value) { return ConvertValue(value); } public double ConvertFram(double value) { return ConvertValuelnverse(value); } }
Методы convertTo () и convertFromt) всего лишь служат обертками для защищенных методов ConvertValue () И ConvertValuelnverse О , соответственно, И сами ПО себе не выполняют никакой полезной работы. Но вспомните пример, когда, оплачивая наши покупки, мы не позволяем кассиру прямой доступ к нашим деньгам в бумажнике, а лишь непосредственный, вынимая их из бумажника сами и потом передавая кассиру. Поэтому, хотя с первого взгляда и кажется, что данные методы не делают ничего полезного, в действительности они служат в качестве барьера, подобного барьеру между вашим бумажником и кассиром. Кроме этого, они предоставляют нам определенную гибкость в работе с кодом. Например,
допустим,
ЧТО
методы
CurrencyTrader.ConvertValue()
И
CurrencyTrader.ConvertValuelnverse() б ы л и о б ъ я в л е н ы public, а не protected.
Тогда любой пользователь класса ActiveCurrencyTrader мог бы пользоваться функциональностью, предоставляемой классом CurrencyTrader. Далее допустим, что кто-то решил изменить функциональность методов ConvertValue () и ConvertValuelnverse(). Это бы породило проблемы, т. к. изменения в классе currencyTradder автоматически подразумевают изменения в классе ActiveCurrencyTrader. Определяя собственные методы, мы обеспечиваем возможность приспособиться к будущим потенциальным изменениям без необходимости модифицирования компонентов, вызывающих класс ActiveCurrencyTrader. На этом создание функциональности класса ActiveCurrencyTrader для коммерческого валютного маклера завершено, и можно приняться за класс HoteicurrencyTrader для обменного пункта отеля.
Реализация класса HotelCurrencyTrader Разница между классами HotelCurrencyTrader И ActiveCurrencyTrader СОСТОИТ в наличии значительного спрэда в первом.
Добавление конструктора к классу HotelCurrencyTrader Как И С классом ActiveCurrencyTrader, начнем создавать класс HotelCurrencyTrader С добавления конструктора. В конструктор HotelCurrencyTrader необходимо ДО-
Глава 10
184
бавить дополнительный параметр для спрэда. Далее приводится код конструктора HoteicurrencyTrader, включая информационные свойства: public class HotelCurrencyTrader : CurrencyTrader { string _fromCurrency; string _toCurrency; double _spread; public HotelCurrencyTrader(double currExchange, double spread, string fromCurrency, string toCurrency) { ExchangeRate = currExchange; _fromCurrency = fromCurrency; „toCurrency = toCurrency; } public string FromCurrency {
get { return _fromCurrency; } } public string ToCurrency { get { return _toCurrency; } } }
Дополнительный параметр spread в конструкторе HotelCurrencyTrader назначен
члену данных _spread и представляет вычисление, модифицирующее обменный курс.
Добавление в класс HotelCurrencyTrader методов для преобразования валют Вспомните, как в предыдущем разделе казалось, что методы convertTo ()
и
ConvertFrom () не ВЫПОЛНЯЮТ Никакой полезной работы. В классе HotelCurrencyTrader
эти методы будут выполнять полезную работу, а также продемонстрируют пользу от создания возможности непрямого предоставления данных. Сумма, выплачиваемая за валюту, зависит от обменного курса, а курс имеет спрэд, в случае с обменным пунктом отеля, весьма значительный. Как было показано ранее в главе, это означает, что при продаже вы никогда не получите за свою валюту столько, на сколько надеялись, а при покупке всегда заплатите больше, чем ожидали. Далее приводится исходный код методов ConvertTo О и ConvertFrom () для класса HotelCurrencyTrader: public class HotelCurrencyTrader : CurrencyTrader { string _fromCurrency;
Методы ConvertTo о и convertFrom () содержат дополнительную логику для до-
бавления или вьгчитания спрэда из обменного курса. Методы считывают текущий обменный курс, сохраняют его во временной переменной, вычисляют новый обменный курс с учетом спрэда, вьгчисляют сумму для выплаты, после чего восстанавливают обменный курс. Для выполнения требуемых вычислений В методах ConvertTo о И ConvertFrom ()
выполняется обмен значениями. Это абсолютно приемлемая практика, и вам придется прибегать к ней довольно часто в своей работе. Важным аспектом в этом
186
Глава 10
является ограничение, какие классы могут делать это. Так как свойство ExchangeRate имеет область видимости restricted, то лишь производные классы могут присваивать и изменять его значение. А этим подразумевается, что производный класс знает, что он делает с данными. Это верное предположение, из которого можно извлечь пользу. Вызывающий компонент не знает об этом обмене, т. к. в классе HoteicurrencyTrader применяется объектно-ориентированный способ для предотвращения доступа внешним кодом к состоянию типа. Ну, вот и все — мы создали наше приложение для обмена валют. В оставшемся материале главы будут рассмотрены некоторые важные дополнительные подробности.
Дополнительные сведения о директивах препроцессора, свойствах и абстрактных методах В рассмотрение примера этой главы не были включены некоторые подробности о директивах препроцессора, свойствах и ключевом слове abstract. Эти аспекты заслуживают отдельного рассмотрения, т. к. их необходимо знать при написании кода.
Директивы препроцессора Ранее в этой главе рассматривалось использование символа # и условных операторов для включения или исключения кода из компиляции. На техническом жаргоне это называется предварительной обработкой (preprocessing) кода, а операторы называются директивами препроцессора. В табл. 6.1 приведен список директив препроцессора. Таблица
6.1.
Директивы
препроцессора
Директива
Описание
#define
Применяется для определения идентификаторов компиляции, таких как идентификатор INTEGRATE_TESTS, использованный в примере этой главы. Определение выполняется в начале файла исходного кода, чтобы активировать условные операторы препроцессора, используемые во всем файле исходного кода. Область видимости директивы #def ine ограничена одним файлом исходного кода
#undef
Применяется для отмены определения идентификатора. Директива #undef применяется при необходимости изменить глобальную установку. Допустим, что идентификатор установлен глобально, но для определенного файла исходного кода необходимо поведение, как будто бы данный идентификатор не был установлен. В таком случае применяется директива #undef. Другим способом получить такое поведение можно с помощью символа ! перед идентификатором
Основы
объектно-ориентированного
187
программирования Таблица
6.1
(окончание)
Директива
Описание
#ifи #епdif
Используются для условного включения или исключения фрагмента исходного кода. Условные директивы применяются для определения разных конфигураций исходного кода, например, определения отладочной или промышленной сборки
#elif
Данная директива позволяет определить несколько условий для включения или исключения кода из компиляции. Директива #elif применяется для определения нескольких разных конфигураций исходного кода, например, для отладочной, промышленной и повышенной производительности сборок
#else
Определяет блока кода, который включается, если операторы #if не активируются
#region и #endregion
Данные директивы не имеют никакого отношения к компиляции исходного кода. Они применяются Visual Studio для создания областей исходного кода, которые можно "сворачивать" подобно сворачиванию дерева каталогов. Таким образом, можно временно скрыть блок кода. Постоянное прокручивание длинных файлов исходного кода не дает ничего в терминах производительности. Свернув код, мы можем уменьшить объем прокрутки или совсем устранить ее, а свернутый код можно развернуть в любое время
В следующем фрагменте кода демонстрируется использование директив препроцессора: #define ACTIVATE_1 #undef ACTIVATE_2 namespace TestDefine { class Example { #if ACTIVATE_1 int _dataMember; #elif
!N0_ACTIVATE_10
int _dataMember3; #else int _defaultValue; #endif } }
В общем, директивьг препроцессора применяются при создании отладочной или конечной версии кода. Отладочная версия означает, что код компилируется таким образом, что его можно отлаживать и анализировать с помощью так называемых декоративных символов. Конечная версия означает, что скомпилированный код также можно отлаживать, но декоративные символы в нем менее информативны.
Гпава 15
188 ПРИМЕЧАНИЕ
Visual С# Express не позволяет выбрать отладочную или конечную версию разрабатываемого кода. Для этой возможности необходима полная версия Visual Studio.
Декоративные символы можно сравнить с информационными дорожными знаками. При исполнении в отладочном режиме эти знаки предоставляют обширную информацию, типа: "Сейчас вызывается такой-то метод в таком-то файле исходного кода" или "Оба-на, этот код свойства в исходном файле не работает должным образом". В конечной версии декоративные символы предоставляют лишь краткую информацию, типа "До города X осталось у километров". Хотя эта информация и лучше чем ничего, тем не менее, она очень ограничена, например, она ничего не говорит нам о промежуточных населенных пунктах.
Область видимости В примерах этой главы область видимости блоков get и set свойств всегда была одинаковой. Но это не является обязательным, и эти блоки могут иметь разную область видимости. Разная область видимости устанавливается с целью разрешить реализацию логики, когда классам в цепочке наследования разрешается присваивать свойству значения, а классам вне этой цепочки разрешается только чтение свойства. Вот пример установки блока set свойства как protected, а блока get — как public: class PropertyScopeExample { int _value; public int Value { protected set { _value = value; } get { return _value; } } }
Ключевое слово abstract В примерах этой главы ключевое слово abstract использовалось для объявления класса, к которому можно обращаться, но из которого нельзя создавать экземпляры. Кроме этого назначения, с помощью ключевого слова abstract можно определять методы с отложенной реализацией. Целью такого объявления является позволить разработчику определить свое намерение в базовом классе, а реализовать в производном классе. В реализации классов HotelCurrencyTrader И ActiveCurrencyTrader были определены два метода: ConvertTo О И ConvertFrom*). Разработчик мог бы
Основы
объектно-ориентированного
программирования
189
решить, что эти классы содержат общие методы, которые можно было бы определить в базовом классе CurrencyTrader. Это неплохая идея. Вернемся к классу CurrencyTrader и добавим в него эти два метода как абстрактные (выделено жирным шрифтом): public abstract class CurrencyTrader { private double _exchangeRate; protected double ExchangeRate { get { return _exchangeRate; } set { _exchangeRate = value; } } protected double ConvertValue(double input) { return _exchangeRate * input; } protected double ConvertValuelnverse(double input) { return input / _exchangeRate; } public abstract double ConvertTo(double value); public abstract double ConvertFrom(double value); }
Класс, в котором объявляется абстрактный метод с отложенной реализацией, также необходимо объявить абстрактным. Любой класс, который наследуют от CurrencyTrader, должен реализовать методы ConvertToо И ConvertFrom(). В случае классов HotelCurrencyTrader И ActiveCurrencyTrader это не является проблемой, т. к. в них эти методы уже реализованы. Но их необходимо слегка изменить, как показано здесь: public override double ConvertTo(double value) { //
...
} public override double ConvertFrom(double value) { II...
}
Модификация методов заключается в добавлении в их объявление ключевого слова override, чтобы указать, что функциональность, реализованная в этих методах, подменяет функциональность класса CurrencyTrader. В то время как мы рассмотрели все технические аспекты абстрактных методов, остается открытым вопрос, зачем вообще нам нужна эта возможность. Вернемся
190
Гпава 15
к реализации класса ActiveCurrencyTrader, в котором абстрактные методы не реализуются. Чтобы использовать этот класс и метод ConvertToO, можно написать такой код: ActiveCurrencyTrader els = new ActiveCurrencyTrader(); double converted = els.ConvertTo(100.0);
Теперь представьте себе ситуацию, когда значения для преобразования даны в текстовом представлении. Для общей возможности обработки можно применить такой код: public double ConvertToTextField(ActiveCurrencyTrader els) { return els.ConvertTo(int.Parse(textl.Text)); }
Но реализация метода ConvertToTextField () содержит серьезную ошибку — в нем предполагается, что преобразование всегда будет выполняться с помощью класса ActiveCurenceTrader. Если бы мы захотели использовать класс HotelcurrencyTrader, то нам пришлось бы реализовать другой метод, с параметром типа HotelcurrencyTrader. Данная ситуация является классическим примером проблемы полиморфизма, для решения которой и применяются абстрактные методы. Посмотрите на модифицированный метод ConvertToTextField (): public double ConvertToTextField(CurrencyTrader els) { return els.ConvertTo(int.Parse(textl.Text)); }
В данной реализации метода ConvertToTextField () используется базовый класс CurrencyTrader, а Т. К. м е т о д ы ConvertToO И ConvertFrom() о б ъ я в л е н ы , ТО К НИМ
можно обращаться. Каким образом вызывается метод ConvertToTextField () и создается экземпляр класса HotelcurrencyTrader ИЛИ класса ActiveCurrencyTrader, В ЭТОЙ главе рас-
сматриваться не будет, т. к. полиморфизм обсуждается в следующей главе. Когда будете читать эту главу, в которой рассматриваются такие понятия, как компоненты и интерфейсы, то просто имейте в виду, что применением ключевого слова abstract достигается то же самое, что и с помощью этих концепций.
Советы разработчику В этой главе мы рассмотрели некоторые основньге понятия объектно-ориентированного программирования. Из этого материала рекомендуется запомнить следующие ключевые аспекты. • Код состоит из структурной функциональности (или функциональности базового класса) и архитектурной функциональности, связанной с определенной областью деятельности.
Основы
объектно-ориентированного
программирования
191
•
Функциональность базового класса направлена на решение определенной проблемы. Проблема может быть решена и общим способом, но это решение будет применимо только к данной проблеме. Функциональность базового класса требует от разработчика специфических знаний в данной области. Это можно рассматривать как реализацию калькулятора, при которой главной задачей разработчика является обеспечение правильности вычислений.
•
Архитектурная рабочая функциональность является функциональностью более высокого уровня, для реализации которой требуется общее знание области деятельности. Идея состоит в том, чтобы с помощью базовых классов решить проблему из определенной области деятельности. Это можно рассматривать как использование калькулятора, где вашей главной задачей является получить результаты вычислений.
П Свойства необходимо использовать, не предоставляя к их членам данных общего доступа. О Многие программисты относятся к свойствам отрицательно, т. к. они считают, что использование свойств способствует распространению плохих навыков программирования. Следует смотреть дальше такого отношения к свойствам и рассматривать их как механизм для предотвращения прямого доступа, аналогично тому, как вы предотвращаете прямой доступ кассиру к вашему бумажнику. Свойства имеют свое назначение и пользу. •
В общем, классы не должны представлять доступ к своему состоянию. Чтобы не представлять состояние, нужно создавать методы, которые реализуют общее предназначение класса.
D Наследование является фундаментальной частью С#, и необходимо знать, как пользоваться ею. Одним из способов реализации наследования является использование ключевого слова abstract. П Подмена (overriding) означает, что мы оставляем интерфейс без изменений, но модифицируем поведение. П Перегрузка (overloading) означает, что в производном классе определяется такой же идентификатор, как и идентификатор какого-либо базового класса. Перегруженный компонент может использоваться и работать по-другому. П Операторы условной компиляции применяются для включения или исключения конкретных блоков кода в определенные версии приложения. О Частичные классы применяются для обособления функциональности, имеющей специальное назначение. В примере данной главы частичные классы использовались для добавления тестового кода без нарушения запрета на предоставление состояния.
Гпава 15
192
Вопросы и задания для самопроверки Для закрепления пройденного материала, выполните следующие задания: 1. В коде методов ConvertToO И ConvertFrom() класса HotelCurrencyTrader содержится потенциально серьезная ошибка. Найдите эту ошибку и исправьте ее. 2. В примерах обменный курс устанавливается кодом, вызывающим классы HotelCurrencyTrader И ActiveCurrencyTrader. Реализуйте функциональность для динамического получения обменного курса. Подсказка: рассмотрите возможность преобразования ExchangeRate в свойство, обращающееся к абстрактному базовому классу, который можно использовать в качестве каталога обменных курсов для пар разных валют. 3. Свойство ExchangeRate имеет тип double. Реализуйте его, используя тип decimal.
4. Разработайте тестовый код ДЛЯ классов ActiveCurrencyTrader И HotelCurrencyTrader.
Глава 7
Компоненты и иерархии объектов
В предыдущей главе мы рассмотрели основы объектно-ориентированного программирования. Мы узнали, как наследуются классы, создавая при этом иерархию классов, посредством которой они могут разделять функциональность. В этой главе мы рассмотрим подробности иерархий классов, включая их расширение, с тем чтобы производные классы могли специальным образом адаптировать разделяемую функциональность. Для демонстрации этих концепций мы создадим приложение для вычисления налогов. Данный тип приложения является хорошим примером использования иерархий, т. к. общая идея уплаты налогов одинаковая для всех стран, но подробности реализации отличаются для разных стран. С технической точки зрения, мы рассмотрим следующие вопросы: •
интерфейсы, которые являются основой компонентов программного обеспечения;
•
подробности подмены (overriding) и Перегрузки (overloading) методов;
•
когда и как использовать фабрики.
Начнем с рассмотрения фундаментальных понятий налогообложения, имеющих отношение к нашему приложению.
Введение в основы налогообложения Независимо от того, в какой стране вы живете, в ней существует та или иная система налогообложения, по которой вы платите государству налоги на свои доходы (которые, конечно же, направляются на общее благосостояние). Налогооблагаемый доход — это весь полученный доход, на который нужно платить налоги. Налогооблагаемый доход не обязательно равняется сумме всех полученных вами доходов. Он может быть меньше, если применяются налоговые вычеты или зачисление некоторой суммы дохода откладывается на более поздний период налогообложения. В некоторых случаях отсрочка зачисления дохода может увеличить налогооблагаемый доход. Налоговые вычеты — это суммы, изымаемые из общего дохода. Некоторые люди ошибочно думают, что налоговые вычеты отнимаются от суммы налога, подлежащей
194
Гпава 15
уплате. Другое ошибочное мнение: налоговые вычеты всегда выгодны, что не совсем так. Допустим, что вам разрешается вроде бы большая налоговая скидка в $1000. Но если ваш общий доход равняется $2 ООО ООО, то такая скидка практически ничего не стоит, т. к. после нее ваш налогооблагаемый доход будет практически теми же двумя миллионами долларов. Если налог взимается только с части общего дохода, подлежащего налогообложению, или по заниженным тарифам, то имеет место частичное налогообложение. Часто частичному налогообложению по пониженным тарифам подвергается капитальная прибыль. Капитальная прибыль — это разница между покупной и продажной ценой, естественно, если только последняя выше первой. В противном случае это будет капитальный убыток. Разновидностью частичного налогообложения является дробление общего дохода с целью понижения ставки налогообложения. Допустим, что в семье из двух человек муж работает, зарабатывая, скажем, $100 000, а жена — домохозяйка. В таком случае семья в целом облагается налогом по высшей ставке, чем такая же семья, но где оба и муж и жена работают, зарабатывая по $50 000 каждый. Хоть в обоих случаях общий доход семьи одинаковый, первая семья платит больше налогов. Чтобы облегчить эту несправедливость, в некоторых странах разрешается часть заработка одного члена семьи переместить на другого, таким образом, перемещая его в категорию с более низкой ставкой налогообложения. При вычислении общего налога, подлежащего уплате, некоторые страны линейно повышают ставку налогообложения по мере повышения дохода. Разновидностью этого подхода является разбиение уровня дохода на возрастающие группы, для каждой из которых применятся более высокая ставка налогообложения. Таким образом, доходы до определенной суммы облагаются налогом по одной ставке, а сумма дохода выше данной верхней границы, но ниже другой верхней границы — по более высокой ставке. Такое прогрессивное повышение ставки налога для каждой более высокой группы дохода продолжается до достижения некой последней верхней границы, после чего любой доход выше данной границы облагается налогом по максимальной ставке.
Организация приложения для вычисления налогов Как правило, в каждой стране граждане должны платить налог на получаемый ими доход. Но налогооблагаемый доход можно понизить вычитанием из общего дохода определенных расходов. Налоговая ставка и разрешенные вычеты из общего дохода различные в разных странах. Это обстоятельство принимается во внимание в приложении для вычисления налогов, которое мы разработаем в этой главе. В данном приложении реализуются следующие возможности: •
определение налогооблагаемого дохода;
•
определение последовательности вычетов из общего дохода;
Компоненты
и
иерархии
объектов
195
• реализация движка вычисления налогов, принимающего во внимание число лиц в семье, имеющих заработок. Как и в предыдущих примерах, структура приложения для вычисления налогов будет состоять из двух проектов: тестового консольного приложения и библиотеки класса, содержащей требуемую функциональность. Решения библиотеки класса называется LibTax, а тестового приложения — TestTax. Но прежде чем приступить к разработке приложения, нам необходимо ознакомиться с концепциями интерфейсов и компонентов.
Программирование с использованием идей На данный момент у нас нет ни малейшего представления, с чего начинать разработку нашего приложения для вычисления налогов, т. к. мы не знаем подробностей реализации. В отличие от предыдущих примеров, где мы могли легко прикинуть, какие тесты были необходимы, в данном примере такую прикидку сделать не такто и просто. Можно было бы начать с реализации общих правил налогообложения, после чего применить их к конкретной налоговой системе. Но что если наши реализации общих налоговых правил окажутся неправильными? В таком случае написание кода для них будет просто потерей времени. Допустим, что вы написали набор базовых классов, представляющих общее ядро налогообложения, на основе информации, представленной в разд. "Введение в основы налогообложения"ранее в этой главе. Чтобы применить эти базовые классы, нужна конкретная задача, и вы ожидаете клиента с такой задачей. Вскоре к вам обращается такой клиент из Великобритании с заказом программы для вычисления налогов. Так как у вас уже имеется базовая заготовка программы, вы приятно удивляете клиента, сообщая ему, что он может получить свою программу намного быстрее, чем рассчитывал. В этом и заключается суть базовых классов — создав одну базовую заготовку программы, сэкономить время на разработке приложений под конкретные требования. Теоретически, без базовых классов, каждое приложение нужно было бы разрабатывать практически с нуля. Но опыт показывает, что если только базовые классы не разработаны на основе высокопрофессиональных анализов хозяйственной деятельности, то шансы, что они будут полезными, довольно незначительные. Скорее всего, чтобы заставить программу на основе базовых классов работать должным образом, их код придется модифицировать всяческими способами. Для разработки программы для клиента из другой страны весь процесс подгонки базовых классов под конкретное налоговое законодательство придется выполнять заново. Поставленные перед этим обстоятельством руководители начинают осознавать, что вложение денег и усилий в разработку базовых классов было не такой уж хорошей идеей. В чем же причина? Может быть, виноваты разработчики, которые создали "не те" базовые классы? Или же программа вычисления налогов очень сложная, и трудностей в ее реализации никак нельзя избежать? Ни то, ни другое. Настоящая причина
196
Гпава 15
провала базовых классов заключается в самой идее базовых классов. Дело в том, что вы захотели создать базовые классы для решения несуществующей проблемы. Это подобно попытке строить основы моста, не зная, где этот мост будет находиться, сколько людей будет им пользоваться, будет ли этот мост только для пешеходов или для автомобилей и т. п. В случае с мостами, их не начинают строить до тех пор, пока не выяснены все необходимые подробности. В области же разработки программного обеспечения мы постоянно видим проекты, которые фокусируются на разработку общей инфраструктуры, а не на решения конкретной проблемы. Все сказанное не следует воспринимать как утверждение, что базовые классы сами по себе или разработка общей инфраструктуры являются плохой идеей. К чему я веду, так это к тому, что для разработки полезных базовых классов необходимо иметь знания в области их применения. Если у вас таких знаний нет, то вам не следует писать базовые классы. Но каким образом можно получить опыт в области применения базовых классов, который позволил бы вам их разрабатывать? Можно начать с изложения своих идей в виде конструкций С#, а потом реализовывать эти идеи. Генерация идей с последующей их реализацией является частью процесса разработки, называющегося разработкой программного обеспечения, управляемого тестами (test-driven architecture). Процесс разработки программного обеспечения, управляемого тестами, начинается с определения требований и нахождения общего решения для них. В случае нашего приложения, требованием является создать движок для вычисления налога на доходы. В общих чертах, это означает, что нам нужно вычислить общий доход, отнять из него налоговые вычеты, а на оставшуюся сумму подсчитать налог по соответствующей налоговой ставке. В программировании на языке С# общие идеи воплощаются в исходный код с помощью интерфейсов.
Представление идей с помощью интерфейсов С# Интерфейсы С# можно рассматривать как программные конструктивные блоки, с помощью которых можно по-быстрому записать свои идеи. В процессе нахождения решения проблемы мы обычно записываем одну идею, на основе которой у нас рождается другая, из которой следует третья и т. д., пока мы не набросаем все идеи. Первая идея обычно является центральной. В нашем приложении для вычисления налогов центральной идеей является сам движок для вычисления налогов. Движок для вычисления налогов применяется для операции, отображенной в его названии, т. е. для вычисления налогов, и как связывающий компонент для всех других элементов программы. Эти другие элементы представляют собой некие неопределенные понятия, необходимые для завершения приложения вычисления налогов. Лично я предпочитаю называть эти прочие компоненты зависимостями (dependencies). Зависимость — это идея, требуемая для завершения предыдущей идеи.
Компоненты
и
иерархии
объектов
197
Сами идеи представляют собой план решения определенной проблемы или дают представление, каким это решение должно быть. Идея, воплощенная в исходный код, становится чертежом, который принуждает определенную форму реализации. Идеи, изложенные в исходном коде С#, называются интерфейсами; самостоятельно исполняться интерфейсы не могут. С точки зрения программирования, интерфейс сродни абстрактному базовому классу, в том, что к нему можно обращаться, но с него нельзя создать экземпляр. Чтобы идея заработала, создается ее реализация, или реализация интерфейса. Вот пример объявления интерфейса: interface IExample { }
В объявлении интерфейса ключевое слово interface ассоциируется с идентификатором IExample; с точки зрения синтаксиса, оно используется подобно ключевому слову class. Интерфейсу можно присвоить общую (public) область видимости. Интерфейс содержит методы и свойства, которые определяют поведение классов, реализующих интерфейс. Рассмотрим пример интерфейса, содержащего один метод и одно свойство: interface IExample { void Method(); int Property { get;
set;
}
}
Свойства и методы интерфейса определяются в фигурных скобках после идентификатора интерфейса. После самих методов и свойств фигурные скобки не применяются, т. к. мы создаем только сигнатуру метода или свойства, а их реализация будет выполнена классом. Опять же, данный процесс похож на определение абстрактного метода абстрактного базового класса. Вот простой пример реализации определенного интерфейса: class Examplelmplementation : IExample { public void Method() { } public int Property { get { } set { } } }
Здесь интерфейс IExample реализуется с помощью механизма наследования, подобно реализации абстрактного базового класса, рассмотренного в предыдущей главе. ПРИМЕЧАНИЕ Язык С# и среда CLR являются моделями единичного наследования, в том, что производный класс может иметь только один базовый класс. Но в С# класс может реали-
Гпава 15
198
зовать столько интерфейсов, сколько необходимо. Если класс наследует от другого класса или интерфейса, то первым идентификатором после двоеточия является имя наследуемого класса. Применение другого формата вызовет ошибку компилятора.
При реализации интерфейса в классе должны быть реализованы все его методы и свойства. В противном случае при компиляции исходного кода компилятор выдаст сообщения об отсутствующих методах или свойствах и будет считать реализацию незавершенной. Так как для реализации интерфейса применяется механизм наследования, то интерфейс можно рассматривать как базовый класс, не имеющий реализации. В следующем фрагменте кода иллюстрируется создание экземпляра реализации интерфейса и присвоение этого экземпляра переменной интерфейса: IExample els = new Examplelmplementation(); els.Method();
В данном примере создается экземпляр класса Examplelmplementation, который присваивается переменной els типа IExample. Данное создание экземпляра и его присвоение является понижающим приведением производного класса к базовому классу, или, более верно, к базовому типу. Понижающее приведение происходит, когда производный тип в цепочке наследования (например, Examplelmplementation) приводится к базовому классу в цепочке наследования (например, к IExample). Автоматическое понижающее приведение является возможным, потому что Examplelmplementation можно выразить, как тип IExample. Это как будто бы вы говорите: "Я создаю экземпляр класса Examplelmplementation и присваиваю ему тип IExample, находящийся в иерархии наследования". При вызове метода cls.Methodo, вызывающий код в действительности вызывает метод Examplelmplementation.Method(), ХОТЯ ОН И не знает об ЭТОМ, Т. К. ОН ИСПОЛЬЗует базовый ТИП IExample. В то время как механика определения интерфейса и его соответствующей реализации является довольно простой, вопрос заключается в том, зачем заниматься всем этим? Попробуем найти ответ на этот вопрос с помощью еще одной аналогии. Допустим, вы пришли в ресторан поужинать. Сидя за столом, вы ожидаете, что официант возьмет ваш заказ, принесет заказанные блюда, уберет пустую посуду, а в конце ужина принесет вам счет. Все эти действия являются идеями, применяющимися для управления рестораном. Вы пользуетесь этой идеей, как и миллионы других клиентов, т. к. эта идея применяется во всех ресторанах. Подумайте над сказанным немного. Идеей является официант, но ее реализацией являет человек. Усаживая вас за стол, ваш официант может представиться, но вы когда-либо обращались к официанту по имени? Большинство людей обычно так и обращается к нему — официант, т. к. они видят в нем не человека, а кого-то, кто обслуживает их. Суть заключается в том, человек — это всего лишь реализация, но нас он действительно не интересует как человек, а только как официант, выполняющий свои функции. Например, хотя вы можете посочувствовать, если у вашего официанта выдался плохой день, тем не менее, вы не хотите, чтобы это отразилось
Компоненты
и
иерархии
объектов
199
на вашем ужине. А еще более прямолинейно, то нам, скорее всего, было бы совсем безразлично, если бы любимую кошку официанта забрали шкуродеры. Вот это и есть суть интерфейсов и реализаций. Интерфейс определяет роль с задачами, которые реализации должна выполнять. Вас не интересует, может ли реализация делать что-либо другое, или если у нее "плохой день". Все, что вас интересует, — это только то, чтобы реализация делала то, что интерфейс ее обязывает делать. Что мы здесь имеем, так это развязывание идеи от интерфейса, точно так же, как и в ресторане, где нам безразлично, зовут ли нашего официанта Петей, Васей, Сашей или Галей в случае официантки. Более того, слишком ли вас волновало, если бы вместо человека вас обслуживал робот? Скорее всего, что нет, т. к. единственное, что вас заботит, — это хорошо поужинать. Это является важным аспектом интерфейсов и реализаций: реализации заменяемы, и мы можем заменить одну реализацию другой. При использовании интерфейсов и типов, реализующих их, мы пишем компонентно-ориентированное программное обеспечение. Компоненты и наследование являются двумя разными объектно-ориентированными технологиями. Наследование применяется для реализации интерфейсов, а компоненты — для воплощения идей.
Принципы работы наследования и компонентов Наследование заключается в определении базовых классов, обладающих функциональностью, которую можно или нельзя подменить или перегрузить, как было рассмотрено в предыдущей главе. Компоненты определяют подсистемы, которые складываются в одно целое подобно частям картинки-пазла. Идеей компонентов является предоставить возможность ассоциировать два экземпляра интерфейса и заставить их работать совместно, при этом каждый из них не знает, что делает другой. Чтобы почувствовать разницу между наследованием с использованием классов и компонентами с использованием интерфейсов и классов, мы рассмотрим классический пример наследования и преобразование этого примера в компоненты.
Иллюстрация наследования с помощью фигуры, прямоугольника и квадрата Одним из наиболее популярных примеров использования наследования является вычисление площади фигур. Начальной точкой данного наследования является абстрактный класс, имеющий одно свойство и один метод, чтобы указывать один размер и соответствующую площадь. Например, соответствующее определение абстрактного базового класса может выглядеть таким образом: abstract class Shape { public abstract double CalculateArea(); }
Гпава 15
200
Метод caicuiateArea () используется для вычисления площади фигуры. Он объявлен абстрактным и должен быть реализован в производном классе. Для начала, определим класс Square, который представляет квадратную фигуру: class Square : Shape { double _width; public double Width { get { return _width; } set { _width = value; } } public override double CaicuiateArea() { return Width * Width; }
> Квадрат имеет только один размер, width, который представляет размер стороны квадрата. В определении класса был определен метод CaicuiateAreaо, который вычисляет площадь квадрата, умножая свойство width на само себя. Так как прямоугольник является разновидностью квадрата, то класс Rectangle наследует от класса square: class Rectangle :
Square {
double _length; . public double Length { get { return _length; } set { _length = value; } } public new double CaicuiateArea() { return Width * _length; } }
Так как для описания прямоугольника размера только одной стороны недостаточно, то мы добавляем свойство Length. В реализации метода для вычисления площади Rectangle . CaicuiateArea () длина умножается на ширину.
Компоненты
и
иерархии
объектов
201
Посмотрите внимательно, как объявлен метод caicuiateArea (). В случае с методом Rectangle.caiculateAreaо было использовано ключевое СЛОВО new, а не override. Это было сделано с целью обеспечения однородности вычислений. Под однородностью вычислений имеется в виду, что при выполнении определенных вычислений с типом мы получаем ответ, ожидаемый от данного типа, а не от какого-либо другого типа. Предположим, что вы создали экземпляр типа Rectagle, с которым впоследствии выполнили понижающее приведение к типу square. При вызове метода CaiculateArea () вы хотите, чтобы он выполнял вычисления, как для квадрата, а не как для прямоугольника. Таким образом, использование ключевого слова new в методе Rectangle .CaiculateArea () обеспечивает, что площадь квадрата вычисляется способом для квадрата, а прямоугольника — способом для прямоугольника. Но не все так просто, как кажется. Допустим, что Rectangle приведен с понижением к shape. Так как при вызове метода CaiculateArea () объявляется наследование, то площадь вычисляется методом для квадрата, что является неправильным. Поэтому кажется, что вместо ключевого слова new нужно использовать ключевое слово override. Но если ЭТО решает проблему С методом Shape.CaiculateArea о, ТО при преобразовании прямоугольника в квадрат площадь представляет прямоугольник, а не квадрат. Таким образом, мы имеем ситуацию, для которой нет решения. Для иллюстрации этих различий рассмотрим следующий исходный код для вычисления площади фигуры Rectangle: Rectangle els = new Rectangle(); els.Width = 2 0 ; els.Length = 30; double area = els.CaiculateArea();
В данном примере создается экземпляр класса Rectangle и свойствам width и Length присваиваются значения 20 и 30 соответственно. При вызове метода CaiculateArea () вычисленное значение площади сохраняется в переменной area. Данный исходный код работает так, как от него ожидается. Он создает прямоугольник, присваивает размеры его сторонам и вычисляет его площадь. Но тип Rectangle может также быть типом square. Для этого первоначальный код нужно модифицировать, как выделено жирным шрифтом в следующем коде: Rectangle rectangle = new Rectangle(); rectangle.Width = 20; rectangle.Length = 30; Square square = rectangle; double area = square.CaiculateArea(); Console.WriteLine("Square Area is " + square.CaiculateArea() + " Rectangle Area is " + rectangle.CaiculateArea());
Здесь переменная rectangle имеет тип Rectangle. Сторонам прямоугольника присваиваются размеры, после чего прямоугольник преобразуется в квадрат и при-
202
Гпава 15
сваивается переменной square. Когда используется ключевое слово new, то площадь будет 400, что верно, т. к. квадрат имеет размер только для одной стороны, который равен 20. Теперь допустим, что мы использовали ключевое слово override и привели тип к Square. Значение width так и останется 20, но площадь уже будет 600. И если мы протестируем вычисление площади квадрата, то результаты этого тестирования будут отрицательными. Пример с классом shape демонстрирует, что даже если вы думаете, что квадрат подобен прямоугольнику, один из этих классов не может наследовать от другого, не вызывая какого-либо рода проблем. Поэтому shape должен быть реализован как интерфейс, а не как базовый класс. А класс Square является базовым классом класса Rectangle, но каждый из этих классов реализует интерфейс shape. Тогда у нас будет единообразное поведение. Решение выглядит таким образом: interface IShape { double CalculateArea(); } class Square : IShape { double _width; public double Width { set { width = value; } get { return _width; } } public double CalculateArea() { return _width * _width; } } class Rectangle : Square, IShape { double Height; public double Height { set { height = value; } get { return Height; } } public new double CalculateArea() {
Компоненты
и
иерархии
объектов
203
return Width * Height; } }
В результате данной модификации наследования с использованием как интерфейсов, так и классов поведение метода calculateArea () будет единообразным, независимо от того, к какому типу может привестись экземпляр Rectangle. Верификацию на отсутствие проблем с единообразным поведением можно выполнить с помощью следующего кода: Rectangle rectangle = new Rectangle(); rectangle.Height = 30; rectangle.Width = 20; Square square = rectangle; IShape shapeCastFromRectangle = rectangle; IShape shapeCastFromSquare = square; Console.WriteLine("Area Rectangle (" + rectangle.CalculateArea() + ") Area Square (" + square.CalculateArea()+ ") Area Cast From Rectangle (" + shapeCastFromRectangle.CalculateArea() + ") Area Cast From Square (" + shapeCastFromSquare. CalculateArea() + ")");
Разные приемы, использованные в этом фрагменте кода, объяснены в последующем материале данной главы. ПРИМЕЧАНИЕ Данный пример иллюстрирует, что, применяя наследование, можно выполнить понижающее приведение типа и получить требуемое поведение. Но это возможно, только если иерархия наследования спроектирована должным образом. Необходимо понимать, что поведение зависит от типа, полученного из дерева наследования. Если не соблюдать должную осторожность, то можно получить очень странные побочные эффекты. Язык С# позволят явно определить операции, выполняемые каждым методом, и следует очень внимательно обдумать, что должен делать каждый метод.
Иллюстрация использования компонентов с помощью фигуры, прямоугольника и квадрата Фигуру также можно реализовать с помощью компонентов. Использование компонентов означает определение идеи, которое следует определением реализации этой идеи. Разработка и реализация компонентов отличаются от разработки и реализации иерархий наследования. При наследовании необходимо принимать во внимание приведение типов, функциональность базовых классов, а также подмену и перегрузку методов и свойств. {Приведением типа называется преобразование одного типа в другой с применением или без применения явного оператора приведения типа.) При работе с компонентами необходимо мыслить концепциями и их реализацией в виде интерфейсов.
Гпава 15
204
Рассмотрев реализации shape, Rectangle и square, мы можем определить интерфейс ishape следующим образом: interface IShape { double CaicuiateArea(); double Width { get;
set; }
}
К этому объявлению можно даже добавить свойство Length, но, тем не менее, общая идея интерфейса ishape неверна. Думаем ли мы о фигуре в терминах ее длины и ширины? Пожалуй, нет. Скорее, мы представляем себе фигуру как комбинацию площади, периметра и других свойств, общих для всех фигур. А длина и ширина не являются определяющими для всех фигур. Например, круг определяется радиусом или диаметром, а треугольник— размером основания, высотой и смещением вершины треугольника. Суть состоит в том, что концепция фигуры не является концепцией прямоугольника или квадрата. Поэтому правильное определение концепций в виде интерфейсов будет выглядеть так: interface IShape { double CaicuiateArea(); } interface ISquare : IShape { double Width { get;
set; }
} interface IRectangle : IShape { double Width
{ get;
set; }
double Length { get;
set; }
}
Данное определение содержит три интерфейса: ishape, определяющий фигуру; IRectangle, определяющий прямоугольник; isquare, определяющий квадрат. Инт е р ф е й с ы IRectangle И ISquare ЯВЛЯЮТСЯ прОИЗВОДНЫМИ И н т е р ф е й с а IShape. Э т о
означает, что они также ishape. Интерфейсы isquare и IRectangle независимы друг от друга, т. к. они являются разными фигурами, хотя с виду и похожи (вообщето квадрат — это разновидность прямоугольника, но прямоугольник не обязательно квадрат). Эта разъединенность интерфейсов квадрата и прямоугольника демонстрирует, что при разработке интерфейсов необходимо фокусироваться на конкретном поведении интерфейса, а не на общем поведении. Общим поведением мы занимаемся при разработке классов. Моделирование реальных событий определяется в реализациях, как показано в следующем примере: class Squarelmpl : ISquare, IRectangle { } class Rectanglelmpl -. IRectangle { >
Компоненты
и
иерархии
объектов
205
Класс Squarelmpl реализует поведение интерфейсов ISquare И IRectangle, а также моделирует реальность, где квадрат также является и прямоугольником. А класс Rec tangle imp 1 реализует только поведение IRectangle, демонстрируя, что прямоугольник может быть только прямоугольником, но не квадратом. Теперь написание кода, в котором реализация выдает неоднородные результаты, является невозможным. Например, следующий код является невозможным: IRectangle rectangle = new Rectanglelmpl(); ISquare square = (ISquare)rectangle;
Но этот код также разрешается: IRectangle rectangle = new Squarelmpl(); ISquare square = (ISquare)rectangle; ПРИМЕЧАНИЕ При определении идей, получившиеся интерфейсы можно рассматривать как характеристики поведения, которыми может обладать реализация. Вполне допустимо, что реализация может иметь несколько характеристик поведения. Это означает, что, например, реализация может быть одновременно как квадратом, так и прямоугольником. В случае с рестораном, официанты — это люди, которые имеют чувства, желания, предпочтения и антипатии, но мы не знаем и не хотим знать об этих аспектах. Все, что нас интересует в данных людях — это их функциональность как официанта.
В качестве оптимизации было бы возможным следующее наследование интерфейса: interface ISquare : IShape { double Width { get; set; } } interface IRectangle : ISquare { double Length { get; set; } }
Но это не было бы очень хорошей идеей, т. к. на уровне интерфейсов мы подразумеваем, что квадрат и прямоугольник взаимосвязаны. Хотя они могут и не быть связаны на уровне реализации. Например, допустим, что мы создаем суперфигуру с характеристиками прямоугольника и треугольника. При создании взаимоотношения между интерфейсами мы подразумеваем, что, в зависимости от применяемого наследования интерфейса, суперфигура должна иметь характеристики квадрата,
206
Гпава 7
хотя она может и не иметь их. Таким образом, при использовании наследования с интерфейсами ishape в качестве базового интерфейса iRectangie является допустимым, но взаимосвязь между iRectangie и isquare — нет. Имейте в виду, что данное взаимоотношение может возникнуть при реализации и может быть извлечено с помощью приведения типов. Теперь, когда вы у вас имеется представление о различиях между применениями наследования и компонентов, мы можем приступить к созданию нашего приложения для вычисления налогов. В процессе работы над созданием этого приложения также будут предоставляться подробности о реализации интерфейсов.
Реализация движка для вычисления налогов На данном этапе мы рассмотрели базовые концепции налогообложения, возможности, которыми должно обладать наше приложение для вычисления налогов, а также теорию наследования, интерфейсов и компонентов. Теперь мы готовы реализовать движок для вычисления налогов. Предпочтительным подходом к выполнению данной задачи будет разработка основной идеи, после чего — создание других компонентов, или зависимостей, приложения.
Определение интерфейсов Сложив все вместе для начала создания движка для вычисления налогов, мы получим следующую структуру: public interface ITaxEngine { double CalculateTaxToPay(ITaxAccount account); ITaxDeduction CreateDeduction(double amount); ITaxIncome Createlncome(double amount); ITaxAccount CreateTaxAccount(); } public interface ITaxAccount { vo id AddDeduc t i on (I TaxDeduc t i on deduc t i on) ; void Addlncome(ITaxIncome income); double GetTaxRate(double income); ITaxDeduction[] Deductions { get; } ITaxIncome[] Income { get; } } public interface ITaxIncome { double RealAmount { get; } double TaxableAmount { get; } } public interface ITaxDeduction { double Amount { get; } }
Компоненты
и
иерархии
207
объектов
Структура содержит четыре интерфейса: ITaxIncome, ITaxDeduction, ITaxEngine И
ITaxAccount.
Интерфейсы
ITaxIncome
И
ITaxDeduction
ЯВЛЯЮТСЯ
ЧИСТО
поведенческими интерфейсами. Чисто поведенческий интерфейс выполняет одну задачу, но может быть реализован совместно с другими интерфейсами. Интерфейсы ITaxEngine и ITaxAccount являются поведенческо-функциональными интерфейсами. Поведенческо-функциональные интерфейсы обычно реализуются самостоятельно, а не совместно с другими интерфейсами. ПРИМЕЧАНИЕ По большому счету, типы определяются с применением подробных самоочевидных наименований. Лично я при определении классов применяю такие имена как TaxEngine и BaseTaxEngine. А при определении интерфейсов я начинаю наименование интерфейса С заглавной буквы I, например ICanadaEngine или ITaxEngine. Заглавная I в начале наименования интерфейса является общепринятой практикой. Чтобы ваш код согласовывался с другим кодом .NET, необходимо следовать этой практике.
Например, система для вычисления швейцарских налогов будет содержать два класса, определенных таким образом: class SwissTaxEngine : ITaxEngine { } class SwissTaxAccount : ITaxAccount { }
А в системе для вычисления американских налогов эти два класса будут определены следующим образом: class AmericanTaxEngine : ITaxEngine { } class AmericanTaxAccount : ITaxAccount { }
Пользователи как американской, так и швейцарской системы не будут знать конкретных деталей этих систем. Вначале пользователям нужно будет решить, какую систему они хотят использовать. Такое решение принимается с помощью так называемой фабрики. Более подробно этот аспект рассматривается в разд. "Абстрагирование создания экземпляров с помощью фабрик" далее в этой главе.
Реализация движка базового класса для вычисления налогов Определенные интерфейсы нужно реализовывать. В большинстве случаев для реализации создается абстрактный класс, представляющий определенный объем стандартной функциональности. Назначение данного абстрактного класса такое же, как было описано в предыдущей главе: предоставление определенного объема базовой функциональности. В случае движка для вычисления налогов нам необходимо реализовать интерфейс ITaxEngine и предоставить стандартные реализации для некоторых методов, а также реализовать некоторые абстрактные методы, которые производный класс должен
Гпава 15
208
реализовать в д р у г и х методах. П о л н ы й и с х о д н ы й код реализации б а з о в о г о класса в ы г л я д и т так: public abstract class BaseTaxEngine : ITaxEngine{ protected double _calculatedTaxable; public BaseTaxEngine() { } public virtual double CalculateTaxToPay(ITaxAccount account) { _calculatedTaxable = 0.0; foreach (ITaxIncome income in account.Income) { if (income != null) { _calculatedTaxable += income.TaxableAmount; } } foreach (ITaxDeduction deduction in account.Deductions) { if (deduction != null) { _calculatedTaxable -= deduction.Amount; } } return account.GetTaxRate(_calculatedTaxable) * _calculatedTaxable; } public virtual ITaxDeduction CreateDeduction(double amount) { return new TaxDeduction(amount); } public virtual ITaxIncome Createlncome(double amount) { return new Taxlncome(amount, 1.0); } public abstract ITaxAccount CreateTaxAccount(); } Б а з о в ы й класс д о л ж е н реализовать все методы интерфейса, независимо от того, реализован ли д а н н ы й метод или нет. К л а с с реализует методы calculateTaxToРау(), CreateDeduction() И Greatelncome(). Но ДЛЯ метода CreateTaxAccount() реализации нет, и он объявлен а б с т р а к т н ы м . В о б ъ я в л е н и я х р е а л и з о в а н н ы х метод о в имеется к л ю ч е в о е слово virtual. О н о указывает, что л ю б о й класс, производный от класса BaseTaxEngine, может подменить д а н н у ю ф у н к ц и о н а л ь н о с т ь . В реализации метода caiculateTaxToPayO суммируются все доходы (account. income), после чего из с у м м ы д о х о д о в в ы ч и т а ю т с я все н а л о г о в ы е вычеты (account. Deductions). Итоговая Сумма (account. GetTaxRate () ) ИСПОЛЬЗуеТСЯ ДЛЯ получения налоговой ставки, по которой вычисляется налог. ПРИМЕЧАНИЕ Метод CalculateTaxToPay () является разделяемой функциональностью, что подразумевает невозможность существования любого кода, специфического для производ-
Компоненты
и
иерархии
объектов
209
ного типа. Все вычисления и манипуляции данными исполняются по отношению к интерфейсу, что позволяет обобщить операции. При реализации методов базового класса или разделяемых компонентов кода следует сохранять производный исходный код независимым от класса.
Подмена специализированной функциональностью В реализации базового класса, член данных _calculatedTaxable объявлен как protected. Как мы узнали в предыдущей главе, это означает, что производный класс может манипулировать этим членом данных. Но если мы посмотрим, каким образом используется член данных, то увидим, что значение ему присваивается только методом calculateTaxToPay(). Назначением этого члена данных является предоставление информации об операции calcuiateTaxToPayO без точных подробностей данной операции. Целью члена данных _calculatedTaxable И объявления метода CalculateTaxToPay () является предоставить механизм, чтобы производному классу не требовалось повторять вычисления. Допустим, что если в какой-то стране налогооблагаемый доход превышает 400 денежных единиц, то взимается дополнительный налог в 10 денежных единиц. Мы не знаем размер нашего налогооблагаемого дохода до тех пор, пока не выполнится функция CalculateTaxToPay о, но и она возвращает только общую сумму налога, подлежащую уплате. Как же мы тогда можем знать, должны ли мы выплачивать дополнительный налог? Одним из решений было бы выполнить обратное вычисление налогооблагаемого дохода из подлежащих уплате налогов, но для этого потребовалось бы выполнить довольно много дополнительных операций. Более легким решение будет добавить в метод CalcuiateTaxToPayO базового класса код, который сохраняет налогооблагаемый доход для использования подклассом. Первоначальная реализация метода CalcuiateTaxToPayO не принимает во внимание добавочный налог, поэтому функциональность по его вычислению должна реализовываться в производном классе. Так как метод CalcuiateTaxToPayO может быть подменен С отсутствующим членом данных _calculatedTaxable, то производный класс должен будет реализовать эту функциональности базового класса для определения, взимать или нет дополнительный налог. Далее приводится пример реализации производного класса движка для вычисления налогов для такой ситуации. Чтобы отличить его от базовой функциональности, применяется пространство и м е н LibTax. Surtax. namespace LibTax.Surtax { internal class TaxEngine : BaseTaxEngine { public override double CalculateTaxToPay(ITaxAccount account) { double taxToPay = base.CalculateTaxToPay(account); if (_calculatedTaxable > 400) { taxToPay += 10;
Гпава 15
210
} return taxToPay; }
public override ITaxAccount CreateTaxAccount() { throw new Exception("The method or operation is not implemented."); } } } В реализации метода calculateTaxToPay () ключевое слово virtual было заменено ключевым словом override, что подразумевает, что функциональность производного класса TaxEngine подменяет функциональность базового класса BaseTaxEngine. Но если при вызове класса TaxEngine отсутствует реализация метода TaxEngine.CalculateTaxToPay(), то налог не вычисляется. Так как в нашей вымышленной стране базовые налоги вычисляются, как и в большинстве других стран, то МОЖНО использовать функциональность метода BaseTaxEngine.CalculateTaxToPay*). Таким образом, В первой строчке метода TaxEngine.CalculateTaxToPay() применяется метод base .CalculateTaxToPay О. Это означает, что вызывается метод CalculateTaxToPay () базового класса BaseTaxEngine. Вызовом метода базового класса и вычисляется сумма базового налога, подлежащего уплате. Нам необходимо определить, применим ли дополнительный налог, и для этого нам и пригодится защищенный член данных _calculatedTaxable. Вызов метода BaseTaxEngine.CalculateTaxToPay() присваивает члену Данных _caiculatedTaxable значение суммы, которая облагается налогом. Таким образом, метод TaxEngine. CalculateTaxToPay о может определить, превышает ли налогооблагаемый доход 400 денежных единиц. В случае если превышает, значение переменной taxToPay увеличивается на 10 денежных единиц. Если бы у нас не было члена данных _calculatedTaxable, то для того, чтобы узнать, применим ли дополнительный налог методу TaxEngine.CalculateTaxToPay(), нужно было бы узнать базовую налоговую ставку, вызвав для этого функциональность базового класса, после чего вычислить по этой ставке налогооблагаемую сумму. ПРИМЕЧАНИЕ Подмена методов применяется, когда нам требуется специальная функциональность. Это не означает, что будет вызвана функциональность базового класса. Это подразумевает, что мы можем вызвать базовую функциональность и выполнить какие-либо дополнительные операции. Поэтому при разработке функциональности базового класса важно отслеживать вычисления или операции, использующие защищенные члены данных. Использование членов данных предотвращает повторное исполнение операций производными классами, что замедляет исполнение приложения и позволяет избежать возможных ошибок.
ИСПОЛЬЗОВАНИЕ
ПРОСТРАНСТВ
ИМЕН
Пространства имен применяются для определения родственных блоков функциональности. Для реализации движков вычисления налогов нам нужно будет использовать пространства имен, т. к. для вычисления налогов для каждой страны нам потре-
Компоненты
и
иерархии
211
объектов
буется отдельный движок, и их всех нужно держать отдельно. Иногда можно даже создать специальную сборку для каждого набора реализаций интерфейса, но даже тогда необходимо создавать пространство имен. Вопрос создания пространства имен не зависит от создания или не создания отдельной сборки. В примерах я использую пространства имен типа LibTax. Surtax и LibTax. Canada. Эти пространства имен обычно создаются путем добавления папок с помощью команды меню A d d | New Folder в Solution Explorer (рис. 7.1).
Рис. 7.1. Команда для создания новой папки
В примерах исходного кода не используется ключевое слово using и предполагается, что необходимые пространства имен созданы в начале исходного кода. Посмотреть, как используются пространства имен, можно в исходном коде примеров.
Абстрагирование создания экземпляров с помощью фабрик Внимательно посмотрите на объявление области видимости движка для вычисления
налогов
и
сравните
его с
объявлением
области
видимости
в
интерфейсе
ITaxEngine. К а к М О Ж Н О в и д е т ь , о б л а с т ь в и д и м о с т и д в и ж к а ITaxEngine о б ъ я в л е н а public, a B a s e T a x E n g i n e — internal. С о г л а с н о с т р у к т у р е н а ш е г о п р о е к т а э т о о з н а ч а е т , ч т о л ю б ы м о б р а щ е н и я м к LibTax б у д у т в и д и м ы и н т е р ф е й с ы ITaxEngine и BaseTaxEngine, н о н е и н т е р ф е й с TaxEngine. Н а п р и м е р , с л е д у ю щ и й к о д б у д е т неправильным: ITaxEngine taxengine = new LibTax.Surtax.TaxEngine();
Гпава 15
212
Причиной этому является то обстоятельство, что если при объявлении типа не указана область видимости public, то данный тип является частным (private) для решения, в котором он объявлен. Вы, наверное, сейчас думаете: "Прекрасно! Объявили тип, из которого нельзя создавать экземпляры. Как же тогда использовать этот тип?" Тем не менее, данные области видимости были объявлены правильно и иллюстрируют шаблон разработки, называющийся фабрикой. Фабрика предоставляет способ абстрагировать создание экземпляра от вызывающего компонента, с тем, чтобы интерфейс мог отличаться от своей реализации. По аналогии с официантом это означает, что когда нам нужен официант, мы не хотим знать, как его зовут. Мы предпочитаем иметь общий механизм, когда ресторан предоставляет нам официанта. В противном случае, чтобы заказать ужин в ресторане, нам бы пришлось знать имя официанта, что было бы не совсем эффективно. Фабрика объявляется таким образом: namespace LibTax { public static class EngineCreator { public static ITaxEngine CreateSurtaxTaxEngine() { return new LibTax.Surtax.TaxEngine(); } } }
Обычно фабрика объявляется как статический метод (CreateSurtaxTaxEngine () ) класса. Чтобы предотвратить создание экземпляров фабрики, перед объявлением класса добавляется ключевое слово static. В реализации метода CreateSurtaxTaxEngine() создается экземпляр типа LibTax.Surtax.TaxEngine, который ПОТОМ приводится К типу интерфейса ITaxEngine. ПРИМЕЧАНИЕ Добавление ключевого слова static в объявление класса является хорошим способом предотвратить неумышленное создания экземпляра типа. С точки зрения проектирования ключевое слово static подобно ключевому слову abstract, т. к. они оба принуждают определенное использование.
Класс EngineCreator объявляется с областью видимости public. Это означает, что любой код, обращающийся к сборке, может видеть данный класс. Таким образом, тестовый код можно модифицировать следующим образом: ITaxEngine taxengine = EngineCreator.CreateSurtaxTaxEngine();
После вызова метода EngineCreator.CreateSurtaxTaxEngine() В тестовом коде имеется действительный экземпляр класса ITaxEngine. Очень важно осознавать, что тестовый код не имеет никакого представления о том, какой тип реализовал интерфейс. Это позволяет сборке изменять тип, к которому выполняется обращение В реализации метода CreateSurtaxTaxEngine о, не информируя об ЭТОМ КОД, вызывающий метод.
Компоненты
и
иерархии
объектов
213
Обращаясь к аналогии ресторана и официанта, это означает, что официанта можно заменить. Иными словами, если в вашем любимом ресторане постоянно обслуживающий вас официант Саша однажды по какой-либо причине не выйдет на работу, вас все рано обслужат, заменив его официанткой Машей. Для ресторана назначать конкретного официанта для конкретного клиента не было бы хорошей идеей.
Стандартные реализации В некоторых случаях в базовых классах нет необходимости. Иногда можно просто создать стандартную реализацию, которая может охватывать несколько подсистем. В случае с движком для вычисления налогов, доход есть доход, что в Канаде, что в Соединенных Штатах, что в Германии. Разница заключается в том, каким образом доход обрабатывается при вычислении налогов. Еще одним постоянным аспектом для всех стран является то обстоятельство, что капитальный доход облагается только частичным налогом. Для дохода можно создать реализацию, одинаковую для разных движков для вычисления налога: sealed class Taxlncome : ITaxIncome { double „amount; double „taxableRate; public Taxlncome(double amount, double taxableRate) { _amount = amount; _taxableRate = taxableRate; } public double RealAmount { get { return „amount; } } public double TaxableAmount { get { return „amount * „taxableRate; } } }
Интерфейс
iTaxincome имеет два свойства, которые реализуются в классе И TaxableAmount. Значения Э Т И Х двух С В О Й С Т В считаются доступными только для чтения и определяются конструктором класса Taxlncome. Назначением конструктора является присвоить эти два значения, после чего объект становится неизменяемым. Таким образом, изменить значения в интерфейсе Taxlncome: RealAmount
8 Зак. 555
Гпава 15
214
ITaxIncome можно, только создав новый экземпляр класса Taxincome. Хотя создание нового экземпляра каждый раз, когда нам нужно изменить значения свойств ReaiAmount и TaxabieAmount, может показаться дополнительной работой, данный подход имеет определенные преимущества в терминах производительности и управления ресурсами. В коде примера свойство TaxabieAmount является результатом умножения членов данных _amount и _taxableRate. Например, при вычислении налогооблагаемой части капитальных доходов в Канаде общая сумма умножается на 50%. Поэтому значение члена данных _taxabieRate будет о. 50. Следует также обратить внимание на то, что в объявление класса Taxincome область видимости указана не как public, а как sealed. Это означает, что из данного класса нельзя получать производные классы. В аспекте проектирования, это говорит, что класс Taxincome является разделяемым и не подлежит наследованию, чтобы не повлиять каким-либо образом на разделяемое поведение. Ключевое слово sealed может также применяться с методами; такие методы нельзя подменять или перегружать. Обычно ключевое слово sealed используется с методом, когда мы не хотим, чтобы производный класс изменял поведение метода. Реализация класса ITaxDeduction похожа на реализацию класса ITaxIncome: sealed class TaxDeduction : ITaxDeduction { double _amount; public TaxDeduction(double amount) { _amount = amount; } public double Amount { get { return _amount; } } }
Область видимости классов Taxincome И TaxDeduction объявлена как seladed, Т. К. их функциональность будет общей для всех реализаций. Но разумно ли предоставлять интерфейсы, а не сами классы? Интерфейсы применяются как средство для отделения реализаций от идей. Интерфейсы меняются очень редко, в то время как реализации могут меняться и в действительности меняются чаще. Но если реализация ведет себя подобно интерфейсу в терминах изменения сигнатуры интерфейса, то почему бы не предоставлять сам класс? Потому, что иногда мы будем предоставлять класс, а иногда— интерфейс. Для движка для вычисления налогов предоставление классов Taxincome И TaxDeduction, имеющих область ВИДИМОСТИ sealed, скорее всего, не оказалось бы проблемой. Практическим правилом в данном аспекте будет предоставлять классы только тогда, когда имеется уверенность в том, что сигнатуры интерфейсов методов и свойств не будут меняться слишком часто.
Компоненты
и
иерархии
объектов
215
Реализация базового налогового счета Интерфейс iTaxAccount также можно реализовать как функциональность базового класса. Соответствующий код будет выглядеть так: abstract class BaseTaxAccount : ITaxAccount { ITaxDeduction[] _deductions; ITaxIncome[] _incomes; public BaseTaxAccount() { _deductions = new ITaxDeduction[100]; _incomes = new ITaxIncome[100]; } public void AddDeduction(ITaxDeduction deduction) { for (int cl = 0; cl < 100; cl ++) { if (_deductions[cl] == null) { _deductions[cl] = deduction; break; } } } public void Addlncome(ITaxIncome income) { for (int cl = 0; cl < 100; cl ++) { if (_incomes[cl] == null) { _incomes[cl] = income; break; } } } public ITaxDeduction[] Deductions { get { return _deductions; } } public ITaxIncome[] Income { get { return _incomes; }
} •
public abstract double GetTaxRate(double income); }
Гпава15
216
Посмотрим, что мы сделали на данном этапе, и решим, предоставляет ли базовую функциональность наш движок для вычисления налогов. Итак: •
были определены идеи для всего движка для вычисления налогов;
•
были реализованы в виде базовых классов некоторые интерфейсы;
•
некоторые интерфейсы были реализованы в виде стандартных реализаций с областью видимости sealed.
Мы может считать движок для вычисления налогов завершенным, т. к. в аспекте базовой функциональности все интерфейсы были определены и приняты во внимание либо как базовые классы, либо как стандартные реализации. Это не означает, что интерфейсы всегда будут реализованы. Иногда некоторые интерфейсы не будут иметь базовой функциональности или базовых классов. При определении базовой функциональности важно помнить, что все интерфейсы должны иметь какое-либо назначение. Не определяйте интерфейс как заполнитель для функциональности, которую вы, возможно, планируете реализовать в будущем. Когда пользователь видит интерфейс, то ожидает, что тот выполняет какого-либо рода функцию. ПРИМЕЧАНИЕ Практическое правило для интерфейсов гласит, что определенные и запущенные в работу интерфейсы не меняются. Данное практическое правило почти что вырезано в камне. После того как интерфейс запущен в работу, он никогда не меняется, потому что это вызовет хаос — весь код, в котором применяется данный интерфейс, необходимо будет переписать. В общем, вместо интерфейса, который необходимо изменить, создается новый интерфейс.
Теперь, когда базовая функциональность готова, мы может приступить к реализации системы вычисления налогов для определенной страны.
Использование базовой функциональности движка для вычисления налогов Мы используем базовую функциональность движка для вычисления налогов в Канаде. Я выбрал канадскую налоговую систему, потому что знаю и понимаю ее; кроме этого, для нее имеется большой объем документации в Интернете. ВВОДНАЯ
ИНФОРМАЦИЯ
О
КАНАДСКИХ
НАЛОГАХ
Самое первое, что я могу сказать о канадской налоговой системе, — это то, что канадцы платят много налогов, слишком много налогов. Некоторым утешением этому является простота их вычисления, т. к. налогоплательщикам не предоставляется слишком много налоговых вычетов, что возвращает нас обратно к первому обстоят е л ь с т в у — канадцы платят слишком много налогов. (Налогоплательщики в других странах, скорее всего, придерживаются точно такого же мнения.) В Канаде налоги взимаются как на федеральном уровне, так и на уровне провинций. Кроме этого, канадские налоги меняются довольно существенно каждый год. Таким образом, для вычисления налогов требуется знать провинцию налогоплательщика и год,
Компоненты
и
иерархии
объектов
217
за который уплачиваются налоги. В аспекте реализации это означает, что налоговый движок должен знать о федеральных налогах, местных налогах и год, для которого вычисляются налоги. Налоговая ставка на капитальные доходы составляет 50%. Иными словами, если вы поручили 200 канадских долларов капитального дохода, то вам нужно платить налог только на 100 из них.
Реализация налогового движка и налогового счета Для реализации канадского налогового движка нужен класс, производный от класса BaseTaxEngine. Это означает, что необходимо реализовать метод CreateTaxAccount (). Кроме этого, нужно создать соответствующее пространство имен, называющееся, скажем, LibTax. Canada. Подробности пространства имен не показываются в коде, т. к. они указываются неявно. Реализация будет выглядеть таким образом: internal class TaxEngine : BaseTaxEngine { public override ITaxAccount CreateTaxAccount() { return new TaxAccount(); } }
В реализации метода CreateTaxAccount () создается экземпляр класса TaxAccount.
Это производный класс от класса BaseTaxAccount и поэтому реализует интерфейс ITaxAccount. Реализация класса TaxAccount выглядит таким образом: internal class TaxAccount : BaseTaxAccount { Province _province; int _year; public TaxAccount() { } public override double GetTaxRate(double income) { if (_year == 2007) { if („province == Province.Ontario) { return OntarioTax2007.TaxRate(income); } } throw new NotsupportedException("Year " + _year + " Province " + „province + " not supported"); } }
Метод GetTaxRate () возвращает соответствующую налоговую ставку для данной суммы налогооблагаемого дохода. Как уже упоминалось, в Канаде налоговая ставка зависит от провинции, в которой проживает налогоплательщик, и от года, за который
218
Гпава 15
уплачивается налог. Метод GetTaxRate о реализует возможность вычисления налогов за 2007 год для провинции Онтарио. Но здесь у нас имеется проблема с членами данных _province и _уеаг. Эти члены данных используются в вычислениях в методе GetTaxRate о, но им не присвоены значения.
Присваивание состояния, когда этого не может сделать интерфейс Проблема с канадским налоговым счетом является распространенной и встречается во многих ситуациях. Суть ее заключается в том, что необходимо назначить состояние, специфическое для реализации, не нарушая при этом первоначальное предназначение общего интерфейса. Для иллюстрации проблемы скажем, что метод GetTaxRate о будет содержать ссылку на провинцию и год. Соответствующим образом модифицированный интерфейс ITaxAccount будет выглядеть так: public interface ITaxAccount { void AddDeduction(ITaxDeduction deduction); void Addlncome(ITaxIncome income); double GetTaxRate(double income, Province province, int year); ITaxDeduction[] Deductions { get; } ITaxIncome[] Income { get; } }
Дополнительные параметры для вычисления канадских налогов выделены жирным шрифтом. Но хорошо ли такое решение? Нет, это особенно плохое решение. Данные параметры являются специфическими для реализации, в частности, для канадской реализации. Параметр year еще можно оправдать, т. к. во многих странах налоговая ставка и виды облагаемого налогом дохода зависят от конкретного года. Но для параметра province нет никаких оснований. Представьте себе, что вам нужно реализовать британскую налоговую систему, при этом указывая графство (аналог провинции), тогда как в Великобритании местные налоги не взимаются. Возможным решением данной проблемы может быть переопределение интерфейса следующим образом: public class Specifics { public Province CanadianProvince; public State AmericanState; } public interface ITaxAccount { void AddDeduction(ITaxDeduction deduction); void Addlncome(ITaxIncome income);
Компоненты
и
иерархии
объектов
219
double GetTaxRate{double income, int year, Specifics specifics); ITaxDeduction[] Deductions { get; } ITaxIncomeU Income { get; } }
В данной реализации применяется параметр specifics типа Specifics. Тип specifics определяет класс, содержащий разнообразную информацию, необходимую для определения правильной ставки налога. Но подход с применением класса specifics неправильный по следующим причинам: •
он должен знать реализацию, что в случае с наследованием является плохой идеей. Это подобно требованию, чтобы в ресторане обслуживающий нас официант был блондином;
•
даже если применение типа Specifics и было бы приемлемым, то в зависимости от количества реализуемых налоговых систем нам пришлось бы добавлять или убирать из него данные. Это плохая идея, т. к. возникают сложности в обслуживанием кода.
Таким образом, рассмотренные решения не являются приемлемыми. Кроме этого, у нас все еще остается проблема решения, какую налоговую ставку использовать.
Реализация идей с типом Specifics Чтобы реализовать решение, начнем с исправления класса TaxAccount. Модифицированная версия класса будет содержать определенный тип функциональности с членами данных, которые указывают год и провинцию. Исправленная реализация класса TaxAccount будет выглядеть так: internal class TaxAccount : BaseTaxAccount { Province „province; int „year; public TaxAccount(Province province, int year) { „province = province; year = year; } public override double GetTaxRate(double income) { if („year == 2007) { if („province == Province.Ontario) { return OntarioTax2007.TaxRate(income); } } throw new NotSupportedException("Year " + „year + " Province " + „province + " not supported"); } }
220
Гпава 15
Чтобы исправить класс, мы добавили конструктор, содержащий в качестве параметров провинцию и год. Это довольно распространенное решение проблемы, при котором мы не меняем интерфейсы, а просто меняем способ создания экземпляров реализаций. Помните, что когда мы создаем экземпляр конкретной реализации, то знаем, какая функциональность нам требуется, и поэтому можем предоставить дополнительные параметры. Когда мы находимся на уровне интерфейса, то должны обойтись использованием только общих идей. Теперь нам нужно исправить класс TaxEngine. Он является ответственным за создание экземпляра TaxAccount, поэтому, для того чтобы создать экземпляр класса TaxAccount для канадской налоговой системы, ему необходимы дополнительные параметры: internal class TaxEngine : BaseTaxEngine { public override ITaxAccount CreateTaxAccount() { return new TaxAccount(Province.Ontario, 2007); } }
В реализации метода GreateTaxAccount () предполагается, что налоги вычисляются за 2007 год для провинции Онтарио. Данное решение обходит стороной вопрос, каким образом вычислять налоги для налогоплательщика, например, из Британской Колумбии за 2008 год. Посмотрев на реализацию класса TaxEngine, мы увидим, что он довольно небольшого объема. Это наталкивает на мысль, что можно создать тип TaxEngine для каждой провинции для каждого года. Далее показаны два примера таких классов: internal class Ontario2007TaxEngine : BaseTaxEngine { public override ITaxAccount CreateTaxAccount() { return new TaxAccount(Province.Ontario, 2007); } } internal class BritishColumbia2008TaxEngine : BaseTaxEngine { public override ITaxAccount CreateTaxAccount() { return new TaxAccount(Province.BritishColumbia, 2008); } }
Это решение не такое и плохое, т. к. для того чтобы создать экземпляр необходимого налогового движка, нам нужно лишь определить фабрику, которая знает, экземпляр какого класса нужно создать. Но для данной проблемы такое решение будет чрезвычайно трудоемким, т. к. оно может вылиться в сотни, если не тысячи, определений класса TaxEngine. Решение такого типа, со специфическими реализациями, является приемлемым только в случаях с ограниченным числом вариантов, около 10—12. Лучшим подходом будет добавить интерфейс, специфичный для канадской налоговой системы. Работает такой подход следующим образом. При создании экземпляра
Компоненты
и
иерархии
объектов
221
налогового движка нам нужно будет знать, какую налоговую систему использовать. Фабрика избавляет нас от необходимости знать, экземпляр какого из типов нужно создать, но нет ничего плохого в предоставлении фабрике определенной дополнительной информации. Таким образом, правильным решением будет создать новый интерфейс, называющийся icanadaTaxEngine. Данный интерфейс добавляет методы фабрики для создания экземпляров типов с параметрами, специфичными для определенной реализации. Определение интерфейса icanadaTaxEngine выглядит таким образом: public enum Province { Alberta, BritishColumbia, Manitoba, NewBrunswick, Newfoundland Labrador, NovaScotia, Nunavut, Ontario, PrinceEdwardlsland, Quebec, Saskatchewan, Yukon } public interface IcanadaTaxEngine { ITaxAccount CreateTaxAccount(Province province, int year); ITaxIncome CreateCapitalGain(double amount); }
Определение icanadaTaxEngine содержит два дополнительных метода: •
метод CreateTaxAccount () создает экземпляр налогового счета для определенной провинции и года;
•
метод CreateCapitalGain () создает экземпляр ITaxIncome, пользуясь вычисле-
ниями для канадских капитальных доходов. Реализация TaxEngine становится следующей: internal class TaxEngine : BaseTaxEngine, IcanadaTaxEngine { public override ITaxAccount CreateTaxAccount() { return new TaxAccount(Province.Ontario, 2007); } public ITaxAccount CreateTaxAccount(Province province, int year) { return new TaxAccount(province, year); }
222
Гпава 15
public ITaxIncome CreateCapitalGain(double amount) { return new Taxlncame(amount, 0.50); } }
В модифицированной реализации класс TaxEngine также является производным от класса BaseTaxEngine, таким образом, удовлетворяя требованиям, предписывающим, чтобы это был налоговый движок общего назначения. А для дополнительных требований канадской налоговой системы мы реализуем интерфейс icanadaTaxEngine. Определение интерфейса для конкретной налоговой системы является нормальным, т. к. такой интерфейс не привязан к какой-либо определенной реализации. Чтобы лучше понимать данный метод реализации, конкретный интерфейс можно рассматривать как характеристику, которую может поддерживать реализация. Это следует из примера с фигурами, когда квадрат может поддерживать как интерфейс I Square, так И интерфейс IRectangle.
Применение налогового движка Последним шагом в создании налогового движка будет его применение. Далее приводится пример вычисления налогов за 2007 год для провинции Онтарио. ITaxEngine engine = EngineCreator.CreateCanadianTaxEngine(); ICanadaTaxEngine canadaEngine = engine as ICanadaTaxEngine; ITaxAccount account = canadaEngine.CreateTaxAccount(Province.Ontario, 2007); ITaxIncome income = engine.Createlncome(100); ITaxIncome capitalGain = canadaEngine.CreateCapitalGain(100); account.Addlncome(income); account.Addlncome(capitalGain); ITaxDeduction deduction = engine.CreateDeduction(20); account.AddDeduction(deduction); double taxToPay = engine.CalculateTaxToPay(account); Console.WriteLine("Tax to pay (" + taxToPay + ")");
Обратите внимание на определение переменных engine и canadaEngine. Это нормально, т. к. мы выбираем характеристику, которая может быть динамически запрошена.
Дополнительные сведения о наследовании и приведении типов В этой главе были представлены интерфейсы и компоненты, а также было продолжено рассмотрение наследования. В этом разделе мы обсудим дополнительные сведения о наследовании и преобразовании типов.
Компоненты
и
иерархии
объектов
223
Наследование В данном разделе мы рассмотрим в деталях механизм работы наследования в С#. Для этого будут предоставлены семь сценариев использования наследования. После каждого примера дается объяснение ключевых аспектов, демонстрируемых в данном сценарии. Демонстрация всех этих возможных сценариев должна дать вам хорошее представление о механизме работы наследования. ПРИМЕЧАНИЕ Во всех примерах для демонстрации наследования применяются методы, но такие же способы наследования можно применять с помощью свойств.
i Сценарий 7.1. Перегрузка функциональности базового класса class Base { public void Method() { Console.WriteLine("Base.Method"); } } class Derived : Base { public new void Method() { Console.WriteLine("Derived.Method"); } } class Test { public static void Run() { Derived derivedCls = new Derived(); Base baseCls = derivedCls; // Вызываем метод Derived.Method derivedCls.Method(); // Вызываем метод Base.Method baseCls.Method(); } }
В данном сценарии используется ключевое слово new, чтобы указать перегрузку метода. Перегрузка метода означает изменение его функциональности в производном классе. Вызов конкретного метода в наследовании зависит от типа объекта, в котором вызывается метод. Таким образом, для переменной типа Base вызывается метод Base.Method(), а ДЛЯ переменного типа Derived — метод Derived.Method().
Гпава 7
224 Сценарий 7.2. Перегрузка функциональности базового класса class Base { public virtual void Method() { Console.WriteLinef"Base.Method"); } } class Derived : Base { public override void Method() { Console.WriteLine("Derived.Method"); } } class Test { public static void Run() { Derived derivedCls = new Derivedf); Base baseCls = derivedCls; // Вызываем метод Derived.Method derivedCls.Method(); // Вызываем метод Derived.Method baseCls.Method(); } }
Ключевое слово virtual в базовом классе указывает, что поведение метода можно модифицировать в производном классе. Ключевое слово override в производных классах указывает метод с модифицированным поведением. Многократные уровни наследования требуют соответствующего многократного применения ключевого слова override. Перегрузка метода означает изменение функциональности базового класса на функциональность производного класса. В случае множественных уровней наследования применяется функциональность созданного экземпляра типа. Метод виртуального базового класса можно также объявить с помощью ключевого слова abstract. Разница между virtual и abstract состоит в том, что virtual имеет реализацию метода или свойства, в то время как abstract такой реализации не имеет. Сценарий 7.3. Реализация интерфейса interface Ilnterface { void Method(); }
Компоненты
и
иерархии
объектов
225
class Implementation : Ilnterface { public void Methodf) { Console.WriteLine("Implementation.Method"); } } class Test { public static void Run() { Implementation implementation = new Implementation(); Ilnterface inst = implementation; // Вызываем метод Implementation.Method implementation.Method(); // Вызываем метод Implementation.Method inst.Methodf); } } Рассматриваемые ключевые слова не используются. Поведение класса, реализующего интерфейс, подобно производному классу, реализ у ю щ е м у абстрактный метод. Н е з а в и с и м о от т о г о , о б р а щ а е м с я ли мы к э к з е м п л я р у и н т е р ф е й с а или к с а м о м у к л а с с у , в ы з ы в а е т с я м е т о д Implementation.Methodf). Сценарий 7.4. Реализация двух интерфейсов с одинаковым именем метода или свойства interface Ilnterfacel { void Method(); } interface Ilnterface2 { void Methodf); } class Implementation : Ilnterfacel, Ilnterface2 { void Ilnterfacel.Methodf) { Console.WriteLine("Implementation.Ilnterfacel.Method"); } void IInterface2.Methodf) { Console.WriteLine("Implementation.Ilnterfacel.Method"); } } class Test {
Гпава 15
226 public static void Run() { Implementation implementation = new Implementation(); Ilnterfacel instl = implementation; Ilnterfacel inst2 = implementation; // Метод implementation.Ilnterfacel.Method()нельзя вызывать. // Вызываем метод Implementation.Ilnterfacel.Method instl.Method(); // Вызываем Implementation.Ilnterface2.Method inst2.Method(); } }
В данном методе применяется специальная нотация для реализации определенного м е т о д а и н т е р ф е й с а ( н а п р и м е р , Ilnterfacel .Method() И IInterface2 .Method() ).
Специальная нотация состоит в указании идентификатора интерфейса перед именем метода. Перед методом нельзя указывать идентификатор области видимости, т. к. метод должен быть частным для данного класса. Методы нельзя вызывать как стандартные методы класса. Это можно делать, только выполнив приведение типа к соответствующему интерфейсу. Дополнительные подробности о приведении типов обсуждаются в следующем разделе. При реализации двух интерфейсов с одинаковым именем метода или свойства применение данной нотации не является обязательным. Можно использовать объявление метода MethodO, как в предыдущих примерах, за исключением того, что для каждого интерфейса вызывается один и тот же метод или свойство. Сценарий 7.5. Реализация интерфейса в производном классе interface Ilnterface { void Methodf); } class Baselmplementation { public void MethodO { }
Console.WriteLinef"Implementation.Method"); .
} class implementationDerived : Baselmplementation, Ilnterface { } class Test { public static void Run О { ImplementationDerived implementation = new ImplementationDerived!);
Компоненты
и
иерархии
объектов
227
Ilnterface inst = implementation; // Вызываем метод Implementation.Method implementation.Method(); I! Вызываем Implementation.Method inst.Methodf); } } Рассматриваемые ключевые слова не используются. При реализации интерфейса не обязательно, чтобы базовый класс являлся подклассом интерфейса. Базовый класс можно определить с соответствующими сигнатурами метода и свойства. При поиске соответствующих методов и свойств в производном классе компилятор С# просматривает всю иерархию наследования. Е с л и не с о б л ю д а т ь о с т о р о ж н о с т ь с п о д м е н о й и п е р е г р у з к о й м е т о д о в , то э т о т с п о соб может вызвать странные побочные эффекты. Сценарий 7,6. Реализация интерфейса, позволяющего подмену interface Ilnterface { void Method(); } class Implementation : Ilnterface { public virtual void Method() { Console.WriteLine("Implementation.Method"); } } class implementationDerived : Implementation { public override void Method() { Console.WriteLine("ImplementationDerived.Method"); } } class Test { public static void Run() { ImplementationDerived implementation = new ImplementationDerived(); Ilnterface inst = implementation; // Вызываем метод ImplementationDerived.Method implementation.Method(); // Вызываем метод ImplementationDerived.Method inst.Method(); } }
228
Гпава 7
В д а н н о м с ц е н а р и и п р и м е н я ю т с я к л ю ч е в ы е с л о в а virtual и override. По умолчанию реализация означает, что производный ях э т о я в л я е т с я а д е к в а т н ы м му нам н у ж н о и с п о л ь з о в а т ь
и н т е р ф е й с а без п р и м е н е н и я к л ю ч е в о г о с л о в а virtual класс может подменять только метод. Во многих случап о в е д е н и е м , т. к. мы х о т и м п е р е г р у з и т ь м е т о д и поэток л ю ч е в ы е с л о в а virtual и override.
Это распространенная проблема, и поведение перегрузки приводит большинство н а ч и н а ю щ и х п р о г р а м м и с т о в С# в з а м е ш а т е л ь с т в о . Сценарий 7.7. Дерево наследования с подменой и перегрузкой class Base { public virtual void Method() { Console.WriteLine("Base.Method"); } } class Derivedl : Base { public override void Method() { Console.WriteLine("Derivedl.Method"); } } class Derived2 : Derivedl { public new virtual void MethodO { Console.WriteLine("Derived2.Method"); } } class Derived3 : Derived2 { public new virtual void MethodO { Console.WriteLine("Derived3.Method"); } } class Test { public static void Run() { Derived3 derivedCls = new Derived3(); Base baseCls = derivedCls; Derived2 derived2cls = derivedCls; // Вызываем метод Derived3.Method derivedCls.Method(); // Вызываем метод Derived.Method baseCls.MethodO ; // Вызываем метод Derived3.Method derived2cls.Methodf); } }
Компоненты
и
иерархии
объектов
229
В этом сценарии применяются ключевые слова virtual, override и new. Данная иерархия наследования вызывает затруднения у большинства программистов С# и требует внимательного отношения. Представленная иерархия наследования указывает, что метод Derivedi .Method() подменяет метод Base.Method(). Метод Derived2.Method() перегружает метод Derivedi. Method (), при этом устанавливая новый подменяющий базовый метод. Метод Derived3 .Method () подменяет метод Derived2 .Method (), НО не метод Base .Method (), что очень важно иметь в виду. При работе со сложной иерархией наследования, подобной показанной в сценарии 7.7, важно начинать с базового класса и продвигаться вверх по иерархии.
Приведение типов Ранее в главе было рассмотрено несколько примеров приведения типов. В С# имеются два способа выполнения приведения типов: •
принудительное приведение типов, которое можно применять с обычными типами;
•
приведение типов, запрашивающее, возможно ли осуществление данной операции.
Рассмотрим следующую иерархию: class Base { public void Method() { Console.WriteLine("Base.Method"); } } class Derived : Base { public new void Method!) { Console.WriteLine("Derived.Method"); } }
Следующим шагом будет создание экземпляра типа Derived и приведение его типа к базовому типу: Derived derivedCls = new DerivedO; Base baseCls = derivedCls;
При приведении производного типа к типу базового класса явное приведение необязательно, и можно предполагать, что оно выполняется неявно. При приведении экземпляра базового класса к экземпляру производного класса требуется принудительное приведение. Далее представлен исходный код для принудительного приведения типа (предполагается иерархия наследования с предыдущего приведения). DerivedClass backToDerived = (DerivedClass)baseCls;
Гпава 15
230
Принудительное приведение находится с правой стороны знака равенства, при этом требуемый тип заключен в скобки. Приведение является принудительным, т. к. преобразование к указанному типу будет выполнено независимо от того, является ли это возможным или нет. Если преобразование невозможно, то выдается исключение приведения типа. Другим способом приведения типов является приведение по запросу, как показано в следующем коде (здесь также предполагается иерархия наследования, используемая в данном разделе): DerivedClass backToDerived = baseCls as DerivedClass;
Приведение выполняется с помощью ключевого слова as и указания желаемого целевого типа. В данном случае будет выполнена попытка приведения к указанному типу. В случае успешного приведения экземпляр типа присваивается переменной backToDerived. В случае же неуспешного завершения попытки приведения переменной backToDerived присваивается нулевое значение. При данном способе приведения типа, неуспешная попытка приведения не вызывает исключения. Этот способ приведения типов применим только для ссылочных типов.
Советы разработчику В этой главе мы рассмотрели интерфейсы и их реализации. Из представленного материала рекомендуется запомнить следующие ключевые аспекты. •
Механизм интерфейсов отличается от механизма наследования. Это два разных решения, хотя в механизме интерфейсов и применяется наследование.
•
На абстрактном уровне интерфейсы представляют идеи о желаемой работе приложения.
•
Выраженные в интерфейсах идеи должны быть общими и применимы к множественным реализациям приложения для данной области.
•
Идеи реализуются с помощью интерфейсов С#, которые в свою очередь реализуются с помощью классов или структур. Но следует обратить внимание на то, что интерфейсы являются ссылочными типами. Интерфейсы и реализации являются компонентами.
•
Для создания экземпляра реализации и возвращения объекта интерфейса применяются фабрики. Использование фабрики позволяет пользователю интерфейса не знать, для какой реализации необходимо создавать экземпляр.
•
Интерфейсы можно рассматривать как атрибуты, направленные на специфическую характеристику реализации. Но как было показано в предыдущей главе, интерфейсы не предоставляют внутреннее состояние или внутренний механизм реализаций.
•
Компоненты представляют собой базовый способ разработки кода. Они должны быть вашим основным способом разработки кода. До конца книги мы будем использовать интерфейсы при любом удобном случае. Старайтесь уловить и понять идею в основе интерфейса.
Компоненты
и
иерархии
объектов
231
Вопросы и задания для самопроверки Для закрепления рассмотренного в данной главе материала выполните следующие упражнения: 1. Реализуйте свою налоговую систему, используя предопределенные базовые классы. ПРИМЕЧАНИЕ По причине большого количества возможных налоговых систем решение для этого упражнения не предоставляется. Если вы хотите, чтобы я проверил ваше решение, можете отослать его мне электронной почтой по адресу [email protected].
2. Термин упаковка (boxing) обозначает автоматическое неявное преобразование значимого типа в ссылочный тип. Приведите пример упаковки. (Упаковка не рассматривалась в этой главе, но она рассматривается в главе 9. Обильный справочный материал на эту тему можно также с легкостью найти в Интернете.) 3. Добавьте к базовым классам налогового движка функциональность минимального дохода, не облагаемого налогом. Иными словами, если общий доход не превышает определенную сумму, то налоги с него не взимаются. 4. Реализуйте систему работы с фигурами, используя интерфейсы для четырех фигур: квадрата, прямоугольника, круга и треугольника.
Глава 8
Компонентно-ориентированная архитектура До настоящего времени мы изучали основы языка С#. Владея данными основами, вы можете писать функциональные приложения, использующие классы, объекты, интерфейсы и наследование. В данной главе мы рассмотрим метод программирования, который некоторые разработчики называют конструкционным. Конструкционное программирование применяется, когда нужно решить не рабочую проблему, а проблему, связанную с созданием приложения. Другой целью, преследуемой в данной главе, является помочь вам набраться опыта в разработке компонентно-ориентированного кода. В частности мы рассмотрим, как создавать ядро. Разработка ядра позволит нам увидеть мощь и гибкость компонентноориентированного подхода к разработке приложений. При таком подходе мы может создать полностью функциональную систему, даже если мы не знаем наперед все возможные реализации. Данный метод позволяет разбить процесс разработки на модули, в том смысле, что индивидуальные команды занимаются разработкой определенных интерфейсов. А когда все компоненты реализованы, они собираются вместе подобно частям мозаики. Конечно же, само применение интерфейсов и компонентов не является гарантией успеха, но таким образом мы добиваемся того, что одной команде не нужно ожидать, пока другая команда закончит работу над своей частью кода. Кроме этого мы рассмотрим две другие концепции программирования в С# — индексаторы и оператор yield. Индексаторы применяются для обращения к однородным полям объекта как к элементам массива. Ключевое слово yield используется совместно с ключевым словом foreach для обработки в цикле типов, которые, возможно, не поддерживают коллекции. Для демонстрации всех этих концепций мы создадим приложение для управления освещением в здании, применяя при этом подход программирования ядра.
Понятие ядра Допустим, что вам надоели ваши огромные счета за электричество, и вы хотите найти способ уменьшить потребление электричества. Одним из способов осуществления этого желания будет автоматизировать систему освещения в вашем доме,
234
Гпава 15
чтобы освещение было включено только тогда, когда оно нужно. Для этого вам потребуется контроллер и управляемые им устройства. Контроллер управляет устройствами, о которых он не знает наперед, посредством выполнения контракта. Контроллер, управляющий системой освещения, в программировании называется ядром, так он не знает наперед, освещение в каких комнатах он будет контролировать. Эти информация становится доступной ему только тогда, когда он фактически применяется для управления системой освещения. Подход на основе программирования ядра заключается в разработке базовой функциональности. Базовая функциональность не способна работать самостоятельно, т. к. она полагается на другие компоненты приложения. Это называется разработкой компонентов, которые используют интерфейсы и реализации. Компоненты реализуются на техническом уровне, используя интерфейсы и производные от них классы. Интерфейс представляет идею, а его производный класс реализует данную идею. Один класс может реализовать несколько интерфейсов, в то время как каждый интерфейс представляет уникальную характеристику класса. Идеи и интерфейсы также представляют контракты или стандарты. Ядро определяет стандарт, а компонент обязан реализовать данный стандарт. Процесс реализации ядра подобен работе тренера футбольной команды. Тренер продумывает, какого игрока поставить играть в какой позиции, а также разрабатывает стратегии, которые игроки должны реализовать. Но во время фактической игры игроки делают то, что они считают нужным или лучшим при данных обстоятельствах, а тренер обычно не в силах оказать на их поведение какого-либо существенного влияния. Тренер может обучать игроков, но применение полученных от тренера знаний зависит от каждого индивидуального игрока. Применительно к программированию, ядро является тренером, а манипулируемые внешние реализации — игроками. При разработке интерфейсов, которые будут реализованы другими функциональными компонентами, мы не можем наблюдать за разрабатывающими их программистами, с тем, чтобы они делали все так, как мы считаем нужным. Данная ситуация требует доверия, но нам также нужно реализовать такой режим программирования, при котором мы можем полагать, что программисты будут выполнять возложенные на них задачи должным образом. Это не имеет ничего общего с индивидуальными возможностями этих программистов. Я имею в виду, что мы должны быть уверенными в том, что даже если кто-либо из них и сделает какую-либо ошибку, наше ядро будет продолжать работать должным образом. Помните, что при реализации ядра мы реализуем контроллер и разрабатываем стратегию приложения, но не имеем отношения ко всем внешним реализациям. Если при разработке коммерческого приложения вас назначили ответственным за создание ядра, то считайте, что вам крупно повезло. Но не забывайте, что вместе с высоким доверием на вас также возлагается большая ответственность. Если ваше ядро плохо спроектировано или содержит ошибки, то внешние реализации также будут содержать ошибки и, возможно, будут плохо спроектированы. Ядро является одновременно и краеугольным камнем, и фундаментом всего приложения.
Компонентно-ориентированная
архитектура
235
Организация приложения управления освещением Пока представим, что мы не разрабатываем программное обеспечение, а строим дом, который собираемся оборудовать центрально-управляемой осветительной системой. Лампочки, светильники, котроллер, управляющий системой, и прочие компоненты системы — все от разных производителей. Несмотря на это обстоятельство, все эти компоненты, собранные в одну систему, работают друг с другом без проблем. Это возможно благодаря тому, что все производители различных осветительных компонентов придерживаются определенного стандарта. Стандартизацию в общем можно наблюдать во всем вокруг нас, а компоненты осветительной системы являются ее частным случаем. Применительно к программному обеспечению ядро представляет стандарт, позволяющий интеграцию компонентов. Приложение для управления освещением будет содержать следующие компоненты и возможности: •
комнату, в которой освещение можно контролировать либо с помощью простого выключателя, либо с помощью механизма плавной регулировки;
•
контроллер представляет здание, а комнаты в здании можно группировать, что упрощает управление освещением одновременно в нескольких комнатах;
•
комнатам присваиваются идентификаторы, что позволяет управлять освещением в индивидуальных комнатах;
•
комнаты можно ассоциировать с набором атрибутов, указывающих поведение, которое они поддерживают или не поддерживают.
В исходном коде контроллер будет реализован в виде проекта библиотеки, называющейся LibLightingSystem. В этом проекте библиотеки класса также определяются интерфейсы, которые будут реализованы компонентами. Для демонстрации создания завершенного рабочего приложения два других проекта реализуют интерфейсы и представляют компоненты Museum и ноте. Основной характеристикой системы управления освещения музея является наличие комнат, которые никогда не освещаются ночью, и поэтому освещение в них управляется исключительно контроллером. В других же комнатах музея освещение управляется индивидуально с помощью выключателя в каждой комнате, но также может управляться контроллером. А основной характеристикой системы управления освещением дома является индивидуальное управление освещением каждой комнаты, а также наличие датчика для автоматического управления. Некоторые элементы управления освещением дома зависят от определенных требований. Например, время включения ночников будет зависеть от времени года, или при отсутствии хозяев освещение в комнатах может включаться и выключаться автоматически, создавая, таким образом, видимость их присутствия. Как обычно, у нас будет тестовое приложение, называющее-
Гпава 15
236
ся TestLightingSystem, для проверки этих компонентов. Структура решения показана на рис. 8.1.
Рис. 8.1. Структура приложения управления освещением
Создание ядра Система управления освещением реализуется в два этапа. На первом этапе пишется исходный код, который работает должным образом. А на втором этапе в решение интегрируется код, написанный другими программистами. Здесь же выполняется проверка, что если данный код по каким-либо причинам не работает должным образом, то это не скажется на коде, разработанном нами. Разработка данного приложения усложняется тем обстоятельством, что нам приходится работать с неизвестными. В предыдущих примерах мы контролировали каждый класс, интерфейс и определение. На этот же раз у нас такой возможности полного контроля нет, и поэтому нам необходимо применить защитный стиль программирования. Это означает, что нам нужно разработать множественные тесты и держать определенную информацию частной.
Компонентно-ориентированная
архитектура
237
Определение интерфейсов Основной задачей контроллера освещения является управление освещением в любой комнате здания. Комнаты можно определить и организовать с помощью интерфейсов. Интерфейсов нам потребуется четыре. А именно: •
iRoom — интерфейс-заполнитель для концепции комнаты;
•
iNoRemotecontroiRoom— интерфейс для комнат, которые не должны управляться контроллером;
•
iRemotecontrolRoom— интерфейс для комнат, которые должны полностью управляться контроллером;
•
isensorRoom— интерфейс для комнат, в которых освещение управляется состоянием (т. е. присутствием или отсутствием в них людей).
Интерфейсы для комнат с управляемым освещением — IRemotecontrolRoom и isensorRoom— будут зависеть от определенных логических механизмов. Эти интерфейсы должны предоставлять входные данные и принимать выходные данные. Логический механизм также может получить другие данные, например время дня или уровень наружной освещенности. Все это сводится к определению некой логики, которая реализуется в ядре. Это является ключевым аспектом и подобно отношениям между родителями и детьми. В то время как родители воспринимают детей как разумных существ, способных принимать самостоятельные решения, в конечном счете, окончательное решение принимается родителями. Таким же образом, хотя ядро может принимать к рассмотрению ввод и предлагаемые решения, конечное решение оно принимает самостоятельно.
Интерфейс IRoom Для целей разработки, самой простой и базовой концепцией является комната, которую можно определить следующим образом (в библиотеке контроллера LibLightingSystem): public interface IRoom { }
Этот интерфейс не имеет никаких методов или свойств, и называется заполнителем. Заполняющий тип не имеет никакого другого назначения, кроме как для указания, что реализация относится к определенному типу. Применение интерфейсовзаполнителей упрощает группирование объектов с конкретными возможностями. Представьте определение объектов без помощи интерфейса-заполнителя. Например, следующим образом: class Typel { } class Туре2 { }
Из этих определений классов Typel и туре2 мы не видим никакой взаимосвязи между ними. Не имеется ничего, что позволило бы нам заключить, что Typel и туре2 имеют какие-либо общие атрибуты. (Ладно, технически классы взаимосвязаны в том, что оба являются производными класса o b j e c t . Но такая взаимосвязь сродни
238
Гпава 15
той, что каждый из людей является человеком.) Используя интерфейс-заполнитель, классы Typei и туре2 можно ассоциировать друг с другом таким образом: class Typel : IRoom { } class Type2 : IRoom { } IRoom[] rooms = new IRoom[10]; rooms[0] = new Typel(); rooms[1] = new Type2();
Реализуя интерфейс IRoom классами Typel и туре2 и при этом не делая ничего, кроме создания производных классов от IRoom, мы создаем взаимосвязь между классами Typel и туре2. Данная взаимосвязь заключается в том, что как класс Typei, так и класс туре2 являются комнатами. На данном этапе мы не знаем, какие комнаты они представляют, находятся ли эти комнаты в одном здании и т. п. Все, что мы знаем о них, — это лишь то, что они комнаты. При разработке ядра использование интерфейсов-заполнителей играет очень важную роль. Заполнитель указывает, что тип должен принадлежать группе. С помощью группирования ядро может определить список сходных элементов. Это что-то сродни запрашиванию информации о возрасте у претендента на получение водительских прав. Информация о возрасте не содержит никакой другой информации о человеке, например пол, умственные способности или сведения о его способностях к вождению машины. Это всего лишь заполнитель, который указывает, является ли данный человек членом группы, которая может получить водительское удостоверение. В случае с нашей системой управления освещением, определением интерфейсазаполнителя IRoom мы указываем, что любой экземпляр, ассоциированный с IRoom, сообщает нам о своем желании быть частью ядра контроллера управления освещением. Когда мы определяем тип с помощью интерфейса-заполнителя, то указываем, что данный тип можно использовать в конкретном контексте, который определяется данным интерфейсом-заполнителем.
Интерфейс INoRemoteControlRoom Хотя по идее система должна управлять освещением во всем доме, некоторые комнаты должны быть исключены из системы. Это могут быть частные комнаты или помещения, в которых освещением по каким-либо причинам нежелательно управлять с помощью контроллера. Возьмем, например спальню. Хотим ли мы, чтобы освещение в ней управлялось контроллером? Если освещение в спальне управляется автоматическим контроллером, то существует возможность, что он может выключить свет, когда человек читает. Или наоборот, контроллер может включить свет, когда человек решил немного вздремнуть. Конечно же, в обоих случаях человек может вручную исправить ошибку контроллера, но это будут лишние хлопоты, избавиться от которых и было первоначальной целью установки контроллера. Так как неудобство, причиняемое
Компонентно-ориентированная
архитектура
239
ошибками контроллера, превышает пользу, приносимую его правильными действиями, то контроллер не должен управлять освещением в данной комнате. Определение интерфейса (в библиотеке контроллера LibLightingSystem), указывающее, что освещение в данной комнате.не управляется контроллером, будет выглядеть таким образом: public interface INoRemoteControlRoom : IRoom { }
Так же как и интерфейс-заполнитель IRoom, интерфейс INoRemoteControlRoom не имеет ни методов, ни свойств. Но в данном случае методы и свойства отсутствуют, т. к. для ядра они не требуются. Интерфейс INoRemoteControlRoom служит для указания, что реализующий интерфейс тип является комнатой, но комнатой, освещение в которой не должно управляться контроллером. Используя спальню в качестве примера, реализация (определенная в проекте ноте) будет выглядеть таким образом: class Bedroom : INoRemoteControlRoom { }
Данное определение комнаты позволяет ядру использовать экземпляр комнаты следующим образом: IRoom[] rooms = new IRoom[10]; rooms[0] = new Bedroom(); if (rooms[0] is INoRemoteControlRoom) { // He делаем ничего и, возможно, изменяем направление исполнения кода. }
Данный код создает массив комнат и присваивает элементу 0 массива экземпляр класса Bedroom. В операторе if выполняется проверка, не содержит ли элемент 0 массива экземпляр типа INoRemoteControlRoom. ПРИМЕЧАНИЕ Использование интерфейсов-заполнителей и наследования формирует очень мощную архитектуру, позволяющую создавать группировки. Впоследствии отдельные экземпляры можно отфильтровать на основе дополнительных признаков в группе. Все это возможно благодаря ключевым словам as и is, которые позволяют опрашивать производные типы экземпляра. Данное опрашивание выполняется неинвазивно и не вызывает исключений. Опрашивание позволяет принимать решения в зависимости от того, требуется ли ассоциировать интерфейс с определенной группировкой на основе интерфейса.
Интерфейс IRemoteControlRoom Еще одним типом комнаты является комната, в которой освещение полностью управляется контроллером. Для таких комнат контроллер не рассчитывает на ввод с датчиков, а управляет освещением на основе логики, подходящий для определенной комнаты.
Гпава 15
240
Например, для выставочного зала музея для определенных периодов дня освещение не требуется. А после окончания рабочего дня и уборки освещение может быть выключено вообще. В начале же рабочего дня освещение включается автоматически в определенное время. Такая простая логика может быть полностью реализована контроллером. Интерфейс для комнат с освещением, полностью управляемым контроллером, определяется таким образом (в библиотеке LibLightController): public interface IRemotecontrolRoom : IRoom { double LightLevel { get; } void LightSwitch(bool lightState); void DimLight(double level); }
Единственным вводом, предоставляемым интерфейсом IRemotecontrolRoom, является информация о состоянии включенности освещения и его уровня. Данная информация предоставляется посредством свойства LightLevel. Это свойство доступно только для чтения (у него имеется лишь оператор get), т. к. в противном случае может произойти десинхронизация контроллера и уровня освещения. Например, допустим, что однажды уборщики задержались в выставочном зале после выключения света. Чтобы завершить уборку, они включают освещение вручную. Местное устройство может сделать одно из двух: позволить ручное управление освещением, не требуя одобрения контроллера, или же запретить это, требуя вмешательства контроллера. Лучшим подходом будет позволить локальное ручное управление и дать уборщикам включить свет. Свойство LightLevel необходимо для того, что контроллер мог удостовериться в том, что состояние освещения такое, каким он его ожидает. ПРИМЕЧАНИЕ При определении ядра иногда требуется добавить в интерфейс функциональность, которая проверяет состояние реализации. Так как ядро не контролирует реализацию, то оно не должно делать предположений о состоянии, т. к. оно может измениться по какой-либо причине. В случае с системой управления освещением такое изменение может быть вызвано включением света уборщиком после того, как он был выключен.
Методы LightSwitch () и DimLight () класса IRemotecontrolRoom включают И выключают освещение и устанавливают его уровень, соответственно. С помощью этих методов осуществляется контроль состояния реализации.
Интерфейс ISensorRoom Наконец, имеется еще один тип комнаты — освещение в которой управляется контроллером при определенных обстоятельствах. Возвратимся к нашему примеру с задержавшимися уборщиками, включившими вручную освещение, выключенное контроллером. Если контроллер обнаружит, что освещение включено после того, как он выключил его, должен ли он выключить его опять? Одним из ответов может
Компонентно-ориентированная
архитектура
241
быть, что контроллер должен выключить освещение опять. Но это будет неправильный ответ. Представьте себе ситуацию, когда уборщик включает освещение, а контроллер сразу же выключает его. Естественно, уборщик включает освещение опять, но и контроллер тоже знает свои обязанности и сразу же выключает его. Будучи мыслящим человеком, уборщик придумывает способ победить контроллер — закрепить выключатель во включенном положении, скажем, скотч-лентой. Это вызывает непрерывное включение и выключение освещения, но поскольку интервал между противоположными состояниями измеряется в миллисекундах, то практически освещение остается включенным. Хоть такой подход и даст желаемый практический результат, лучше было бы поставить на выключатель таймер, позволяющий освещению оставаться включенным на определенный период времени после ручного включения. Но и здесь есть своя проблема. Сколько времени должно освещение оставаться включенным? Еще одним способом будет не использование таймера, а улучшение интерфейса, чтобы позволить контроллеру узнать состояние освещения. Такой улучшенный интерфейс, называющийся isensorRoom, определяется в библиотеке класса LibLightcontroller: public interface ISensorRoom : IRemoteControlRoom { bool IsPersonlnRoom { get; }
}
Интерфейс isensorRoom имеет одно булево свойство IsPersonlnRoom. Значение true данного свойства означает, что в комнате присутствуют люди; в противном случае — значение false — комната свободна от людей. Каким образом реализация определяет наличие или отсутствие людей в комнате, что не является проблемой ядра, т. к. оно предполагает, что реализация знает, каким образом узнать это. ПРИМЕЧАНИЕ Как правило, ядро может взаимодействовать с реализацией только через интерфейс. Ядро никогда не должно предполагать определенную реализацию интерфейса. Оно должно применять подход, при котором оно получает то, что видит. Таким образом, если ядру требуется дополнительная информация, необходимо спроектировать расширение интерфейса или реализовать новый интерфейс. Конечно же, это не означает, что необходимо расширять интерфейс для каждого возможного состояния. Иногда необходимо определить конкретный интерфейс (iCanadianTaxEngine), как в примере с налоговым приложением в предыдущей главе.
Теперь, когда у нас имеются все готовые интерфейсы, можно приступить к реализации ядра.
Реализация ядра В данном примере ядро будет реализовано в виде простого класса, содержащего всю функциональность контроллера. Это означает, что отдельные реализации, тестирование и приложения будут взаимодействовать с одним классом.
242
Гпава 15
Далее приводится пример реализации метода DimLights для плавного понижения уровня освещения С ПОМОЩЬЮ класса LightingController: public class LightingController { public void DimLights(object grouping, double level) { } }
Данный метод применяется таким образом: LightingController controller = new LightingController(); object grouping = null; controller.DimLights(grouping, 0.50);
Код пользователя явно создает экземпляр класса LightingController и также явно использует метод DimLights (). Но явное использование класса не позволяет изменять код контроллера, не затрагивая при этом пользователей, т. к. существует тесная связь между пользовательским кодом и ядром. Вот почему все это время я пространно доказывал важность использования интерфейсов, концепций и реализаций. Но в данной ситуации с контроллером абсолютно ничего из этой теории не применяется на практике. Причина для использования класса вытекает из примера в предыдущей главе и интерфейсов ITaxDeduction и ITaxIncome. В данном примере для каждого интерфейса была только одна реализация, и ни одну из этих реализаций не намечалось изменять. Как было объяснено в предыдущей главе, интерфейсы можно было бы представить в виде классов. Эта же логика применима и к контроллеру. Контроллер, в аспекте сигнатуры методов и свойств, не изменится особо и будет реализован только в одном экземпляре. Таким образом, интерфейс не является обязательным, и использование класса является вполне приемлемым подходом, который и применяется в данной главе. Но в некоторых ситуациях может быть желательным реализовать ядро в виде интерфейса, а не класса. Этот аспект рассматривается в разд. "Определение ядра в виде интерфейса, а не класса" далее в этой главе. Контроллер представляет здание, чьи комнаты можно организовать в группы. На основе таких групп контроллер может выполнять такие операции, как включение и выключение освещения или установка определенного уровня освещения. При выполнении каждой из подобных операций контроллер должен принимать во внимание требования к освещению каждой комнаты, для чего он опрашивает определенный интерфейс, как было описано в предыдущем разделе. На контроллер возлагается две основные обязанности: вызывать соответствующие методы интерфейса и организовывать экземпляры интерфейса. Для организации экземпляров применяются коллекции, массивы или связанные списки. В данном примере мы используем связанный список.
Компонентно-ориентированная
архитектура
243
Сохранение коллекции с помощью связанного списка В примерах в предыдущих главах коллекции объектов создавались с помощью массива, как показано в следующем примере: МуТуреП array = new MyType[10]; array[0] = new MyType(); array[2] = new MyType();
В этом коде создается массив, который может содержать самое большее 10 элементов (мутуре [ ю ]). Если нам потребуется сохранить большее число элементов, скажем 20, то нужно будет создать новый массив требуемого размера и скопировать содержимое старого массива в новый. Одной из особенностей массива является то, что значения его элементам присваиваются не обязательно в последовательном порядке. В данном примере значения были присвоены первому и третьему элементам массива, оставив нулевым значение второго элемента. Таким образом, код, который будет в цикле обрабатывать элементы массива, должен проверять их на нулевое значение. Структура, созданная предыдущим кодом, показана на рис. 8.2.
Рис. 8.2. Массив ссылочных элементов
На рис. 8.2 показан очень важный аспект ссылочных типов: элемент массива содержит ссылку на объект, а не сам объект. Если бы массив был обычного типа, тогда каждый из его элементов содержал бы весь объект, а не только ссылку на него. Массив с такой же легкостью можно было бы создать в виде объекта, содержащего несколько переменных, как показано в следующем коде: class MyTypeArray { public MyType Elementl; public MyType Element2; }
Гпава 15
244
Так как элементы массива являются набором хранящихся в типе ссылок, этим обстоятельством можно воспользоваться, чтобы создать тип, единственным назначением которого является предоставление ссылок на список элементов, обычно называемый связанным списком. В связанном списке отдельные объекты связаны друг с другом и указывают на другой близлежащий элемент. Элемент двунаправленного связанного списка содержит ссылки только на два других объекта: следующий и предыдущий. (Элемент однонаправленного связанного списка указывает только на один другой объект — на следующий.) В двунаправленном связанном списке тип всегда будет иметь два члена данных: Next и prev. Каждый из этих членов данных указывает на другой элемент списка (рис. 8.3). Для последовательной обработки элементов списка мы начинаем с левой или с правой стороны и переходим к члену данных Next или prev, соответственно. Далее приводится пример кода для такой обработки: МуТуре curr = GetHeadOfList() ; while (curr != null) { // Выполняется какая-либо операция с curr. curr = curr.Next; }
Рис. 8.3. Структура двунаправленного связанного списка
Как видим, в связанные списки можно с легкостью добавлять новые элементы. Но они имеют и недостаток— нахождение конкретного объекта является очень трудоемким процессом, требующим последовательного просмотра всех элементов списка до тех пор, пока не будет найден нужный. ПРИМЕЧАНИЕ В большинстве случаев используется класс List, но также существует класс LinkedList. Дополнительную информацию О классе System.Collection.Generic .LinkedList для версии .NET можно найти в документации MSDN 3.0.
Для ядра нашего приложения мы используем двунаправленный список, чтобы связать комнаты в набор групп.
Компонентно-ориентированная
архитектура
245
Создание связанного списка Можно создать отдельный код для каждого из членов данных Next и prev двунаправленного списка, но более эффективным подходом будет определить базовый класс. Начальная структура класса BaseLinkedList (определяемого в библиотеке LibLightingSystem) ВЫГЛЯДИТ ТЭКИМ о б р а з о м : public abstract class BaseLinkedList { private BaseLinkedList _next; private BaseLinkedList _prev; public BaseLinkedList Next { get { return _next; } } public BaseLinkedList Prev { get { return _prev; } } }
Базовый класс BaseLinkedList объявляется абстрактным, чтобы указать, что использование данного класса подразумевает создание производных от него классов. Члены данных Prev и Next являются свойствами, которые могут только считывать значения частных членов данных _prev и _next.
Добавление и удаление элементов связанных списков Написание кода для вставки и удаления объектов связанного списка требует особой внимательности, чтобы выполняемые в нем операции не повредили список. Эту задачу не следует делегировать пользователям связанного списка, т. к. они могут непреднамеренно внести в него искажения. Далее приводится код для вставки и удаления объектов связанного списка. Данный код является частью класса BaseLinkedList. public void Insert(BaseLinkedList item) { item._next = _next; item._prev = this; if (_next != null) { _next._prev = item; } _next = item; } public void Removed { 9 Зак. 555
Метод insert () предполагает, что объекты вставляются в список, содержащий хотя бы один элемент. Для применения метода insert () требуется, по крайней мере, следующий код: BaseLinkedList singleElement = GetHeadOfList(); BaseLinkedList anotherElement = CreateListElement(); singleElement.Insert(anotherElement);
При добавлении нового элемента первым делом членам данных _next и _prev объекта (т. е. элемента), добавляемого в список, присваиваются значения. ПРИМЕЧАНИЕ Обратите внимание на то, как в методе insert () можно присвоить частные члены данных другого экземпляра. Как мы знаем, частная (private) область видимости означает, что только данный объявленный тип может обращаться к частным свойствам и методам. В данном случае это правило не нарушается, т. к оно подразумевает, что тип может обращаться к частным членам данных и частным методам других экземпляров данного типа.
После того как членам данных элемента были присвоены значения, элемент вставляется в список. Для этого перенаправляется свойство _prev следующего элемента (если его значение не равно null), после чего свойству _next текущего элемента присваивается вставляемый объект. Метод Remove () выполняет действия, обратные действиям метода insert (). Сначала перенаправляются свойства _next и _prev предыдущего и следующего объектов (если их значения не равны null). После этого членам данных _next и _prev удаляемого элемента присваивается значение null. ПРИМЕЧАНИЕ Объявление членов данных Prev и Next только для чтения является общепринятой практикой. Но для присваивания им значений необходимо применять методы. Применение свойств только для чтения является одним из способов предотвратить искажение внутреннего состояния в случаях, когда к нему необходимо предоставлять доступ.
Тестирование связанного списка Базовый класс BaseLinkedList применяется для предоставления вспомогательных сервисов. Поэтому данный класс можно объявлять в ядре или в сборке определений. Так как это базовый класс, то его необходимо подвергнуть всестороннему
Компонентно-ориентированная
архитектура
247
тестированию, чтобы удостовериться в том, что он не содержит никаких ошибок. В этом разделе мы рассмотрим один тест, который демонстрирует, что и как нужно тестировать в базовом классе. Так как класс BaseLinkedList объявлен абстрактным, для него требуется реализация. Целью реализации является предоставить нам достаточно информации о состоянии и контексте объекта. В таком случае нам нужно определить объект, который сможет протестировать каждую составляющую класса BaseLinkedList. Тестовый класс можно сравнить с манекеном для тестирования автомобилей на прочность при авариях, с прикрепленными к нему кучей датчиков и идущих от них проводников. Далее приводится простая реализация класса в проекте TestLightingSystem. Не забудьте вставить ссылку на LibLightingSystem (щелкни-
те правой кнопкой по пункту References в проекте TestLightingSystem и выберите последовательность команд Add Reference | Projects | LibLightingSystem). using LibLightingSystem; namespace TestLightingSystem { class Linkedltem : BaseLinkedList { private string _identifier; public Linkedltem(string identifier) { _identifier = identifier; } public string Identifier { get { return _identifier; } } public override string ToStringO { string buffer; buffer = "this(" + ..identifier + ")"; if (Next != null) { buffer += " next(" + ((Linkedltem)Next).Identifier + ")"; } else { buffer += " next(null)"; } if (Prev != null) { buffer += " prev(" + ((Linkedltem)Prev).Identifier + ")"; } else {
В классе Linkedltem объявлен ТОЛЬКО ОДИН член данных, ..identifier, который используется для идентификации экземпляра. Тестовый код вызывает методы insert () и Remove (), после чего генерирует наглядное представление связанного списка. В случае если что-то не так, наглядное представление применяется для поиска источника проблемы. Мы не будем писать тесты для наглядного представления, т. к. это слишком усложнит тестирование. Для создания наглядного представления объекта используется перегрузка метода Tostring (). По умолчанию все объекты имеют реализацию Tostr ing (), вся работа которой заключается в предоставлении идентификатора ссылки объекта. Чтобы заставить метод Tostring о делать что-либо полезное, его нужно перегрузить. В примере метод Tostring о создает буфер, содержащий идентификатор объекта BaseLinkedList и идентификаторы следующего и предыдущего объектов. Эти три идентификатора предоставляют информацию о структуре связанного списка. Следующим шагом является написание теста в файле Program.cs проекта TestLightingSystem, КОТОрЫЙ проверяет, работает ЛИ метод Inserto, как требуется. Данный тест выглядит таким образом: namespace TestLightingSystem { class Program { static void Main(string[] args) { Testlnsert(); } public static void Testlnsert() { Console.WriteLine("**************"); Console.WriteLine("Testlnsert: Start"); Linkedltem iteml = new Linkedltem("iteml"); Linkedltem item2 = new Linkedltem("item2"); Linkedltem item3 = new Linkedltem("item3"); string tostring = iteml.Tostring(); Console.WriteLine(tostring); if (iteml.Next !=null || iteml.Prev !=null) { throw new Exception(
Компонентно-ориентированная
архитектура
"Testlnsert: Empty structure is incorrect"); } iteml.Insert(item2); toString = iteml.ToStringO; Console.WriteLine(toString); if (!(iteml.Next == item2 && iteml.Prev == null)) { throw new Exception( "Testlnsert: Iteml->Item2 structure is incorrect"); } toString = item2.ToString(); Console.WriteLine(toString); if (!(item2.Next == null && item2.Prev == iteml)) { throw new Exception( "Testlnsert: Item2->Iteml structure is incorrect"); } item2.Insert(items); toString = item2.ToString(); Console.WriteLine(toString); if (!(item2.Prev == iteml && item2.Next == items)) { throw new Exception( "Testlnsert: Item2->Iteml, Item3 structure is incorrect"); } toString = items. ToStringO; Console.WriteLine(toString); if (!(items.Prev == item2 && items.Next == null)) { throw new Exception( "Testlnsert: Item3->Item2, structure is incorrect"); } toString = iteml.ToString(); Console.WriteLine(toString); toString = item2.ToString(); Console.WriteLine(toString); toString = item3.ToString(); Console.WriteLine(toString); Console.WriteLine("Testlnsert: End"); } } }
249
Гпава 15
250
Результаты теста выглядят таким образом: ************** Testlnsert: Start this(iteml) next(null) prev(null) this(iteml) next(item2) prev(null) this(item2) next(null) prev(iteml) this(item2) next(item3) prev(iteml) this(item3) next(null) prev(item2) this(iteml) next(item2) prev(null) this(item2) next(item3) prev(iteml) this(item3) next(null) prev(item2) Testlnsert: End
Хотя выведенный результат и выглядит аккуратно и красиво, его назначением не является подтверждение, что все операции были выполнены должным образом. Просто пространный вывод упрощает отладку в случае ошибки. В методе Testlnsert () возникает ситуация, когда создаются три экземпляра класса Linkeditem: itemi, item2 и item3. П е р в о н а ч а л ь н о эти т р и э л е м е н т а не с в я з а н ы , но
с помощью метода insert () мы связываем их в структуру (рис. 8.4).
Рис. 8.4. Тестируемая структура двунаправленного связанного списка
Но чтобы получить структуру, показанную на рис. 8.4, требуется выполнить несколько промежуточных шагов, которые тестируются в реализации метода Testlnsert (). На каждом шаге выполняется проверка на правильность значений свойств Next и prev каждого элемента. Если некоторые значения не совпадают, то выдается исключение, указывающее неправильную структуру. В случае исключения приобретает важность генерирование наглядной структуры. Кстати, при разработке алгоритмов для методов insert () и Remove () наглядные структуры помогли мне вычислить источник ошибки. Метод Testlnsert о является хорошим примером всестороннего тестирования контекста. Несколько других образцов исчерпывающих тестов можно найти в исходном коде для этой книги, доступном для скачивания через Интернет.
Компонентно
-ориентированная
ИНСТРУМЕНТЫ
ДЛЯ
251
архитектура ТЕСТИРОВАНИЯ
И
ОТЛАДКИ
Некоторые могут думать, что для того чтобы вычислить причину неудачного теста, необходимо применить отладчик. Но если тесты разработаны должным образом, применяются как часть всеохватывающей инфраструктуры тестирования и выдают подробную информацию о результатах тестирования, то надобность в отладчике уменьшается. Программисты, исповедующие разработку TDD (Test-Driven Development, разработка, управляемая тестами), включая меня, ставят под вопрос пользу, приносимую отладчиком. Согласно статье "Test-Driven Development" в Википедии (http://en.wikipedia.org/wiki/Test-driven_development): "Программисты, практикующие чистую разработку TDD, говорят, что они редко испытывают необходимость прибегать к отладчику. Совместно с использованием системы управления версиями, при неуспешном тестировании, возвращение к последней версии, успешно прошедшей все тесты, почти всегда более эффективно, чем отладка". Отладчик хорош для обнаружения проблем, но не как средство для вычисления природы проблемы. Хорошие тесты проверяют сценарии. Чем больше сценариев, тем больше тестов, тем более проверенным будет разрабатываемый код. Неуспешное выполнение определенного сценария служит индикатором проблемы. И если все было в порядке до тех пор, пока вы не внесли незначительные изменения, вы знаете, в чем заключается проблема. Тестовые сценарии являются своего рода вехами, указывающими, что работает, а что, возможно, не работает. При использовании отладчика мы часто проверяем большие фрагменты кода, в то время когда нам нужно направить наши усилия на отыскание ошибки. Отладчик имеет свое применение, но, создавая хорошие тесты для большого числа сценариев, вы редко будете в нем нуждаться. Говоря о создании тестов, как было сказано в главе 6, их написание можно значительно облегчить, применяя инфраструктуры для тестирования, такие как NUnit (http://www.nunit.org) или Microsoft Visual Studio Team System (http://msdn2.microsoft.com/ en-us/vstudio/default.aspx). При разработке коммерческого кода, вы, скорее всего, будете использовать одну из таких инфраструктур. Хотя сами по себе эти инфраструктуры не предоставляют прямой помощи в написании тестов, они предоставляют утилиты для генерирования и протоколирования ошибок и отображения прогресса тестов. Не верьте рекламным заявлениям, утверждающим, что их инструменты могут сами создавать тесты. Никакой инструмент не может создавать тесты, т. к. для этого ему нужно было бы понимать контекст тестируемого кода. А так как таких инструментов в данное время не существует, то вам придется создавать собственные тесты.
Реализация комнатных группировок Комнатные группировки — это коллекции комнат с определенной организацией. Целью применения комнатных группировок является получение возможности выполнять групповые операции, не сортируя комнаты перед этим. Например, в случае с музеем, при выполнении каждой глобальной операции нам не нужно будет вычислять, является ли комната общей или частной. Коллекция организована таким образом, что несколько комнатных группировок могут быть связаны между собой, а каждая отдельная группировка содержит взаимосвязанные комнаты. Структура связанного списка имеет два уровня и выглядит таким образом (код находится в Проекте LibLightingSystem): class RoomGrouping г BaseLinkedList { public Room Rooms;
Гпава
252
8
public string Description; } class Room : BaseLinkedList { public IRoom ObjRoom; }
Объявление класса Room представляет отдельную комнату. Но обратите внимание на то, каким образом он происходит от класса BaseLinkedList, что вроде бы подразумевает, что класс Room представляет множественные комнаты. Это является частью реализации связанного списка, который подобен цепочке, состоящей из отдельных звеньев. Класс RoomGrouping имеет два члена данных: Rooms, который представляет список комнат в группировке, и Description, представляющий понятное описание группировки. А класс Room имеет всего лишь один член данных: ссылку на экземпляр интерфейса IRoom. Этот член данных не знает о коллекции и управляется другим объектом, который содержит ссылки на отдельные экземпляры IRoom, подобно массиву экземпляров IRoom. Для управления комнатными группировками применяется класс LightingController. Первоначальная реализация данного класса выглядит таким образом: public class LightingController { private BaseLinkedList _roomGroupings = new RoomGrouping(); }
При работе со связанными списками возникает проблема определения первого элемента списка. При использовании массивов пустой список массивов является массивом без ссылок, но имеется явный объект массива. При использовании же связанных списков, пустой связанный список— это несуществующий список. Таким образом, для создания списка требуется комната. Первым элементом класса LightingController является экземпляр класса RoomGrouping, который не содержит никаких комнатных группировок, а всего лишь служит в качестве заполнителя. Новую комнатную группировку можно добавить с помощью следующего кода: _roomGroupings.Insert(newRoomGroup);
А если бы у нас не было заполнителя для комнатных группировок, то чтобы добавить элемент в список комнатных группировок, пришлось бы использовать следующий код: if (_roomGroupings == null) { _roomGroupings = newRoomGroup; } else { __roomGroupings.Insert(newRoomGroup); }
Компонентно
-ориентированная
архитектура
253
Как можно в и д е т ь , использование заполнителя упрощает код и уменьшает его объем; но при этом также требуется не выполняющий никаких операций пустой экземпляр класса RoomGrouping. Я предпочитаю последний подход, т. к. я решил, что комнатная группировка без идентификатора будет группировкой по умолчанию.
Добавление комнатной группировки Следующий код (расположенный в классе LightingController) добавляет комнатную группировку: public object AddRoomGrouping(string description) { RoomGrouping grouping = new RoomGrouping { Description = description. Rooms = null }; _roomGroupings.Insert(grouping); return grouping ,}
В процессе добавления новой комнатной группировки создается экземпляр класса RoomGrouping, присваиваются значения членам данных, после чего вызывается метод _roomGroupings. insert О, чтобы добавить новую комнатную группировку в связанный список. Посмотрим на способ присваивания значений членам данных, называющийся инициализацией объекта. В предыдущих примерах, для присвоения значений по умолчанию членам данных экземпляра объекта мы применяли конструктор с соответствующими параметрами. Но можно также создать объект и определить блок кода для присвоения значений соответствующим членам данных. В случае класса RoomGrouping значения присваиваются двум общим членам данных — Description и Rooms — таким образом: Description = description, Rooms = null
К членам данных Description и Rooms разрешен доступ для присваивания им значений, что является важным обстоятельством, т. к. данный способ не работает со свойствами с доступом только для чтения. Чтобы иметь возможность присваивать значения членам данных, при создании экземпляра с помощью ключевого слова new опускаются круглые скобки. Вместо них применяются фигурные скобки, внутри которых перечисляются разделенные запятыми пары "ключ/значение". Ключ представляет член данных, которому необходимо присвоить значение, а значение является данными, которые присваиваются члену данных. Еще одним способом, заслуживающим внимания в коде для добавления комнатной группировки, является определение дескриптора данных при передаче информации: return grouping;
Глава 10
254
В реализации AddRoomGrouping () переменной grouping присваивается экземпляр класса RoomGrouping. В объявлении класса RoomGrouping его область видимости ограничена сборкой LibLightingSystem, в то время как LightingController имеет область видимости public. Если бы метод AddRoomGrouping() попытался возвратить экземпляр класса RoomGrouping, то компилятор усмотрел бы в этом ошибку по причине несоответствия областей видимости. Полагая на время, что нам, в самом деле, нужно возвратить экземпляр класса RoomGrouping, единственным способом сделать это было бы объявление данного класса как public. Но такое изменение области видимости при объявлении класса RoomGrouping будет неправильным решением, т. к., за исключением методов базового класса, данный класс не содержит объявленных методов и имеет общие члены данных. Этот класс имеет конкретное назначение, и его не следует разделять. Таким образом, требуется решение иное, нежели объявление класса RoomGrouping как public. Можно было бы добавить в объявление член данных, играющий роль счетчика, и возвращать целочисленное значение, указывающее экземпляр RoomGrouping в списке, к которому выполняется обращение. Но для этого требовалось бы получить доступ к списку, после чего последовательно обработать каждый элемент списка, пока не будет найден требуемый экземпляр RoomGrouping. Решением будет объявить метод, возвращающий объект типа. При использовании объекта, мы определяем метод для возвращения экземпляра объекта. Тип данного экземпляра может быть известен или нет, и в случае с методом AddRoomGrouping () тип неизвестен. Но в этом нет ничего страшного, т. к. мы как пользователь будет рассматривать данный экземпляр как ключ, управляемый классом LightingController. На техническом жаргоне, данный объект является дескриптором, который мы передаем какому-либо другому компоненту, который знает, что с ним делать. В данном примере, дескриптор передается классу LightingController, т. к. он знает, что дескриптор является экземпляром класса RoomGrouping. ПРИМЕЧАНИЕ Дескрипторы были очень популярны в языке С и являются указателями на область памяти. Вызывающий код не знает, куда указывает указатель, но использует его при работе с интерфейсом API. В настоящее время применение дескрипторов пошло на убыль, т. к. вместо них применяются объекты, обобщения .NET и другие конструкции программирования. Но, тем не менее, иногда дескрипторы бывают очень полезными. С их помощью можно избежать открытия внутреннего состояния вашего интерфейса API, не создавая при этом иерархии объектов для отслеживания обращений к объектам.
Нахождение комнатной группировки В списке, содержащем несколько комнатных группировок, нам потребуется найти группировку, отвечающую определенному описанию. Так как комнатные группировки организованы в двунаправленный список, для этого нужно проверять каждый
Компонентно
-ориентированная
архитектура
255
следующий элемент списка до тех пор, пока не будет найдена требуемая группировка. Код для этой операции выглядит таким образом: public object FindRoomGrouping(string description) { RoomGrouping curr = _roomGroupings.Next as RoomGrouping; while (curr != null) { if (curr.Description.CompareTo(description) == 0) { return curr; } curr = curr.Next as RoomGrouping; } return null; }
Данный код цикла похож на код цикла, рассмотренный в разд. "Сохранение коллекции с помощью связанного списка" ранее в этой главе. Единственная разница заключается в том, что переменная curr имеет тип RoomGrouping, и т. к. Next относится к типу BaseLinkedList, то требуется выполнить приведение типов. Цикл выполняется с помощью оператора while и при каждой итерации цикла curr .Description сравнивается с параметром description. Если требуемый объект обнаружен, то возвращается дескриптор RoomGrouping; в противном случае возвращается значение null. Этот метод используется таким образом: object foundHandle = controller.FindRoomGrouping("description");
Но связанный список комнатных группировок является коллекцией, к которой можно обращаться, как к массиву. В языке С# имеются конструкции, с помощью которых класс LightingController можно снабдить функциональностью массива, называемой индексатором. Далее приводится код метода класса LightingController, который выполняет данную операцию: public object this[string description] { get { return FindRoomGrouping(description); } }
Индексатор объявляется подобно свойству, за исключением того, что в качестве идентификатора свойства используется ключевое слово this, после которого следуют заключенные в фигурные скобки параметры массива. Возвращаемый индексатором тип указывается идентификатором перед ключевым словом this. В данном примере определена только часть get индексатора, поэтому доступ к нему предоставляется только для чтения. Данный индексатор можно использовать следующим образом: object foundHandle = controller["description"];
Гпава
256
8
Таким образом, с помощью индексатора можно определить доступ к массиву посредством иных, нежели числовых, индексов. ПРИМЕЧАНИЕ Индексаторы предоставляют сервисную функциональность, и их лучше всего вставлять в классы, управляющие коллекциями. Класс LightingController, который управляет коллекцией комнатных группировок, является хорошим кандидатом для снабжения индексатором.
Определенную комнатную группировку можно найти с помощью методов или индексатора класса LightingController. Но иногда пользователю требуется информация обо всех имеющихся комнатных группировках. Для этого можно определить числовой индексатор и обработать в цикле отдельные элементы. Соответствующий код будет выглядеть так: public string this[int index]
{
get { } }
Предыдущий пример индексатора и метод FindRoomGrouping () возвращают дескриптор объекта. Но данный пример индексатора возвращает строку. Для обработки в цикле комнатных группировок нам не нужен дескриптор, т. к. мы не знаем, что представляет данный дескриптор. Если вызвать метод FindRoomGrouping () и выполнить поиск на основе описания, то возвращенный дескриптор будет иметь перекрестную ссылку с описанием. При обработке элементов в цикле с помощью числового индексатора возвращенный дескриптор объекта не имеет для нас никакого значения, будучи всего лишь связанным с определенным индексом. Но что нам действительно нужно знать, так это какие имеются описания, и поэтому числовой индексатор возвращает строку с перекрестной ссылкой на описание комнатной группировки. ПРИМЕЧАНИЕ Тип может иметь несколько определений индексаторов, но все они должны иметь разные параметры массива.
Допустим, что у нас имеется числовой индексатор. Для обработки в цикле отдельных комнатных группировок мы можем использовать такой код: for (int cl = 0; cl < controller.Length; cl ++) { string description = controller[cl]; }
В то время как данный код цикла является приемлемым, в нем необходимо добавить свойство Length в класс LightingController. Лучшим подходом будет использование ключевого слова foreach таким образом: foreach (string description in controller.RoomGroupinglterator()) { // Выполняется какая-либо операция с описанием. }
Компонентно
-ориентированная
архитектура
257
Данный код предпочтительней, т. к. оператор foreach имеет более простой синтаксис. Не играет роли, что мы потеряли информацию о том, какое смещение относится к какому описанию, т. к. эта информация все равно не представляет никакой ценности. Не забывайте, что мы имеем дело со связанным списком, элементы в котором могут размещаться в любом порядке. Поэтому числовой идентификатор' в данном случае совершенно бесполезен. Единственным надежным способом найти комнатную группировку является наличие ее описания или специфичного для коллекции индекса. ПРИМЕЧАНИЕ Если только вы полностью не уверены в том, что элементы манипулируемой коллекции не перемещаются с одного места на другое, использование индекса в качестве уникального описания объекта может быть опасным и потенциально исказить состояние приложения. В этой главе я уже показал два других способа обращения к определенному объекту, а именно — дескриптор и индексатор.
Так как класс LightingController не имеет встроенной функциональности foreach, необходимо прибегнуть к помощи ключевого слова yield, чтобы добавить ее. Далее приводится код для реализации цикла с помощью ключевого слова yield. (В самом начале файла LightingController необходимо добавить оператор using System. Collections;, чтобы получить доступ К интерфейсу I Enumerable.) public IEnumerable RoomGroupinglterator() { RoomGrouping curr = _roomGroupings.Next as RoomGrouping; while (curr != null) { yield return curr.Description; curr = curr.Next as RoomGrouping; } }
В итераторе с помощью переменной curr создается другой цикл for, но для нас важным является код, выделенный жирным шрифтом. Здесь ключевое слово yield используется совместно с ключевым словом return. Ключевое слово return не следует рассматривать отдельно, как указывающее выход из функции. В данном случае ключевые слова yield и return составляют одну цельную конструкцию, применяемую для передачи сообщений. Ключевое слово yield всегда доставляет разработчикам большие трудности с его пониманием. Самым лучшим способом понять его будет исследование, как оно работает совместно с ключевым словом foreach: 1. Когда код доходит до оператора foreach, устанавливается контекст, в котором элементы коллекции обрабатываются в итераторе. Установка контекста состоит в получении коллекции и выделении места для отдельного элемента. 2. Вызывается итератор коллекции, что для данного примера означает вызов метода RoomGroupinglterator() .
Глава 10
258
3. Метод RoomGroupingiterator () присваивает переменной curr значение, указывающее на первый элемент двунаправленного списка комнатных группировок. 4. Начинается выполнение цикла, продолжающееся до тех пор, пока значение curr не станет равным нулю. 5. Код доходит до комбинации yield return, означающей, что результат после ключевого слова return нужно сохранить в области памяти для отдельного элемента, выделенной в шаге 1. 6. Код создает закладку, помечающую последний исполненный оператор в итераторе, и переходит обратно к оператору f oreach. 7. Оператор foreach продолжает выполнение кода, которым в данном примере является комментарий // Выполняется какая-либо операция с описанием.
8. Когда оператор foreach приступает к выполнению следующей итерации, извлекается ранее сохраненная закладка и исполнятся код, следующий сразу же после закладки. В результате исполняется код curr=curr .Next as RoomGrouping В методе RoomGroupingiterator (). 9. Исполнение итератора продолжается, и шаги 4—9 выполняются до тех пор, пока значение curr не станет равным нулю. 10. Когда значение curr становится равным нулю, выполнение итератора прекращается, вызывая остановку цикла foreach. Частью, трудно поддающейся пониманию, является механизм, применяемый, когда конструкция yield return вызывает прекращение исполнения с последующим его возобновлением. Программисты не привыкли к тому, что можно выходить и входить в метод и продолжать исполнение. Но не забывайте, что это можно делать только в контексте комбинации оператора foreach и конструкции yield return. Этот механизм, основанный на использовании закладок, неприменим в С# ни в какой другой ситуации. В этом примере было показано применение ключевого слова yield в цикле, но цикл не является обязательным. Так в следующем фрагменте кода yield используется в коллекции из трех чисел: public IEnumerable Numberlterator() { yield return 1; yield return 2; yield return 3; } ВАЖНОСТЬ
ИНДЕКСАТОРОВ
И
КЛЮЧЕВОГО
СЛОВА
"YIELD"
В примере для этой главы демонстрируется использование индексаторов и ключевого слова yield для придания типу вида обычной коллекции. Использование данного примера имеет определенную цель. Но хороший ли это пример? Я пытаюсь научить вас,
Компонентно-ориентированная
архитектура
259
как писать программное обеспечение профессионально на высоком уровне, т. е. научить вас инженерии разработки ПО. Умение работать со связанными списками является частью этой инженерии разработки ПО, но используются ли связанные списки в сегодняшнем программировании? Не очень. Так нужно ли современным программистам знать о связанных списках? Конечно же, точно так же как необходимо знать, как выполнять арифметические операции, хотя для выполнения их мы сегодня пользуемся не карандашом и бумагой, а калькулятором. Таким образом, если пример иллюстрирует некоторые возможности С#, но в действительности мы вряд ли будем делать что-либо подобное показанному в примере. Полезны ли эти возможности? Да, эти возможности полезны. Представьте, что вы строите дом и вам необходимы стропила для крыши. Чтобы ускорить сборку стропил и чтобы они были наиболее одинаковые, вы сначала делаете приспособление для удерживания компонентов стропил необходимым образом. Данные приспособления не используются непосредственно в строительстве дома, они служат лишь для ускорения сборки элементов его конструкции. Подобным образом индексаторы и ключевое слово yield упрощают и ускоряют разработку классов, соответствующих стандартной парадигме программирования на языке С#. Иногда приходится создавать конструкционные классы, которые не делают ничего, кроме как обеспечивают работоспособность других компонентов, выполняющих полезную работу. Вот для создания конструкционных классов нам и понадобятся индексаторы, а также ключевое слово yield. Так что думайте об индексаторах и ключевом слове yield как о механизме, который позволяет множественным элементам выглядеть, как коллекция С#.
Добавление комнат к группировкам Дескриптор данных, определенный при добавлении группировки, применяется для добавления комнаты к группировке. Данный дескриптор предоставляет ссылку, которая может быть использована ядром. Так как данный дескриптор является экземпляром типа RoomGrouping, то при добавлении комнаты к группировке, использующей дескриптор, искать данную группировку нет необходимости. Дескриптор является группировкой, и нужно всего лишь выполнить приведение типов. В следующем коде показано добавление комнаты к комнатной группировке: public void AddRoomToGrouping(object grouping, IRoom room) { RoomGrouping roomGrouping = grouping as RoomGrouping; if (roomGrouping == null)
{
throw new Exception("Группировка дескриптора не является " + "действительным экземпляром комнатной " + "группировки"); } Room oldRooms = roomGrouping.Rooms as Room; if (oldRooms == null) { roomGrouping.Rooms = new Room { ObjRoom = room }; } else { roomGrouping.Rooms.Insert(new Room { ObjRoom = room }); } }
260
Глава 10
В реализации метода AddRoomToGrouping () первым делом выполняется приведение группировки дескриптора к экземпляру RoomGrouping. Для приведения используется оператор as. Так что в случае неуспешного приведения необходимо только проверить, не равно ли null значение переменной roomGrouping. Выполнение проверки на значение null является весьма важным; в противном случае могут выполниться операции, вызывающие исключения. После приведения дескриптора к экземпляру RoomGrouping добавить комнату в связанный список не составляет никаких проблем. Для этого нужно только назначить первый элемент для пустого списка или вызвать метод insert о, если в списке уже имеются комнаты.
Выполнение операций с группой С определенной группировкой можно выполнять глобальные операции, воздействующие на все комнаты в группировке. Одним из примеров таких операций может быть выключение освещения во всех комнатах группировки, основанной на экземпляре интерфейса iRoom. Соответствующий код может выглядеть таким образом: public void TurnOffLights(object grouping) { foreach (IRoom room in Roomlterator(grouping)) { IRemotecontrolRoom remote = room as IRemotecontrolRoom; ISensorRoom sensorRoom = room as ISensorRoom; if (sensorRoom != null) { if (!sensorRoom.IsPersonlnRoom) { continue; } } else if (remote != null) { remote.LightSwitch(false); }
> }
Обратите внимание на то, что дескриптор не преобразуется в экземпляр RoomGrouping. Дескриптор передается методу Roomlterator о, который подобно методу RoomGroupingiterator использует ключевое слово yield, чтобы позволить методу TurnoffLights() использовать оператор foreach для обработки в цикле отдельных комнат. ПРИМЕЧАНИЕ Комбинация конструкции yield return и ключевого слова foreach является мощным и легким способом для последовательной обработки коллекции данных. Достоинством конструкции yield return является то, что обрабатываемые данные не обязательно должны быть в цикле или коллекции. Они могут быть сгенерированы с помощью алгоритма или иметь фиксированное число элементов.
Компонентно
-ориентированная
архитектура
261
Для каждой итерации цикла foreach комната экземпляра IRoom приводится к типам iRemoteControlRoom и isensorRoom. Приведение к этим двум типам необходимо потому, что, в зависимости от типа комнаты, нужно выполнять разные алгоритмы. Например, ДЛЯ комнат типа ISensorRoom СО значением свойства IsPersonlnRoom, равным true, освещение надо оставить включенным. Если освещение нужно оставить в его текущем состоянии, то необходимо выполнить следующую итерацию с помощью ключевого слова continue. Если обработка продолжается, выполняется проверка, может ли освещение данной комнаты управляться удаленно, что подразумевает реализацию интерфейса IRemoteControlRoom. Если значение переменной remote не равно null, то вызывается метод Lightswitch (), которому передается параметр false, чтобы выключить освещение. Таким образом, в цикле обрабатываются все комнаты группировки. На этом разработка ядра завершена, но прежде чем приступить к рассмотрению его применения в приложении управления освещением, я бы хотел обсудить альтернативный подход к реализации ядра.
Определение ядра в виде интерфейса, а не класса Как было отмечено ранее, вместо определения ядра в виде класса, его можно определить в виде интерфейса с последующей реализацией. Если компания намеревалась выпускать несколько реализаций контроллера, интерфейс был бы уместен, но только если бы все реализации интерфейса использовали один и тот же набор методов. Не следует путать множественные реализации с множественными реализациями, предоставляющими абсолютно разные наборы возможностей. Например, контроллер версии 1 и контролер умопомрачающей версии 1 ООО могут управлять комнатами одинаковых типов, но ввод, вывод, логика и алгоритмы каждой из этих версий могут быть абсолютно разными. В данном случае использование интерфейса не даст никаких преимуществ. Интерфейс версии 1 можно было бы использовать на версии 1000 с целью наследования, т. к. более старый интерфейс представляет более старые идеи. Интерфейс можно применить для контроллера в том случае, когда множественные контроллеры реализует один и тот же интерфейс. Интерфейс также можно применить, если требуется гибкость для последующего создания множественных реализаций, использующих один и тот же интерфейс. С другой стороны, если применяется только одна реализация для одного объявления интерфейса, то будет намного легче использовать класс типа public. Если вы решите объявить контроллер, используя интерфейс и реализацию, то проект необходимо будет структурировать иначе, чем в примере, приведенном в этой главе. Причиной этому является то обстоятельство, что интерфейсы и реализации нельзя объявлять в одном и том же проекте.
Глава 10
262
Представьте себе ситуацию, когда вы пытаетесь предложить множественные реализации ядра. В таком случае, чтобы пользователи могли использовать интерфейсы, им нужно будет обращаться к проекту, содержащему определенную реализацию ядра. Поэтому структуру необходимо сделать модульной, организованной подобно показанному на рис. 8.5.
Рис. 8.5. Организация модульного интерфейса и архитектура реализации
На рис. 8.5 отдельные прямоугольники представляют одну сборку .NET. Каждая сборка имеет специфичное назначение. •
Сборка Definitions содержит все интерфейсы, используемые другими сборками. Данная сборка изменяется очень редко и является основой приложения. Вместе с интерфейсами в эту сборку также добавляются сервисные классы общего назначения, которые будут использоваться всеми сборками.
•
Сборка user — главное приложение, которое взаимодействует с интерфейсами объектов, реализоваными В сборке Kernel ИЛИ Implementations. Сборка User является ответственной за связывание вместе всех типов (например, присваивание экземпляров интерфейсов ИЗ сборки Implementations сборке Kernel).
•
Сборка Kernel определяет основную функциональность приложения и манипулирует экземплярами, реализующими интерфейсы со сборки Definitions. Ядро не знает, где находятся реализации интерфейсов, и ожидает, что какой-либо другой блок кода владеет этой информацией.
•
Сборка implementations содержит реализации интерфейсов, которыми манипулирует ядро. Допускается одна или несколько сборок implementations. Реализации знают ТОЛЬКО О сборке Definitions, НО не О сборке Kernel.
I Компонентно-ориентированная
архитектура
263
Создание полного приложения Весь код, рассмотренный на данный момент, имеет отношение к ядру, и может показаться, что приложение готово. Но на самом деле, ядро не делает ничего другого, кроме как организовывает и манипулирует комнатами. В ядре не определена ни одна реализация для конкретной комнаты. Поэтому рассмотрим, как можно определить комнаты и использовать их с ядром. Идея заключается в том, чтобы позволить разработчику добавлять функциональность к ядру, не затрагивая само ядро. Для примера рассмотрим определение двух комнат в музее (в проекте Museum). ПРИМЕЧАНИЕ Реализация проекта ноте в данной книге не рассматривается, но включена в исходный код книги, который можно загрузить через Интернет.
Определение комнат Определения комнат выполняются в отдельной сборке, называющейся Museum, и не являются частью ядра. Далее приводится пример кода реализации комнаты. Не забудьте вставить ссылку на LibLightingSystem (щелкните правой кнопкой по пункту References в проекте Museum и выберите последовательность команд Add Reference | Projects | LibLightingSystem). using LibLightingSystem; namespace Museum { class PrivateRoom : INoRemoteControlRoom { } class PublicRoom : ISensorRoom { public bool IsPersonlnRoom { get { return false; } } double _lightLevel; public double LightLevel { get { return _lightLevel; } } public void LightSwitch(bool lightState) { if (lightState) {
Область видимости обоих определений комнат, PrivateRoom и PublicRoom, ограничена сборкой. Для каждой комнаты реализуется требуемый для нее интерфейс. Для комнаты PrivateRoom реализуется интерфейс iNoRemoteControlRoom. Это означает, что LightingController не управляет освещением в данной комнате. Для комнаты PublicRoom реализуется интерфейс isensorRoom. Это означает, что данная комната будет сообщать контроллеру, когда в ней находятся люди, и позволяет ему контролировать освещение в ней. Реализация класса PublicRoom тривиальна и, честно говоря, не очень полезна, но она иллюстрирует реализацию абсолютного минимума требуемых возможностей. В реальной жизни класс PublicRoom имел бы доступ, по крайней мере, к таким внешним устройствам, как датчик температуры и управляющие элементы освещения. Класс PublicRoom отправляет и получает сигналы от LightingController и выполняет действия. В круг ответственностей класса PublicRoom не входит интересоваться, правильно ли данное решение контроллера или нет. Например, если контроллер указывает выключить освещение, хотя в комнате находятся люди, класс PublicRoom не будет докладывать об этом контроллеру, а просто выполнит его указание. ПРИМЕЧАНИЕ При разработке приложений архитектуры ядра реализации являются воплощениями идей и никогда не должны сомневаться в правильности указаний контроллера. Реализации могут не знать полной картины, и если они начнут сомневаться в правильности решений контроллера, то это может вызвать сбой в работе алгоритма. Конечно же, к этому правилу есть исключение — если решение контроллера может причинить физические повреждения или вызвать сбой программы. В таком случае реализация должна выдать исключение, указывая на неправильность решения контроллера.
Создание экземпляров классов PublicRoom и PrivateRoom Как было описано в предыдущей главе, при разработке компонентов интерфейсы следует держать отдельно от реализаций. Это дает нам гибкость в модифицировании реализации в сборки, не требуя перекомпиляции своего кода пользователями сборки.
Компонентно
-ориентированная
архитектура
265
Для создания экземпляров реализаций нам нужна фабрика, что точно так же относится к музею и его реализациям PrivateRoom и pubiicRoom. Но конструкционный метод, который собирает здание из ВОЗМОЖНЫХ комбинаций PrivateRoom И PubiicRoom,
будет предоставлен вместе с музеем. Конструкционный метод полезен тем, что он предопределяет стандартное здание, со всеми комнатными группировками и комнатами, вставленными должным образом. ПРИМЕЧАНИЕ Конструкционный метод можно рассматривать как способ создания предопределенной структуры, таким образом, избавляющий пользователей от необходимости делать это самим. Конструкционный метод всего лишь создает структуру, которой впоследствии можно манипулировать для тонкой настройки.
Далее приводится код для реализации фабрики музея, которая добавляется в прое к т Museum: public static class FactoryRooms { public static IRoom CreatePrivateRoom() { return new PrivateRoom(); } public static IRoom CreatePublicRoom() { return new PubiicRoom(); } public static LightingController CreateBuilding() { LightingController controller = new LightingController(); object publicAreas = controller.AddRoomGrouping("public viewing areas"); object privateAreas = controller.AddRoomGrouping("private viewing areas"); controller.AddRoomToGrouping(publicAreas, new PublicRoom()); controller.AddRoomToGrouping(privateAreas, new PrivateRoom)); return controller; } }
Реализация имеет три метода: CreatePrivateRoom(), CreatePublicRoom () и CreatingBuilding(). To, ЧТО метод CreatePrivateRoom() И класс PrivateRoom имеют
похожие наименования, является чистой случайностью. Метод с таким же эффектом МОЖНО было бы назвать CreateNonControlledRoomO. Методы CreatePrivateRoom () и CreatePubl icRoom () предназначены для определения идентификаторов методов,
которые пользователи могут понимать. Данные методы должны возвращать экземпляр IRoom.
266
Глава 10
Метод createBuiiding () является конструкционным методом и возвращает экземпляр LightingController. Возвращение экземпляра LightingController является приемлемым, т. к. этот тип имеет глобальную область видимости и может служить в качестве основы для конструкционного метода. В реализации конструкционного метода создаются экземпляры комнатных группировок и комнат, которые добавл я ю т с я К экземпляру LightingController. Э т о работа, КОТОруЮ КОНСТруКЦИОННЫЙ
метод выполняет вместо пользователя. Кроме этого, применение конструкционного метода позволяет избежать создания структур здания с грубыми ошибками в них. ПРИМЕЧАНИЕ Типы фабрик применяются для создания экземпляров типов и определяют конструкционные методы, но могут также использоваться для выполнения общих операций со структурой. Допустим, что в нашем музее имеется крыло с тремя общими комнатами и одной частной. Мы может определить конструкционный метод, создающий крыло, которое добавляется к уже созданному зданию. Общая идея в основе типа фабрики заключается в избежании ошибок и централизации повторяющихся операций по созданию экземпляров.
Частные классы и инициализация объектов В этой главе мы рассмотрели, как использовать интерфейсы, реализации и компоненты в приложениях на основе ядра. Это в большой мере тот тип программирования, с которым вы будете сталкиваться по мере пользования языком С#. В этом разделе предоставляется дополнительная информация об использовании частных классов и об инициализации объектов вложенными типами данных.
Частные классы Классы RoomGrouping И Room определены В проекте LibLightingController, и ИХ область видимости ограничена данной библиотекой. Это потому, что эти классы н>окны только классу LightingController для поддержки его функциональности. Каждый из этих классов объявлен внутри сборки, что является положительным аспектом, но, тем не менее, разработчики могут использовать классы в сборке ядра для своих целей. Иногда это желательная возможность, а иногда — нет. В случае с классом LightingController другим подходом может быть объявление этих классов в его контексте, как показано в следующем коде: public class LightingController { private class RoomGrouping { } private class Room { } }
Здесь классы RoomGrouping И Room объявлены внутри класса LightingController, что делает их частными для данного класса. Это означает, что только класс LightingController может создавать экземпляры и использовать эти классы, и полностью исключаются ситуации, когда другой класс может создать их экземпляры.
Компонентно
-ориентированная
архитектура
267
В случае с классом LightingController было бы предпочтительнее объявить классы RoomGrouping И Room таким образом. Частные классы также используются в контексте фабрики. Представьте, например, ситуацию, когда вы хотите, чтобы никто, кроме фабрики, не мог создавать экземпляр комнаты. Объявление iRoom и фабрики может выглядеть таким образом: public static class Factory { private class MyRoom : IRoom { } public static IRoom CreateMyRoom() { return new MyRoom(); } }
В данной реализации MyRoom мы можем быть уверены в том, что только Factory может когда-либо создавать экземпляры MyRoom. Также мы можем быть уверены в том, что MyRoom можно манипулировать только посредством интерфейса iRoom. Слишком часто разработчики ленятся и создают экземпляры типов, содержащихся в сборке, и используют тип реализации, когда интерфейс не имеет методов или свойств, которые им требуются.
Инициализация объектов вложенными типами данных В этой главе мы рассмотрели присвоение значений членам данных инициализацией объекта вместо применения конструктора. Инициализация объектов также работает для вложенных типов данных. Рассмотрим ситуацию, когда один тип ссылается на другой тип. Инициализируя объекты, можно создать экземпляры объектов и присвоить им значения на нескольких уровнях. Допустим, у нас имеется следующий исходный код: class МуТуре { int _dataMember; public МуТуре() { } public int DataMember { get { return _dataMember; } set { _dataMember = value; } }' } class EmbeddedMyType {
268
Глава 10
MyType „embedded; public EmbeddedMyType() { } public MyType MyType { get { return „embedded; } set { „embedded = value; } } }
Тип EmbeddedMyType имеет свойство, которое ссылается на тип MyType. При создании экземпляра EmbeddedMyType мы бы, скорее всего, также хотели бы создать свойство мутуре и присвоить ему значение. Это можно сделать инициализацией объекта следующим образом: EmbeddedMyType els = new EmbeddedMyType { MyType = new MyType { DataMember = 1 0 } };
Советы разработчику В этой главе мы рассмотрели создание ядра приложения, используя для этого индексаторы и ключевое слово yield. Рекомендуется запомнить следующие основные аспекты рассмотренного материала. П Приложение на основе ядра является примером компонентно-ориентированной архитектуры, когда разработчик не контролирует определенные реализации. Применение компонентов позволяет разбить процесс разработки на модули с тем, чтобы отдельные задачи выполнялись отдельными командами. П Интерфейсы — это контракты между модулями, и мы тестируем интерфейсы, а не реализации. •
Для упрощения группирования объектов применяются интерфейсы-заполнители.
•
Индексаторы и ключевое слово yield являются конструкционными элементами, упрощающими и ускоряющими выполнение задачи создания рабочего кода.
•
С помощью индексаторов типу можно придать свойства массива.
Компонентно -ориентированная
архитектура
269
П Ключевое слово yield применяется совместно с ключевым словом foreach для обработки в цикле типов, которые, возможно, не поддерживают коллекции. Например, таким образом можно обрабатывать элементы математической последовательности.
Вопросы и задания для самопроверки Для закрепления рассмотренного в главе материала рекомендуется выполнить след у ю щ и е упражнения: 1. Метод LightingController. AddRoomGrouping () содержит ошибку. НапИШИТе несколько тестов, чтобы найти эту ошибку, после чего исправьте код и снова протестируйте его, чтобы убедиться в том, что ошибка была действительно исправлена. 2. Тестовый метод Testinserto является одним из примеров теста для проверки вставки элементов, но не все варианты вставок были протестированы. Напишите другой тестовый метод, для тестирования вариантов вставок, которые не были протестированы. 3. Классы RoomGrouping и Room объявлены не самым оптимальным образом. Оптимизируйте данные объявления. 4. Создайте общий класс коллекции на основе своего опыта использования класса LightingController. Подсказка: посмотрите, как объявляется связанный список для Room, и вычислите способ абстрагировать этот принцип для создания общего класса коллекции. 5. При вызове метода LightingController.AddRoom() выполняется внутренняя проверка, является ли дескриптор типа RoomGrouping. Можете ли вы придумать более защитный метод, чтобы гарантировать, что код, передаваемый ядру, не вызовет его сбой? Подсказка: подумайте, что могло бы вызвать ошибку в методах для включения и выключения освещения.
Глава 9
Списки, делегаты и лямбда-выражения Одним из наиболее распространенных видов кода, который вам придется писать, будет код для управления множественными объектами. В предыдущих примерах управление множественными объектами осуществлялось с помощью массивов. В главе 8 было рассмотрено, что, применяя индексатор и ключевое слово y i e l d со связанным списком, можно обычный объект представить коллекцией. В данной главе рассматриваются коллекции .NET, которые предоставляют легкий способ управления набором экземпляров объектов. Коллекцию можно рассматривать, как бесконечный ящик, в который можно класть вещи, проходиться по ним в цикле и извлекать их для пользования. Сначала мы рассмотрим, как управлять коллекциями. После этого будет рассмотрен пример кода, который подозревается в неправильной работе; этот код будет улучшен с помощью делегатов, потом с помощью анонимных методов и, наконец, с помощью лямбда-выражений. Проект для примеров данной главы организован в виде простого консольного приложения. Так как мы будем разрабатывать не законченное приложение, а набор примеров, то создавать для них тесты или библиотеки мы не будем.
Управление коллекциями В действительности коллекция — это объект, который указывает на множество других объектов. Сравните это с реляционной базой данных, где набор результатов может содержать одну запись, несколько записей или ни одной записи. Для взаимодействия с базой данный применяется язык SQL (Structured Query Language, язык структурированных запросов), для которого не существует такого понятия, как отдельная запись, и который рассматривает все как коллекцию. (В некоторых реализациях баз данных применяются расширенные версии языка SQL, позволяющие обращение к отдельной записи, но за это обычно приходится платить понижением производительности.) Производительность коллекции в языке С# не страдает, чего нельзя сказать о простоте использования. Для управления коллекциями в языке С# предоставляются специальные классы коллекций. Начиная в версии 2.0, в С# применяется другой подход к коллекциям,
Глава 10
272
который решил многие проблемы, существующие в более ранних версиях С#. Здесь мы рассмотрим управление коллекциями до и после версии С# 2.0; это должно помочь вам в понимании использования коллекций.
Управление коллекциями до С# 2.0 До С# 2.0 основные классы коллекций находились в пространстве имен System.Collections. Далее приводится список некоторых классов и интерфейсов данного пространства имен. • ArrayList — общая коллекция, управляющая всеми объектами, на которые имеются ссылки, с помощью внутреннего массива. Данный класс решает проблему с увеличением размера массива. П HashTabie — коллекция, в которой отдельные объекты хранятся в виде пар "ключ/значение". В предыдущей главе для получения комнатной группировки по ее идентификатору применялся индексатор. То же самое можно было бы Сделать С П О М О Щ Ь Ю КОЛЛеКЦИИ HashTabie. •
icollection— интерфейс, реализуемый классом ArrayList и предоставляющий базовую функциональность, которая копирует все элементы в другой массив.
•
iDictionary— интерфейс, реализуемый классом HashTabie и позволяющий ассоциировать ключ со значением.
•
iList — интерфейс, реализуемый классом ArrayList и предоставляющий механизм общего доступа для манипулирования коллекцией элементов.
• Queue — коллекция, реализующая механизм FIFO (First In — First out, первым пришел — первым обслужен, очередь). Данный класс можно использовать для обработки набора инструкций. Инструкция, которую необходимо обработать первой, будет добавлена в коллекцию первой. •
s t a c k — коллекция, реализующая механизм LIFO (Last In — Last Out, последним пришел — первым обслужен, стек). Данный класс можно рассматривать как стопку листов бумаги, из которой первым снимается лист, положенный в нее последним.
Все ТИПЫ коллекций— ArrayList, HashTabie, Queue И Stack— реализуют способ для хранения набора типов. Разница между этими типами коллекций заключается в том, каким образом отдельные объекты хранятся и извлекаются из коллекции. Примеры использования разных типов коллекций приводятся в разд. "Дополнительные сведения о типах коллекций" далее в этой главе.
Простой пример коллекции Рассмотрим шаг за шагом пример использования коллекции в стиле до С# 2.0. Создайте новое консольного приложение; назовите его oneToManySampies. Потом добавьте к проекту новый класс. Для этого щелкните правой кнопкой мыши
Списки,
делегаты
и
лямбда-выражения
273
по названию проекта в Solution Explorer и выберите команду меню Add | Class | Class. Присвойте ему имя Example.cs и вставьте в него следующий код: using System.Collections; class Example { int _value; public int Value { get { return _value; } set { _value = value; } } } static class Tests { static void PlainVanillaObjects() { IList objects = new ArrayList(); objects.Add(new Example { Value = 1 0 }); objects.Add(new Example { Value = 20 }); foreach (Example obj in objects) { Console.WriteLine("Object value (" + obj.Value + ")"); } } public static void RunAllO { PlainVanillaObjects(); } }
Это тип кода, применяемый до версии С# 2.0; при его написании выполняется стандартная последовательность шагов: 1. Определяется пользовательский тип. В данном примере название типа — Example. 2. Создаются и добавляются в коллекцию экземпляры пользовательского типа. В примере в коллекцию типа ArrayList добавляются два экземпляра класса Example.
3. Выполняется манипулирование коллекцией, чтобы получить доступ для работы с пользовательскими типами. В примере коллекция ArrayList является экземпляром интерфейса IList.
274
Глава 10
Жирным шрифтом в примере выделен код, выполняющий основные действия. Созданием экземпляра типа Array реализуется администратор коллекции. После этого экземпляр ArrayList присваивается переменным объектов типа iList. Интерфейс iList позволяет использовать коллекцию в контексте компонентно-ориентированной среды разработки. Чтобы добавить в коллекцию два объекта, дважды вызывается метод Add (). Для прохождения в цикле по элементам коллекции применяется оператор foreach. ПРИМЕЧАНИЕ То обстоятельство, что классы коллекции можно использовать в контексте компонентно-ориентированного приложения, не является случайностью. Когда в Microsoft создавали библиотеку .NET, компонентам в ней отводилась одна из главных ролей.
Чтобы исполнить тесты, откорректируйте файл Program.es следующим образом: class Program { static void Main(string!] args) { Tests.RunAll(); } }
Для запуска программы нажмите комбинацию клавиш +.
Проблема со смешанными типами В коде примера уникальным является то, что оператор foreach в самом деле работает должным образом и знает, что объекты в коллекции принадлежат к типу Example. Но следующий код вставляет в коллекцию объект, который вызовет сбой в работе цикла: class Another { }
IList objects = new ArrayListO; objects.Add(new Example { Value = 10 }}; objects.Add(new Example { Value = 2 0 }); objects.Add(new Another()); foreach (Example obj in objects) { Console.WriteLine("Object value (" + obj.Value + ")"); }
Жирным шрифтом выделен код, иллюстрирующий, что объект коллекции содержит два экземпляра типа Example и один экземпляр типа Another. Данный код скомпилируется без ошибок, таким образом, вводя нас в заблуждение, что с ним
Списки,
делегаты
и лямбда-выражения
275
все в порядке. Но при попытке выполнить приложение (в обычном или отладочном режиме) будет выведено следующее сообщение: Unable to cast object of type 'Another' to type 'Example'.1
Что же теперь, применять в коллекции несколько типов? Аргументы имеются за и против такого решения, но проблема не заключается в возможности смешивания типов, а в том, что их можно смешивать, даже когда это не входит в намерения разработчика. Использование ключевого слова foreach со смешанными типами вызовет исключение, т. к. в каждой итерации объект коллекции приводится к типу Example. Так как последний элемент коллекции имеет тип Another, приведение будет неуспешным, что и вызывает исключение. До .NET 2.0 в коллекциях нельзя было принуждать непротиворечивость типов, и это было проблемой. Для смешанных типов правильным циклом foreach был бы следующий: foreach (object obj in objects) { if (obj is Example) { //
...
} else if (obj is Another) { II... } }
Проблема с обычными типами Другой проблемой с коллекциям в более ранних версиях С#, чем версия 2.0, является низкая производительность. Для примера рассмотрим следующий код, который манипулирует обычными типами: IList objects = new ArrayListO; objects.Add(l); objects.Add(2); foreach (int val in objects) { Console.WriteLine("Value (" + val + ")"); }
В примере опять создается экземпляр ArrayList, но на этот раз в коллекцию добавляются числа 1 и 2. Потом эти числа обрабатываются в цикле foreach. Хотя этот код и работает, в нем имеется не сразу видимый аспект, отрицательно влияющий на производительность. В коллекцию добавляются значения обычного типа, что означает манипулирование памятью стека. Но определение IList использует объекты: public interface IList : ICollection, IEnumerable { 1
Невозможно привести объект типа Another к типу Example.
276
Гпава 8
// Методы int Add(object value); void Clear() ; bool Contains(object value); int IndexOf(object value); void Insert(int index, object value); void Remove(object value); void RemoveAt(int index); // Свойства bool IsFixedSize { get; } bool IsReadOnly
{ get; }
object this[int index] { get;
set; }
}
Способ определения i L i s t и обычного типа должен настораживать. Так как объект является ссылочным типом, у нас имеется конфликт: в i L i s t хранятся ссылочные типы, но i n t является обычным типом. Здесь среда .NET знает о конфликте и исправляет его. Не следует рассматривать это исправление как решение на скорую руку для данной проблемы, а как действие, в котором принимают участие все среды виртуальных машин, подобных .NET. В среде .NET для выражения преобразования ссылочного типа в обычный и обратно применяются термины "упаковка" (boxing) и "распаковка" (unboxing) соответственно. Для облегчения понимания идей упаковки и распаковки рассмотрим пример. Допустим, что вы создаете список, обращающийся к обычным типам. Массив является ссылочным типом, хранящимся в куче, в то время как обычные типы хранятся в стеке. Если массив будет обращаться к данным в стеке, то это вызовет проблему совместимости типов. Таким образом, нам необходимо переместить память со стека в кучу, но это бы нарушило принцип, лежащий в основе обычных типов. Решением является компромисс в виде упаковки и распаковки. Чтобы проиллюстрировать, что происходит при упаковке, я написал код, работающий подобно операции упаковки обычного типа. Разница состоит в том, что в коде операция выполняется явно, а при упаковке — неявно. class ReferenceHeap { public int Value; } public static void MethodO { int onStack = 1; ReferenceHeap onHeap = new ReferenceHeap {Value = onStack}; }
Списки,
делегаты
и
лямбда-выражения
277
В данном примере в методе Method*) объявляется переменная onstack обычного типа, память для которого выделяется в контексте метода, т. е. в стеке. Тип ReferenceHeap является классом и поэтому ссылочным типом; соответственно, все его данные автоматически сохраняются в куче. Когда объявляется и инициализируется переменная опнеар, значение переменной onstack перемещается в кучу и присваивается экземпляру опнеар. То же самое происходит и при выполнении операции упаковки, но только автоматически и прозрачно для пользователя. При работе со списками в версиях, предшествующих С# 2.0, все обычные типы автоматически упаковываются и распаковываются. ПРИМЕЧАНИЕ Важно помнить, что при выполнении упаковки и распаковки перемещаются значения. Поэтому, при изменении значения переменной onstack, значение переменной опнеар не изменится.
При распаковке значение перемещается с кучи в стек, что в случае с нашим примером означает перемещение значения из переменной опнеар в переменную onstack. Упаковка и распаковка выполняются автоматически, но за это приходится расплачиваться понижением производительности, т. к. выполняются операции выделения памяти и присваивания значений.
Управление коллекциями в С# 2.0 и последующих версиях Разработчики корпорации Microsoft усердно искали решения проблем с хранением смешанных типов и потерей производительности при операциях упаковки и распаковки. После долгих дискуссий и обдумываний было предложено решение в виде обобщений .NET. Вкратце, обобщения решают обе проблемы с коллекциями, принудительно устанавливая тип. (Обобщения .NET также применяются для решения более широких проблем.) В силу своей утилитарности, коллекции являются идеальным кандидатом для применения обобщений. Коллекции не применяются для решения проблем типа вычисления налогов. Они служат для создания наборов данных о доходах и налоговых вычетах. Далее приводится пример использования коллекций с применением обобщений: IList<Example> 1st = new List<Exaznple>(); lst.Add(new Example { Value = 10 }); lst.Add(new Example { Value = 20 }); foreach (Example item in 1st) { Console.WriteLine("item (" + item.Value + ")"); }
Жирным шрифтом выделен код, в котором применяется обобщение .NET. Код для добавления объекта и для цикла foreach идентичен коду в примере для версий языка до С# 2.0. ЮЗак. 555
278
Глава 10
В угловых скобках (<>) заключен идентификатор применяемого общего подхода. Все, что находится внутри скобок при объявлении i L i s t или List, как бы говорит: "Я хочу, чтобы экземпляры в моей коллекция были указанного в скобках типа". Добавить тип, не связанный с типом, определенным в i L i s t или List, нельзя, поэтому следующий код не скомпилируется: 1st.Add(new Another());
Причиной этому является то обстоятельство, что коллекция обобщений .NET обеспечивает типовую безопасность и не позволяет смешивания типов. Разрешаются только объекты типа Example. Объявляя список таким образом: IList<Example> 1st;
мы говорим, что список имеет метод, объявленный так: void Add(Example item);
При программировании на языке С# версии 3.0 следует использовать классы коллекций для версий 2.0 и более поздних. Классы коллекций обобщений иных, чем обобщения .NET, в большей степени являются унаследованным кодом. Всюду, где возможно, следует использовать только обобщения .NET. Теперь, когда мы знаем, как управлять коллекциями объектов, мы можем приступить к рассмотрению распространенных проблем с коллекциями, после чего исследовать способы их решения.
Верен ли код? Начнем рассмотрение проблем с коллекциями с широко распространенной проблемы: добавление всех элементов в коллекции. Рассмотрим следующий код: IList elements = new List(); elements.Add(1); elements.Add(2); elements.Add(3); int runningTotal = 0; foreach (int value in elements) { runningTotal += value; }
Данный код состоит из трех логических частей: инициализации элементов, добавления чисел к элементам и обработки в цикле всех значений в элементах, которые суммируются в переменной runningTotal. Код вроде бы выглядит нормально. Но скажем, что вам нужно написать другой фрагмент кода, в котором вместо
Списки,
делегаты
и
лямбда-выражения
279
вычисления суммы необходимо найти максимальное значение. Такой код может выглядеть следующим образом: IList elements = new List(); elements.Add(l); elements.Add(2); elements.Add(3); int martValue = int.MinValue; foreach (int value in elements) { if (value > manValue) { martValue = value; } }
Разница между двумя фрагментами кода выделена жирным шрифтом. Инициализация выполняется по-другому, и это нормально. Но цикл также организован подругому, и это уже не нормально. В отдельных фрагментах кода повторение не очевидно, но что если мы сложим их вместе? В следующем коде вычисляется сумма всех элементов и находится наибольшее значение: IList elements = new List(); elements.Add(1); elements.Add(2); elements.Add(3); int ruimingTotal = 0 ; foreach (int value in elements) { runningTotal += value; } Console.WriteLine("RunningTotal (" + runningTotal + ")"); int makValue = int.MinValue; foreach (int value in elements) { if (value > martValue) { martValue = value; } } Console.WriteLine("Maximum value is (" + maxValue + ")");
Другой вариант может быть таким: IList elements = new List(); elements.Add(1); elements.Add(2); elements.Add(3);
280
Гпава
8
int runningTotal = 0 ; int makValue = int.MinValue; foreach (int value in elements) { if (value > maXValue) { maatValue = value; } runningTotal += value; } }
Независимо от используемого варианта, проблема решается методом копирования и вставки. Для одного или двух экземпляров написать цикл foreach не так и сложно, но если бы нам потребовалось использовать код итератора в десятке-полтора мест, это уже бы было проблематичным. Такой тип кода труднее поддается сопровождению и расширению. Одним из способов повышения эффективности было бы поместить код в абстрактный базовый класс, реализованный для вычисления поточной общей суммы или нахождения максимального значения. Далее привидится пример исходного кода трех таких абстрактных базовых классов: IteratorBaseClass.cs, RunningTotal.cs и MaximumValue.cs. Для тестирования каждый из этих классов можно поместить в отдельный файл, abstract class IteratorBaseClass { IList Collection; protected IteratorBaseClass(IList collection) { Collection = collection; } protected abstract void ProcessElement(int value); public IteratorBaseClass Iterate() { foreach (int element in Collection) { ProcessElement(element); } return this; } } class RunningTotal : IteratorBaseClass { public int Total; public RunningTotal(IList collection) : base(collection) { Total = 0; } protected override void ProcessElement(int value) { Total += value; }
Списки,
делегаты
и
281
лямбда-выражения
class MaximumValue : IteratorBaseClass { public int MaxValue; public MaximumValue(IList collection) : base(collection) { MaxValue = int.MinValue; } protected override void ProcessElement(int value) { if (value > MaxValue) { MaxValue = value; } } } static void Main(string[] args) { lList elements = new List(); elements.Add(1); elemants.Add(2); elements.Add(3); Console.WriteLine("RunningTotal (" + ((new RunningTotal(elements).lterate()) as RunningTotal).Total + ") Maximum Value (" + ((new MaximumValue (elements) .Iterated ) as MaximumValue) .MastValue + ")"); }
Модифицированный код намного длиннее, хотя пользовательский код (выделенный жирным шрифтом) — намного короче. Тем не менее, и этот код все еще неправильный. Неправильность его состоит в том, что проблему, решаемую в нем, можно решить другим, более простым, способом. Так что, в общем, можно сказать, что задача состоит в том, что мы хотим решить отдельную определенную техническую проблему, используя для этого элегантный код, который не содержит скопированные и вставленные повторяющиеся фрагменты. Решение данной задачи с помощью делегатов, анонимных делегатов и лямбда-выражений рассматривается в следующих разделах. Идея заключается в том, чтобы показать практический пример, в котором каждая возможность применяется натуральным образом. ОЦЕНКА
ПРЕИМУЩЕСТВ
ПОВТОРНОГО
ИСПОЛЬЗОВАНИЯ
КОДА
Очень часто код, который выполнят задачу прямым образом, более короткий и более прямолинейный. Когда же мы начинаем абстрагировать код и разрабатывать общие классы, его объем начинает увеличиваться, что является платой за возможность повторного использования данного кода. Так когда же абстрагирование кода стоит затраченных на это усилий?
Глава 10
282
Попробуем ответить на этот вопрос аналогией с постройкой дома. Допустим, что вы строите сами себе дом и вам требуется 50 стропил. Стропила можно собрать двумя способами: укладывая и соединяя индивидуально их компоненты для каждого стропила, или же можно соорудить специальный удерживающий шаблон для точной укладки и соединения компонентов. Проблема заключается в том, какой из этих двух способов выбрать. Если сборка стропил без шаблона займет 3 дня, а с шаблоном 1 день, то вроде бы стоит собирать стропила с помощью шаблона. Но не все так просто, как кажется. Что если сборка самого шаблона требует 3 дня? В таком случае, время, сэкономленное применением шаблона, будет утрачено на создание шаблона. Но если вы строите несколько домов с одинаковыми стропилами, то тогда создание шаблона для их сборки будет оправдано. То же самое применимо и к программному обеспечению. Иногда, даже если код получится более сложным и раздутым, его абстрагирование позволяет сэкономить время, т. к. упрощается код конечного пользователя. Для принятия решения, писать ли направленный код для решения специфической задачи или общий код для повторного использования, вам нужно будет полагаться на свой опыт. Практическим правилом в этом отношении будет сначала решить проблему, а потом, если выяснится, что полученный код можно использовать повторно, абстрагировать его.
Делегаты С самого начала в языке С# применялась концепция делегатов. Делегат — это метод, не имеющий типа. Рассмотрим, например, следующее определение типа: interface IExample { void Method(); }
Если данный интерфейс преобразовать в делегата, то код будет выглядеть так: delegate void Method();
Делегаты и интерфейсы играют одинаковую роль в том, что они являются типами без реализаций, применяемые для создания компонентов. Интерфейс может иметь множественные методы и свойства. А делегат является объявлением метода и может определять только параметры и возвращаемые типы. Делегаты предоставляют возможность определения общего механизма для вызова методов, не требуя при этом выполнения лишней работы по реализации интерфейса. Для решения проблемы, представленной в предыдущем разделе, можно применить подход с использованием делегатов. Для этого нужно определить функциональность для выполнения операций в цикле. Такая функциональность называется итератором. А для выполнения операций с итератором посредством делегата интегрируется другая функциональность. В результате у нас имеются две отдельные функциональности, объединенные посредством применения технологии компонентов. Исходный код предыдущего примера для оператора foreach, модифицированный с применением делегатов, будет выглядеть таким образом: public delegate void ProcessValue(int value); public static class Extensions {
Списки,
делегаты
и
283
лямбда-выражения
public static void Iterate(this ICollection collection, ProcessValue cb) {' foreach (int element in collection) { cb(element);
> } } static class Tests { static int _runningTotal; static void ProcessRunningTotal(int value) { _runningTotal += value;
•
} static int _maxValue; static void ProcessMaximumValue(int value) { if (value > _maxValue) { _
maxValue = value;
} } static void DoRunningTotalAndMaximum() { List 1st = new List { 1, 2, 3, 4 }; _runningTotal = 0; 1st.Iterate(new ProcessValue(ProcessRunningTotal)); Console.WriteLine("Running total is (" + _runningTotal + ")"); _maxValue = int.MinValue; 1st.Iterate(new ProcessValue(ProcessMaximumValue)); Console.WriteLine("Maximum value is (" + _maxValue +")"); } public static void RunAllO { DoRunningTotalAndMaximum() ; } }
Объявление делегата и использование методов расширения Д е л е г а т о б ъ я в л я е т с я в п е р в о й строке к о д а : public delegate void ProcessValue(int value); О б ъ я в л е н и е д е л е г а т а н а х о д и т с я в н е о б л а с т и в и д и м о с т и к л а с с а или и н т е р ф е й с а , но д е л е г а т д о л ж е н и с п о л ь з о в а т ь с я в к о н т е к с т е к л а с с а . П о э т о м у в то в р е м я как
284
Глава 10
для объявления делегата не требуется окружающий тип, то для его реализации требуется. Типом делегата является идентификатор метода, которым в нашем случае выступает ProcessValue. Делегат будет использован в примере для предоставления общего механизма обратных вызовов в итераторе. Итератор объявляется следующим образом: public static class Extensions { public static void Iterate(this IList collection, ProcessValue cb) { foreach (int element in collection) { cb(element); } } }
Статический класс Extensions имеет статический метод. Как было объяснено в главе 4, это означает, что для данного класса никогда нельзя создавать экземпляров, а вызов метода iterate*) выполняется следующим способом: Extensions.Iterate(...);
В первом параметре методу iterate*) передается список для обработки в цикле, а во втором — экземпляр делегата. Обратите внимание на то, что первый параметр объявлен с ключевым словом t h i s . Представьте, что метод объявлен без использования этого ключевого слова и используется как статический метод. Вызывающая структура будет выглядеть таким образом: IList collection; ProcessValue cb; Extensions.Iterate(collection, cb);
Этот код несколько неуклюж, т. к. в нем ясно подразумевается необходимость знать о существовании метода, обрабатывающего список. Было бы лучше, если бы могли сначала объявить список, а потом использовать IntelliSense, чтобы узнать, имеется ли данный метод. В С# версии 3.0 это возможно посредством методов расширения, которые позволяют разработчику создавать методы, ассоциированные с классами, иными, чем те, в которых они были объявлены. В контексте текущего примера методы расширения позволяют написать следующий код: IList collection; ProcessValue cb; collection.Iterate(cb);
Метод iterate () кажется расширением IList, при этом модифицировать IList не требуется. Методы расширения объявляются посредством объявления статического класса со статическим методом, первому параметру которого предшествует ключевое слово this. Этот параметр не требуется в вызове метода, но представляет тип, который нужно расширить.
Списки,
делегаты
и
лямбда-выражения
285
ПРИМЕЧАНИЕ Методы расширения следует применять только тогда, когда нужно расширить тип, не изменяя его. Такая ситуация может возникнуть при использовании стандартных типов .NET, таких как int, double или IList, или если изменение типов потребовало бы слишком много усилий и времени. Методы расширения применяются только ради функциональности, повторно используемой по всему коду. Их можно было применить в одном или двух случаях, но в долгосрочном плане это может оказаться проблематичным по причине возможных перекрытий или конфликтов.
В реализации метода i t e r a t e * ) каждый элемент коллекции обрабатывается в цикле foreach, в котором переменная cb вызывается, как будто бы она была методом. Вызов переменной cb отделяет итератор от обработки итерации цикла. Представьте себе метод для вычисления текущей общей суммы или максимального значения нескольких значений. Для обработки в цикле элементов нам нужно было бы вызовом метода создать экземпляр делегата и вызвать метод i t e r a t e * ) следующим образом: 1st.Iterate(new ProcessValue(ProcessRunningTotal)); 1st. Iterate (new ProcessValue (ProcessMaximumValue)) ,-
Таким образом, с помощью метода расширения и делегата мы создали компактное и простое общее решение. Для автоматической итерации кода необходимо предоставить только реализацию делегата.
Реализация делегата Реализация делегата является простым процессом. Нужно только объявить метод в классе, который имеет такую же сигнатуру метода. Делегат можно реализовать с помощью статического метода или метода экземпляра; разницы нет никакой. В следующем коде демонстрируется реализация делегата ProcessValue на основе обоих видов методов, class Delegatelmplementations
{
void InstanceProcess(int value) { } static void StaticProcess(int value) { } public static ProcessValue Staticlnstantiate() { return new ProcessValue(StaticProcess); } public ProcessValue Instancelnstantiate() { return new ProcessValue(InstanceProcess); } }
В примере методы InstanceProcess*) и StaticProcess*) являются реализациями делегата ProcessValue. Делегат не имеет никаких ассоциаций. При реализации метода интерфейса класса мы знаем, какие методы каким интерфейсам принадлежат.
286
Глава 10
С делегатами такого везения у нас нет. Если у нас имеются два делегата с одинаковыми сигнатурами параметров и возвращаемого типа, тогда метод с такой же сигнатурой можно использовать для определения любого делегата. Чтобы методы распознавались как делегаты, необходимо взглянуть на методы Staticlnstantiate () и Instancelnstantiate (). Каждый метод создает экземпляр делегата с помощью ключевого слова new, и каждый экземпляр имеет один параметр конструктора, являющийся методом, который нужно ассоциировать с экземпляром делегата. Обратите внимание на то, каким образом метод staticlnstantiate о создает экземпляр делегата с методом staticProcess (). Это возможно потому, что оба метода являются статическими. Так как статические методы преобразуются в делегаты, не играет роли, сколько раз создается экземпляр делегата — каждый раз вызывается один и тот же экземпляр метода. В реализации метода instancelnstantiate*) создается делегат, который служит оберткой для метода instanceProcess (). С первого взгляда может показаться, что п о в е д е н и е м е т о д о в Instancelnstantiate() и Statelnstantiate() СХОДНО, НО меж-
ду этими двумя способами создания экземпляра существует большая разница. А именно, чтобы выполнить метод instancelnstantiate!), необходимо создать экземпляр Delegate implementations. Это очень важный аспект, который нужно принимать во внимание. Рассмотрим следующий исходный код, в котором используется экземпляр Delegatelmplemen tat ions, public ProcessValue GetMeADelegate() { Delegatelmplementations els = new Delegatelmplementations(); return els.Instancelnstantiate(); }
В реализации GetMeADelegate () создается экземпляр класса Delegatelmplementations и вызывается метод instancelnstantiate о. Так как область видимости объекта els ограничена методом GetMeADelegate*), может показаться, что сборка мусора для этого объекта выполняется после завершения исполнения метода. Но это не так. При вызове метода instancelnstantiate!) создается экземпляр делегата, который ссылается на метод instanceProcess <). Поэтому, хотя делегат ссылается на метод, на экземпляр класса els существует ссылка, что препятствует выполнению сборки мусора для него. ПРИМЕЧАНИЕ Практичным правилом касательно делегатов будет, что если делегат ссылается на метод экземпляра, то делегат также ссылается на объект, поэтому для данного объекта нельзя выполнять сборку мусора.
Теперь посмотрим, как можно реализовать делегаты в примере с вычислением текущей суммы и максимального значения: static class Tests { static int „runningTotal; static void ProcessRunningTotal(int value) {
Методы ProcessRunningTotal () и ProcessMaximumValue () имеют одну И ту же сигнатуру ProcessValue(), поэтому являются кандидатами на создание делегатов. В каждой реализации делегата вычисляется текущая сумма или находится максимальное значение нескольких значений. Код с использованием делегатов выглядит так: static void DoRunningTotalAndMaximum() { List 1st = new List { 1, 2, 3, 4 }; _runningTotal = 0; 1st.Iterate(new ProcessValue(ProcessRunningTotal)); Console.WriteLine("Running total is (" + _runningTotal + ")"); _maxValue = int.MinValue; 1st.Iterate(new ProcessValue(ProcessMaximumValue)); Console.WriteLine("Maximum value is (" + _maxValue + ")"); }
В п р и м е р е метод DoRunningTotalAndMaximum*) создает экземпляр класса 1st И присваивает ему значение, используя нотацию инициализатора объекта. Потом для обработки в цикле отдельных элементов вызывается метод 1st. iterate о с делегатом для метода ProcessRunningTotal О. После вычисления и вывода на экран суммы значений, находится и выводится на экран максимальное значение. Решение с применением делегатов является более компактным, чем предыдущее решение с применением абстрактного базового класса. Большим преимуществом делегатов является предоставляемая ими возможность решить проблему кодом меньшего объема, который можно разработать по частям. Реализация делегатов не представляет никаких трудностей, как и их использование.
Анонимные методы Начиная с версии С# 2.0, использование делегатов можно сделать более эффективным с помощью анонимных методов. В предыдущих примерах использования делегатов код для вычисления суммы и максимального значения был определен в явных методах, содержащихся в типе. При использовании анонимных методов код метода определяется в вызове метода.
Глава 10
288
Подход с применением анонимных методов использует тот же итератор класса и делегат ProcessValue. Разница же заключается в том, каким образом используются итератор и ProcessValue О . Методы реализации делегатов ProcessRunningTotal () и ProcessMaximumValue () больше не нужны, а вызывающий код модифицируется следующим образом: List 1st = new List { 1, 2, 3, 4 }; int runningTotal = 0; 1st.Iterate{ delegate( int value)
{
runningTotal += value; }); Console.WriteLine("Running total is (" + runningTotal + ")"); int maxValue = int.MinValue; 1st.Iterate{ delegate(int value) { if (value > maxValue) { maxValue = value; } }); Console.WriteLine("Maximum value is (" + maxValue + ")");
Анонимные методы выделены жирным шрифтом. Анонимный метод представляет собой полное объявление метода в другом методе. Сигнатурой метода является идентификатор delegate, за которым следуют параметры определенного делегата. Определять возвращаемое значение не требуется, т. к. оно подразумевается в объявлении делегата ProcessValue(). Теория в основе анонимных методов несколько сложновата, т. к. код анонимного метода не исполняется при объявлении метода. Лучшим способом понять, как работает анонимный метод, будет рассматривать его, как способ объявления кода, который будет исполнен позже. Посмотрев на первую часть кода, выдленного жирным шрифтом, мы увидим, что данная реализация идентична реализации метода ProcessRunningTotalО. Объявляя анонимным метод для вычисления текущей суммы, код как бы говорит: "Когда вы готовы что-то делать, вот код, который нужно исполнять". Посмотрев на реализацию обоих анонимных методов, мы увидим ссылки на состояние, которое объявлено в контексте родительского метода. Большим преимуществом анонимных методов является возможность разделения состояния. Анонимные методы предпочтительны формально объявленным методам потому, что они предоставляют нам возможность написания компактного кода для решения проблемы без отказа от переносимости.
Списки,
делегаты
и
лямбда-выражения
289
Групповое использование делегатов В примерах с делегатом существует взаимно однозначное отношение. Но делегаты по своему существу способны к групповым взаимоотношениям. Так, в примере с итератором для вычисления текущей суммы и максимального значения список обрабатывается в цикле только один раз. Далее приводится модифицированный код примера, в котором вызываются две реализации делегатов в одной итерации. List 1st = new List { 1, 2, 3, 4 }; int runningTotal = 0; int maxValue = int.MinValue; ProcessValue anonymous = new ProcessValue{ delegate(int value) { runningTotal += value; }) ; anonymous += new ProcessValue( delegate(int value) { if (value > maxValue) { maxValue = value; } }) ; 1st.Iterate(anonymous); Console.WriteLine("Running total is (" + runningTotal + ")"); Console.WriteLine("Maximum value is (" + maxValue + ")");
Жирным шрифтом в примере выделены присваивание и добавление реализации делегата переменной. Имеется лишь одна переменная anonymous, которая при вызове с использованием нотации делегата представляет один вызов метода. Среда исполнения .NET понимает, что одна переменная может представлять множественные реализации делегатов и добавляет все необходимые механизмы для обработки группового обращения. В результате данного группового обращения метод i t e r a t e () нужно вызывать только один раз, чтобы выполнить две операции. Реализация делегата удаляется с переменной с помощью оператора -=. void RemoveDelegate( ProcessValue toRemove) { anonymous -= toRemove; }
Лямбда-выражения Теперь мы готовы рассмотреть решение проблемы этой главы с помощью лямбдавыражений, в основе которых лежат те же идеи, что и для анонимных методов. Далее приводится код примера для вычисления текущей суммы и максимального значения, модифицированный с использованием лямбда-выражений: public static class Extensions { public static void Iterate(this ICollection collection,
Глава 10
290 Func lambda) { foreach (int element in collection) { lambda(element); } } } static class Tests { static void DoRunningTotalAndMaximum() { List 1st = new List { 1, 2, 3, 4 }; int runningTotal = 0; 1st.Iterate( (value) => { runningTotal += value; return true; >); Console.WriteLine("Running total is (" + runningTotal + ")"); int maxValue = int.MinValue; 1st.Iterate( (value) => { if (value > marfValue) { maxValue = value; } return true; }); Console.WriteLine("Maximum value is (" + maxValue + ")"); } public static void RunAll() { DoRunningTotalAndMaximum(); } }
Жирным шрифтом выделен измененный код примера, использующего анонимный метод. Первым основным отличием кода с применением лямбда-выражений является отсутствие необходимости определять делегаты, т. к. они уже предопределены. Рассмотрим следующее объявление; Funccint, bool> MyMethod;
Здесь объявляется метод с параметром int и возвращаемым значением типа bool. Данный метод выглядит таким образом: bool MyMethod(int value) { return false; }
Списки,
делегаты
и
лямбда-выражения
291
Интерфейс API .NET позволяет определять одинаковым образом методы, принимающие до пяти параметров. Если бы нам был нужен метод с пятью параметрами, то определенный метод имел бы шесть общих параметров .NET — последний параметр указывал бы тип возвращаемого методом значения: Funccint, int, bool, int, int, bool> FiveArgs;
А если бы нам был нужен метод совсем без параметров, то определенный метод имел бы один общий параметр .NET, указывающий тип возвращаемого методом значения: Func NoArgs;
Объявлять идентификатор делегата не обязательно, т. к., используя обобщения .NET и объявления делегатов, можно определить любую требуемую комбинацию объявлений методов. Единственным невозможным объявлением метода является метод делегата без параметров и без возвращаемого типа. ПРИМЕЧАНИЕ Чтобы определить Func () без возвращаемого типа, нужно определить явный делегат, например void Func ( ) . Но лямбда-выражения можно продолжать использовать, т. к. компилятор С# подстроится под ситуацию и подберет соответствующий код.
Теперь посмотрим на лямбда-выражение в коде, заменившем анонимный метод, который реализует сигнатуру делегата, состоящую из параметра int и возвращаемого типа bool. (value) => { runningTotal += value; return true; }) ;
В лямбда-выражении отсутствует ключевое слово delegate или идентификатор делегата. В ключевом слове и идентификаторе нет надобности по той причине, что они подразумеваются, т. к. лямбда-выражение является анонимным методом. Параметры лямбда-выражения определяются в круглых скобках, но их тип не указывается. Информация о типе параметров не требуется по той причине, что она подразумевается на основе объявления метода iterate*). Мы знаем, что значение параметра имеет тип int, т. к. в противном случае метод iterate о не скомпилировался бы. Символы => отделяют объявление параметра от реализации метода. Хотя в примере применяются круглые и фигурные скобки, лямбда-выражения можно объявлять без скобок. В таком случае символы => неявно указывают, что далее следует выражение. Фигурные скобки, как и в других типах исходного когда С#, подразумевают блок исполняемого кода. Давайте еще раз посмотрим на идентичный анонимный метод: new ProcessValue* delegate(int value) {
292
Глава 10 runningTotal += value;
} );
Анонимной метод, с его ключевым словом new, переменной ProcessValue и идентификатором delegate, имеет довольно многословный синтаксис, который не добавляет ничего в действительности значимого. Лично я при реализации анонимных методов постоянно считаю скобки, чтобы их было правильное количество. Сравните это с компактным и легко читаемым лямбда-выражением. Преимущество последнего очевидно.
Применение лямбда-выражений Использование лямбда-выражений не облегчит вашу работу по программированию. И, несомненно, поначалу у вас будут проблемы с их пониманием. Но когда вы разберетесь с ними, то они сделают решение определенного класса проблем тривиальным. Идея лямбда-выражений заключается в отложении выполнения на позднее время. Они как бы говорят: "Когда будем выполнять операцию х, тогда также выполним операцию у . В предыдущем разделе мы рассмотрели один из сценариев использования лямбдавыражений. Чтобы продемонстрировать другое их применение, рассмотрим проблему иного рода. Возьмем простую электронную таблицу, в которой нужно пересчитать формулы в ячейках, не нарушая состояние ячеек. Соответствующие ячейки показаны на рис. 9.1.
Рис. 9.1. Ячейки электронной таблицы
Наша электронная таблица имеет девять ячеек, три из которых содержат значения. Ячейки А2 и Bi содержат значения, а ячейка сз — формулу, которая суммирует значения первых двух ячеек. Результаты ячейки сз умножаются на 2 и помещаются в ячейку С2. Все это стандартные операции с электронной таблицей. Теперь рассмотрим исходный код для выполнения вычислений в таблице. Подумайте немного, как это сделать, прежде чем продолжать читать дальше.
Списки,
делегаты
и
лямбда-выражения
293
Создание алгоритма Проблема с данной задачей состоит в том, что мы не можем выполнить обработку от одного угла к другому. Вообразите, что ячейку можно представить следующим интерфейсом, содержащим один метод Execute (). interface ICell { void Execute(); }
Метод Execute о можно рассматривать как "волшебный", т. к. он сам знает, что делать с ячейкой. Тогда всю таблицу можно представить следующей коллекцией: IListcIListcICell» spreadsheet;
Коллекция внутри объявления коллекции создает двумерный список ячеек. Данное объявление является примером электронной таблицы с динамическими размерами. Для сравнения, рассмотрим следующее объявление массива электронной таблицы постоянных размеров: ICell[,]
spreadsheet;
Как видим, электронную таблицу можно объявить несколькими способами. Для данного примера мы будем пользоваться таблицей, объявленной как коллекция коллекций. Для обработки таблицы создается цикл foreach, в котором исполняется метод ICell.Execute(): foreach (IList rows in spreadsheet) { foreach (ICell cell in rows) { cell.Executed ; } }
Алгоритм проходит в цикле по коллекциям и обрабатывает содержащиеся в них ячейки. Но такой подход будет неправильным, т. к. в нем ячейка С2 будет обработана раньше, чем ячейка сз. Но логика нашей электронной таблицы обратная данному направлению обработки, т. е. ячейка сз должна обрабатываться перед ячейкой С2. Чтобы алгоритм работал должным образом, структуру ячеек необходимо реорганизовать таким образом, чтобы ячейка сз обрабатывалась перед ячейкой С2. Нам необходимо создать другую структуру, включающую иерархию обработки.
Реализация алгоритма с помощью лямбда-выражения Другим подходом к организации электронной таблицы будет использование лямбда-выражений. Рассмотрим следующее объявление электронной таблицы: class Spreadsheet { public Func