Введение
Лекция 1. Введение в C# и .NET Framework
Назначение Visual C#
Понятие сборки
Как Common Language Runtime работает со сборками
Инструменты .NET Framework
Раздел 2. Создание проектов в среде разработки
Основные черты Visual Studio 2010
Шаблоны проектов Visual Studio 2010
Шаблоны приложений в Visual Studio 2010
Структура проектов и решений Visual Studio
Создание приложения .NET Framework
Как скомпилировать и запустить приложение C#
Раздел 3. Создание приложения на C#
Структура консольного приложения
Ввод и вывод в консольном приложении
Рекомендации по использованию комментариев в C#
Раздел 4. Создание приложений с графическим интерфейсом
Структура WPF-приложения
Библиотека элементов управления WPF
События WPF
Создание простого WPF-приложения в Visual Studio 2010
Раздел 5. Документирование приложения
Теги, обычно используемые в XML-комментариях
Создание документации из XML-тегов
Использование Sandcastle для создания справочного файла в формате .chm
Раздел 6. Отладка приложений в среде Visual Studio 2010
Использование точек останова
Пошаговая отладка в Visual Studio
Использование окон отладки
Лекция 2. Программные конструкции C#
Что такое тип данных?
Объявление и инициализация переменных
Область видимости переменной
Приведение типов
Константы и переменные только для чтения
Раздел 2. Выражения и операции
Операции C#
Приоритет операций
Несколько замечаний об использовании операции + для конкатенации строк
Раздел 3. Создание и использование массивов
Объявление и инициализация массивов
Общие свойства и методы, предоставляемые массивами
Доступ к данным в массиве
Раздел 4. Операторы выбора
Оператор if в полной форме
Условная операция
Вложенные операторы if
Оператор множественного выбора switch
Раздел 5. Использование операторов цикла
Цикл do
Цикл for
Лекция 3. Объявление и вызов методов
Создание метода
Вызов методов
Создание и вызов перегруженных методов
Использование списка параметров
Рефакторинг участка кода в метод
Тестирование метода
Раздел 2. Параметры по умолчанию и выходные параметры
Использование именованных аргументов при вызове методов
Выходные параметры
Лекция 4. Обработка исключений
Использование блоков try/catch
Использование свойств исключений
Использование блока finally
Использование ключевых слов checked и unchecked
Раздел 2. Выбрасывание исключений
Выбрасывание исключения
Рекомендуемые методики работы с исключениями
Лекция 5. Работа с файлами
Чтение и запись в файлы
Управление директориями
Работа с классом Path
Использование стандартных диалоговых окон для работы с файловой системой
Использование классов OpenFileDialog и SaveFileDialog
Раздел 2. Чтение и запись файлов с использованием потоков
Чтение и запись бинарных данных
Чтение и запись текстовых файлов
Чтение и запись данных встроенных типов
Лекция 6. Создание новых типов
Создание перечисляемых типов данных в C#
Присваивание значений переменным перечисляемых типов
Раздел 2. Создание и использование классов
Добавление членов в класс
Объявление конструкторов и инициализация объектов
Создание объектов
Доступ к членам класса
Использование частичных классов и частичных методов
Раздел 3. Создание и использование структур
Объявление и использование структур
Инициализация структур
Раздел 4. Сравнение ссылочных типов и типов значений
Передача методом простых типов по ссылке
Упаковка и распаковка
Нулевые типы
Лекция 7. Инкапсуляция данных и методов
Закрытые и открытые члены
Внутренний и открытый доступ
Раздел 2. Разделяемые методы и данные
Создание и использование статических методов
Создание статических типов и использование статических конструкторов
Создание и использование методов расширения
Лекция 8. Наследование от классов и реализация интерфейсов
Иерархия наследования .NET Framework
Переопределение и сокрытие методов
Вызов методов и конструкторов базового класса
Присваивание значений и ссылки на классы в иерархии наследования
Полиморфизм
Использование ключевого слова sealed для классов и методов
Раздел 2. Определение и реализация интерфейсов
Создание и реализация интерфейса
Доступ к объектам через интерфейс
Явная и неявная реализация интерфейса
Раздел 3. Определение абстрактных классов
Понятие абстрактного метода
Лекция 9. Управление временем жизни объектов и работа с ресурсами
Управляемые ресурсы в .NET Framework
Как работает сборщик мусора?
Использование деструкторов
Раздел 2. Управление ресурсами
Шаблон проектирования Dispose
Вызов метода Dispose из деструктора
Управление ресурсами приложения
Лекция 10. Инкапсуляция данных и определение   перегруженных операций
Объявление свойства
Автоматические свойства
Инициализация объектов с использованием свойств
Объявление свойств в интерфейсе
Рекомендации по объявлению и использованию свойств
Раздел 2. Создание и использование индексаторов
Как объявить индексатор
Сравнение индексаторов и массивов
Объявление индексаторов в интерфейсах
Раздел 3. Перегрузка операций
Синтаксис перегрузки операции
Ограничения при перегрузке операций
Рекомендации по перегрузке операций
Реализация и использование операций преобразования типа
Список литературы
Текст
                    Министерство образования и науки Российской Федерации
Ярославский государственный университет им. П. Г. Демидова
В. В. Васильчиков
Программирование на языке C#
для .NET Framework
Курс лекций
Часть 1
Учебное пособие
Рекомендовано
Научно-методическим советом университета
для студентов, обучающихся по направлению
Прикладная математика и информатика
Ярославль 2013

УДК 004.43(042.4) ББК 3973.2-018.1я73 В 19 Рекомендовано Редакционно-издательским советом университета в качестве учебного издания. План 2013 года. Рецензенты: кандидат физико-математических наук С. И. Щукин; кафедра теории и методики обучения информатике ЯГПУ им. К. Д. Ушинского Васильчиков, В. В. Программирование на языке C# В IQ для .NET Framework : курс лекций. Часть 1 : учебное пособие / В. В. Васильчиков; Яросл. гос. ун-т им. П. Г. Демидова. —Ярославль : ЯрГУ, 2013. — 196 с. ISBN 978-5-8397-0912-6 Курс лекций посвящен рассмотрению устройства и основных механизмов среды .NET Framework версии 4.0, а также разработке для нее программных приложений и компонентов на языке C# версии 4.0. Предназначен для студентов, обучающихся по на- правлению 010400.62 Прикладная математика и ин- форматика (дисциплина «Программирование в .NET Framework на языке С#», цикл БЗ), очной формы обучения. Библиогр.: 5 назв. ISBN 978-5-8397-0912-6 УДК 004.43(042.4) ББК 3973.2-018.1я73 © Ярославский государственный университет им. П. Г. Демидова, 2013
Введение Начиная с 2005 года на математическом факультете и факультете информатики и вычислительной техники ЯрГУ автор читал лекции и проводил практические занятия по программированию на языке C# приложений и компонентов, предназначенных для работы в среде .NET Framework. В качестве среды разработки использовалась Microsoft Visual Studio. За прошедшее время сменилось несколько версий языка, среды .NET Framework и Visual Studio. В настоящий момент занятия проводятся с использованием Microsoft Visual Studio 2010 и .NET Framework версии 4.0, а также четвертой версии языка С#. Разумеется, развитие и языка, и программных инструментов продолжается, однако можно предположить, что ключевые их элементы уже устоялись и навыки использования упомя- нутых версий будут полезны в течение достаточно долгого времени. Литературы по программированию на C# в .NET Framework в настоящее время издано довольно много, однако она издается малыми тиражами и не всегда доступна, в том числе и по цене. Кроме того, объем изложенного там материала весьма велик, что не всегда удобно для первого знакомства с предметом. Этим и обусловлена необходимость компактно изложить базовые моменты, которые следует усвоить в первую очередь. Для углубления же полученных знаний студенты далее могут воспользоваться книгами, изданными в сериях "для профессионалов". Некоторые издания последних лет приводятся в списке рекомендуемой литературы. Поскольку объем материала, изучаемого на занятиях, все равно слишком велик для изложения в одном учебном пособии, автор счел целесообразным разбить его на две части. В первой части данного курса лекций студенты познакомятся с устройством и основными механизмами среды .NET Framework, а также с базовыми элементами (не всеми) языка программирования C# версии 4.0. Кроме того, в ней затронуты вопросы тестирования приложений и компонентов с использованием встроенных инструментов Visual Studio 2010. Автор исходит из предположения, что студенты к моменту начала занятий как минимум умеют программировать на языке С. Весьма желательным было бы понимание принципов объектно-ориентированного программирования, языка C++, а также наличие опыта использования какой-либо версии Microsoft Visual Studio, впрочем, для полноценного усвоения материала это все не является обязательным. з
Лекция 1. Введение в C# и .NET Framework Microsoft Visual Studio 2010 и Microsoft .NET Framework 4 обеспе- чивают полнофункциональную среду для разработки, позволяющую вам отлаживать и развертывать свои приложения, а также управлять ими. В данной лекции мы рассмотрим цели .NET Framework 4 и процесс создания приложений с использованием Visual Studio 2010. Раздел 1. Введение в .NET Framework 4 В разделе мы познакомимся с ключевыми концепциями .NET и некоторыми инструментами, существенно облегчающими процесс разра- ботки приложений. Что такое .NET Framework 4? .NET Framework 4 обеспечивает комплексную платформу разработки, которая предлагает быстрый и эффективный способ построения при- ложений и служб. Используя Visual Studio 2010 и .NET Framework, разработчики могут создавать очень разнообразные решения, работающие в широком диапазоне вычислительных устройств. .NET Framework 4 содержит три основных элемента: CLR (Common Language Runtime), библиотеку классов .NET Framework и набор оболочек и шаблонов разработки. Общеязыковая среда исполнения CLR CLR - это среда, которая управляет исполнением кода. Она же обеспечивает обычные потребности программы, такие как управление памятью, транзакции, взаимодействие процессов, многопоточность и многие другие. CLR обеспечивает надежную и безопасную среду испол- нения, что в результате упрощает и ускоряет процесс разработки. Библиотека классов .NETFramework .NET Framework предоставляет нам обширную библиотеку классов, которые можно использовать для создания приложений. Эти классы обеспечивают основную функциональность и конструкции, избавляя разработчиков от рутины и необходимости изобретать велосипед. Например, класс System.IO.File предоставляет разработчикам ос- новную функциональность для работы с файлами в файловой системе Windows. И кроме того, вы можете расширять функциональность этих классов и создавать собственные библиотеки. Шаблоны разработки .NET Framework 4 предоставляет разработчикам множество шаблонов для создания приложений и служб разных типов. Они обеспечивают вас необходимыми компонентами и инфраструктурой для разработки. В частности, вам предоставляются следующие варианты: 4
• ASP.NET - для создания Web-приложений серверной стороны. • WPF (Windows Presentation Foundation) - для создания клиент- ских приложений с развитым интерфейсом. • WCF (Windows Communication Foundation) - позволяет создавать безопасные и надежные приложения, ориентированные на службы. • WF (Windows Workflow Foundation) - позволяет создавать специальные решения для моделирования бизнес-процессов. Назначение Visual C# CLR - среда исполнения для кода, сгенерированного компилятором. Разумеется, можно использовать и другие языки, если есть соответствующий компилятор. В Visual Studio 2010 есть как минимум компиляторы C++, Visual Basic, F#, С#. Для других языков используются компиляторы сторонних поставщиков. C# выбирают многие разработчики. Его синтаксис весьма похож на синтаксис с C++, Jana. При этом он содержит ряд расширений и функций, предназначенных для работы с .NET Framework. Разработчики, ранее имевшие дело с С, C++ или Java, очень легко усваивают этот язык. Язык стандартизован (в соответствии со спецификацией ЕСМА-334). Сторонние разработчики также могут создавать С#-компиляторы. Visual C# - это название реализации от Microsoft. Она интегрирована в Visual Studio. В Visual Studio для работы есть полнофункциональный редактор кода, компилятор, шаблоны проектов, дизайнеры, мастера кода, мощный и простой в использовании отладчик и множество других инструментов. Имеется также вариант Visual C# Express Edition. Замечание. Язык постоянно эволюционирует. Visual C# 2010 использует версию C# 4.0, которая содержит некоторые расширения для С#, не являющиеся частью ЕСМА стандарта. Понятие сборки При компиляции Visual C# приложений в Visual Studio 2010 компилятор генерирует код, который может работать под CLR. Результирующий файл называется сборкой. Это код в промежуточном формате MSIL (Microsoft intermediate language). Все компиляторы .NET Framework генерируют код именно в этом формате, независимо от языка программирования, который был использован для написания приложений, и именно этим обеспечивается полное межъязыковое взаимодействие. Сборка - основной строительный блок .NET Framework. Это единица развертывания, контроля версий повторного использования и обеспечения безопасности. Можно также представлять сборку как коллекцию типов и ресурсов, которые работают вместе и образуют логическую единицу функцио- нальности. Сборка предоставляет CLR информацию, необходимую для выпол- нения кода. Два типа сборки: исполняемая программа или библиотека, 5
которая содержит исполняемый код и которую могут использовать другие программы. Использование библиотек придает разработке модульный характер. Обычно при предоставлении заказчикам сборки как части приложения разработчик хочет быть уверен, что она содержит информацию о версии, а также в том, что сборка подписана. Как правило, приложения периодически обновляются. Информация о версии может помочь вам определить, какие версии приложения уже установлены, и позволяет выполнять необходимые шаги для обновления приложения. Кроме того, эта информация может помочь при докумен- тировании и исправлении ошибок. Подписывание позволяет убедиться, что сборка не была модифи- цирована или испорчена. Подписанной сборке можно дать так называемое строгое имя. Вся эта информация (версия, подпись) сохраняется как метаданные в манифесте сборки. Там же содержится информация об области видимости сборки и используемых ею классах и ресурсах (в том числе внешних). Как правило, манифест сохраняется внутри PE-файла (хотя есть варианты). Версия сборки Версия сборки задается четырьмя числовыми значениями: • основной номер версии • дополнительный номер версии • номер сборки • номер редакции Подписывание сборки Это важный шаг, который позволяет: • защитить сборку от модификации • поместить сборку в GAC • гарантировать уникальность имени Для подписывания сборок есть специальная утилита Sign Tool, но можно использовать и встроенные средства Visual Studio 2010. Как Common Language Runtime работает co сборками Сборки содержат код на языке MSIL, который не является исполни- мым. При запуске приложения .NET Framework CLR загружает MSIL-код из сборки и преобразует его в машинный код (исполнимый на данном компьютере). Среда исполнения CLR - основной компонент .NET Framework. Она управляет выполнением кода и обеспечивает работу служб, необходимых для разработки. CLR содержит несколько компонентов, выполняющих разные задачи: 6
1. Class Loader. Находит и загружает все сборки, необходимые для работы приложения. Сборки уже скомпилированы в MSIL. 2. Компилятор из MSIL. Результат компиляции - машинный код, готовый для исполнения. CLR выполняет ряд проверок на тот случай, если вы писали MSIL-код вручную (это тоже можно делать). В случае компиляции из C# в MSIL они являются излишними, однако CLR не делает никаких предположений на этот счет. 3. Code Manager. Загружает исполнимый код и передает управление методу Main. 4. Garbage Collector. Обеспечивает автоматическое управление памятью для всех объектов, создаваемых приложением. Неис- пользуемые объекты уничтожаются, и память освобождается. 5. Exception Manager. Обеспечивает работу механизма исключений, взаимодействует с работой механизма исключений Windows. Инструменты .NET Framework Ниже приводится список некоторых утилит командной строки, которые используются для упрощения процесса разработки .NET- приложений. Caspol.exe (Code Access Security Policy Tool). Позволяет изменять политику безопасности компьютера, пользователя. Можно сформировать собственный набор разрешений и запрещений, можно включить сборки в список полного доверия. Makecert.exe (Certificate Creation Tool). Позволяет пользователям создавать сертификаты для использования в среде разработки (по спецификации х.509). Обычно они используются для подписи сборок и при работе с протоколом защищенных сонетов (SSL). Gacutil.exe (Global Assembly Cache Tool). Позволяет управлять сборками в GAC: поместить, удалить. GAC представляет собой место для хранения сборок, совместно используемых несколькими приложениями. Ngen.exe (Native Image Generator). Может использоваться для предварительной компиляции из MSIL в машинный код, что позволяет несколько ускорить запуск (обычно используется ЛТ-компиляция). Ildasm.exe (MSIL Disassembler). Утилита для получения детальной информации о сборке (своего рода дизассемблер): посмотреть MSIL-код, манифест и т. и. Sn.exe (Strong Name Tool). Используется для подписи сборок со строгим именем. Утилита позволяет сгенерировать пару ключей, извлечь из этой пары открытый ключ, проверить сборку и т. и. Раздел 2. Создание проектов в среде разработки Visual Studio 2010 В разделе мы познакомимся со средой разработки Visual Studio 2010, предлагаемыми ею шаблонами проектов, а также с возможностями, встроенными в интегрированную среду разработки (IDE). 7
Основные черты Visual Studio 2010 Visual Studio 2010 - интегрированная среда разработки, которая позволяет быстро спроектировать, реализовать, собрать, протестировать и развернуть приложения и компоненты разных типов и на разных языках. Перечислим основные черты и возможности, обеспечивающие удобство использования Visual Studio 2010 для разработки приложений и компонентов: • Встроенные инструменты с интуитивно понятным способом доступа к ним. Это средства проектирования, создания кода, сборки, тестирования и развертывания. • Быстрая разработка приложений. Есть специальные инструменты для легкого и удобного проектирования сложных графических интерфейсов. Очень удобный редактор кода с хорошо организованной системой IntelliSense и дополнительный контроль при наборе кода. Также предоставляются различные мастера для ускорения разработки тех или иных компонентов. • Встроенные средства для работы с данными. Вам предоставляется Server Explorer, позволяющий зайти на сервер базы данных, исследовать базу и системные службы. Вы получаете удобный способ для создания, доступа и изменения БД, которую использует приложение. • Средства отладки. Вы можете использовать как локальную, так и удаленную отладку. Все возможности, включая точки останова, отслеживание значений переменных и выражений, пути выполнения и многое другое. • Работа с ошибками. Удобные средства диагностики и лока- лизации ошибок. • Помощь и документация. Это и IntelliSense, и готовые фрагменты кода, и встроенная система помощи, которая содержит документацию и множество примеров. Шаблоны проектов Visual Studio 2010 Visual Studio 2010 поддерживает разработку различных типов прило- жений: с Windows-интерфейсом, с Web-интерфейсом, различные службы и библиотеки и многое другое. Чтобы облегчить начало работы, Visual Studio 2010 предоставляет вам набор шаблонов приложений, при использовании которых вы сразу: • получаете стартовый код, на основе которого можете быстро создавать функциональность вашего приложения; • включаете в свой проект необходимые компоненты и элементы управления (в зависимости от выбранного типа проекта); • настраиваете среду разработки на выбранный тип приложения; • добавляете ссылки на сборки, обычно используемые для приложений такого типа. 8
Шаблоны приложений в Visual Studio 2010 Рассмотрим некоторые общие шаблоны приложений, которые можно использовать в Visual Studio 2010. • Консольные приложения. Шаблоны содержат настройки среды, инструменты, ссылки на проекты и стартовый код для разработки приложения, которое работает с интерфейсом командной строки. Этот вариант считается несколько более простым, чем, например, приложения Windows Forms. • WPF-приложения. Содержат аналогичные настройки для создания приложений с развитым графическим интерфейсом. Приложения WPF считаются новым поколением Windows- приложений. • Библиотека классов. Шаблон содержит настройки среды, инструменты и стартовый код для построения .dll-сборок. Они применяются для создания функциональности, которая может использоваться другими приложениями. • Приложения Windows Forms. Шаблон содержит настройки, ссылки на проекты и стартовый код для создания графических приложений Windows Forms. • ASP.NET Web-приложения. Шаблон для создания приложений серверной стороны по технологии ASP.NET. • ASP.NET МУС2-приложения. Шаблон для создания серверных Web-приложений ASP.NET. Технология MVC (Model-View- Controller) отличается от стандартных Web-приложений тем, что разделяет его на отдельные слои: слой представления, слой бизнес-логики и слой доступа к данным. Отметим, что технология постоянно совершенствуется и уже сейчас есть более свежие версии MVC. • Silverlight-приложения. Web-приложения с развитым графи- ческим интерфейсом. В каком-то смысле это Web-аналог WPF. • Служба WCF. Шаблон для создания службы в стиле SOA (Service Orientated Architecture). Структура проектов и решений Visual Studio Проекты и решения - это своего рода концептуальные контейнеры для организации исходных файлов в процессе разработки. Это позволяет упростить процесс построения и развертывания приложений .NET Framework. Проекты Visual Studio Проект используется для организации исходных файлов, ссылок, конфигурационных настроек на уровне проекта для создания единого .NET Framework-приложения или библиотеки. При создании проекта в Visual Studio он автоматически встраивается в решение. 9
Рассмотрим некоторые типы файлов, которые вы можете найти в проектах Visual Studio. • .cs. Исходный код на С#. Есть почти в любом типе проекта. Является собственностью конкретного решения. • .csproj. Файл проекта. В нем прописаны параметры проекта, в частности целевая платформа, куда писать результат. Есть прак- тически в любом проектном решении. • .aspx. Web-страницы. ASP.NET. Чаще содержит только разметку, но может содержать и код, например на C# (а в файле .aspx.cx обычно содержится его фоновый код). • .config. Конфигурационные файлы в ХМЕ-формате. Их, в частности, используют для хранения настроек уровня приложения, например строк подключения к БД, чтобы при ее изменении не требовалось перекомпилировать приложение. • .xaml. Используются в WPF и Silverlight-приложениях для опре- деления элементов пользовательского интерфейса (.xaml.cs - фоновый код). Решения Visual Studio По-существу, это контейнер для одного или нескольких проектов. По умолчанию при создании нового проекта автоматически создается решение для него, но можно указать, в какое существующее решение его поместить. Это удобно, например, если вы создаете библиотеку и использующее ее приложение. Их разумно поместить в одно решение и отлаживать в одном экземпляре Visual Studio. Решение может также содержать элементы, не привязанные к конкретному проекту, и любой из проектов данного решения может их использовать. Например, решение ASP.NET может содержать единую таблицу стилей (.css), которую любой из проектов использует для стандартизации внешнего вида Web-страниц. Такое объединение нескольких проектов в рамках одного решения может принести определенную выгоду: • позволяет работать с несколькими проектами в одном сеансе Visual Studio; • позволяет применять общие параметры конфигурации сразу к нескольким проектам; • позволяет развернуть несколько проектов в рамках единого решения. Для определения решения используются файлы со следующими расширениями: • .sin. Основной файл. Обеспечивает единую точку доступа к нескольким проектам, элементам проектов и элементам решения. Это текстовый файл, но руками его править не рекомендуется. • .suo. Вспомогательный файл. Хранит настройки среды разработки. ю
Создание приложения .NET Framework Шаблоны приложений, предлагаемые Visual Studio, позволяют создать заготовку проекта требуемого типа с минимальными усилиями. После этого можно настраивать проект в соответствии со своими потребностями и добавлять в него необходимый код. Для создания заготовки проекта можно, например, в меню File выбрать пункт New и далее Project. Затем в диалоговом окне New Project нужно выбрать требуемый тип проекта и пройти интуитивно понятную процедуру настройки его параметров, на чем здесь нет смысла останав- ливаться. В процессе набора кода очень помогает IntelliSense, избавляя нас от необходимости слишком часто обращаться к файлам справки и документации. В частности, это средство предоставляет нам следующие удобные инструменты: • Quick Info. При наведении мыши на идентификатор в желтом выплывающем окне мы видим всю информацию об этом элементе. • Авто заполнение. После ввода несколько первых символов (даже без соблюдения регистра) нажмите Ctrl-пробел - элемент запол- нится автоматически (или вам будут предложены варианты, если их несколько). Средство Code Snippet Picker позволяет вставлять в код заготовки типовых конструкций: операторов цикла, операторов выбора, обработки исключений и т. и. Можно добавлять к этим шаблонам собственные заготовки. Для этого через меню Tools можно вызвать диалоговое окно Code Snippet Manager. Также можно задать комбинацию клавиш для вставки шаблона. Много разных возможностей доступно через контекстное меню (right- click). Это, например, рефакторинг, переход к определению элемента, создание модульных тестов и многое другое. Как скомпилировать и запустить приложение C# Это можно сделать как из среды разработки Visual Studio 2010, так и из командной строки, первый способ, разумеется, удобнее. Процесс компиляции и запуска программы (под управлением отладчика либо без такового) из Visual Studio интуитивно очевиден, поэтому мы не будем здесь на нем останавливаться. В качестве компилятора командной строки используется csc.exe. При запуске необходимо, чтобы в переменных окружения был прописан путь к этому файлу, иначе система его не найдет. Чтобы гарантировать наличие пути, можно вызвать консольное окно через следующую последо- вательность нажатий: кнопка Start, затем All Programs, выбрать Microsoft Visual Studio 2010, затем Visual Studio Tools и, наконец, Visual Studio Command Prompt (2010). Мы пока ограничимся примером вызова компи- лятора csc.exe: п
esc. exe /t: exe /out: "C: \Users\Student\Documents\Visual Studio 2010\ MyProject\myApplication.exe" "C:\Users\Student\Documents\ Visual Studio 2010\MyProject\*.cs" В примере использованы опции компилятора /t для задания типа сборки, здесь - исполняемый файл (ехе) и /out - задание имени и пути для результирующего файла. Раздел 3. Создание приложения на C# В разделе мы разберем структуру простого приложения на С#. Оно содержит один или несколько классов. Также мы увидим, как можно использовать функциональность классов, описанных в других сборках или библиотеках, как использовать класс Console из библиотеки классов .NET Framework для простейшего ввода и вывода. И наконец, мы разберемся, как и зачем следует добавлять комментарии к своим приложениям. Классы и пространства имен - первое знакомство C# - это объектно-ориентированный язык, и, как в любом другом объектно-ориентированном языке, основным понятием в нем являются классы, которые можно представлять себе как своего рода логические компоненты. Для улучшения структурированности и во избежание кон- фликтов имен классы помещаются в пространства имен. Класс - это развитый тип, в котором перечислены характеристики сущности. Он может содержать и свойства, определяющие текущее состо- яние объекта, и методы, задающие его поведение. Пространство имен представляет собой логический набор классов. Физически классы хранятся в сборках, а пространства имен в основном нужны для разрешения проблемы неоднозначности в случае совпадения имен классов. Например, пространство имен System.10 содержит классы для работы с файловой системой, такие как File, Fileinfo, Directory, Directoryinfo, Path. Однако в своем пространстве имен вы можете иметь классы с точно такими же именами. Для того чтобы воспользоваться классом, определенным в .NET Framework, нужно: • добавить в проект ссылку на сборку, содержащую скомпили- рованный код класса; • сделать видимым (входящим в область видимости) пространство имен, содержащее этот класс. Например, для того чтобы воспользоваться методом WriteAIIText класса File, нам надо сделать видимым пространство имен System.10, а уже затем вызывать метод. Приведем пример использования директивы using для добавления пространства имен в область видимости: using System; using System. 10; using System.Collections; 12
После использования этих конструкций можно обращаться к классу просто по имени. Возможен также вариант без директивы using - в этом случае потребуется полная квалификация имени (System.Console вместо Console). Структура консольного приложения Когда вы создаете новое консольное приложение на базе предложенного шаблона, Visual Studio 2010 выполняет следующие действия: • создает новый файл с расширением .csproj, который пред- ставляет проект и структуру всех компонентов, по умолчанию включаемых в консольный проект; • добавляет ссылки на сборки в библиотеке классов .NET Framework, которые обычно используются в консольных приложениях. Обязательно есть ссылка на сборку System; • создает файл Program.cs, в котором есть метод Main, пред- ставляющий точку входа в консольное приложение. Пример сгенерированного кода файла Program.cs представлен ниже: using System; namespace MyFirstApplication { class Program { static void Main (string [] args) { } } } Обратите внимание на следующие ключевые моменты этого кода: • включение в область видимости пространства имен System; • объявление нового пространства имен с именем, производным от имени проекта; • объявление нового внутреннего (internal - по умолчанию) класса по имени Program; • объявление метода Main - закрытого (private - по умолчанию), статического (static), не возвращающего значения (void), прини- мающего в качестве аргумента массив строк. Любое приложение .NET Framework, представленное как исполнимый файл, должно иметь метод, который CLR вызовет первым - это метод Main. Когда вы разрабатываете приложение для .NET, хорошим стилем считается сделать этот метод максимально облегченным, не нагружая его сложной логикой. 13
Основные характеристики созданного автоматически метода Main: • он закрытый, а значит, виден только в пределах класса Program, впрочем, это не мешает среде исполнения CLR запускать его; • он статический, а значит, может быть вызван без создания экземпляра класса Program; • он имеет тип void , то есть не возвращает результата; • на входе он принимает массив строк - это аргументы командной строки. Ввод и вывод в консольном приложении В пространстве имен System есть класс Console, который имеет ряд методов, обеспечивающих базовую функциональность ввода и вывода информации. Ниже перечислены чаще всего используемые методы. • Clear - очищает окно консольного вывода и буфер консоли. using System; Console.Clear(); • Read - считывает очередной символ с консоли. using System; int nextCharacter = Console.Read() ; • Read Key - считывает очередное нажатие клавиши. using System; ConsoleKeylnfo key = Console.ReadKeyO ; • Read Line - считывает строку символов. using System; string line = Console.ReadLine(); • Write - выводит строку в консольное окно. using System; Console.Write("Hello there!"); • WriteLine - выводит строку в консольное окно, а после нее перевод каретки. using System; Console.WriteLine("Hello there!"); 14
Рекомендации по использованию комментариев в C# Хорошим стилем программирования предполагается сопровождение всех процедур кратким комментарием для указания, что она делает. Это полезно и для автора, и для того, кто его код изучает. В Visual C# комментарии, знакомые из С и C++, сохраняются, чаще пользуются однострочными комментариями (//...), но можно использовать и многострочные (/* ... */). Работая с текстовым редактором, для того чтобы закомментарить или раскомментарить участок кода, удобно пользоваться кнопками Comment и Uncomment соответственно. При усложнении кода использование комментариев делает его более читабельным и более легким в работе. Объяснение на человеческом языке очень помогает в случае, если код не слишком прозрачен. Дадим несколько рекомендаций общего характера относительно того, когда следует использовать комментарии. • В случае процедуры (метода) в комментарии следует описать цель процедуры, возвращаемый результат, аргументы и т. и. • В длинных процедурах разумно снабжать комментариями отдельные участки кода. • При объявлении переменной следует указать, для чего она будет использоваться. • При описании процесса принятия решения следует указать, как оно будет принято и что означает тот или иной вариант. Раздел 4. Создание приложений с графическим интерфейсом Раздел посвящен вопросам создания графических приложений, в нем приводится пример WPF-приложения. Мы разберем назначение WPF, структуру WPF-приложения, рассмотрим примеры используемых элемен- тов управления и настройки их свойств. Здесь же мы поговорим о кон- цепции события и о том, как WPF контролирует использование событий. Наконец, будет рассмотрен демонстрационный пример создания простого WPF-приложения в среде Visual Studio 2010. Что такое WPF? Windows Presentation Foundation (WPF) - это графическая подсистема для Windows, которая обеспечивает основу для построения приложений с принципиально новым подходом к отображению графики. Он объединяет в себе создание, отображение и управление документами, а также мульти- медиа и пользовательским интерфейсом. WPF позволяет организовать буквально ошеломляющее взаимодействие с пользователем. 15
Основные особенности WPF В качестве основных характеристик этой технологии можно отметить следующие. • Расширенная поддержка для разработки клиентских приложений. Вы можете создавать привлекательные высокофункциональные приложения. WPF включает в себя несколько функций для рендеринга текста, такие как ОрепТуре и TrueType. • Простота дизайна пользовательского интерфейса. WPF предо- ставляет набор встроенных элементов управления. Он основан на концепции разделения управления от внешнего вида, что обычно считается хорошим архитектурным принципом. • Использование XAML. XAML - extensible Application Markup Language - позволяет разработчикам использовать XML-модель для декларативного управления объектной моделью. XAML быстрее и легче реализовать, чем процедурный код. В приложениях WPF пользовательский интерфейс описывается посредством XAML. • Поддержка совместимости с приложениями на более старых технологиях. Разработчики могут использовать WPF внутри Win32- кода или ранее созданный Win3 2-код внутри WPF-приложения. Структура WPF-приложения Когда вы создаете новое WPF-приложение с использованием соответствующего шаблона, Visual Studio делает следующее: • создает новый файл .csproj, который представляет проект WPF и структуру всех его компонентов по умолчанию; • добавляет ссылки на необходимые сборки, в том числе PresentationCore, PresentationFramework, System, System. Core, System.Xaml; • создает файл разметки App.xaml и файл кода App.xaml.cs, кото- рые можно использовать для определения разметки на уровне приложения и функциональности; • создает файл MainWindow.xaml (он содержит разметку) и MainWindow.xaml.cs (код), которые можно использовать в качестве стартовой точки при создании вашего первого WPF- окна. Ниже представлены примеры генерируемой по умолчанию разметки MainW indow. xaml: <Window x: Class=' 'Wpfi^plicaticnl. LfainWindow'' xmlns="http: //schemas .microsoft. ocm/winfx/2006/xaml/pi?esentaticn" xmlns :x="http: //schemas .microsoft. ocm/winfx/2006/xaml" TitLe='MainWindow'' Height="350" Width="525"> <Grid> </Grid> </Window> 16
и кода из соответствующего файла MainWindow.xaml.cs: namespace WpfApplicationl { public partial class MainWindow : Window { public MainWindow () { Initial! zeComponent () ; } } } Приведенная разметка определяет простое окно с названием по умолчанию, задает его высоту и ширину. Вы можете изменить эти свойства либо руками, отредактировав XAML-код, либо в окне Properties. Также эти свойства можно изменить динамически во время выполнения программы, если написать соответствующий код. В коде разметки присутствует контрол (элемент управления) Grid, который управляет расположением вложенных элементов управления, то есть всех контролов, размещенных на поверхности вашего окна. В качестве контейнера могут выступать и другие контролы, при желании вы можете использовать их вместо Grid. А далее приводится содержимое файла App.xaml, созданное по умолчанию: -explication х: Class="Wj?fApplicationl. АрР" xmlns="http: //schemas .microsoft. ccm/winfx/2006/xaml/presentation" xmlns: x="http: //schemas .microsof t. com/winfx/2006/xaml" StartupUri="MainWindow. xaml"> -explication. Resources> -e/XpUcation. Resources> </Xplication> Обратите внимание, что элемент Application имеет атрибут StartupUri, указывающий на окно, которое следует открыть при запуске приложения. Оба файла (MainWindow.xaml и App.xaml) содержат разметку на XAML, что позволяет на этапе проектирования отделить пользовательский интерфейс от логики приложения (которая в виде фонового кода размеща- ется в .cs-файлах). Библиотека элементов управления WPF WPF включает в себя богатую библиотеку контролов, которые можно использовать для создания WPF-приложений. В ней имеются контролы, которые обычно используются для создания интерфейса Windows-прило- жений, например кнопки, поля ввода и т. и. Однако вы можете определять и собственные элементы управления. 17
Стандартные элементы управления Рассмотрим чуть подробнее наиболее часто используемые элементы управления из этой библиотеки, перечислим некоторые их свойства и приведем примеры объявления. Контрол Описание Пример на XAML Button Типичная кнопка, как в большинстве Windows- приложений <Button Name="myButton" BorderBrush="Black" BorderThickness="1" Click="myButtonOnClick" ClickMode="Press"> Click Me </Button> Canvas Холст. Представляет собой панель для расположения других контролов (дочерних) с абсолютным позиционированием CCanvas Background="Black" Height="200" Width="200"> <!— Child controls —> </Canvas> ComboBox Выпадающий список, пользователь может его прокручивать и делать выбор CComboBox Name="myComboBox"> <ComboBoxItem> Item a </CorriboBoxItem> <ComboBoxItem> Item b </CorriboBoxItem> </CorriboBox> Grid Таблица, которая может содержать несколько гибко настраиваемых строк и столбцов. Обычно используется для размещения дочерних элементов управления <Grid ShowGridLines="True" Width="200" Height="200"> <Grid.ColumnDefinitions> <ColumnDefinition /> <ColumnDefinition /> </Grid. ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition /> </Grid.RowDefinitions> <!— Child controls —> </Grid> Label Метка, содержит статический текст, который предназначен только для чтения CLabel Name="myLabel"> Hello </Label> 18
Контрол Описание Пример на XAML StackPanel Контейнер, позволяющий располагать дочерние элементы управления горизонтально или вертикально <StackPanel Name=" my StackPanel" Orientation="Vertical"> <Label>Item 1</Label> <Label>Item 2</Label> <Label>Item 3</Label> </StackPanel> TextBox Редактируемое поле ввода, позволяет использовать буфер обмена CTextBox Name="myTextBox"> </TextBox> Обратите внимание, что определять элементы управления можно не только в разметке (статически), но и динамически - в файле, содержащем фоновый код. Свойства элементов управления У каждого контрола есть набор свойств, которые можно использовать для настройки внешнего вида и определения поведения. Так, большинство элементов имеют свойства Height и Width, свойство Margin, который определяет, на каком месте относительно контейнера он располагается. Варианты управления значениями свойств: • декларативно, путем редактирования XAML; • в окне Properties. Собственно, это тоже редактирование XAML, только не напрямую; • во время выполнения программы. Для этого нужно написать соответствующий код на С#. При этом код собственно XAML- разметки не меняется. События WPF Большинство технологий программирования (включая WPF, ASP.NET, Windows Forms) основаны на идее управления событиями. Каждая форма или контрол имеют предопределенный набор событий, которые могут произойти. Задача программиста - написать код обработки события (обработчик), который будет выполняться в ответ на срабатывание события. Обработка событий Вы можете указать обработчик для события во время разработки прямо в XAML-редакторе. Достаточно указать событие и имя метода- обработчика. То же самое можно сделать в окне Properties (вкладка 19
Events) - код XAML будет соответственно изменен. Собственно код обра- ботчика должен быть прописан в файле с фоновым кодом. В примере приведен код XAML для кнопки с указанием обработчика события Click и соответствующий код С#, который определяет метод- обработчик. Когда пользователь нажмет кнопку myButton, будет вызван метод myButton_Click. При вызове метода система передает методу через его параметры информацию о кнопке и собственно событии (определяется во время выполнения). <!—XAML-разметка элемента управления—> <Button Name="myButton" Click="myButton_Click"> Click Me </Button> // Visual С#: обработчик события private void myButton_Click(object sender, RoutedEventArgs e) { // Код обработки события } Следующий пример демонстрирует, как можно создать обработчик для контрола Window, реагирующий на закрытие окна. <!—XAML-разметка элемента управления—> <Window х:Class="WpfApplication.MainWindow" Name="myWindow" xmlns="..." xmlns:x="..." Title="MainWindow" Height="350" Width="525" Closing= "myWindow_Closing" > </Window> // Visual С#: обработчик события private void myWindow_Closing(object sender, System. ComponentModel. CancelEventArgs e) { // Код обработки события } Создание простого WPF-приложения в Visual Studio 2010 Процесс создания простого WPF-приложения в Visual Studio 2010 состоит из следующих шагов. 1. Запустить Microsoft Visual Studio 2010. 2. В меню File выбрать пункт New, затем Project. 20
3. В появившемся диалоговом окне New Project выполнить следующие действия, по завершении нажать кнопку ОК. • В центральной панели выбрать тип проекта WPF Application. • В поле ввода Name ввести имя своего приложения. • В поле ввода Location ввести путь для сохранения проекта. 4. Добавить элементы управления для своего WPF приложения. • В меню View выбрать пункт Toolbox. • В окне Toolbox сделать двойной щелчок на добавляемом контроле. • Для настройки контрола далее можно использовать окно Design внутри окна XAML. 5. Установить значения свойств элемента управления. • В окне XAML выбрать контрол для настройки. • Произвести настройку свойств либо непосредственно редактируя текст в этом окне, либо через окно Properties. 6. Добавить обработчики событий для элементов управления. • В окне Design выбрать контрол, для которого требуется создать обработчик события. • В окне Properties на вкладке Events выбрать событие, для которо- го требуется создать обработчик, например событие Click. 7. Добавить код обработчика события. • В окне Solution Explorer выбираем XAML-файл, для которого пишем код, и выбираем для него в контекстном меню вариант View Code. • Набираем код, используя встроенный редактор кода. Раздел 5. Документирование приложения В разделе мы познакомимся с XML-комментариями и узнаем, как их можно использовать при разработке .NET-приложений. Кроме того, мы познакомимся с утилитой Sandcastle, предназначенной для создания файла справки для вашего приложения на основании этих комментариев. XML-комментарии В Visual Studio 2010 вы имеете возможность использовать специ- альные комментарии, которые далее могут быть автоматически преоб- разованы в XML-файл. Этот файл может использоваться для создания справочной документации по классам из вашего кода. Его можно также использовать, чтобы включить поддержку IntelliSense при работе с вашим компонентом. Встроенные комментарии являются частью стандарта С#, а вот XML- комментарии - это уже расширение от Microsoft, и они часто используются сторонними производителями (пример - утилита Sandcastle). 21
Эти специальные комментарии начинаются с комбинации из трех подряд слэшей (///), за которым следует XML-тег, используемый при автоматическом создании документации. В следующем примере используются теги <summary> и <seealso>. Ill <summary> Класс Hello выводит приветствие на экран III </summary > public class Hello { III <summary> Мы используем консольный ввод-вывод. Ill Для дополнительной информации о методе WriteLine III смотри <seealso cref ="System.Console.WriteLine() "/> III </summary> public static void Main() { Console.WriteLine("Hello World"); } } Теги, обычно используемые в XML-комментариях В следующей таблице перечислены несколько стандартных тегов. В дополнение к ним вы можете создавать свои собственные. Тег Назначение <summary> ... </summary> Используется для краткого описания, для более длинного обычно используют <remarks>. <remarks> ... </remarks> Развернутое описание, может содержать вложенные параграфы, списки и теги других типов. <example> ... </ехаир1е> Позволяет привести пример того, как ме- тод, свойство или другой член могут быть использованы, обычно используются в комбинации с вложенными тегами <code>. <code> ... </code> Указывает, что представленный текст - это код приложения. <retums> ... </retums> Описывает тип и смысл значения, возвращаемого методом. Создание документации из XML-тегов В среде Visual Studio вы можете для преобразования XML-тегов в комментариях в файл с документацией установить в окне свойств проекта флажок "XML documentation file" (раздел Build). Если приложение компи- лируется из командной строки, можно указать опцию /doc. 22
Если ошибок в XML-комментариях нет, вы получите ХМЕ-файл, который можно посмотреть, например, в Internet Explorer, а можно пере- дать утилите Sandcastle для создания файла справки. Замечание. Sandcastle не входит в поставку Visual Studio, но доступна для загрузки на веб-сайте CodePlex. Рассмотрим пример использования опции /doc для создания XML- файла с документацией при использовании строчного компилятора. esc. exe /1: exe /doc: "C: \Users\Student\Docurnents\Visual Studio 2010\ My Pro ject\myCornments .xml" /out: "C: \Users\Student\Documents\Visual Studio 2010\MyProject\ myApplication.exe" "C: \Users\Student\Documents\Visual Studio 2010\MyProject\* . cs" Полученный XML-файл будет выглядеть так: <?xml version="l.0"?> <doc> <assembly> <name>MyProj ect</name> </assembly> <members> <member name="T:Hello"> <summary> The Hello class prints a greeting on the screen </summary> </member> <member name="M:Hello.Main"> <summary> We use console-based I/O. For more information about WriteLine, see <seealso cref="M:System.Console.WriteLine"/> </summary> </member> </members> </doc> Использование Sandcastle для создания справочного файла в формате .chm Посредством утилиты Sandcastle вы теперь можете получить профессионально оформленный файл справки в формате .chm. Перечислим последовательность действий, которую для этого следует выполнить. 1. Запустить приложение Sandcastle Help File Builder GUI из папки Sandcastle Help File Builder. 2. В меню File menu выбрать пункт New Project. 3. В диалоговом окне Save New Help Project As выполнить следующие действия, после чего нажать кнопку Save: • Указать путь для сохранения проекта Sandcastle. • Указать имя проекта Sandcastle. 23
4. В окне Project Explorer щелкнуть правой кнопкой мыши на пункте Documentation Sources, после чего выбрать Add Documentation Source. 5. В открывшемся диалоговом окне указать XML-файл, затем нажать кнопку Open. 6. В меню Documentation выбрать пункт Build Project. Для построения проекта и создания файла документации может потребоваться несколько минут. Раздел 6. Отладка приложений в среде Visual Studio 2010 Раздел посвящен основам процесса отладки приложений в среде разработки Visual Studio 2010. В частности, мы познакомимся с панелью инструментов Debug, использованием точек останова и окон отладки для изучения поведения приложения и пошагового исполнения кода. Основные моменты отладки в Visual Studio 2010 Отладка является неотъемлемой частью разработки приложений. Синтаксические ошибки позволяет исправить компилятор и средства IntelliSense, а вот ошибки в логике можно исправить только в процессе тестирования с использованием специальных отладочных средств. Более того, уже после передачи приложения пользователям они могут сообщать вам о неправильной работе при тех или иных обстоятельствах, а вы обязаны устранить возникающие проблемы. Visual Studio 2010 предоставляет вам ряд инструментов для отладки кода. Их можно использовать и при написании кода, и во время тестовой фазы, и после того, как приложение передано в эксплуатацию. Вы можете запустить приложение и в отладочном режиме, и без использования отладки. При работе в режиме отладки вы можете получить доступ к функциям отладки (например, пошаговое выполнение) через соответствующие элементы управления в меню или на панели Debug, а также используя специальные комбинации клавиш. Управление отладкой Рассмотрим основные средства управления отладкой, доступные через меню Debug, панель Debug и соответствующие сочетания клавиш. Опция меню Кнопка панели инструментов Сочетание клавиш Описание Start Debugging Start / Continue F5 Запуск отладки. Возможен, если приложение еще не запущено либо было приостановлено. В по- следнем случае приложение будет запущено с точки остановки. 24
Опция меню Кнопка панели инструментов Сочетание клавиш Описание Break АП Break all Ctrl + Alt + Break Приостановка работы. Доступно, если приложение работает. Stop Debugging Stop Shift + F5 Остановка процесса отладки (возобновить можно будет только сначала). Доступно, если приложение работает или приостановлено. Restart Restart Ctrl + Shift + F5 Перезапуск. Равносильно остановке и запуску заново (сначала). Доступно, когда приложение работает или приостановлено. Step Into Step into F11 Один шаг с заходом в метод. Step Over Step over F10 Один шаг без захода в метод. Step Out Step out Shift + F11 Дойти до выхода из метода. Windows Windows Разные Доступ к окнам отладки, комбинация клавиш для каждого своя. Использование точек останова При работе в режиме отладки выполнение можно приостановить. Далее его можно, например, выполнять пошагово, строка за строкой, при этом вы можете просматривать и изменять значения переменных, выполнять дополнительный код, вычислять выражения и многое другое. Место, где вы остановились, указывается желтой стрелочкой. Войти в режим приостановки можно, например, через вызов Break АН, однако в этом случае вы вряд ли сможете точно сказать, где остановитесь. Точки останова позволяют точно указать место в коде, где следует остановиться (с точностью до строки кода). Кстати, по этой причине разумно в строке иметь не более одного оператора. Установить, удалить, включить или выключить точку останова можно разными способами: используя меню Debug, сочетания клавиш (указаны в меню Debug), щелчки мышью в полоске, расположенной слева от окна текстового редактора. Все эти действия интуитивно очевидны, поэтому мы не будем на них останавливаться подробнее. 25
Пошаговая отладка в Visual Studio Перемещаясь по коду в пошаговом режиме, легче понять, как он работает, проверить его логику. При этом вы можете просматривать и редактировать значения переменных. Когда вы доходите до условного оператора, то легко можете определить, по какой ветви пошли вычисления. Есть разные варианты пошагового выполнения: вы можете входить внутрь вызываемого в операторе метода и осуществлять пошаговое выполнение там, а можете считать выполнение метода одним шагом. Рассмотрим их чуть подробнее. • Step Into. Выполняется оператор в текущей позиции. Если там со- держится вызов метода, то текущая позиция переместится на код внутри метода. После этого внутри метода вы также можете вы- полнять код пошагово. Этот вариант относится и к свойствам. Если приложение еще не запущено, то выбор Step Into приведет к запуску в пошаговом режиме с начальной позиции. • Step Over. Отличается от варианта Step Into тем, что не позволяет выйти на код внутри метода или свойства. Вместо этого позиция пере- мещается на действие, следующее за вызовом метода. Исключением является ситуация, когда в вызываемом методе имеются точки останова. При достижении первой из них выполнение приостанав- ливается. • Step Out. Эта функция позволяет выполнить оставшийся код в методе, аксессоре свойства или цикле. Выполнение останавливается на операторе, который должен выполняться сразу после упомянутого участка кода. Пропуск кода В состоянии приостановки желтая стрелка указывает на следующий выполняемый оператор, а сам оператор выводится на желтом фоне. Вы можете изменить естественное поведение программы и через контактное меню (right-click) установить другой оператор, который надо будет выполнить. Такая техника, правда, чревата серьезными ошибками, поскольку вы не всегда способны полностью осознать все последствия изменения порядка выполнения операторов программы. Продолжение выполнения и перезапуск Закончив исследование кода в пошаговом режиме, вы можете продолжить его работу в обычном режиме под управлением отладчика (например, через F5). Он продолжит работу до конца, либо до Break АН, либо до точки остановки. Если вы хотите остановить процесс отладки и начать выполнение заново, можно выбрать функцию Restart. 26
Использование окон отладки Visual Studio 2010 предоставляет вам несколько окон, которые можно использовать в процессе отладки. Они доступны всегда во время выпол- нения, однако используются в основном в режиме приостановки. Рассмотрим некоторые из наиболее часто используемых отладочных окон. Окно Описание QuickWatch Модальное окно, которое позволяет посмотреть значения переменных и целых выражений. Вводим имя переменной или выражение и нажимаем Reevaluate. Для продолжения отладки окно надо закрыть. Locals Окно позволяет просматривать и редактировать локальные (в области видимости) переменные. Immediate Окно позволяет вычислять выражения, выполнять операторы, выводить значения переменных и выражений. Output Окно служит для вывода информации об ошибках иных информационных сообщений. Memory Окно позволяет просматривать и редактировать содержимое памяти, которую использует приложение. Редактированием в этом окне следует заниматься очень осторожно, этот процесс требует очень четкого понимания со стороны разработчика. Process В окне представлена информация о процессах, к которым присоединен отладчик. Modules Окно позволяет просматривать информацию о модулях (сборках и исполняемых файлах), которые использует приложение. Выводится информация о местоположении, номере версии и т. и. Callstack Это окно позволяет просматривать стек вызовов, которые привели нас к текущей позиции в коде. Собственно текущая позиция выводится в верхней части окна, а последовательность вызовов, которая к ней привела, - ниже. Threads Окно для исследования потоков приложения и управления ими. 27
Лекция 2. Программные конструкции C# Лекция посвящена введению в язык С#, рассмотрению основных типов данных и программных конструкций, включая их синтаксис и семантику. Здесь мы научимся: • создавать и инициализировать переменные; • использовать выражения и операторы; • создавать и использовать массивы; • использовать операторы выбора; • использовать операторы цикла. Раздел 1. Объявление и инициализация переменных Все приложения имеют дело с данными. Они могут быть получены через интерфейс пользователя из базы данных, из сети, каких-то еще источников. Для их хранения и правильного использования необходимо ознакомиться с тем, как определять и использовать переменные и типы данных в языке С#. В данном разделе мы разберемся, как C# использует переменные и встроенные типы данных. Кроме того, мы познакомимся с процессом преобразования данных из одного типа в другой. Что такое переменная? Переменная - именованное место в памяти, предназначенное для хранения данных определенного типа. Во время работы программы эти данные могут изменяться. Переменная характеризуется следующими аспектами. • Имя. Уникальный идентификатор, который позволяет в коде ссылаться на переменную. • Адрес. Место в памяти, где она хранится. • Тип данных. Тип и размер данных, которые может хранить переменная. • Значение. Собственно данные. • Область видимости. Определенные участки кода, из которых можно получить доступ к переменной. • Время жизни. Период времени, когда переменная существует и доступна для использования. Что такое тип данных? При объявлении переменной для хранения данных необходимо вы- брать соответствующий тип. Надо отметить, что тип данных в C# - весьма емкое понятие. C# является строго типизированным языком (type-safe). 28
Это означает наличие гарантий со стороны компилятора, что значения, хранящиеся в переменных, всегда имеют соответствующий тип. Часто используемые типы данных В таблице перечислены наиболее часто используемые типы данных в С#, а также их основные характеристики. Тип Описание Размер (в байтах) Диапазон значений int Целые значения 4 от-2 147 483 648 до 2 147 483 647 long Целые значения (больший диапазон представления) 8 от -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 float Вещественные числа (с плавающей точкой) 4 +/-3.4 х Ю38 double Вещественные числа удвоенной точности 8 +/- 1.7 х Ю308 decimal Специальный тип для финансовых вычислений 16 28 значащих десятичных цифр char Символ 2 не определено bool Логическое значение 1 true или false DateTime Момент времени 8 от 0:00:00 01/01/0001 до 23:59:59 12/31/9999 string Последовательность символов (строка) 2 на символ не определено Объявление и инициализация переменных Прежде чем использовать переменную, вы обязаны объявить ее, чтобы указать ее имя и характеристики. Требования к имени Имя должно быть идентификатором. В C# идентификатор должен удовлетворять следующим правилам: • допустимы буквы (латинские), подчеркивания; • первый символ - не цифра; • идентификатор не может совпадать ни с одним из ключевых слов в С#. 29
Замечание. Язык C# чувствителен к регистру. Это, например, означает, что идентификаторы myData и MyData считаются разными. Однако, как правило, не рекомендуется использовать имена, различаю- щиеся только регистром. В разных организациях могут быть приняты собственные соглашения по наименованию (корпоративный стиль). В этом случае следует придерживаться именно его. Все замечания, касающиеся использования регистров символов в идентификаторах, в дальнейшем носят только рекомендательный характер. Разумеется, имена для переменных в любом случае следует делать осмысленными для лучшего понимания кода. Как объявить переменную При объявлении мы резервируем место в памяти для хранения данных. При этом мы должны указать тип этих данных. Синтаксис объявления: DataType variableName; // Или DataType variableNamel, variableName2; Инициализация переменной После объявления переменной ей можно присвоить значение с помощью операции присваивания, например так: variableName = value; Можно (и обычно рекомендуется) производить инициализацию в одном операторе с объявлением int price = 10; DataType variableName = value; Тип выражения в правой части оператора присваивания должен соответствовать типу переменной, иначе код компилироваться не будет, как, например, в следующем случае int numberOfErrployees; nuiriberOfEroployees = "Hello"; Использование неинициализированных переменных являлось одним из источников ошибок в С и C++. Язык C# не позволяет использовать неинициализированные переменные - программа в этом случае не будет компилироваться. Неявно типизированные переменные В случае инициализации при объявлении вы можете вместо явного указания типа использовать ключевое слово var. В качестве типа при этом будет использоваться тип выражения для инициализации. Разумеется, в 30
дальнейшем присвоить переменной значение другого типа будет уже нельзя. Область видимости переменной Это понятие определяет те части программы, откуда возможен доступ к переменной. При попытке обращения к ней из-за пределов области видимости компилятор выдает ошибку. Уровни области видимости Переменные могут иметь один из следующих уровней видимости: • уровень блока; • уровень процедуры; • уровень класса; • уровень пространства имен. Уровень блока Блок - набор операторов, заключенный в рамках открывающей и закрывающей конструкции. Если вы объявляете переменную внутри блока, то только внутри блока ее можно использовать. Время жизни у перемен- ной такое же, как и у всего блока. Пример: if (length > 10) { int area = length * length; } Уровень процедуры Переменные, объявленные в процедуре, недоступны вне этой процедуры. Их принято называть локальными. Пример: void ShowName() { string name = "Bob"; MessageBox.Show("Hello " + name); } Уровень класса Если вы хотите, чтобы время жизни переменной распространялось за пределы действия процедуры, ее следует объявить на уровне класса, в этом случае она становится переменной класса. При объявлении вы можете указать модификатор доступа. Пример: private string message; void SetString() { message = "Hello World!"; } 31
void ShowString () { MessageBox. Show (message) ; } Уровень пространства имен При объявлении переменных на уровне класса с использованием ключевого слова public (модификатор доступа) они становятся доступны для всех процедур в данном пространстве имен. Пример объявления и использования такой переменной: public class CreateMessage { public string message = "Hello"; } public class DisplayMessage { public void ShowMessage() { CreateMessage newMessage = new CreateMessage(); MessageBox.Show(newMessage.message); } } Приведение типов При разработке приложений вам может потребоваться преобра- зование данных из одного типа в другой, например строковое значение "99", считанное из текстового файла, в целое число 99. Этот процесс и называется конверсией или приведением типа. В .NET Framework есть два варианта приведения типа: неявное (implicit) и явное (explicit). • Неявное преобразование. Автоматически выполняется обще- языковой средой выполнения (CLR) в тех случаях, когда операция гарантированно пройдет успешно без потери информации. • Явное преобразование. Требует явного написания кода для осуществления преобразования, которое могло бы привести к ошибке или потере информации. Это уменьшает вероятность ошибки. C# запрещает неявные преобразования, если существует возможность потери точности. Надо иметь в виду, что некоторые явные преобразования могут привести к неожиданным результатам. 32
Неявное преобразование Происходит, когда значение автоматически преобразуется из одного типа в другой. Не требует специального синтаксиса в исходном коде. C# допускает только безопасные неявные преобразования. Пример неявного преобразования из типа int в тип long: int а = 4; long b; b = а; // Неявное преобразование из типа int в тип long Такое преобразование, очевидно, всегда успешно. В обратную сторону это, разумеется, не так. В таблице перечислены неявные преобразования, которые поддерживаются в С#. Исходный тип Результирующий тип sbyte short, int, long, float, double, decimal byte short, ushort, int, uint, long, ulong, float, double, decimal short int, long, float, double, decimal ushort int, uint, long, ulong, float, double, decimal int long, float, double, decimal uint long, ulong, float, double, decimal long, ulong float, double, decimal float double char ushort, int, uint, long, ulong, float, double, decimal Явные преобразования В C# для этой цели можно использовать специальную операцию приведения (type): DataType variableNamel = (castDataType) variableName2; Так можно осуществлять только интуитивно очевидные преобра- зования, например из типа long в тип int. Однако если требуется принципиально изменить формат данных, например при преобразовании из строки в число, нужно использовать методы класса System.Convert. Использование класса. System. Convert Класс предоставляет методы, которые могут преобразовать один тип в другой. Эти методы имеют такие имена, как ToDouble, Tolnt32, ToString и т. д. Класс и его методы могут использоваться во всех языках, под- держиваемых CLR. Им удобно пользоваться еще и потому, что система IntelliSense помогает выбрать нужный метод. Пример метода То I nt32: string possiblelnt = "1234"; int count = Convert.Tolnt32(possiblelnt); 33
Кстати, многие типы реализуют собственный метод ToString. Пример его вызова для переменной типа int: int number = 1234; string nuiriberString = count.ToString() ; Ряд встроенных типов имеют метод Try Parse, который позволяет проверить, удастся ли преобразование. Пример его использования для преобразования из строки в целое: int number = 0; string nuiriberString = "1234"; if (int.TryParse(nuiriberString, outnumber)) { // Преобразование прошло успешно, number теперь равно 1234 } else { // Преобразование не удалось, number теперь равно 0 } Константы и переменные только для чтения Они используются фактически для одних целей - хранения инфор- мации, которая не подлежит изменению во время работы, вместе с тем между ними есть тонкие различия. Сравнение констант и переменных только для чтения Различие между ними относится к способу их инициализации. Константу можно инициализировать только при объявлении, следо- вательно, еще до начала работы программы. Read-only переменную можно инициализировать как при объявлении, так и в конструкторе класса, членом которого она является (т. е. во время выполнения программы). Впоследствии это значение изменить уже нельзя. Синтаксис Объявление переменной только для чтения происходит с исполь- зованием ключевого слова readonly. Для объявления констант упот- ребляется ключевое слово const. Примеры объявления приводятся ниже. В первом примере объявляется read-only переменная для хранения текущей даты и времени. В примере используется класс DateTime и его свойство Now, которое позволяет во время работы определить текущую дату и время. Попытка использовать здесь константу привела бы к ошибке компиляции. readonly string currentDateTime = DateTime.Now.ToString(); 34
Далее приводится пример объявления константы Pi и использования ее для вычисления площади круга и длины окружности. const double PI = 3.14159; int radius = 5; double area = PI * radius * radius; double circumference = 2 * PI * radius; Раздел 2. Выражения и операции Обычно значение, присваиваемое переменной, является результатом вычислений, которые выполняются во время работы программы. Раздел посвящен вопросам построения выражений с помощью операций, предоставляемых языком С#. Также рассматриваются правила вычисления выражений, в частности приоритет операций. И, кроме того, обсуждается вопрос о повышении эффективности программы при построении строк. Что такое выражения? Выражения являются основной конструкцией при написании любого приложения на С#, так как именно они используются для вычислений и управления данными. Выражение состоит из операндов и операций. • Операнды по существу являются значениями. Они могут быть представлены константами, переменными свойствами или результатами вызова методов. • Операции определяют действия, которые необходимо выполнить над операндами для получения результата: арифметические, логические, манипуляции с битами и т. и. Значением выражения является результат, вычисляемый во время исполнения программы. Этот результат имеет тип, который зависит от типа операндов и выполняемых операций. В C# не существует никаких ограничений на длину выражения, кроме памяти компьютера и вашего терпения. Разумно, конечно, не делать их слишком длинными, а производить вычисления по частям, что облегчает чтение и отладку программы. Примеры выражений В первом примере в качестве выражения выступает просто операнд (переменная). Пример не слишком полезен: а Это выражение построено с использованием операции +. Она может работать с разными типами данных, так что результат зависит от типа операндов (возможно, потребуется преобразование типов): а + 1 35
В следующем примере использовано деление (целочисленное, как в языке С, так что результат имеет целый тип и значение 2): 5/2 А вот здесь результат 2.5 и тип double. При вычислении используется неявное преобразование 2 в тип double: 5.0 / 2 Можно, конечно, употребить в одном выражении и несколько операций: а + Ь - 2 Некоторые операции (например, +) могут использоваться по-разному для целого ряда типов. Например, для типа string + переопределен, как конкатенация. Здесь использован вызов метода ToString для объекта с: "Answer: " + с.ToString() Библиотека классов .NET Framework содержит множество допол- нительных методов, которые можно использовать, например, для выполнения математических или строковых операций над данными. Так, в пространстве имен System.Math есть методы, реализующие различные математические функции. Пример вычисления тангенса: b * System.Math. Tan (theta) Операции C# Операции задают действия над операндами при вычислении выражений. C# предоставляет программисту широкий спектр операций для выполнения основных математических и логических действий. Есть три категории операций (по количеству операндов): • Унарные. Один операнд. Пример: унарный минус. • Бинарные, имеют два операнда. Большинство операций отно- сится именно к этой категории. • Тернарная. Это только одна операция ?: (как в С) - условное выражение. В следующей таблице перечислены операции языка С#, разбитые по группам в соответствии со своим назначением. Тип операции Операция Арифметические +, *, А % Увеличение, уменьшение Сравнение II II л V Л и V и (Л Конкатенация строк + 36
Тип операции Операция Логические и побитовые операции &, |, л, !, &&, || Индексация (от нуля) [] Приведение типа (), as Присваивание Л II Л - JI + V J* II о'' II Qo II jF > и Побитовый сдвиг л л V V Информация о типе sizeof, typeof Добавление и удаление делегата + - J Контроль переполнения checked, unchecked Косвенная адресация (только в неуправляемом коде) *, > [], & Условная операция (тернарная) ?: Приоритет операций Операции, которые используются для построения выражения, имеют приоритет, определяющий порядок исполнения. Кроме того, у операций одной группы приоритета может быть разный порядок (слева направо или справа налево), в котором они выполняются. Для изменения порядка выполнения операций используются круглые скобки. В следующей таблице перечислены операции языка С#, разбитые по группам в соответствии со своим приоритетом. Приоритет операции Операция Самый высокий ++, -- (префиксные), +, - (унарный), !, ~ *, /, % + - 5 л л V V л V л II V II = = 1= 5 • & л 1 && II Операции присвоения Самый низкий ++, -- (постфиксные) 37
Для определения порядка выполнения операций, относящихся в одной группе приоритета, используются термины "левоассоциативная операция" (Left-associative - выполняются слева направо) и "правоассоциативная операция" (Right-associative - выполняются справа налево). Например, деление - левоассоциативная операция. Впрочем, все бинарные операции в С#, за исключением присваивания, - левоассоциативные. Присваивание - правоассоциативная операция. Для изменения порядка выполнения операций можно, как обычно, использовать скобки. Несколько замечаний об использовании операции + для конкатенации строк Мы уже видели, что конкатенация строк в C# очень проста, благодаря переопределению операции + для класса String. Тем не менее объединение нескольких (более 2) строк с использованием + в С#, например так, считается плохим стилем программирования: string address = "23"; address = address + ", Oxford Street"; address = address + ", Thornbury"; Причина в том, что класс String неизменяем и каждая операция создает в момент новую строку, а старую потом система должна удалить. Рекомендуемый альтернативный подход - использование класса StringBuilder. Этот класс объявлен в пространстве имен System.Text и позволяет работать со строками динамично и более эффективно. Пример его использования для конкатенации строк приведен ниже. StringBuilder address = new StringBuilder(); address.Append("23"); address.Append(", Oxford Street"); address.Append(", Thornbury"); string concatenatedAddress = address.ToString(); Раздел 3. Создание и использование массивов В разделе вводится понятие массива в C# и объясняется, как их можно использовать для хранения и управления данными. Что такое массив? Массив представляет собой набор объектов одного типа, с которыми мы можем обращаться как с единым объектом. Можно создавать одно- мерные, двумерные, трехмерные массивы, а также массивы больших размерностей. Ко всем массивам относятся следующие замечания: • каждый элемент массива содержит некоторое значение; • элементы доступны по индексам, которые отсчитываются от нуля; 38
• длиной массива мы будем называть общее количество элементов, которые он может содержать; • нижняя граница массива - это индекс его первого элемента (используется очень редко); • массивы бывают одномерные, многомерные и зубчатые; • словом ранг (rank) принято называть размерность массива. Массивы, как было сказано, могут содержать данные только одного определенного типа. Если нужно управлять набором объектов разного типа, то можно использовать один из типов коллекций, которые определены в пространстве имен System.Collections. Объявление и инициализация массивов При объявлении массива требуется указать тип данных, которые он будет хранить, а также имя массива. Само по себе объявление не приводит к выделению памяти для хранения элементов, поэтому размер при объявлении не указывается. Размер нужно указать при создании массива с использованием ключевого слова new. Именно в этот момент CLR создает собственно массив (выделяет память под элементы). Одномерные массивы При объявлении указываем тип элементов, за которым следуют пустые скобки [ ], чтобы показать, что переменная является массивом. Далее можно создать массив с помощью ключевого слова new с указанием размера. Можно также инициализировать массив, задав в фигурных скобках список значений. В этом случае компилятор определит размер массива, исходя из размера списка инициализаторов. Пример объявления и создания массива без списка инициализаторов и с таковым (есть и другие варианты синтаксиса): Туре[] arrayNamel = new Туре[ Size ] ; Туре[] arrayName2 = new Type{elementl, element2, elementN}; Если при создании массива вы не инициализировали его элементы, то они будут содержать нули соответствующего типа: целого, вещественного, false, либо null. Многомерные массивы Массив может иметь более одного измерения. В C# можно иметь размерность до 32, но редко требуется более трех. Как и в случае одно- мерного массива, есть объявление переменной типа массив (с указанием ранга - по количеству запятых) и есть отдельно создание массива с инициализацией элементов или без таковой. Туре [ , , . . . ] arrayNamel = new Type [ Sizel, Size2 , ... ] ; Type[ , , .. . ] arrayName2 = {{elementl, elements, elements}, {element4, elements, elements}, {element_N_2, element_N-l, element_N}}; 39
Замечание. Конечно, с увеличением числа размерностей объем памя- ти, необходимой для его размещения, быстро растет, так что не следует объявлять массивы больше, чем вам реально требуется. Зубчатые массивы Многомерные массивы должны в каждом измерении иметь одинаковое количество элементов. C# поддерживает также зубчатые (jagged) массивы (или лоскутные, или массивы массивов), на которые это требование не распространяется. Следующий пример показывает, как можно объявить и инициа- лизировать такой массив. Обратите внимание, что при создании указывается первый размер (количество строк), а далее каждая строка создается отдельно. Синтаксис обращения к элементам такой же, как и в С (через [][]). Туре [] [] JaggedArray = new Type [10] [] ; JaggedArray[0] = new Type[5] ; //Можно указать разные размеры JaggedArray[1] = new Type[7]; JaggedArray[9] = new Type[21]; Неявно типизированные массивы Аналогично неявно типизированным переменным вы можете использовать ключевое слово var для объявления и создания неявно типизированных массивов. В этом случае тип элементов определяется из типа инициализаторов. Они все должны быть одного типа, иначе компилятор выдает ошибку. var numbers = new[]{l, 2, 3, 4, 5, б, 7, 8, 9, 10}; Пример ошибочного кода (компилятор не может подобрать подхо- дящий тип элемента): var mixed = new[]{l, DateTime.Now, true, false, 1.2}; Общие свойства и методы, предоставляемые массивами Массивы в C# очень полезны для хранения данных и при этом обеспечивают полезную функциональность, позволяющую манипули- ровать данными. Дело в том, что все массивы в C# являются экземплярами типа System.Array (базовый для всех массивов), а он предоставляет нам общие функции, которые мы можем использовать применительно к своим массивам. В следующей таблице перечислены некоторые основные свойства и методы, которые обеспечивают нам массивы. 40
Член класса Тип Описание BinarySearch() Метод Поиск определенного значения в отсортиро- ванном одномерном массиве с использова- нием алгоритма бинарного поиска (O(log2n)) int[] numbers = { 1, 2, 3, 4, 5 }; object numbersClone = numbers.Clone(); Clone() Метод Создает "плоскую" копию массива (копиру- ются ссылки, а не объекты, на которые они указывают - для ссылочных типов). int[] numbers = { 1, 2, 3, 4, 5 }; object numbersClone = numbers.Clone(); СоруТо() Метод Копирует все элементы из одного массива в другой. int[] oldNumbers = { 1, 2, 3, 4, 5 }; int[] newNumbers = new int[oldNumbers.Length]; oldNumbers.CopyTo(newNumbers, 0); GetEnumerator() Метод Позволяет получить перечислитель для перебора элементов массива. int[] oldNumbers = { 1, 2, 3, 4, 5 }; lEnumerator results = oldNumbers.GetEnumerator(); // Или foreach (int number in oldNumbers) { } GetLength() Метод Получение длины конкретного измерения массива. int[] oldNumbers = { 1, 2, 3, 4, 5 }; int count = oldNumbers.GetLength(O); GetValue() Метод Получить значение элемента в массиве по индексу (возвращает тип object, а значит, потребуется приводить тип). int[] oldNumbers = { 1, 2, 3, 4, 5 }; object number = oldNumbers.GetValue(2); // Возвратит значение 3 Length Свойство Количество элементов в массиве. int[] oldNumbers = { 1, 2, 3, 4, 5 }; int numberCount = oldNumbers.Length; // Возвратит значение 5 Rank Свойство Количество измерений в массиве. int[] oldNumbers = { 1, 2, 3, 4, 5 }; int rank = oldNumbers.Rank; // Возвратит значение 1 41
Член класса Тип Описание SetValue() Метод Установить новое значение для элемента с заданным индексом. int[] oldNuiribers = { 1, 2, 3, 4, 5 }; oldNuiribers.SetValue(5000, 4); // Изменит значение с 5 на 5000 Sort() Метод Отсортировать элементы одномерного массива. int[] oldNuiribers = { 5, 2, 1, 3, 4 }; Array.Sort(oldNuiribers) ; // Массив теперь содержит: 12345 Доступ к данным в массиве Для доступа к данным в массиве существуют следующие варианты: • доступ по индексу к конкретному элементу; • итерация по всей коллекции для перебора элементов. Доступ к конкретным элементам Указываем индекс для каждого измерения, учитывая, что индексация начинается с нуля и, следовательно, последний элемент имеет индекс N-1, где N - размер данного измерения. При попытке доступа к элементу за пределами этого диапазона CLR выбросит исключение IndexOutOfRangeException. Пример обращение по индексу 2, возвращаемое значение равно 3: int[] oldNuiribers = { 1, 2, 3, 4, 5 }; int number = oldNuiribers [2] ; Перебор элементов Можно перебрать элементы с помощью цикла for. В примере количество элементов определяется через свойство Length. int[] oldNuiribers = { 1, 2, 3, 4, 5 }; for (int i = 0; i < oldNuiribers.Length; i++) { int number = oldNuiribers [i] ; } Альтернативный подход: использование цикла foreach (более подробно мы разберем механизм его исполнения позже). int[] oldNuiribers = { 1, 2, 3, 4, 5 }; foreach (int number in oldNuiribers) { } 42
Здесь автоматически в цикле извлекаются по очереди все элементы массива и записываются в переменную number. Раздел 4. Операторы выбора В любом языке высокого уровня для обеспечения возможности выбора из двух или более вариантов продолжения вычислительного процесса используются операторы выбора. В данном разделе мы познакомимся с тем, какие средства для этого предусмотрены в С#, а именно: • оператор if else • условная операция ?: • оператор множественного выбора switch Оператор if в сокращенной форме Оператор if в языке C# очень похож на аналогичный оператор в языке С. Синтаксис сокращенной формы этого оператора выглядит так: if ([условие]) [код для исполнения] или так: if ([условие]) { [код для исполнения при выполнении условия] } Однако, в отличие от языка С, условие имеет булевский тип, так что такой оператор (правильный в С) компилироваться не будет (предпола- гается, что i имеет тип int): if (i) i++; Пример правильной конструкции: bool validPercentage; if ((percent >= 0) && (percent <= 100)) { validPercentage = true; } Поскольку мы исходим из предположения, что читатели знакомы с языком С, более подробно рассматривать здесь этот оператор не имеет смысла. Оператор if в полной форме Полная форма оператора if в языке C# также очень похожа на полную форму этого оператора в языке С. Разница, как и ранее, состоит в том, что в качестве условия можно использовать только выражение булевского типа. Ограничимся приведением элементарного примера: 43
bool validPercentage; if ((percent >= 0) && (percent <= 100)) { validPercentage = true; } else { validPercentage = false; } Условная операция Альтернативой оператору if else является условная операция ?:. Ее синтаксис: Type result = [условие] ? [выражение 1] : [выражение 2]; Здесь условие есть выражение булевского типа, а два других выражения должны иметь одинаковый тип. Логика вычисления такая же, как и в языке С. Пример: string carColor = "green"; string response = (carColor == "red") ? "You have a red car" : "You do not have a red car"; Вложенные операторы if Для всех, кто знаком с языком С (да и почти с любым другим языком высокого уровня), мы не можем сообщить о них ничего нового, так что этим замечанием и ограничимся. Оператор множественного выбора switch Для множественного выбора пользоваться этим оператором намного удобнее, чем комбинацией if...else, о чем знают все, кто знаком с языком С. Однако в C# синтаксис, да и семантика использования этого оператора имеют заметные отличия от аналогичного оператора С, поэтому обсудим вопрос поподробнее. Формальный синтаксис оператора switch выглядит так: switch ([выражение для проверки]) { case [константа1]: [оператор выхода из case] case [константа12]: [оператор выхода из case] 44
default: [оператор выхода из case] } Логика исполнения такая же, как и в языке С, однако в синтаксисе есть различия. Основные моменты, на которые следует обратить внимание: • Тип выражения для проверки может быть любым целочисленным (включая char и byte) либо string (в этом случае в качестве case- константы допустимо употреблять null). • Case-константы должны быть литералами, при этом не допускается их совпадение. • Ветвь default необязательна (как и в С). • Несколькими case можно пометить один блок операторов. • Блок должен завершаться явно! Допустимые варианты: • break; • return; • goto case литерал; (такого в языке С не было!). Совет', предпочтительнее для завершения блока использовать опера- тор break. Раздел 5. Использование операторов цикла В разделе рассматриваются операторы цикла, аналогичные тем, которые есть в С, поэтому мы ограничимся беглым обзором. В C# есть также очень удобный оператор цикла foreach, однако он рассматривается позже, поскольку для понимания механизма его работы мы знаем еще недостаточно. Цикл while Оператор очень похож на аналогичный оператор в языке С, с той лишь разницей, что тип выражения в скобках после ключевого слова while обязательно должен быть булевским. В качестве примера рассмотрим вычисление того, за сколько лет при фиксированной процентной ставке величина вклада достигнет значения targetBalance. double balance = 100D; double rate = 2.5D; double targetBalance = 1000D; int years = 0; while (balance <= targetBalance) { balance *= (rate / 100) + 1; years-H- ; } 45
Цикл do Оператор цикла do также очень похож на аналогичный оператор в языке С. Разница в том, что тип выражения в скобках после ключевого слова while должен быть булевским. В качестве примера рассмотрим ввод строки с требованием, чтобы ее длина была не менее 5 символов. string userinput = ""; do { userInput = GetUserInput(); if (userInput.Length < 5) { Console.WriteLine ("You must enter at least 5 characters"); } } while (userInput.Length < 5); Цикл for Смысл и синтаксис такой же, как и в С, однако второе выражение в конструкции должно обязательно иметь булевский тип, если оно опущено, то считается равным true. Таким образом, конструкции типа for (;;) так же допустимы, как и в языке С. Пример: for (int i = 0; i < 10; i += 2) { // Код для выполнения, можно использовать значение i. } Напомним, что областью видимости описанной таким образом пере- менной i является весь цикл for до завершающей фигурной скобки. Разумеется, в теле любого цикла можно употреблять любые допустимые операторы, а значит, циклы могут быть вложенными. Так же, как и в языке С, можно употреблять операторы break и continue, их синтаксис и смысл остались без изменений, так что более подробно на этом вопросе мы останавливаться не будем. 46
Лекция 3. Объявление и вызов методов Ключевым моментом разработки любого приложения является деление решения на логические компоненты. В объектно-ориентирован- ных языках, таких как С, метод - это блок кода, предназначенный для выполнения конкретной части работы. Данная лекция представляет собой введение в вопросы описания методов и их использования на языке С#. Раздел 1. Объявление и вызов методов В разделе мы разберем, что такое метод, как его создать и вызвать, как создать перегруженные методы и методы, которые принимают произ- вольное число параметров. Также здесь мы познакомимся с инструментами рефакторинга, предлагаемыми Visual Studio для создания метода из существующего блока кода, и средствами поддержки модульного тестирования для про- верки работоспособности методов. Что такое метод? Методы реализуют поведение типа. Метод содержит блок кода, опре- деляющий действие, которое тип может выполнить. Весь написанный вами код принадлежит методам - невозможно создать Сопрограмму, не имею- щую хотя бы одного метода. Возможность определения и вызова методов является одним из основных компонентов объектно-ориентированного программирования, и именно методы позволяют типу инкапсулировать операции с целью защиты данных внутри типа. Как правило, разрабатываемые вами приложения для .NET Framework будут содержать много методов, каждый для своей цели. Некоторые их них принципиально важны для работы приложения. Например, любое С#- приложение должно иметь метод Main, который определяет точку входа для приложения. Вызывает его среда исполнения CLR. Некоторые методы могут быть предназначены для использования только внутри типа и, следовательно, недоступны из других типов. Другие методы могут быть предназначены для того, чтобы их можно было вызывать извне, а значит, они открыты для внешнего мира. C# поддерживает две категории методов: • Экземплярные методы. Они выполняются в контексте конкрет- ного объекта и могут напрямую обращаться к данным, принад- лежащим данному объекту. Например, метод ToString, который встречался нам ранее, - это экземплярный метод. Вызывать экземплярные методы можно, указав объект, которому они принадлежат. 47
• Статические методы. Это методы, связанные с типом, а не с конкретным объектом. Ранее мы в примерах встречали, например, вызов Convert.Tolnt32. Tolnt32 - это статический метод класса Convert. При вызове мы указываем тип, а не объект. Более детально различия между статическими и экземплярными методами мы рассмотрим в лекции 7. Создание метода Метод содержит два элемента: • заголовок (определяет спецификацию метода); • тело метода (собственно код). В заголовке указывается имя метода и список параметров, которые в совокупности образуют сигнатуру метода. Она должна быть уникальной в пределах данного типа. В заголовке также описывается тип возвращаемого метода, однако эти элементы в сигнатуру не входят. Правила наименования методов Имя должно быть идентификатором (те же правила, что и для переменной). Поскольку язык C# чувствителен к регистру, имена, различающиеся только по регистрам некоторых символов, считаются разными, однако их использование не считается хорошим стилем про- граммирования. Обычно рекомендуется соблюдать следующие соглашения: • для имен методов использовать глаголы и глагольные выражения - так легче понимается структура вашего кода; • использовать для имен стиль PascalCasing (каждое слово с заглав- ной буквы). В качестве первого символа не следует использовать подчеркивание. Реализация тела метода Тело метода представляет собой набор операторов С#, заключенный в фигурные скобки. Внутри тела метода вы можете определить переменные. Время их жизни ограничено временем выполнения метода. Описание параметров метода По своей природе они являются локальными переменными метода, которые инициализируются при его вызове. Любой метод имеет список параметров (возможно, пустой - тогда в описании есть только круглые скобки). Если параметров несколько, они разделяются запятыми. Для каждого параметра указывается его тип и имя. Рекомендуется для имен параметров использовать стиль camelCasing. Разумно использовать осмысленные имена для удобства работы с кодом. 48
Указание типа возвращаемого значения Все методы должны иметь тип возвращаемого значения. Если значение не возвращается, для указания типа используется ключевое слово void. Тип возвращаемого значения указывается перед именем в заголовке метода. Для возвращения значения используется оператор return. В качестве примера рассмотрим возврат строки. string MyMethod () { return "Hello"; } Выражение, указанное в операторе return должно иметь тип, совпадающий с типом, указанным в заголовке метода (или сводимым к нему неявным образом). Значение его вычисляется при выполнении оператора return, после чего работа текущего метода завершается, а управление передается методу, вызвавшему данный метод. Рассмотрим несколько примеров. Метод без параметров: void ClearReport() { // Некоторая обработка } Метод с двумя параметрами: void CreateReport(string reportName, string reportDescription) { // Некоторая обработка } Метод, возвращающий значение булевского типа: bool LockReport(string reportName, string userName) { bool success = false; // Некоторая обработка return success; } Замечание. Переменные, объявленные внутри блока, имеют область видимости, ограниченную данным блоком. Вызов методов Вы вызываете метод для исполнения его кода. Вы даже не обязаны понимать, как он работает, что часто бывает, если у вас нет его исходного кода (например, это метод одного из классов библиотеки .NET Framework). Чтобы вызвать метод, нужно указать его имя и набор фактических параметров (выражения соответствующего типа). Если метод возвращает 49
значение, оно используется, как правило, путем присвоения его пере- менной подходящего типа либо в процессе вычисления некоторого выражения. Пример Пусть у вас есть метод LockReport, который блокирует доступ к документу для конкретного пользователя. Возвращаемое значение должно сигнализировать об успехе данной операции. bool LockReport (string reportName, string userName) { bool success = false; // Некоторая обработка return success; } Вызов этого метода с указанием названия документа и имени пользователя может выглядеть, например, так: bool isReportLocked = LockReport("Medical Report", "Don Hall"); При вызове метода происходит вычисление значений фактических параметров, присвоение их соответствующим формальным параметрам и передача управления на первый оператор в блоке кода метода. По правилам C# вычисление значений фактических параметров происходит строго слева направо. Это означает, что такой метод int Sum(int first, int second) { return first + second; } при таком вызове вернет значение 5 (так как first==1, second==4): int i = 1; int j = 2; int result = Sum(i++; i+j) ; Создание и вызов перегруженных методов Иногда бывает полезно определить несколько реализаций метода с разным набором параметров. При этом каждая версия выполняет, по существу, одну и ту же работу (можно, конечно, и разную, что не слишком умно), используя при этом разные данные. В качестве примера можно привести метод WriteLine класса Console. Он имеет 19 различных версий, которые позволяют выводить данные различных типов. Приведем пример его использования для вывода целого числа и булевского значения. 50
int intData = 99; bool booleanData = true; Console.WriteLine(intData); Console.WriteLine(booleanData); Эта техника называется перегрузкой (overloading). Вы можете создать столько перегруженных версий метода, сколько вам нужно. Главное, чтобы у каждой версии была уникальная сигнатура. Замечание. Перегрузку разумно использовать только в том случае, когда разные варианты метода делают семантически одно и то же. Определение перегруженных методов Перегруженные методы имеют одно и то же имя, чтобы подчеркнуть, что они передают одну и ту же цель. Тем не менее сигнатура у них должна быть уникальной, чтобы компилятор мог точно определить, какая версия вызывается. Таким образом, они должны иметь разный набор параметров (по количеству или по типу). Напомним, что тип возвращаемого значения, равно как и модификатор доступа, в сигнатуру не входит. Пример допустимой перегрузки метода Deposit (3 варианта) в классе BankAccount: public class BankAccount { private decimal _balance; public void Deposit (decimal amount) { _balance += amount; } public void Deposit (string amount) { _balance += decimal.Parse(amount); } public void Deposit(int dollars, int cents) { _balance += dollars + (cents / 100.0m) ; } } При вызове метода Deposit компилятор по количеству и типу аргу- ментов сможет определить, какую версию вызвать. Использование списка параметров Перегрузка - полезный прием, но не во всех случаях он подходит. Это верно, например, если требуется метод, способный принимать произ- вольное число параметров (ограничение на количество отсутствует). 51
Пусть вы хотите написать метод Add, способный вычислить сумму произвольного количества целых значений. Можно, конечно, начать так: int Add(int one, int two) { return one + two; } int Add(int one, int two, int three) { return one + two + three; } int Add(int one, int two, int three, int four) { return one + two + three + four; Такой подход годится, если вам гарантированно будет нужно вычислить сумму не более чем, скажем, 4 чисел. А если 100? Писать 99 перегрузок? Да и сколько их вообще надо? Решение №1 - передать в качестве аргумента массив. Теоретически его размер не ограничен. Тогда можно метод Add оформить так: int Add(int[] data) { int sum = 0; for (int i = 0; i < data.Length; i++) { sum += data[i]; } return sum; В этом случае требуется всего одна версия метода. Недостатком такого подхода, причем существенным, является неудобство использова- ния метода. Вам придется каждый раз создавать и заполнять массив, например так: int [ ] myData = new int [...]; myData[0] = 99; myData[1] = 2; myData[2] = 55; myData[3] = -26; int sum = myObject. Add (myData) ; Использование ключевого слова params В C# для решения этой задачи имеется ключевое слово params. Оно используется в заголовке метода перед массивом параметров. Тогда 52
компилятор сможет автоматически сгенерировать код, который создает массив из набора параметров, использованных при вызове метода. С его помощью становится возможным такое объявление и использование метода Add: int Add (params int[] data) { int sum = 0; for (int i = 0; i < data.Length; i++) { sum += data[i]; } return sum; } int sum = myObject.Add(99, 2, 55, -26) ; Замечание. Если в дополнение к этому варианту есть перегрузка с конкретным числом параметров, то при вызове предпочтение будет отдано именно ей. Вы можете, кроме списка однотипных параметров, указывать и другие параметры, но в этом случае список должен быть последним, следова- тельно, при описании метода список параметров с ключевым словом params может быть только один. Рефакторинг участка кода в метод Вообще-то словом "рефакторинг" принято называть процесс какой- либо оптимизации кода, например исключение дублирования. Однако здесь мы обсудим только один его вариант, а именно автоматическое преобразование участков кода в метод. Если при помощи кода своего приложения вы заметили, что несколько раз написали один и тот же (или очень похожий) код, есть смысл преобразовать этот кусок кода в метод. При этом и дальнейшая работа с кодом упростится, так как изменения нужно будет вносить только в одном месте. Для автоматизации этой работы Visual Studio 2010 предоставляет вам мастера Extract Method. Его использование выглядит так: 1. В окне редактора выделяем код, в контекстном меню (вызывается правой кнопкой мыши) выбираем Refactor, а затем Extract Method (если ему что-то не понравится в коде, он скажет). 2. В диалоговом окне указываем имя метода и щелкаем ОК. Например, пусть до преобразования у вас был такой код: 53
string messageContents = "My message text here"; string filePath = @"C:\Users\Student\Desktop"; if (messageContents = = null || messageContents = = String.Empty) { throw new ArgumentException("Message cannot be empty") ; } if (filePath == null || ’System. 10.File.Exists(filePath)) { throw new ArgumentException("File path must exist") ; } System.10.File.AppendAllText(filePath, messageContents); Тогда после рефакторинга он превратится в такой: string messageContents = "Му message text here"; string filePath = @"C:\Users\Student\Desktop"; LogMessage(messageContents, filePath); private void LogMessage(string messageContents, string filePath) { if (messageContents = = null || messageContents = = String.Empty) { throw new ArgumentException ("Message cannot be empty"); } if (filePath = = null || !System.10.File.Exists(filePath)) { throw new ArgumentException("File path must exist") ; } System. 10. File.AppendAllText (filePath, messageContents) ; } Обратите внимание, что Visual Studio обнаружила используемые в коде переменные и включила их в число параметров вновь созданного метода. Замечание. Кроме преобразования участка кода в метод Visual Studio 2010 предоставляет и другие операции рефакторинга, которые могут помочь вам улучшить внутреннюю структуру своего приложения. 54
Тестирование метода При построении любого приложения проверка его правильного функционирования является существенной частью процесса разработки. Кроме того, очень важно иметь возможность быстрого и легкого повто- рения процесса тестирования после любого изменения кода. Реализация модульного тестирования в Visual Studio 2010 позволяет упростить этот процесс и помогает убедиться, что тестированием охвачен достаточный объем кода. Используя юнит-тестирование, вы получаете возможность создать целую серию тестов, которую сможете запустить в автоматическом режиме в любой момент и узнать, функционирует ли ваше приложение как должно. Преимущества использования юнит-тестов • Они обеспечивают мгновенную обратную связь. • Они помогают вам документировать свой код и сделать его проще для понимания другими разработчиками. • Они позволяют постоянно запускать регрессионное тестирование, что сводит к минимуму появление новых ошибок. • Они позволяют свести к минимуму усилия для проведения повторного тестирования. Как создать юнит-тест Пусть в вашем коде есть такой метод public int Calculate(int operandOne, int operandTwo) { int result = 0; // Выполняем какие-то вычисления return result; } Выполним следующую последовательность действий. 1. В текстовом редакторе Visual Studio щелкнем правой кнопкой мыши на этом методе и в появившемся контекстном меню выберем вариант Create Unit Tests. 2. В появившемся списке выберем те методы, для которых следует создать тесты, и убедимся, что в списке Output project выбран вариант Create a new Visual C# test project (если добавляем тест к ранее созданным, то следует выбрать подходящий проект в этом списке). 3. В диалоговом окне New Test Project задаем его имя и нажимаем кнопку Create. После этого Visual Studio создает новый проект для юнит-тестирова- ния с указанным именем и добавляет его в текущее решение. Проект содержит класс, имеющий несколько членов, из которых для нас самый главный - это заготовка тестирующего метода. 55
Следующий пример кода - это то, что Visual Studio создал для тестирования метода Calculate. Ill <summary> III A test for Calculate III </summary > [TestMethodO ] public void CalculateTest() { Program target = new Program () ; // TODO: Initialize to // an appropriate value. int operandOne =0; // TODO: Initialize to // an appropriate value. int operandTwo =0; // TODO: Initialize to // an appropriate value. int expected =0; // TODO: Initialize to // an appropriate value. int actual; actual = target.Calculate(operandOne, operandTwo); Assert.AreEqual(expected, actual); Assert.Inconclusive ("Verify the correctness of this test method"); } Код тестирующего метода выполняет следующие действия: • Создает экземпляр класса, содержащего метод Calculate. • Создает и инициирует параметры для него (у нас - 2 параметра типа int). • Создает переменную для возвращаемого значения. • Вызывает метод Calculate. • Проверяет, совпадает ли возвращаемый методом результат с ожидаемым. Сгенерированная заглушка - это хорошая отправная точка для того, чтобы создать тестирующий метод. Запуск юнит-теста После создания проекта для юнит-тестирования можно запустить тесты в среде Visual Studio. Последовательность действий для этого: 1. В меню Test выбираем Windows и далее Test View. Появившееся окно Test View содержит список всех тестовых методов и содержит элементы управления для осуществления выбора, какие тесты требуется выполнить. 2. Отмечаем нужные тесты и нажимаем кнопку Run Selection. Если требуется запуск под отладчиком, нажимаем Debug Selection. 56
Раздел 2. Параметры по умолчанию и выходные параметры Мы познакомились с методами, которые могут принимать переменное число параметров путем передачи их как массива. Однако в C# можно также определить метод, который имеет фиксированное количество параметров, но позволяет при вызове задавать только те параметры, которые необходимы. Этого можно добиться, определив метод с опциональными (необязательными) параметрами. Второй вопрос, рассматриваемый в данном разделе, - это исполь- зование параметров, через которые можно передавать информацию в вызывающий метод - так называемые выходные параметры. Что такое опциональные параметры? Поскольку C# поддерживает перегрузку методов, без реализации технологии опциональных параметров можно было бы и обойтись. Однако есть другие языки и технологии, используемые для разра- ботки Windows-приложений. Одной из широко распространенных техно- логий является COM (Component Object Model). Она не поддерживает перегрузки методов, но зато позволяет использовать методы с опцио- нальными параметрами. Поэтому для облегчения взаимодействия с СОМ в C# была добавлена поддержка опциональных параметров. Необязательные параметры могут оказаться полезными и в других ситуациях. Они обеспечивают достаточно простое решение в случае, когда нет возможности использовать перегрузку. Так может быть, например, если параметры метода не различаются в достаточной степени для того, чтобы компилятор мог сделать правильный выбор перегруженного метода. Рассмотрим такой пример кода: void MyMethod (int intData, float floatData, int morelntData) { } Метод MyMethod принимает два целых параметра и один вещест- венный. Если нужно, чтобы он принимал только один целый и один вещественный, его можно перегрузить так: void MyMethod (int intData, float floatData) { } Теперь вы можете вызывать метод с передачей ему двух или трех параметров, например так: 57
int argl = 99; float arg2 = 100.OF; int arg3 = 101; // Вызов перегруженной версии с тремя параметрами DoWorkWithData(argl, arg2, arg3); // Вызов перегруженной версии с двумя параметрами DoWorkWithData(argl, arg2); А теперь представим, что нам нужен вариант метода, который принимает или первый или третий параметр. Тогда такой код уже не пройдет: void MyMethod (int intData) { } void MyMethod (int morelntData) { } Компилятор выдает такое сообщение об ошибке: “Type ‘typename’ already defines a member called ‘MyMethod’ with the same parameter types. ”. Использование опциональных параметров позволяет проблему решить. Определение опциональных параметров Опциональные параметры позволяют определить метод и указать значения по умолчанию в списке параметров (на случай их отсутствия). Это делается с помощью операции присваивания. Рассмотрим пример. Здесь опциональным является только последний параметр, остальные обязательны. void MyMethod(int intData, float floatData, int morelntData = 99) { } Требования синтаксиса: сначала указываются обязательные пара- метры, затем опциональные. Так что следующий код ошибочен: void MyMethod(int intData, float floatData = 101. IF, int morelntData) { } 58
Вызов методов с опциональными параметрами Они вызываются так же, как и прочие методы. Разница в том, что необязательные параметры при вызове можно опустить, в этом случае будут использоваться значения, заданные по умолчанию. В примере первый вызов содержит все три параметра, а второй - только два. В этом случае в качестве morelntData будет передано значение 99. // Через аргументы передаются все три параметра MyMethod(10, 123.45F, 99); // Через аргументы передаются только два параметра MyMethod(100, 54.321F); Использование именованных аргументов при вызове методов Обычно при вызове метода порядок фактических параметров должен соответствовать порядку в заголовке метода. Но в C# существует возможность указывать имена параметров и таким образом задавать их в ином порядке, нежели в заголовке метода. Синтаксис такого вызова пред- полагает указание имени аргумента и через двоеточие - его значения. Пример: // Объявление метода void MyMethod(int first, double second, string third) { } // Вызов метода с использованием именованных аргументов MyMethod(third: "Hello", first: 1234, second: 12.12); При использовании именованных параметров одновременно с опци- ональными последние при вызове метода также можно опускать. В этом случае они получают значения по умолчанию. Однако если вы опустите обязательный параметр, получите ошибку при компиляции. Разумеется, требование того, чтобы опциональные параметры в заголовке указывались после обязательных, остается в силе. Выходные параметры Если метод должен возвратить только одно значение, то удобнее всего использовать оператор return. Если возвращаемых значений больше одного, то для этого удобно использовать выходные параметры. Если какой-то параметр прописан в заголовке как выходной (out), то пред- полагается, что в теле метода ему будет присвоено значение (иначе будет ошибка компиляции). При выходе из метода это значение записывается в переменную, указанную при вызове. 59
Пример метода с выходным параметром: void MyMethod(int first, double second, out int data) { data = 99; } Разумеется, выходных параметров может быть и несколько. Как уже было сказано, если до выхода из метода какому-либо выходному параметру значение не было присвоено, код не будет компилироваться. Пример вызова такого метода: int value; MyMethod(10, 101.IF, outvalue); // value = 99 Обратите внимание на то, что использование ключевого слова out при вызове обязательно и что аргументом должна быть переменная. 60
Лекция 4. Обработка исключений Обработка исключительных ситуаций - это очень важный аспект разработки приложений, и его обязательно следует учитывать в процессе работы. Если этого не делать (а мы до сих пор этого как раз и не делали), то при возникновении ошибки во время исполнения наше приложение обвалится самым неуклюжим образом. Средствам языка C# для работы с исключительными ситуациями (их еще называют просто исключениями) и посвящена данная лекция. Раздел 1. Перехват исключений Во время разработки, пока приложение не слишком активно используют и контролируют входную информацию, оно вполне может функционировать должным образом. Но после передачи в эксплуатацию при работе в реальных условиях, с большим количеством данных вероятность возникновения непредвиденных ситуаций значительно возрастает. Чтобы ваше приложение все-таки оставалось пригодным к использованию, следует эти ситуации должным образом обрабатывать. Для этой цели мы будем использовать блоки try/catch/finally, которые позволят нам организовать структурированную обработку исключений в своих приложениях. Также мы познакомимся с классом Exception и клю- чевыми словами checked и unchecked. Что такое исключение ? Во время работы приложения многое может пойти не так, и не только из-за ошибок в логике, но и по внешним причинам: наличие файлов, подключение к базе данных и т. и. При разработке приложения следует предусматривать такие ситуации и вы должны продумать, как приложение может продолжить свою работу, если подобная ситуация возникла. Обычной практикой является проверка значений, которые возвра- щают методы, для того чтобы убедиться, что они завершились корректно. Но и здесь есть свои проблемы: • во-первых, не все методы возвращают значение; • во-вторых, мало знать, что метод сработал неправильно, хотелось бы понять почему; • наконец, этот подход не пригоден в непредвиденных ситуациях (например, памяти не хватило). Раньше многие системы использовали для идентификации ошибки некий глобальный объект. Кусок кода, обнаруживший ошибку, должен был занести в этот объект данные, чтобы можно было выяснить причину ошибки, а потом надо было анализировать этот объект. Понятно, что подход весьма неэффективен, кроме того, он позволяет ошибку просто проигнорир овать. 61
Как распространяются исключения Исключение является признаком ошибки или исключительного состояния. Метод может выбросить исключение, если он обнаруживает, что произошло нечто неожиданное, например он пытается открыть несуществующий файл. К моменту выбрасывания исключения вызывающий код должен быть готов его обнаружить и обработать. Если он этого не сделает, то его работа тоже прерывается, а исключение передается уже на уровень того кода, который вызвал этот код и так далее, до тех пор, пока кто-нибудь не обнаружит и не обработает исключение. Например, метод А вызывает метод В, а тот, в свою очередь, С. Во время работы С выбрасывает исключение. Работа С прерывается, а исключение передается в метод В. Если там предусмотрен его перехват и обработка, это в методе В и происходит, если нет, то работа В прерывается, а исключение выходит на уровень А и т. д. Если в конце концов и метод Main не перехватывает это исключение, работа приложения завершается, а пользователь получает сообщение о необработанном исключении. Метод и сам может ловить и обрабатывать свои исключения, так что вызывающий код об этом ничего не узнает. И это разумно. Например, метод, работающий с базой данных, не может подключиться к ней, выбра- сывает исключение и сам же его обрабатывает, пытаясь подключиться еще раз, возможно с другим набором полномочий. Тип Exception В сценарии, описанном выше, если один из методов берется обрабатывать исключение, он должен получить о нем достаточную информацию. Для этого в .NET Framework предусмотрен класс Exception. Когда метод выбрасывает исключение, он создает объект класса Exception и может заполнить его необходимой информацией, именно этот объект и передается по цепочке наверх тому, кто может его наилучшим образом обработать. Использование блоков try/catch Блоки try/catch - ключевой конструктивный элемент обработки исключений. Код, при исполнении которого может сгенерироваться исключение, помещается в блок try. За этим блоком следует один или несколько блоков catch. Они служат своего рода ловушками для исклю- чений указанного типа. В блоках catch размещается код обработки исключения. Синтаксис использования блоков try/catch представлен ниже: 62
// Код блока try } catch ([Описание исключения 1]) { // Код 1 блока catch } catch ([Описание исключения п]) { // Код п блока catch } Операторы, заключенные в блок try, могут вызывать методы других объектов. Если какой-либо из этих вызовов выбросит исключение, управ- ление немедленно передается соответствующему блоку catch. Замечание. После обработки исключения в блоке catch управление передается первому оператору за блоком try/catch. В заголовке блока catch обычно указывается тип и имя переменной, которая хранит информацию об исключении. Для перехвата разных типов исключений есть смысл использовать разные блоки catch (например, в случае необходимости отслеживания деления на ноль следует перехва- тывать исключение DivideByZeroException). А, скажем, некоторые методы из пространства имен System.10 могут выбрасывать исключение FileNotFoundException. Если возникает исключение типа, который не предусмотрен в наборе блоков catch, его обработка передается на уровень выше. Блок catch максимально общего вида, который ловит все исключения, представлен в следующем примере. try // Код блока try } catch { // Код блока catch } Однако в этом коде вы не получаете информации об исключении (не описана соответствующая переменная). Лучше оформлять его так: try // Код блока try } 63
catch (Exception ex) { // Код блока catch имеет доступ к объекту исключения ех } В следующем примере исключения, вызванные делением на ноль, будут перехвачены первым блоком catch, а все остальные - вторым. try // Код блока try } catch (DivideByZeroException ex) { // Код блока catch имеет доступ к // объекту исключения ех типа DivideByZeroException } catch (Exception ex) { // Код блока catch обрабатывает все остальные исключения } Последовательность расположения блоков catch Важно размещать блоки catch в правильном порядке. Среда исполнения последовательно их перебирает, пока не найдет подходящий блок. Поэтому блоки с наиболее конкретным указанием типа исключения должны идти раньше, чем более общие блоки. Типы исключений в библиотеке классов .NET Framework выстроены иерархически. Например, есть класс ArithmeticException, используется в случае ошибок при вычислениях. Более конкретными его подтипами явля- ются, например, исключения DivideByZeroException, OverflowException, NotFiniteNumberException. Так что блок для перехвата исключения типа ArithmeticException должен стоять только после блока, перехватывающего DivideByZeroException. Нарушение порядка приводит к ошибке компиляции. Если есть общий блок catch, то он идет всегда последним. Вложенные блоки try/catch Блоки try/catch - это такие же программные конструкции, как и другие операторы С#, и их также можно употреблять один внутри другого. Пример оформления вложенных блоков try/catch: try // Код внешнего блока try try // Код вложенного блока try } 64
catch (FileNotFoundException ex) { // Блок catch для вложенного блока try } // Продолжение кода внешнего блока try } catch (DivideByZeroException ex) { // Код блока catch имеет доступ к // объекту исключения ех типа DivideByZeroException } catch (Exception ex) { // Код блока catch обрабатывает все остальные исключения } Если исключение FileNotFoundException возникает во вложенном блоке try, то оно перехватывается блоком catch (FileNotFoundException ex). Если же во вложенном блоке try выбрасывается исключение другого типа, оно переходит на уровень выше и будет перехвачено либо блоком catch (DivideByZeroException ex), либо блоком catch (Exception ex). Приведем еще один пример, в котором делается попытка чтения файла. Если происходит ошибка ввода/вывода, то мы попадаем в первый блок catch, при любой другой ошибке - во второй. StreamReader reader = null; try string fileName = GetFileName(); reader = new StreamReader(fileName); string savedData = reader.ReadToEnd(); } catch (lOException ioex) { // Обработка исключения ввода-вывода } catch (Exception ex) { // Обработка всех остальных типов исключений } Использование свойств исключений Все классы исключений содержат в себе информацию, которая явля- ется общей для всех типов исключений, но могут также содержать допол- 65
нительную информацию, специфичную именно для этого типа исключе- ний. Она доступна через свойства объекта исключения. В следующей таблице перечислены свойства, являющиеся общими для всех типов исключений: Свойство Описание Message Свойство содержит строку с описанием ошибки. Source Строка, указывающая на объект или приложение, которое вызвало ошибку. StackT race Строка, содержащая последовательность вызовов (стек) до точки, где было выброшено исключение. Targetsite Строка, содержащая имя метода, который сгенерировал исключение. InnerException Используется как контейнер для объектов типа "исклю- чение", содержащих дополнительную информацию. Вы его можете использовать во вложенном блоке catch, чтобы поместить туда детальную информацию, которая может пригодиться при обработке на внешнем уровне. HelpLink Свойство можно использовать для хранения ссылки на дополнительную информацию о возникшей ошибке. Data Свойство представляет объект, который можно исполь- зовать для хранения дополнительной информации об ошибке. Следующий пример демонстрирует использование свойства Message. { // Код блока try } catch (DivideByZeroException ex) { Console.WriteLine(ex.Message); } Использование блока finally Некоторые блоки могут содержать код, который необходимо выполнить в любом случае, даже если возникнет необработанное исключение. Например, может потребоваться закрыть файл или освободить захваченные ресурсы. Для этой цели можно использовать блок finally. 66
Он пишется после всех блоков catch в конструкции try/catch и содержит код, который должен быть выполнен при завершении блока независимо от того, выброшены ли были исключения, обработаны или нет. В случае перехвата и обработки исключения код блока finally выполняется после кода блока catch. Можно писать блок finally и без блоков catch. В этом случае не обрабатывается ни одно исключение, но код из блока finally выполняется все равно. Синтаксис блока finally try // Код блока try } catch ([Описание исключения 1]) { // Код 1 блока catch } catch ([Описание исключения п]) { // Код п блока catch } finally { // Код блока finally } Последовательность передачи управления для блоков try/catch/finally В этом случае алгоритм чуть сложнее, чем в случае просто блока try/catch: 1. Выполняется код блока try. 2. Если возникает исключение: • Если есть подходящий блок catch: а) выполняется код из этого блока catch; b) выполняется код из блока finally. • Если есть подходящий блок catch и при выполнении кода он выбрасывает исключение: а) выполняется код из блока catch, перехватывающего исходное исключение; Ь) выполняется блок finally; с) выброшенное исключение передается выше - в охватывающий блок try/catch, а если его нет, в вызывающий метод. 67
• Если нет соответствующего блока catch: а) выполняется блок finally; b) исключение передается выше - в охватывающий блок try/catch, а если его нет, в вызывающий метод. 3. Если исключение не было выброшено блоком try, по его завершении выполняется блок finally. Главное здесь то, что блок finally выполняется всегда, и мы можем поручить ему окончательную очистку, независимо от обработки исклю- чений. Пример: производится попытка работы с файлом. В случае ошибки пользователю выдается сообщение, но в любом случае файл будет кор- ректно закрыт. try OpenFile ("MyFile") ; // Открываем файл WriteToFile(...); // Что-нибудь туда записываем } catch (lOException ex) { Console.WriteLine(ex.Message); } finally { CloseFile("MyFile"); // Закрываем файл } Использование ключевых слов checked и unchecked Изрядную часть многих приложений составляет целочисленная арифметика. Однако ограниченность диапазона представления целых чисел порождает вероятность переполнения при выполнении вычислений. В целях повышения производительности по умолчанию в Visual C# проверка на переполнение отключена. Однако при наличии риска пере- полнения ее можно включить явно на отдельных участках кода. Если у вас есть часть кода, при выполнении которого вы хотели бы держать под контролем возможность арифметического переполнения, вы можете использовать ключевое слово checked. Оно может относиться и к целым блокам кода: checked { int х = . . .; int у = . . . ; int z = . . .; } 68
и к отдельным операциям: int z = checked(х * у); Аналогично, если вы включили в своем проекте проверку по умолчанию, отдельные участки кода или операции можно отметить словом unchecked, отменив тем самым проверку: unchecked { int х = ...; int у = . . .; int z = ...; int z = unchecked(x * y); Включение или выключение проверки по умолчанию можно задать в свойствах проекта (выбрать вкладку Build, нажать кнопку Advanced и установить или снять соответствующий флажок). Использование блока checked Если проверка на переполнение включена, при возникновении тако- вого выбрасывается исключение OverflowException. Для его обработки удобно использовать конструкцию try/catch. Пример: checked { int х = ...; int у = . . .; int z = ...; { z = x * у; // Возможно арифметическое переполнение } catch (OverflowException ex) { ... // Обработка исключения } 69
В случае употребления слова checked применительно к отдельному выражению его действие распространяется только на это выражение. public int Multiply (short operandX, short operandY) { return checked((short)(operandX * operandY)); } Это же верно и применительно к использованию ключевого слова unchecked. Раздел 2. Выбрасывание исключений Использование блока try/catch позволяет приложению перехватывать и обрабатывать исключения. Последние могут быть сгенерированы средой исключения CLR, если, например, приложение пытается выполнить недопустимую операцию. С другой стороны, код приложения сам может обнаружить проблему, например недопустимое значение или сочетание аргументов, на этот случай полезно иметь возможность приложению самостоятельно выбрасывать исключение с описанием причин ошибки. Раздел посвящен тому, как создаются объекты исключений и как они выбр асываются. Создание объекта исключения Обнаружив проблему при исполнении своего кода, вы можете сообщить об этом, выбросив исключение. Вызывающий код может его перехватить и обработать. .NET Framework предлагает нам большой набор типов исключений, которые являются наследниками класса Exception. Вопросы наследования будут подробнее рассмотрены в одной из следующих лекций. Каждый тип исключения предназначен для диагностики конкретной ошибочной ситуации (FileNotFoundException, DivideByZeroException). Вообще-то, выбросить вы можете любое исключение, но хорошим стилем является выбрасывание исключения, максимально точно соответствую- щего типу ошибки. В следующей таблице перечислены некоторые из наиболее часто используемых типов исключений. 70
Тип исключения Описание ArgumentExce ption Применяется, если метод вызван с аргумен- том, который не соответствует требованиям метода. Это обобщающий тип. Для большей детализации вы можете, например, исполь- зовать ArgumentOutOfRangeException (при выходе аргумента за пределы допустимого) или ArgumentNullException (если аргумент равен null, а это не допускается). FormatException Применяется, если аргумент содержит данные, не имеющие требуемого формата. Например, строка для передачи телефон- ного номера имеет недопустимый формат. NotlmplementedException Применяется, чтобы показать, что метод еще не реализован (приложение в стадии разработки, и не весь код написан). NotSupportedException Применяется, если клиент пытается с помощью метода выполнить неподдер- живаемую операцию, например записать что-либо в файл “только для чтения”. FileNotFoundException DirectoryNotFoundException DriveNotFoundException Применяется в методах, которые пытаются открыть файл от имени пользователя, если не найден файл, директория или указанный привод. Замечание. Вы всегда можете создать собственный тип исключения путем наследования от класса System.Exception. Вопросы наследования разбираются в следующих лекциях. Синтаксис для создания объекта исключения При создании любых объектов в C# используется ключевое слово new, и к исключениям, разумеется, это тоже относится. При создании объекта исключения вы указываете его тип и информацию, описывающую причину проблемы. Как правило, эта информация представляет собой строку, которая содержит сообщение об ошибке. Также вы можете создать еще один объект исключения с дополнительной информацией и прицепить его к основному (свойство InnerException). Текст сообщения об ошибке будет доступен в блоке catch через свойство Message объекта исключения. Пример создания и обработки исключения: 71
// Пример 1 // Создание объекта FormatException с сообщением об ошибке FormatException ex = new FormatException("Argument has the wrong format") ; // Пример 2 try ... // Операторы, которые могут привести к исключению ... // из-за недопустимого формата данных } catch (Exception е) { // Создание объекта FormatException с сообщением об ошибке, // содержащего ссылку на исходный объект исключения FormatException ex = new FormatException("Argument has the wrong format", e) ; } Различные классы исключений могут иметь разные дополнительные параметры. Например, в следующем примере используется исключение типа ArgumentOutOfRangeException. У него есть конструктор, принима- ющий два строковых параметра. Первый задает имя аргумента с недопус- тимым значением, второй - сообщение о характере ошибки. ArgumentException argEx = new ArgumentOutOfRangeException ("paraml", "Parameter paraml too large."); Выбрасывание исключения Мы научились создавать объект исключения, но пока не обсудили, как его выбросить, чтобы показать, что исключительная ситуация произошла. Это и есть тема данного параграфа. Замечание. Выбрасывание исключения - довольно дорогостоящая операция с точки зрения затрат времени, поэтому не следует ею з л оупотр еб лять. Синтаксис Для выбрасывания исключения используется ключевое слово throw. Формальный синтаксис этого оператора выглядит так: throw [объект исключения] ; Например, создать и выбросить исключение типа FormatException можно так: 72
FormatException ex = new FormatException("Argument has the wrong format") ; throw ex; или так (без использования вспомогательной переменной): throw new FormatException("Argument has the wrong format") ; Повторный выброс исключения Обычная стратегия заключается в том, чтобы метод или блок кода сам ловил исключения и обрабатывал их. Однако, если они сами не могут полностью устранить ошибку, можно перебросить исключение на более высокий уровень (тому, кто нас вызвал). Для этого можно использовать ключевое слово throw без аргумента. Тогда будет выброшен тот же самый объект исключения (в нашем случае е): { ... // Операторы, которые могут привести к исключению } catch (Exception е) { // Попытка обработать ошибочную ситуацию // Если сами не смогли исправить ситуацию, // переправляем исключение вызвавшему нас коду throw; } Рекомендуемые методики работы с исключениями Для улучшения стиля вашего программирования при обработке исключений рекомендуется прислушаться к следующим советам: • выбрасывать исключение, максимально соответствующее по типу возникшей проблеме; • не следует использовать блоки try/catch в обычных (ожидаемых) ситуациях. Их имеет смысл использовать только для обработки ситуаций, выходящих за пределы ожидаемого логического потока вашего приложения; • при использовании нескольких блоков catch их надо располагать в порядке увеличения общности. Блок, улавливающий исключе- ния типа Exception, должен быть последним; 73
• для диагностических целей создавайте специальные детализи- рованные исключения с сообщениями, удобными и понятными для пользователя. Для удобства последующей локализации текст сообщений может быть помещен в файл ресурсов. Эти сообще- ния следует протоколировать. Следующий пример показывает, как можно сохранить сообщение в журнале событий Windows: using System.Diagnostics; // Имя источника события string source = "Му C# application"; // Журнал событий для записи string log = "Application"; // Сообщение, которое требуется записать string message = "An error with code exl032 has occurred..."; // Если источник событий не существует, создадим его if (!EventLog.SourceExists(source)) { EventLog. CreateEventSource (source, log) ; } // Записываем сообщение в журнал событий EventLog.WriteEntry(source, message, EventLogEntryType.Error) ; • не следует показывать пользователю слишком детализированную информацию об исключении, поскольку она может быть использована злоумышленниками для вывода приложения из строя или доступа к защищенной информации. Также она может помочь ему понять логику вашего приложения и использовать это знание в деструктивных целях; • эффективная обработка исключений предполагает, что прило- жение остается работоспособным, а пользователь не рискует потерять данные. 74
Лекция 5. Работа с файлами Возможность доступа к данным в файлах и управления ими требуется очень многим приложениям. Файлы обеспечивают удобное средство хранения данных, будь то обычные текстовые файлы или бинарные файлы с данными в определенном формате. Лекция посвящена вопросам чтения и записи данных в файлы с использованием классов .NET Framework. Мы разберем основные классы и подходы, которые можно применять для чтения и записи данных в различных форматах. Раздел 1. Доступ к файловой системе Это вводный раздел, в котором мы познакомимся с некоторыми классами, обеспечивающими приложению возможность взаимодействия с файлами и директориями, а именно с классами • File и Fileinfo; • Directory и Directoryinfo; • Path. Управление файлами Общим требованием для многих приложений является возможность взаимодействия с файлами, хранящимися в файловой системе компьютера. Это такие действия, как создание нового файла, копирование или удаление файла, перемещение его из одной директории в другую. Для облегчения выполнения таких действий .NET Framework предоставляет нам несколько классов из пространства имен System.Ю. Для начала рассмотрим классы File и Fileinfo. Класс File Класс предоставляет обширную функциональность для работы с файлами. Так, он предоставляет статические методы, перечисленные в сле- дующей таблице. Метод Описание Пример кода AppendAIIText Позволяет открыть существующий файл, добавить туда текст и закрыть файл - все за один вызов string filePath = string fileContents = ft ft • • • • r File. AppendAIIText( filePath, fileContents); 75
Метод Описание Пример кода Copy Позволяет скопиро- вать существующий файл (в другой файл) string sourceFile = string destFile = bool overwrite = false; File.Copy(sourceFile, destFile, overwrite); Create Позволяет создать но- вый файл в файловой системе Windows. Возвращает объект типа FileStream, кото- рый позволяет рабо- тать с файлом, как с потоком (рассматри- ваются далее) string filePath = int bufferSize = 128; FileStream file = File.Create( filePath, bufferSize, FileOptions.None); Delete Позволяет удалить файл из файловой системы Windows string filePath = File.Delete(filePath); Exists Позволяет узнать, существует ли файл с заданным именем (и путем) string filePath = bool exists = File.Exists(filePath); GetCreationTime Позволяет узнать время создания файла string filePath = DateTime time = File.GetCreationTime( filePath); GetLastAccessTime Позволяет узнать время последнего доступа к файлу string filePath = DateTime time = File.GetLastAccessTime( filePath); Move Позволяет перемес- тить файл на другое место или переименовать файл string sourceFile = string destFile = File. Move(sourceFile, destFile); ReadAIIText Позволяет считать все содержимое текстово- го файла в строку string filePath = string fileContents = File. ReadAIIText ( filePath); 76
Метод Описание Пример кода SetCreationTime Позволяет установить (изменить) время создания файла string filePath = File.SetCreationTime( filePath, DateTime. Now) ; SetLastAccessTime Позволяет установить время последнего доступа к файлу string filePath = File.SetLastAccessTime( filePath, DateTime. Now) ; Write AllText Позволяет создать новый файл, записать туда текст и корректно файл закрыть - все за один вызов string filePath = string fileContents = File .WriteAllText ( filePath, fileContents); Класс Fileinfo Класс предоставляет свойства и экземплярные методы, которые по- зволяют создавать, копировать, перемещать файлы, изменять их харак- теристики. Пример кода, создающего объект Fileinfo для работы с файлом myFile.txt из папки С:\Тешр\: string filePath = @"С:\Tenp\myFile.txt"; Fileinfo file = new Fileinfo(filePath); Класс Fileinfo можно рассматривать как своего рода оболочку для работы с конкретным файлом. Для этого используются свойства и методы, перечисленные в следующей таблице. Метод или свойство Описание Пример кода CreationTime (свойство) Свойство позволяет получить/установить время создания файла string filePath = Fileinfo file = new Fileinfo(filePath); file.CreationTime = DateTime. Now ; DateTime time = file.CreationTime; CopyTo (метод) Метод позволяет скопировать данный файл в какой-то другой string filePath = Fileinfo file = new Fileinfo(filePath); string destPath = file.CopyTo(destPath); 77
Метод или свойство Описание Пример кода Delete (метод) Метод позволяет удалить файл string filePath = Fileinfo file = new Fileinfo(filePath); file.Delete() ; DirectoryName (свойство) Свойство позволяет получить путь к файлу (директория) string filePath = Fileinfo file = new Fileinfo(filePath); string dirPath = file.DirectoryName; Exists (свойство) Определяет, существует ли файл string filePath = Fileinfo file = new Fileinfo(filePath); bool exists = file.Exists; Extension (свойство) Позволяет получить расширение файла string filePath = Fileinfo file = new Fileinfo(filePath); string ext = file.Extension; Length (свойство) Позволяет получать размер файла в байтах string filePath = Fileinfo file = new Fileinfo(filePath) ; long length = file.Length; Name (свойство) Позволяет получить имя файла (без пути) string filePath = Fileinfo file = new Fileinfo(filePath); string name = file.Name; Open (метод) Используется для открытия файла. Возвращает объект File Stream, позволяющий работать с файлом, как с потоком string filePath = Fileinfo file = new Fileinfo(filePath); FileStream stream = file.Open( FileMode.OpenOrCreate); Чтение и запись в файлы Классы File и Fileinfo предлагают несколько методов, которые вы можете использовать для чтения и записи данных в файл. 78
Класс File содержит статические методы, которые вы можете использовать для выполнения атомарных операций по прямому считыванию и записи в файл. Методы атомарные, потому что они могут при одном вызове выполнить несколько основных действий. Например, метод AppendAIILines включает в себя действия по получению дескриптора файла, открытию потока для работы с файлом, записи данных в файл, а затем освобождению дескриптора файла. Класс Fileinfo содержит экземплярные методы, которые при записи и чтении опираются на функциональность классов FileStream и StreamReader. Об использовании потоков мы поговорим в следующем разделе. А в этом разделе обсуждаются статические методы, предлагаемые классом File, которые не используют потоков, но обеспечивают атомар- ность операций. Чтение из файлов При использовании класса File для чтения из файла есть много альтернативных методов с различным поведением. Рассмотрим некоторые из них. • Метод Read All Bytes позволяет прочитать содержимое файла в двоич- ном виде и сохранить его в массиве байтов. string filePath = "myFile.txt"; byte[] data = File.ReadAllBytes(filePath); • Метод ReadAIILines позволяет считать текстовый файл полностью, строка за строкой, и сохранить каждую строку в массиве строк. string filePath = "myFile.txt"; string[] lines = File.ReadAIILines(filePath); • Метод ReadAIIText позволяет считать текстовый файл полностью и сохранить его содержимое в виде строки. string filePath = "myFile.txt"; string data = File.ReadAIIText(filePath); Запись в файлы При записи в файлы с использованием класса File есть несколько вариантов в зависимости от типа сохраняемых данных. Во всех случаях вы можете или дописать данные в существующий файл, или создать новый файл, а затем записать туда данные. Рассмотрим некоторые из этих методов. • Метод AppendAIILines позволяет записать содержимое массива строк в текстовый файл (точнее, дозаписать в конец файла). Если указанный в аргументах файл не существует, то будет создан новый файл. string filePath = "myFile.txt"; 79
string[] fileLines = {"Line 1", "Line 2", "Line 3"}; File.AppendAllLines(filePath, fileLines); • Метод AppendAIIText дозаписывает содержимое строки в текстовый файл. Как и ранее, если файл не существует, то он будет создан. string filePath = "myFile.txt"; string fileContents = "I am writing this text to a file called myFile.txt"; File.AppendAIIText(filePath, fileContents); • Метод WriteAIIBytes позволяет записать содержимое массива в бинарный файл. Если файл уже существует, то его содержимое будет перезаписано полностью. string filePath = "myFile.txt"; byte[] fileBytes = {12, 134, 12, 8, 32}; File.WriteAIIBytes(filePath, fileBytes); • Метод WriteAIILines работает аналогично методу AppendAllLines с той разницей, что в случае существования файла его содержимое будет полностью перезаписано. string filePath = "myFile.txt"; string[] fileLines = { "Line 1", "Line 2", "Line 3" }; File.WriteAIILines(filePath, fileLines); • Метод WriteAIIText записывает содержимое строки в текстовый файл. Отличие его от метода AppendAIIText в том, что, если файл уже существует, его старое содержимое будет стерто. string filePath = "myFile.txt"; string fileContents = "I am writing this text to a file called myFile.txt"; File.WriteAIIText(filePath, fileContents); Управление директориями Файлы хранятся в директориях и папках. .NET Framework предлагает для управления директориями пару классов, аналогичных классам File и Fileinfo. Эти классы называются Directory и Directoryinfo и объявлены в пространстве имен System.10. С их помощью вы можете создать новую ди- ректорию, удалить существующую, изменить содержимое директории и т. и. Класс Directory Подобно классу File, класс Directory предлагает набор статических методов, обеспечивающих необходимую функциональность для управле- ния папками и каталогами. 80
Рассмотрим некоторые из этих методов. Метод Описание Пример кода С reate Di rectory Позволяет создать все директории, указанные в параметре dirPath string dirPath = @"С: \NewFolder\SubFolder" ; Directory.CreateDirectory( dirPath); Delete Позволяет удалить одну или несколько директорий string dirPath = @"C:\Users\Student\" + "MyDirectory"; bool deleteSubFolders = true; Directory.Delete( dirPath, deleteSubFolders) ; GetDirectories Позволяет получить список всех поддиректорий в указанном каталоге string dirPath = string[] dirs = Directory.GetDirectories( dirPath); GetFiles Позволяет получить список всех файлов в указанном каталоге string dirPath = string[] files = Directory.GetFiles(dirPath); Exists Позволяет проверить, существует ли ука- занная директория string dirPath = bool dirExists = Directory.Exists(dirPath); Move Позволяет перемес- тить директорию в другое место, но только на данном приводе string sourcePath = string destPath = Directory.Move( sourcePath, destPath); Класс Directoryinfo Класс предоставляет ряд свойств и экземплярных методов для работы с директориями. Как и в случае с классом Fileinfo, при создании экземпляра этого класса требуется указать путь: string dirPath = @"С:\Users\Student\Music\"; DirectoryInfо dir = new DirectoryInfо(dirPath); Объект класса Directoryinfo можно рассматривать как своего рода оболочку над каталогом, позволяющую выполнять разные действия через свои свойства и методы. Кстати, класс Directoryinfo можно, в том числе, использовать и для создания нового каталога. В следующем примере кода мы проверяем, существует ли директория, и если нет, то создаем ее. 81
string dirPath = @"C: \Users\Student\Music\"; Directoryinfo dir = new Directoryinfo(dirPath); if (!dir.Exists) { dir.Create() ; } Перечислим основные свойства и методы класса Directory Info. Метод или свойство Описание Пример кода Create (метод) Позволяет создать дирек- торию по заданному пути. Если она существует, вызов игнорируется string dirPath = Directoryinfo dir = new DirectoryInfo(dirPath); dir.Create() ; Delete (метод) Позволяет удалить не- сколько директорий. Если каталог не существует, то выбрасывается исключение DirectoryNotFoundException string dirPath = DirectoryInfo dir = new DirectoryInfо(dirPath); dir.Delete() ; Exists (свойство) Позволяет определить, существует ли указанный каталог string dirPath = DirectoryInfo dir = new DirectoryInfо(dirPath); bool exists = dir.Exists; FullName (свойство) Позволяет получить полный путь к каталогу string dirPath = DirectoryInfo dir = new DirectoryInfо(dirPath); string fullName = dir. FullName; GetDirectories (метод) Позволяет получить список всех поддиректорий по ука- занному пути. Метод воз- вращает массив объектов Directoryinfo. Их можно ис- пользовать для любых целей, например для рекурсивного продолжения поиска string dirPath = DirectoryInfo dir = new DirectoryInfо(dirPath); DirectoryInfо[] dirs = dir.GetDirectories(); GetFiles (метод) Позволяет получить список всех файлов в указанном каталоге. Метод возвраща- ет массив объектов типа Fileinfo string dirPath = DirectoryInfo dir = new DirectoryInfо(dirPath); Fileinfo[] files = dir.GetFiles() ; 82
Метод или свойство Описание Пример кода MoveTo (метод) Позволяет переместить каталог в другое место в пределах данного привода string dirPath = Directoryinfo dir = new DirectoryInfo(dirPath); string destPath = dir.MoveTo (destPath) ; Name (свойство) Позволяет получить имя данного каталога string dirPath = Directoryinfo dir = new DirectoryInfo(dirPath); string dirName = dir.Name; Parent (свойство) Позволяет получить объект Directoryinfo, представляю- щий родительский каталог string dirPath = @"C: \Users\Student\Music\" ; DirectoryInfo dir = new DirectoryInfо(dirPath); DirectoryInfo parentDir = dir.Parent; В качестве примера рассмотрим код, осуществляющий перебор всех поддиректорий данного каталога и вывод информации о файлах, в них содержащихся. string dirPath = @"С:\Users\Student\Documents"; // Перебираем все поддиректории в директории Documents string[] subDirs = Directory.GetDirectories(dirPath); foreach (string dir in subDirs) { // Выводим имя директории Console.WriteLine("{0} contains the following files:", dir); // Перебираем все файлы в директории string[] files = Directory.GetFiles(dir); foreach (string file in files) { // Выводим имя файла Console.WriteLine(file); } } Работа с классом Path Различные файловые системы могут иметь разные соглашения и правила для задания правильного имени файла, а также пути к нему. Класс Path предоставляет методы, которые можно использовать для анализа и построения имен файлов и папок для определенной файловой системы. 83
Класс Path предоставляет свою функциональность через статические методы. Некоторые из них представлены в следующей таблице. Метод Описание Пример кода GetDirectory Name Позволяет получить имя директории в указанном пути string path = @"С:\Temp\SubFolder\MyFile.txt"; string dirs = Path.GetDirectoryName(path); GetExtension Позволяет получить расширение в имени указанного файла string path = @"C:\Tenp\SubFolder\MyFile.txt"; string ext = Path.GetExtension(path); GetFileName Позволяет выделить в пути имя файла string path = @ "C: \Tenp\SubFolder\MyFile. txt" ; string fileName = Path.GetFileName(path) ; GetFileName Without Extension Позволяет выделить в пути имя файла без расширения string path = @ "C: \Tenp\SubFolder\MyFile. txt" ; string fileName = Path. GetFileNameWithoutExtension ( path) ; GetRandom FileName Позволяет сгенериро- вать случайное имя для папки или файла string fileName = Path.GetRandomFileName(); GetTempFile Name Позволяет создать новый временный файл во временной папке Windows на локальном компью- тере, возвращает абсолютный путь к этому файлу string tempFilePath = Path. GetTempFileName () ; GetTempPath Позволяет получить путь к временной папке Windows string tempPath = Path.GetTempPath(); Использование стандартных диалоговых окон для работы с файловой системой При построении графического интерфейса было бы неразумно пред- лагать пользователям вручную вводить громоздкие имена путей и файлов. 84
Пользователи ожидают, что вы для этого предложите им удобные диало- говые окна. Разработка и реализация такого диалогового окна (например, для открытия или сохранения файла) с нуля - довольно трудоемкая задача. Однако .NET Framework предоставляет нам классы OpenFileDialog и SaveFileDialog из пространства имен System.Win32. Замечание. Эти классы можно также найти в пространстве имен System.Windows.Forms. До появления WPF Windows Forms была основ- ной технологией для разработки Windows-приложений. Оба класса диалога обеспечивают функциональность, позволяющую пользователю найти файл, указать его имя и создать любые необходимые папки. Только назначение у них разное - один позволяет это сделать для открытия файла, другой - для сохранения. В следующей таблице приведены некоторые основные свойства, общие для классов OpenFileDialog и SaveFileDialog. Свойство Описание CheckFileExists Установка требования, чтобы диалоговое окно выводило предупреждение, если указанный пользователем файл не существует FileName Позволяет создать или получить путь к файлу, выбранному в диалоговом окне Filter Позволяет отфильтровать (по типу) файлы, которые пользователь может выбрать в окне 1 nitialDirectory Позволяет задать или получить каталог по умолчанию, который показывается при первом отображении диалогового окна Title Позволяет задать заголовок диалогового окна Использование классов OpenFileDialog и SaveFileDialog Они используются так же, как и любой класс в .NET Framework. Первый шаг - создание экземпляра класса: OpenFileDialog openDlg = new OpenFileDialog () ; SaveFileDialog saveDlg = new SaveFileDialog(); После этого, используя свойства, вы можете построить его поведение. Многие свойства имеются в обоих классах, однако не все, например свойство Multiselect в классе OpenFileDialog или OverwritePrompt в классе SaveFileDialog. Пример такой настройки: 85
openDlg.Title = "Browse for a file to open"; openDlg. Multiselect = false; openDlg.InitialDirectory = @"C:\Users\Student\Documents"; openDlg.Filter = "Word (*.doc) |*.doc;"; saveDlg.Title = "Browse for a save location"; saveDlg.DefaultExt = "doc"; saveDlg.AddExtension = true; saveDlg.InitialDirectory = @"C:\Users\Student\Documents"; saveDlg.OverwritePrompt = true; Чтобы диалоговое окно было показано пользователю, во время выпол- нения программы нужно вызвать метод ShowDialog. openDlg. ShowDialog () ; saveDlg.ShowDialog() ; И наконец, чтобы узнать путь к файлу, который выбрал пользователь, можно использовать свойство FileName. string selectedFileName = openDlg.FileName; string selectedFileName = saveDlg.FileName; В зависимости от того, выбрал пользователь файл или нет, это свойство будет содержать или полное имя файла, или пустую строку (если пользователь просто закрыл окно). Поэтому, перед тем как работать с файлом, значение этого свойства следует проверить. Раздел 2. Чтение и запись файлов с использованием потоков Чтение и запись данных, организованные как атомарная операция, приемлемы только при работе с небольшим объемом данных. Если данных много, этот подход неэффективен, поскольку потребляет слишком много ресурсов. Альтернативный вариант - использование потоков (имеются в виду потоки данных), чему и посвящен данный раздел. Что такое потоки? При работе с данными неважно, где они хранятся: в файловой системе локального компьютера или на веб-сервере, доступном через сеть, - их объем может оказаться слишком велик, чтобы загрузить их в память или передать в рамках одной атомарной операции. Попробуйте, например, за 86
один прием загрузить сотню гигабайт видео или хотя бы десяток. Мало того, что это долго, это еще и требует огромного количества памяти. Для таких целей .NET Framework позволяет использовать потоки. Поток можно представлять себе как последовательность байт, которые могут поступать из файла, из сети, из памяти и других источников. Потоки позволяют читать или записывать данные небольшими управляемыми пакетами. Как правило, потоки обеспечивают следующие операции: • Чтение блока данных в какой-нибудь тип, например массив байтов. • Запись блока данных в поток. • Запрос текущей позиции в потоке и перезапись порции данных в указанной позиции. Поддержка потоков в .NET Framework .NET Framework предоставляет вам несколько классов потоков, которые позволяют работать с разными данными и источниками данных. При выборе класса следует учитывать: • тип данных, например являются ли они текстовыми или бинар- ными; • место хранения, например локальная файловая система, память или сервер в сети. В пространстве имен System. 10 есть несколько классов, которые можно использовать для потокового ввода и вывода. На верхнем уровне абстракции находится класс Stream, который определяет общие функцио- нальные возможности, обеспечиваемые всеми потоками. Этот класс обес- печивает универсальное представление последовательности байтов, опе- рации и свойства, которые поддерживаются всеми потоками. Также он поддерживает указатель на текущую позицию в источнике данных. Вы не можете использовать класс Stream напрямую. Вместо этого следует использовать один из классов-наследников, обеспечивающий специализированный его вариант, оптимизированный для работы с определенным типом источника данных. Например, FileStream реализует поток с файлом на диске в качестве источника данных, a Memorystream предполагает, что источником является блок памяти. Чтение и запись бинарных данных Поток, который создается с помощью объекта класса FileStream, - это необработанная последовательность байтов. Если файл содержит структурированные данные, необходимо эту последовательность преобра- зовать в соответствующие типы. Это может потребовать времени и, кроме того, чревато ошибками. Библиотека классов .NET Framework содержит классы, которые удобно использовать для чтения и записи текстовых данных или данных примитивных (встроенных) типов в поток, открытый 87
посредством объекта FileStream. Это, в частности, классы StreamReader, StreamWriter, BinaryReader и BinaryWriter. Классы BinaryReader и BinaryWriter Многие приложения сохраняют данные в бинарном виде, поскольку это требует минимума времени и места и непригодно для человеческого восприятия. Для чтения и записи таких данных удобно пользоваться классами BinaryReader и BinaryWriter. Вы создаете экземпляр одного из этих классов, предоставляя ему поток, подключенный к источнику данных, из которого будете читать или в который писать. Пример инициализации объектов BinaryReader и BinaryWriter: string filePath = FileStream file = new FileStream(filePath); BinaryReader reader = new BinaryReader(file); BinaryWriter writer = new BinaryWriter (file) ; После создания объекта Binary Reader вы можете использовать его члены для чтения данных. Важное замечание. После завершения использования объекта типа BinaryReader или BinaryWriter необходимо вызвать метод Close, чтобы очистить поток и освободить связанные с ним ресурсы. Нужно также за- крыть соответствующий объект FileStream. В таблице представлены обычно используемые свойства и методы класса BinaryReader. Метод или свойство Описание BaseStream (свойство) Объект потока, связанного с данным объектом BinaryReader Close (метод) Закрывает объект BinaryReader Read (метод) Считывает в буфер заданное количество байтов с указанной позиции Read Byte (метод) Читает следующий байт в потоке, указатель текущей позиции сдвигается на следующий байт Read Bytes (метод) Читает заданное количество байтов в массив байтов 88
Замечание. Класс BinaryReader содержит еще 16 методов, которые могут использоваться для чтения примитивных типов. В дальнейшем мы с большинством из них познакомимся подробнее. Аналогичным образом класс BinaryWriter предоставляет различные члены для записи данных в поток. Некоторые из них представлены в следующей таблице. Метод или свойство Описание BaseStream (свойство) Объект потока для данного экземпляра BinaryWriter Close (метод) Закрывает объект и освобождает связанные с ним ресурсы Flush (метод) Принудительное выталкивание накопленных в буфере данных в поток Seek (метод) Установить заданное положение текущей позиции для записи в поток (позиционирование) Write (метод) Запись данных в поток, текущая позиция соответственно смещается, имеет несколько перегрузок для записи в поток различных примитивных типов Чтение данных из бинарного потока Ниже приводится пример использования классов BinaryReader и FileStream для чтения из бинарного файла. Обратите внимание на орга- низацию продвижения по потоку в цикле. // Путь к файлу string sourceFilePath = @"С:\Users\Student\Documents\BinaryDataFile.bin"; // Создать объект FileStream для взаимодействия // с файловой системой FileStream sourceFile = new FileStream ( sourceFilePath, // Передаем путь к файлу FileMode.Open, // Открыть существующей файл FileAccess.Read); // Открываем для чтения // Создать объект BinaryWriter, // передав ему объект FileStream BinaryReader reader = new BinaryReader(sourceFile); // Текущая позиция в потоке int position = 0; // Длина потока int length = (int)reader.BaseStream.Length; 89
// Создание массива для хранения всех байтов из файла byte[] dataCollection = new byte[length]; int returnedByte; while ((returnedByte = reader.Read()) != -1) { // Сохранить очередной байт dataCollection[position] = (byte)returnedByte; // Сдвинуть позицию position += sizeof(byte); } // Закрыть потоки и освободить все ресурсы reader.Close(); sourceFile.Close(); Замечание. Вы должны быть уверены, что потоки будут закрыты, а ресурсы (дескрипторы файлов) освобождены в любом случае, даже если в процессе чтения и обработки будет выброшено исключение. Самым разумным будет поместить весь код чтения и записи в блок try, а код закрытия потоков - в блок finally. Запись данных в бинарный поток Пример использования классов BinaryWriter и FileStream для записи в файл последовательности байтов (здесь это просто набор целых значений): string destinationFilePath = @"С:\Users\Student\Documents\BinaryDataFile.bin"; // Коллекция байтов byte[] dataCollection = { 1, 4, б, 7, 12, 33, 26, 98, 82, 101 }; // Создать объект FileStream для взаимодействия // с файловой системой FileStream destFile = new FileStream ( destinationFilePath, // Передаем путь к файлу FileMode.Create, // Создать новый файл FileAccess.Write); // Открываем для записи // Создать объект BinaryWriter, // передав ему объект FileStream BinaryWriter writer = new BinaryWriter(destFile); // Записываем байты в поток foreach (byte data in dataCollection) { writer.Write(data); } 90
// Закрываем оба потока для завершения записи в файл writer.Close(); destFile.Close(); Вот что будет содержаться в файле (шестнадцатеричное представ- ление) в результате выполнения этого кода: 01 04 Об 07 ОС 21 1А 62 52 65 Чтение и запись текстовых файлов Этот процесс весьма похож на работу с бинарными файлами, с той лишь разницей, что в этом случае используются классы StreamReader и StreamWriter. Замечание. Уже знакомый нам класс Console имеет свойство In типа StreamReader и свойство Out типа StreamWriter. Именно с этими потока- ми имеют дело методы класса Console для чтения и записи, например ReadLine и WriteLine. Классы StreamReader и StreamWriter Так же как и при работе с бинарными потоками, вы должны создать объект класса, предоставив его конструктору объект потока для обработки взаимодействия с источником данных. string destinationFilePath = FileStream file = new FileStream(destinationFilePath); StreamReader reader = new StreamReader(file); StreamWriter writer = new StreamWriter(file); В таблице приведены члены класса StreamReader, обычно использу- емые при чтении. Метод или свойство Описание Close (метод) Закрывает объект и освобождает связанные с ним ресурсы EndOfStream (свойство) Свойство, указывающее, достигли ли мы конца потока Реек (метод) Получение очередного символа из потока без удаления оттуда (неразрушающее чтение) Read (метод) Позволяет извлечь из потока следующий символ. Метод возвращает значение типа int с кодом символа, которое, возможно, потребуется преобразовать в символ явным образом 91
Метод или свойство Описание ReadBlock (метод) Прочитать блок символов из потока с указанной позиции ReadLine (метод) Прочитать строку символов ReadToEnd (метод) Прочитать все символы с текущей позиции до конца потока В следующей таблице перечислены обычно используемые члены класса StreamWriter. Метод или свойство Описание AutoFlush (свойство) Булевское свойство. Если true, то требуется выталкивать данные в поток после каждой операции записи Close (метод) Закрывает объект StreamWriter и связанный с ним поток Flush (метод) Метод для явного выталкивания всех данных из выходного буфера в поток NewLine (свойство) Свойство для установки символов, которые означают конец строки Write (метод) Метод для записи данных в поток, текущая позиция в потоке соответственно сдвигается WriteLine (метод) Запись данных в поток, в конце записываются символы окончания строки Замечание. Методы Write и WriteLine имеют много перегруженных вариантов, чтобы позволить работать с другими типами данных (не только с текстом). Чтение текста из файла Пример использования классов StreamReader и FileStream для по- символьного чтения текста с использованием методов Реек и Read: // Путь к файлу string sourceFilePath = @ "С: \Users\Student\Documents\TextDataFile. txt" ; // Создать объект FileStream для взаимодействия // с файловой системой 92
FileStream sourceFile = new FileStream ( sourceFilePath, // Передаем путь к файлу FileMode.Open, // Открыть существующий файл FileAccess.Read); // Открываем для чтения /StreamReader reader = new StreamReader(sourceFile); StringBuilder fileContents = new StringBuilder(); // Проверка, не достигнут ли конец файла while (reader.Peek() != -1) { // Считываем следующий символ fileContents.Append((char)reader.Read ()); } // Загрузка содержимого в новую переменную типа string string data = fileContents.ToString(); // Закрываем потоки и освобождаем дескрипторы файлов reader.Close(); sourceFile.Close(); Приведем также пример использования метода ReadToEnd для чтения потока до конца. // Путь к файлу string sourceFilePath = @"С:\Users\Student\Documents\TextDataFile.txt"; string data; // Создать объект FileStream для взаимодействия // с файловой системой FileStream sourceFile = new FileStream ( sourceFilePath, // Передаем путь к файлу FileMode.Open, // Открыть существующий файл FileAccess.Read); // Открываем для чтения StreamReader reader = new StreamReader(sourceFile); // Считываем все содержимое файла в сороковую переменную data = reader.ReadToEnd(); // Закрываем потоки и освобождаем дескрипторы файлов reader.Close(); sourceFile.Close(); 93
Запись текста в файл Рассмотрим пример использования классов StreamWriter и FileStream для записи строки в файл на диске. string destinationFilePath = @ "С: \Users\Student\Documents\TextDataFile. txt" ; string data = "Hello, this will be written in plain text"; // Создать объект FileStream для взаимодействия // с файловой системой FileStream destFile = new FileStream ( destinationFilePath, // Передаем путь к файлу FileMode.Create, // Создать новый файл FileAccess.Write); // Открываем для записи // Создать новый объект StreamWriter StreamWriter writer = new StreamWriter(destFile); // Записываем строку в файл writer .WriteLine (data) ; // Закрываем потоки для завершения записи в файл //и освобождаем дескрипторы файлов writer.Close(); destFile.Close(); Чтение и запись данных встроенных типов Использование классов BinaryReader и BinaryWriter позволяет рабо- тать с любыми неструктурированными массивами байтов. Эти классы пре- доставляют вам методы, позволяющие считывать и записывать в поток данные любого примитивного (встроенного) типа, включая целые, вещест- венные, булевские данные, а также строки. Замечание. Потоковая модель в .NET Framework поддерживает работу и с определяемыми пользователем типами, такими как классы и струк- туры. Эти типы должны быть сериализуемыми, и для работы с ними вы можете использовать подходящий форматер, например объект типа BinaryFormatter, который предназначен для совместного использования с FileStream. Форматер определяет, как читать и записывать данные. В рамках данного курса сериализация и форматирование объектов не рассматриваются. 94
Чтение встроенных типов Класс BinaryReader позволяет читать любой примитивный тип данных, предоставляя для этого 16 разных методов чтения. В таблице приведены некоторые из них. Метод Описание ReadBoolean Чтение булевского значения ReadChar Чтение символа ReadChars Чтение последовательности символов, при вызове нужно указать, сколько символов вы хотите прочитать ReadDouble Чтение вещественного значения двойной точности Read Int Чтение типа int ReadLong Чтение типа long ReadString Чтение строки Каждый из методов считывания предназначен для работы с определенным типом данных. Метод считывает требуемое число байтов и передвигает текущую позицию в потоке. Если вы читаете массив, то требуется указать количество элементов, которые вы хотите прочитать. Следующий пример демонстрирует чтение файла, который содержит данные разных примитивных типов. // Путь к файлу string sourceFilePath = @"С:\Users\Student\Documents\PrimitiveDataTypeFile.txt"; // Создать объект FileStream для взаимодействия // с файловой системой FileStream sourceFile = new FileStream ( sourceFilePath, // Передаем путь к файлу FileMode.Open, // Открыть существующей файл FileAccess.Read); // Открываем для чтения // Создать объект BinaryReader, // передав ему объект FileStream BinaryReader reader = new BinaryReader(sourceFile); bool boolValue = reader.ReadBoolean(); byte byteValue = reader.ReadByte(); byte[] byteArrayValue = reader.ReadBytes(4); 95
char charValue = reader.ReadChar(); char[] charArrayValue = reader.ReadChars(4); decimal decimalValue = reader.ReadDecimal(); double doubleValue = reader.ReadDouble(); float floatvalue = reader.ReadSingle(); int intValue = reader.Readlnt32(); long longValue = reader.Readlnt64(); sbyte sbyteValue = reader.ReadSByte(); short shortValue = reader.Readlntl6(); string stringvalue = reader.Readstring(); uint unintValue = reader.ReadUInt32(); ulong ulongValue = reader.ReadUInt64(); ushort ushortValue = reader.ReadUIntl6(); // Закрываем потоки и освобождаем дескрипторы файлов reader.Close(); sourceFile.Close(); Запись встроенных типов Аналогичным образом методы записи класса BinaryWriter позволяют записывать в бинарном виде в поток данные любых примитивных типов. Следующий пример показывает, как это можно сделать. string destinationFilePath = @"С:\Users\Student\Documents\PrimitiveDataTypeFile.txt"; // Создать объект FileStream для взаимодействия // с файловой системой FileStream destFile = new FileStream ( destinationFilePath, // Передаем путь к файлу FileMode.Create, // Создать новый файл FileAccess.Write); // Открываем для записи // Создать новый объект BinaryWriter BinaryWriter writer = new BinaryWriter(destFile); bool boolValue = true; writer .Write (boolValue) ; byte byteValue = 1; writer.Write(byteValue); byte[] byteArrayValue = { 1, 4, 6, 8 }; writer.Write(byteArrayValue); char charValue = 'a'; 96
writer .Write (charValue) ; char[] charArrayValue = {'a', 'b', 'c', *d*}; writer.Write(charArrayValue); decimal decimalvalue = 1.00m; writer .Write (decimalvalue) ; double doubleValue = 2.5; writer .Write (doubleValue) ; float floatvalue = 4.5f; writer.Write(floatvalue); int intValue = 999999999; writer.Write(intValue); long longValue = 999999999999999999; writer.Write(longValue); sbyte sbyteValue = 99; writer.Write(sbyteValue); short shortValue = 9999; writer.Write(shortValue); string stringValue = "MyString"; writer.Write(stringValue); uint unintValue = 999999999; writer.Write(unintValue); ulong ulongValue = 999999999999999999; writer.Write(ulongValue); ushort ushortValue = 9999; writer.Write(ushortValue); // Закрываем потоки для завершения записи в файл //и освобождаем дескрипторы файлов writer.Close(); destFile.Close(); Поскольку в этом примере запись происходит во внутреннем пред- ставлении, неудобном для человеческого восприятия, приводить здесь результат работы этого кода нецелесообразно. 97
Лекция 6. Создание новых типов Библиотека базовых классов .NET содержит много типов, которые вы можете использовать в своих приложениях. Тем не менее абсолютно в любом приложении вам придется также создавать собственные типы, кото- рые будут реализовывать логику вашего решения. В данной лекции мы разберемся, как можно создавать свои типы, а также чем отличаются ссылочные типы от простых типов (последние еще называют типами значений). Раздел 1. Создание и использование перечислений Перечисляемый тип данных (в дальнейшем для краткости - пере- числение) - это множество константных значений в предопределенном порядке. Они весьма полезны для работы с данными, которые могут принимать значения только из ограниченного, наперед известного множества. Например, для задания дней недели мы могли использовать целые числа от 0 до 6, однако это по многим причинам неудобно (да и небезопасно). Очевидно, что вместо такого интуитивно непонятного кода d = 5; лучше использовать такой: d = DaysOfWeek.Friday; Здесь DaysOfWeek - имя перечисляемого типа, a Friday - одно из константных значений этого типа. Данный раздел и посвящен тому, каким образом в C# организована работа с перечислениями. Что такое перечисление? Перечисляемый тип - это множество поименованных констант. Он является скалярным типом, который может принимать значения из определяемого пользователем диапазона. Вы можете создать перечисляемый тип, объявить переменные этого типа и присваивать значения этим переменным таким же образом, как вы это делаете со встроенными скалярными типами в С#, например int или float. В случаях, когда данные могут принимать только значения из ограниченного набора, использование перечислений делает код более легким для чтения и сопровождения. Базовая библиотека классов .NET Framework содержит разные готовые типы перечислений, которые вы тоже можете использовать в своих приложениях. Многие методы классов .NET Framework используют параметры такого типа или возвращают такие значения. 98
Перечисления есть и в других языках (например, С, Java, C++), однако они имеют некоторые отличия от перечислений в языке С#. Так, основное отличие от перечислений в языке Java состоит в том, что перечисления в C# основаны на встроенных типах данных (например, int или long), в то время как в Java они производятся от объектов. Реализация перечислений в C# очень похожа на их реализацию в С и C++. Преимущества использования перечислений Перечисления обладают всеми преимуществами использования именованных констант, а кроме того: • код легче поддерживать в работоспособном состоянии, так как переменным можно присвоить только допустимые значения; • код легче читается, поскольку для констант используются интуитивно понятные имена; • код легче набирать, поскольку механизм IntelliSense будет предлагать список допустимых значений; • код хорошо оформлен, поскольку множество допустимых значений типа задано в его объявлении. Создание перечисляемых типов данных в C# Вы можете создавать свои перечисляемые типы с использованием ключевого слова епшп. При объявлении также вы должны указать имя типа и полный список значений, которые он может принимать. Замечание. Поскольку перечисления - это типы, вы можете их объяв- лять в классе или в пространстве имен, но не в теле метода. Пример объявления перечисляемого типа: enum Season {Spring, Summer, Fall, Winter}; Внутри себя система ассоциирует каждое из перечисленных значений с последовательностью целых чисел, начиная отсчет с 0 (по умолчанию). Таким образом, в этом случае для кодировки последовательности значений будут использованы 0, 1, 2, 3. Однако вы можете изменить эту внутрен- нюю кодировку, задав свое значение, например, так: enum Season { Spring = 1, Summer, Fall, Winter } Здесь константам Summer, Fall и Winter автоматически будут соот- ветствовать значения 2, 3 и 4. Числовое значение, соответствующее литералу перечисляемого типа важно, если вы, например, хотите перебрать все значения и используете операции ++ или —. Вы можете иметь более одного литерала с одним и тем же внутренним значением (и смыслом). 99
Пример (здесь литерал Autumn эквивалентен Fall): enum Season { Spring, Summer, Fall, Autumn = Fall, Winter } Когда вы объявляете перечисление, по умолчанию для внутреннего представления используется тип int. Но вы можете изменить это, указав желаемый тип явно. Причиной может быть, скажем, желание сэкономить память (например, тип short занимает меньше места, чем int). В качестве базового можно указывать следующие типы: byte, sbyte, short, ushort, int, uint, long, или ulong. Присваивание значений переменным перечисляемых типов Способ объявления переменных перечисляемых типов и присваивания им значений практически такой же, что и для остальных типов в С#. В качестве типа переменной выступает имя приложения, а значения, кото- рые она может принимать, имеют синтаксис [Тип_перечисления].[Литерал]. Пример объявления и инициализации переменной, представляющей день недели. enum Days { Monday = 1, Tuesday = 2, Wednesday = 3, Thursday = 4, Friday = 5, Saturday = 6, Sunday = 7 }; static void Main (string [] args) { Days myDayOff = Days.Sunday; } Обратите внимание, что переменной myDayOff можно присваивать только одно из значений, перечисленных при объявлении типа Days. Использование переменной перечисляемого типа Вы можете выполнять простые действия с такой переменной по большей части так же, как и с переменной типа int. Пример. Перечисление и распечатка дней недели (обратите внимание на работу метода WriteLine для этого типа): enum Days { Monday = 1, Tuesday = 2, Wednesday = 3, Thursday = 4, Friday = 5, Saturday = 6, Sunday = 7 }; for (Days dayOfWeek = Days.Monday; dayOfWeek <= Days.Sunday; dayOfWeek-H-) { Console .WriteLine (dayOfWeek) ; } 100
/* Вывод будет такой: Monday Tuesday Wednesday Thursday Friday Saturday Sunday */ Арифметические операции применительно к литералам фактически выполняются с теми значениями, которые используются для их внутрен- него представления. В случае если операция (например, ++) приведет к выходу за пределы допустимого диапазона, WriteLine напечатает соответ- ствующее целое значение. Строго говоря, это ошибка программиста, однако C# в данном случае проверки не делает. Замечание. Разумеется, кроме ++ и —, вы можете выполнять и другие операции, применимые к целым числам. Ограничений здесь практически нет, разве что на уровне здравого смысла. Что, например, могло бы означать выражение Days.Monday+Days .Wednesday? Раздел 2. Создание и использование классов C# является объектно-ориентированным языком. Вся логика прило- жения на языке C# сосредоточена в классах и структурах. Здесь мы разберемся, как создать собственный класс и как его исполь- зовать в приложениях. Также вводятся понятия разделяемого (частичного) класса и частичного метода. Понятие класса При создании С#-приложения основными типами в вашем коде будут именно классы. .NET Framework предоставляет разработчикам большое количество служебных классов, однако вам все же придется создавать свои классы, инкапсулирующие данные и логику вашего приложения. Класс можно представлять себе как некий шаблон, чертеж, трафарет, по которому создаются объекты. Класс определяет такие характеристики объекта, как данные, которые он может содержать, и действия, которые он может выполнять. Эти характеристики принято называть членами класса, и мы их скоро рассмотрим. Объект - это экземпляр класса. Если класс рассматривать как проект, то объект - это реализация этого проекта. Если класс является определе- нием элемента, то объект - сам элемент и есть. Замечание. Наряду со словом "объект" мы будем употреблять слово- сочетание "экземпляр класса" в качестве его синонима. 101
Разумеется, можно создать сколько угодно экземпляров одного и того же класса, также как можно выстроить много домов по одному проекту. Их основные аспекты будут одинаковыми, однако некоторые детали могут различаться. Так, у домов, выстроенных по одному проекту, конструк- тивные элементы имеют одинаковый размер и расположение, но, скажем, цвет входной двери может различаться (не говоря уже о расположении самого дома). Определение нового класса Весь код, конечно, можно писать руками, однако среда разработки Visual Studio берет на себя изрядную часть рутинной работы и позволяет вам создать заготовку класса легко и удобно. Обычно код класса размещают в отдельном файле, имя которого совпадает с именем класса. Для создания заготовки в Visual Studio выбираем в контекстном меню проекта пункт Add, а затем Class. В диалоговом окне задаем имя класса и щелкаем Add. При создании класса House мы получим нечто такое: using System; using System.Collections.Generic; using System. Linq; using System. Text; namespace HouseSystem { class House { } } Обратите внимание, что имя пространства имен при создании заготовки генерируется по имени приложения и имени класса. Его можно тут же поменять в текстовом редакторе. Добавление членов в класс После создания заготовки вы можете добавить в класс поля и методы, которые определяют данные и поведение этого класса. Их можно добавить сколько угодно в зависимости от целей и предполагаемой функциональ- ности класса. Замечание. Во всех примерах данного раздела используются экземп- лярные поля и методы. Это означает, что каждый объект имеет свою копию полей (т. е. свои данные), а методы работают с данными кон- кретного (своего) объекта. Поля и методы бывают также статическими (объявляются с ключевым словом static). Они позволяют работать с одними и теми же данными разным экземплярам одного класса. С ними мы познакомимся позже. 102
Объявление полей Можно представлять себе поле как переменную, предназначенную для класса. Все методы, определенные в классе, могут получить доступ к полю. Как и любая переменная, поле имеет имя, тип данных и модифика- тор доступа. Если последний не указан, по умолчанию используется вариант private, что означает, что поле доступно только методам, опре- деленным в классе. Если вы хотите сделать поле доступным всем методам других классов, его можно объявить как public. Замечание. Модификаторы доступа будут более подробно рассмот- рены позже. Размещать объявление полей можно где угодно в коде объявления класса. Зачастую программисты размещают их в начале определения класса, чтобы сделать код более легким для восприятия. При объявлении поля вы можете сразу присвоить ему значение по умолчанию, хотя для той же цели можно использовать конструктор. Использованию конструкторов посвящен следующий раздел. Объявление методов Метод - это процедура или функция в пределах класса. Они используются для реализации поведения класса. Каждый метод имеет список параметров, тип возвращаемого значения, а также модификатор доступа. Метод имеет полный и неограниченный доступ ко всем остальным членам класса. Это очень важный аспект объектно-ориентированного программирования: методы инкапсулируют действия над полями в классе. При обращении к экземплярному полю класса это можно подчеркнуть использованием ключевого слова this (ссылка на данный объект), хотя и совершенно необязательно, так как подразумевается по умолчанию: public bool hasGarage; public void OpenGarageDoor(int doorld) { if (this.hasGarage) { // Код для запуска при наличии гаража } } Пример В примере объявлен класс Residence (место проживания/размеще- ния), который используется в приложении при работе с недвижимостью. Класс имеет 4 поля, которые задают тип помещения, количество спален, наличие гаража и наличие сада. В классе имеются методы для расчета стоимости жилья при продаже и для расчета затрат на восстановление для целей страхования. 103
public enum ResidenceType { House, Flat, Bungalow, Apartment}; public class Residence { public ResidenceType type; public int numberOfBedrooms; public bool hasGarage; public bool hasGarden; public int CalculateSalePrice() { // Вычисление стоимости жилья } public int CalculateRebuildingCostO { // Вычисление затрат на восстановление } } Использование дизайнера классов Можно создавать код класса вручную в окне редактора кода. Однако для этого удобно использовать специальное окно дизайнера классов с графическим интерфейсом. Чтобы использовать окно дизайнера классов, нужно добавить диа- грамму классов в свой проект. Для этого в контекстном меню проекта выбираем пункт View Class Diagram. Появившаяся диаграмма автома- тически включает в себя все перечисления, классы и структуры, опре- деленные в проекте, она же предоставляет набор инструментов, которые можно использовать для добавления новых элементов, а также их членов. Объявление конструкторов и инициализация объектов При создании объекта очень важно быть уверенным, что он пол- ностью инициализирован и все его поля имеют допустимые значения. Для этой цели вы можете написать один или несколько конструкторов класса. Среда исполнения CLR автоматически вызывает конструктор при создании объекта. Замечание. Неинициализированным полям в классе присваивается значение по умолчанию. Если это числовое значение, то оно инициа- лизируется нулем, если логическое - false, если строка - null. Свойства класса также инициализируются нулями. Объявление конструкторов Конструктор - это специальный метод, который CLR вызывает авто- матически при создании объекта. 104
Правила для объявления конструктора: • Конструктор имеет то же имя, что и класс, для которого он объявлен. • Конструкторы не имеют никакого типа возвращаемого значения, даже void. Класс может иметь сколько угодно конструкторов при условии, что они имеют разную сигнатуру (список параметров). Конструктор без параметров принято называть конструктором по умолчанию. • Обычно конструкторы объявляются с модификатором доступа public, чтобы объект можно было создать в любой части программы. Однако в ряде случаев могут оказаться более под- ходящими другие уровни доступности. • Код конструкторов обычно инициализирует некоторые или все поля в объекте, а также выполняет дополнительные задачи инициализации. Важное замечание. Если вы не определили никаких конструкторов для класса, компилятор C# автоматически создает конструктор по умолчанию (без параметров). Он ничего не делает, но позволяет создавать экземпляры класса. Если вы определили хотя бы один свой конструктор, компилятор не будет этого делать. В следующем примере показано, как можно было бы объявить три конструктора для класса Residence, при этом • первый конструктор принимает два параметра и устанавливает в объекте тип помещения и количество спален; • второй принимает три параметра и устанавливает тип помеще- ния, количество спален и наличие гаража; • третий принимает четыре параметра и устанавливает в объекте тип помещения, количество спален, наличие гаража и наличие сада. public enum ResidenceType {House, Flat, Bungalow, Apartment}; public class Residence { public ResidenceType type; public int numberOfBedrooms; public bool hasGarage; public bool hasGarden; public Residence(ResidenceType type, int numberOfBedrooms) { this.type = type; this.numberOfBedrooms = numberOfBedrooms; } 105
public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; } public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage, bool hasGarden) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; this.hasGarden = hasGarden; } } Замечание. Обратите внимание на использование ключевого слова this для разделения одноименных параметров и полей класса. Кроме того, с использованием ключевого слова this можно вызывать один конструктор класса из другого. В этом случае при объявлении конструктора используется соответствующая конструкция: public class Residence { public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage, bool hasGarden) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; this.hasGarden = hasGarden; } // Конструктор по умолчанию создает жилье // с тремя спальнями, гаражом и садом public Residence() : this(ResidenceType.House, 3, true, true) { } } Чаще всего такой прием используется, чтобы не дублировать код инициализации и чтобы удобнее было передать конструктору по умол- чанию желаемые инициализирующие значения. 106
Создание объектов При объявлении переменной класса она не инициализирована. Чтобы ее можно было использовать, требуется создать экземпляр соответству- ющего класса и присвоить его этой переменной. Для создания экземпляров используется оператор new. Оператор new выполняет два действия: просит CLR выделить память для объекта, а затем вызывает конструктор для инициализации объекта. Выбор конструктора определяется набором параметров, которые вы указали в операторе new. Ниже приведены примеры создания объектов класса Residence. // Создать квартиру с двумя спальнями Residence myFlat = new Residence(ResidenceType.Flat, 2) ; // Создать дом с тремя спальнями и гаражом Residence myHouse = new Residence(ResidenceType.House, 3, true); // Создать бунгало с двумя спальнями, гаражом и садом Residence myBungalow = new Residence(ResidenceType.Bungalow, 2, true, true); Если вы в операторе new не указали параметров, используется конструктор по умолчанию. Вспомним, что если у вас есть хотя бы один собственный конструктор, компилятор не создает для вас конструктор по умолчанию - в этом случае следует написать его самостоятельно. Использование инициализаторов объектов Вы инициализируете внутреннее состояние объекта путем вызова конструктора. Однако не всегда возможно обеспечить для класса конст- рукторы, которые позволяли бы производить инициализацию всех возмож- ных комбинаций полей. Пусть, например, в нашем классе предусмотрены три конструктора, как показано в примере. public enum ResidenceType {House, Flat, Bungalow, Apartment}; public class Residence { public ResidenceType type; public int numberOfBedrooms; public bool hasGarage; public bool hasGarden; public Residence(ResidenceType type, int numberOfBedrooms) { this.type = type; this.numberOfBedrooms = numberOfBedrooms; } 107
public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; } public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage, bool hasGarden) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; this.hasGarden = hasGarden; } } Здесь имеются, очевидно, не все комбинации. Например, указать, есть ли сад, можно только в том случае, если вы явно указываете, есть ли гараж. Вы можете предложить решить проблему путем написания дополни- тельного конструктора, например так: public class Residence { public bool hasGarden; public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarage) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarage = hasGarage; } // Конструктор, инициализиругаицй поле hasGarden // без инициализации поля hasGarage. public Residence(ResidenceType type, int nurriberOfBedrooms, bool hasGarden) { this.type = type; this. nurriberOfBedrooms = nurriberOfBedrooms; this.hasGarden = hasGarden; } } 108
Однако конструкторы должны соблюдать правила, установленные для перегрузки методов, а значит, не может быть двух конструкторов с одинаковой сигнатурой. Следовательно, приведенный выше код компи- лироваться не будет. Проблема может быть решена путем использования инициализаторов объекта. Инициализатор объекта создает объект с помощью конструктора и тут же, в этом операторе, инициализирует другие поля по вашему выбору. Синтаксис инициатора объекта иллюстрируется следующим примером. // Создать дом с тремя спальнями и садом Residence myHouse = new Residence(ResidenceType.House, 3) {hasGarden = true}; При этом вызове сначала отрабатывает подходящий конструктор, а затем срабатывает присвоение значений свойствам, при этом вы можете изменить значения, присвоенные конструктором. Доступ к членам класса Чтобы получить доступ к экземплярному члену класса, нужно указать имя экземпляра, далее точку и имя члена. При обращении действуют следующие правила: • При обращении к методу используются скобки после имени мето- да. В скобках указываются фактические параметры вызова. Если вызов параметров не требует, скобки все равно используются. • При доступе к открытому (public) полю указывается имя поля. Можно как получить значение поля, так и установить новое. Приведенный далее пример кода выполняет следующие задачи: • создает экземпляр класса Residence, используя конструктор с указателем типа строения и количества спален; • устанавливает для этого объекта свойство hasGarden (кстати, для этого можно было использовать инициализатор объекта при создании); • вызывает метод CalculateSalePrice для вычисления текущей рыночной стоимости; • вызывает метод CalculateRebuildingCost для вычисления стоимос- ти восстановительных работ на случай страхования. // Создать дом с тремя спальнями Residence myHouse = new Residence(ResidenceType.House, 3); // Указать, что имеется сад myHouse.hasGarden = true; // Вычислить текущую рыночную стоимость int salePrice = myHouse.CalculateSalePrice(); // Вычислить стоимость восстановительных работ int rebuildCost = myHouse.CalculateRebuildingCost(); 109
Использование частичных классов и частичных методов Иногда оказывается удобным разделить определение класса на несколько исходных файлов, например если класс создается несколькими разработчиками или вы хотели бы иметь некоторые части класса в неиз- меняемом виде. В .NET Framework для достижения этой цели вы можете использовать частичные классы. Некоторые классы из библиотеки классов .NET Framework сделаны частичными, и ряд проектов Visual Studio использует эту концепцию. Например, в WPF она используется для того, чтобы выделить в отдельный файл код логики вашего приложения. А отдельно от него хранится код класса, который служит для отображения окна, обработки пользователь- ского ввода и вывода результатов. Объявление частичных классов Для этого используется ключевое слово partial, как показывает следующий пример. В этом случае объявление класса Residence можно разместить кусочками в нескольких файлах. // Filel.cs namespace HouseSystem { public partial class Residence { } } // File2.cs namespace HouseSystem { public partial class Residence { } } При определении частичного класса следует руководствоваться следующими правилами и рекомендациями: • при компиляции класс собирается в единое целое, так что все его части будут доступны; • при объявлении каждой части используется ключевое слово partial; • отдельные части класса нельзя разместить в разных сборках, все они присутствуют в одной сборке; • ключевое слово partial в объявлении предшествует слову class. ио
Объявление частичных методов При объявлении класса один или несколько методов также могут быть объявлены с ключевым словом partial. Для них в одном файле объявляется только сигнатура метода, что уже является достаточным для его вызовов в коде. Собственно логика может быть реализована в другом файле (пример вскоре будет приведен). Если частичный метод не реализован, то его вызовы будут игнорироваться. Частичные методы обычно используются программными оболочками, при этом предполагается, что их при необходимости реализует разра- ботчик третьей стороны. Следующие примеры демонстрируют разделение объявления метода и его реализации. Предполагается, что код содержится в двух разных файлах. В первой части кода объявлен частичный метод DoWork и осу- ществлен его вызов, во втором - его реализация (возможно, другим разработчиком). Если при сборке второй файл не используется, то вызов DoWork компилятором будет игнорироваться. // Код, предоставлявший оболочку public partial class FrameworkClass { partial void DoWork(int data); // Объявление частичного // метода public void FrameworkMethod () { DoWork(99); // Вызов частичного метода } } // Код, позволявшей разработчику использовать оболочку public partial class FrameworkClass { partial void DoWork(int data) { // Код, реализующий метод DoWork } } При определении и использовании частичных методов действуют следующие правила: • они не могут возвращать значения, то есть имеют тип void; • они все неявным образом закрытые (private), вы не можете получить доступ к частичному методу за пределами класса, в котором он объявлен; ш
• все объявления частичного метода вы должны производить с использованием ключевого слова partial; • частичные методы могут иметь параметры, передаваемые по ссылке (ref), но не могут иметь выходных параметров (out). Раздел 3. Создание и использование структур Классы - очень удобный и полезный инструмент, если вы хотите смоделировать реальную сущность и инкапсулировать в нее данные и логику поведения. Вместе с тем создание экземпляров классов связано с определенными накладными расходами, и иногда хочется найти более легкое (в смысле потребляемых ресурсов) решение. Структуры во многом похожи на классы, однако работа с ними сопряжена с меньшими накладными расходами. Правда, структуры по сравнению с классами имеют ряд ограничений. Раздел посвящен вопросам использования структур, а также рас- смотрению различий между структурами и классами. Понятие структуры Структуры очень похожи на классы, а снижение накладных расходов объясняется тем, как CLR создает и управляет экземплярами структуры. Как правило, структуры используются для моделирования элементов, которые содержат относительно небольшое количество данных. Собственно, мы даже использовали структуры, не догадываясь об этом. Многие из простейших типов в языке C# являются просто псевдонимами для структур. Некоторые из них представлены в следующей таблице. Структурный тип Ключевое слово C# System. Byte byte System.Inti 6 short System.Int32 int System.Int64 long System.Single float System.Double double System.Decimal decimal System.Boolean bool System.Char char 112
Структура, как и класс, может содержать поля и методы. Например, структура System.Int32 имеет метод ToString, который возвращает строко- вое представление целого числа. int х = 99; string xAsString = х.ToString(); Обратите внимание, что по умолчанию для структурных типов вы не можете использовать многие из таких обычных операций, как == или !=, если только в объявлении структуры не содержится их реализация. Каким образом можно реализовать ту или иную операцию для структурного типа, рассматривается дальше. Впрочем, все типы, перечисленные в приведен- ной выше таблице, имеют свои реализации этих операций. Объявление и использование структур Синтаксис, используемый для объявления структуры, практически такой же, как и синтаксис объявления класса, с той лишь разницей, что вместо ключевого слова class используется ключевое слово struct. Синтаксис объявления членов структур также очень похож на синтаксис объявления членов класса. Основная разница состоит в том, что при объявлении экземплярных полей в структуре им нельзя тут же присвоить значения. Пример - объявление структурного типа Currency. using System; using System.Collections.Generic; using System.Text; struct Currency { // Код валюты ISO 4217 public string currencycode; // Символ валюты ($,£,...) public string currencySymbol; // количество десятичных знаков дробной части public int fractionDigits; } Использование структур Среда исполнения CLR управляет структурами не так, как классами. При объявлении переменной типа структуры память для нее выделяется автоматически (на стеке). Поэтому для создания структуры нет необ- ходимости использовать оператор new (хотя такая возможность есть, мы обсудим ее позже). После объявления переменной полям структуры можно присвоить значения, используя такой же синтаксис, что и для полей класса (через точку). Доступ по чтению, разумеется, требует такой же конструкции. из
Пример. Создание переменной типа Currency и присвоение значений ее полям: Currency unitedStatesCurrency; unitedStatesCurrency.currencyCode = "USD"; unitedStatesCurrency.currencySyiribol = "$"; unitedStatesCurrency.fractionDigits = 2; Инициализация структур При создании объекта класса используется оператор new, который выделяет под него память и вызывает конструктор для инициализации. При создании объектов структур вам не требуется использовать new для выделения памяти. Однако если вы хотите инициализировать структуру при создании, вы можете определить для нее один или несколько конструкторов. Конструкторы для структур очень похожи на конструкторы для классов, но имеют ряд семантических отличий. Самые значительные среди них следующие. • Нельзя для структуры объявить конструктор по умолчанию (без параметров). В отличие от класса, компилятор всегда генерирует для структуры конструктор по умолчанию, независимо от наличия других конструкторов. • Конструктор должен явно инициализировать все поля в струк- туре. Пока он это не сделает, он не имеет права вызывать никакие методы структуры. В следующем примере вновь определяется структура Currency, но на этот раз она имеет конструктор с двумя параметрами. Также имеется пример объявления переменной unitedKingdomCurrency, которая созда- ется с использованием ключевого слова new, а следовательно, с вызовом конструктора. struct Currency { // Код валюты ISO 4217 public string currencyCode; // Символ валюты ($,£,...) public string currencySyiribol; // количество десятичных знаков дробной части public int fractionDigits; public Currency(string code, string symbol) { this.currencyCode = code; this.currencySyiribol = symbol; this.fractionDigits = 2; } } Currency unitedKindgdomCurrency = new Currency("GBP", "£"); 114
Важное замечание. Если вы создаете экземпляр структуры без вызова конструктора, он считается неинициализированным. Хотя вам разрешается читать и писать отдельные поля неинициализированной структуры, ее запрещается использовать в качестве аргумента метода или присваивать другой переменной, пока не будет явно присвоено значение каждому полю. Поэтому самый простой способ гарантировать полную инициали- зацию структуры - использовать конструктор. Напомним, что конструктор по умолчанию вы имеете всегда, так что, если вас устраивают значения по умолчанию, его можно использовать. Раздел 4. Сравнение ссылочных типов и типов значений Структурные типы, такие как определенные пользователем структуры и примитивные типы, принято также называть типами значений или простыми типами (value types). Когда вы объявляете переменную струк- турного типа, компилятор генерирует код, который выделяет блок памяти, достаточный для того, чтобы хранить значение соответствующего типа. Если указано инициализирующее значение, то оно сразу же копируется в эту область памяти. Для типов классов, которые называются ссылочными типами, CLR поступает иначе. При объявлении переменной не происходит выделения памяти под хранение собственно объекта, выделяется только небольшой блок, способный хранить ссылку (адрес) на этот объект. Эта ссылка инициализируется нулевым значением, чтобы показать, что объект еще не создан. Создан он будет, только когда сработает оператор new - он выде- лит память и вызовет конструктор. В разделе рассматриваются различия между ссылочными и простыми типами, а также объясняется, чем различается их поведение при исполь- зовании в качестве параметров методов. Далее в разделе рассматривается вопрос о преобразовании простых типов в ссылочные и обратно, которое принято называть упаковкой и распаковкой. Кроме того, рассматривается вопрос о создании простых типов, кото- рые способны хранить значение null. Сравнение ссылочных и простых типов Двумя основными областями памяти для среды исполнения CLR являются стек и куча. Значения простых типов создаются на стеке, память для ссылочных берется из кучи. Основные различия между простыми и ссылочными типами проявляются, когда вы их копируете. В следующих примерах используется ссылочный тип Residence и простой (структурный) тип Currency. Когда мы присваиваем значение ссылочной переменной, мы записываем туда ссылку на объект в памяти. При копировании переменной происходит копирование этой ссылки. В результате и копия, и оригинал ссылаются на 115
один и тот же объект. В примере это myHouse (оригинал) и refToMyHouse (копия). // Создание объекта Residence myHouse = new Residence(ResidenceType.House, 2); Residence refToMyHouse = myHouse; При изменении данных с использованием ссылочной переменной myHouse меняется тот же объект, на который и ссылается переменная refToMyHouse. В следующем примере изменение количества спален производится через переменную myHouse, а вывод - через refToMyHouse. Выводится, разумеется, значение 3. myHouse.numberOfBedrooms = 3; Console.WriteLine (refToMyHouse.numberOfBedrooms) ; В следующем примере myCurrency и mySecondCurrency - две пере- менные простого типа Currency. При присваивании CLR создает копию объекта (а не ссылки). В результате переменные относятся к разным данным в памяти и изменение одной никак не сказывается на значении другой. // Создание объекта Currency Currency myCurrency = new Currency("USD", "$"); // Создание второго объекта Currency - копии первого Currency mySecondCurrency = myCurrency; myCurrency.currencyCode = "GBP"; Console.WriteLine(mySecondCurrency.currencyCode); // Выведет "USD" Замечание. Перечисления являются простым типом, и для них присваивание осуществляется так же, как и для структур. Передача методом простых типов по ссылке Принципиальное отличие ссылочных типов от простых заключается в том, что происходит при передаче их методу в качестве параметров. Например, в следующем коде представлен метод UpdateCurrency. Он принимает параметр типа Currency и изменяет у него значение поля currencyCode. public void UpdateCurrency (Currency currencyParam) { currencyParam. currencyCode = "EUR"; } Currency myCurrency = new Currency (...); myCurrency.currencyCode = "USD"; UpdateCurrency (myCurrency) ; Console.WriteLine(myCurrency.currencyCode); 116
При вызове метода UpdateCurrency происходит создание копии переменной myCurrency. В результате выполнение метода приводит к изменению только этой копии и код выведет строку "USD". В случае ис- пользования в качестве параметра ссылочного типа поведение программы меняется. public void UpdateResidence (Residence residenceParam) { residenceParam. nurriberOfBedrooms = 3; } // Создание дома с двумя спальнями Residence myResidence=new Residence(ResidenceType.House, 2) ; UpdateResidence(myResidence); Console.WriteLine(myResidence.nurriberOfBedrooms); В этом примере параметр является ссылочным типом. Ссылка myResidence копируется в формальный параметр residenceParam, в результате они ссылаются на один и тот же объект в памяти. В итоге код метода вызывает изменение объекта и последний оператор примера напе- чатает значение 3. Использование ключевого слова ref Оба только что рассмотренных примера основывались на передаче параметра по значению, то есть происходило его копирование. Если вы хотите передать параметр простого типа по ссылке, можно использовать ключевое слово ref. В результате метод получит не копию данных, а ссылку на них. Это приведет к тому, что любые изменения объекта в методе сохранятся после завершения последнего. Для использования передачи по ссылке вы должны употребить слово ref в двух местах: • перед именем типа в объявлении метода; • перед именем переменной при вызове метода. Пример для типа Currency: public void UpdateCurrency (ref Currency currencyParam) { currencyParam.currencyCode = "EUR"; } Currency myCurrency = new Currency(...); myCurrency.currencyCode = "USD"; UpdateCurrency(ref myCurrency); Console.WriteLine(myCurrency.currencyCode); Здесь изменения, сделанные в коде метода, относятся к объекту, пред- ставленному переменной myCurrency. Выведена будет строка "EUR". 117
Упаковка и распаковка Ссылочные типы и простые типы принципиально различаются: первые ссылаются на объекты, а вторые содержат значения. Но в C# есть специальный тип, который может ссылаться и на те и на другие. Пример объявления переменной типа object и присвоения ей ссылки на объект типа Residence: Residence myHouse = new Residence (...); object obj = myHouse; Тип object полезен, если вы хотите определить метод, который будет принимать параметры разных типов, причем заранее не известно каких. Например, классы коллекций из библиотеки классов .NET Framework позволяют создавать коллекции объектов практически любого типа, т. к. методы этих классов используют параметры типа object. Тип object явля- ется псевдонимом для класса System.Object. Это корневой класс для всей системы типов .NET Framework. Все остальные типы являются его наслед- никами (специализированными версиями). Более подробно вопрос рас- сматривается далее. В некоторых случаях может потребоваться преобразовать простой тип в ссылочный, такой как object. Это делается очень просто, как показано в примере. Currency myCurrency = new Currency () ; // Простой тип object о = myCurrency; // Упаковка простого типа в ссылочный Здесь второй оператор требует небольших разъяснений. Как мы помним, переменная простого типа Currency создается на стеке. А по пра- вилам CLR ссылки могут относиться только к объектам на куче, поскольку размещение ссылок на объекты в стеке создает серьезную угрозу безопас- ности функционирования среды исполнения. Поэтому CLR выделяет память из кучи, копирует туда значение пе- ременной myCurrency и записывает в переменную о ссылку на эту копию. Это автоматическое копирование элементов из стека в кучу и принято называть упаковкой (boxing). Поскольку переменная типа object может ссылаться только на упако- ванную копию исходного значения, вполне резонно выглядит разрешение доступа к этому упакованному значению прямо через переменную о, например так: Currency anotherCurrency = о; Однако такой код вызовет ошибку при компиляции, т. к. через пере- менную типа object можно ссылаться на что угодно, а не только на пере- менные типа Currency. Пример возможного неправильного кода такого же плана (глуповатый, но понятный): 118
Residence myHouse = new Residence (...); Currency myCurrency; object o; о = myHouse; // о ссыпается на Residence, myCurrency = о; //а что загружается в myCurrency? Поэтому, чтобы получить значение из упакованной копии, требуется явное приведение типа: Currency myCurrency = new Currency(...); object о = myCurrency; // Упаковка Currency anotherCurrency = (Currency)о; // Скомпилируется Здесь генератор сгенерирует код, который проверяет, является ли такое приведение возможным (безопасным). Если в приведенном примере сгенерированный компилятором код определит, что переменная о относится к типу Currency, то упакованное значение из кучи копируется в переменную anotherCurrency. Если же компилятор определит несоответствие типов, будет выброшено исключе- ние InvalidCastException. Важное замечание. Упаковка и распаковка происходят только при преобразовании из простого типа в ссылочный и обратно. Если вы преобразуете один ссылочный тип в другой, никаких копий объекта не создается, просто появляется новая ссылка на существующий объект, который размещен на куче. Нулевые типы При создании ссылочной переменной она не инициализирована. До того, как ей будет присвоена ссылка на конкретный объект, разумно установить ее значение равным null, чтобы показать, что она не была инициализирована. Нулевое значение всегда можно в коде проверить, например в условном операторе. Пример такой инициализации, последующей проверки и записи ссылки на реальный объект: Residence myHouse = null; if (myHouse = null) { myHouse = new Residence(...); } В отличие от С или C++, где есть константа NULL, в C# null - это специальное значение ссылочного типа, которое означает, что переменная не указывает ни на какой существующий объект. 119
Однако для простых типов такого специального значения нет, и это может вызвать проблемы. Например, становится затруднительным определить, была ли переменная простого типа инициализирована, а при попытке использовать неинициализированную переменную код не будет компилироваться. Использование в этих целях null для простых типов недопустимо. Такой код вызовет ошибку при компиляции: Currency myCurrency = null; // Неправильно Поэтому в C# определили модификатор, который означает, что пере- менная простого типа может иметь значение null. Такие типы принято называть нулевыми. Модификатор представлен знаком вопроса, сле- дующим за именем типа. Пример объявления и использования такой переменной: Currency? myCurrency = null; // Правильно if (myCurrency = null) { myCurrency = new myCurrency (...); } Такой переменной можно без проблем присваивать значения соответствующего типа. int? i = null; int j = 99; i = 100; // Копирование константы простого типа в нулевой тип i = j; // Копирование переменной простого типа в нулевой тип Отметим здесь, что обратное присваивание вызовет ошибку компи- ляции, т. к. переменной типа int не всегда можно присвоить значение типа int?. Соответственно, если метод ожидает параметр типа int, ему нельзя передавать значение типа int?. j = i; // Неправильно Для того чтобы исправить ситуацию, можно использовать явное пре- образование, например так: int? i = 5; int j = (int)i + 10; Компилятор здесь ошибок не диагностирует, и код будет работать правильно, если только значение i не равно null, в этом случае будет выброшено исключение InvalidCastException. 120
Свойства нулевых типов Нулевые типы имеют пару свойств, которые позволяют определить, имеет ли она нулевое значение и каково это значение: • HasValue. Булевское свойство, равно истине, если переменная имеет значение не null. • Value. Собственно значение. При попытке прочитать его в случае HasValue == false будет сгенерировано исключение. Пример использования этих свойств: Currency? myCurrency = null; if (myCurrency.HasValue) { Console.WriteLine(myCurrency.Value); } Замечание. Свойство Value является свойством только для чтения. Его можно использовать для получения значения, но не для его изменения. Для изменения значения переменной нулевого типа следует пользоваться операцией присваивания. Результаты выполнения операций над нулевыми типами Естественно задать вопрос о том, каким будет результат выполнения той или иной операции над операндами, относящимися к нулевым типам, если один или оба имеют значение null. Ответ таков: в случае всех простых типов, за исключением bool?, результатом будет null. В случае типа bool? существуют предопределенные операции & и |, которые в некоторых случаях возвращают не null. Возможные варианты перечислены ниже. opl op2 opl & op2 opl | op2 true true true true true false false true true null null true false true false true false false false false false null false null null true null true null false false null null null null null 121
Операция ?? С целью упрощения кода, необходимого для работы с нулевыми типами, в C# предусмотрена операция ??. Она еще называется операцией объединения с null и представляет собой бинарную операцию, позволя- ющую задать альтернативное значение для использования в тех выраже- ниях, которые при вычислении могут возвращать null. Если значение операнда не равно null, она возвращает его значение, в противном случае возвращается значение второго операнда. Следующие два выражения функционально эквивалентны: opl ?? ор2 opl = null ? ор2 : opl Здесь ор1 - выражение любого типа, в том числе и ссылочного, а также, что более важно, нулевого типа. Таким образом, мы можем использовать операцию ?? для предоставления значений по умолчанию в тех случаях, если основное значение равно null. int? opl = null; int result = opl * 2 ?? 5; В этом приеме ценно и то, что не требуется выполнять операцию явного приведения типа для помещения результата в переменную типа int. Разумеется, если бы переменная result имела тип int?, это присваивание также являлось бы допустимым: int? result = opl * 2 ?? 5; Это делает операцию ?? универсальным инструментом при работе с нулевыми типами и предоставления значений по умолчанию без исполь- зования дополнительных операторов if или тернарной операции, которые утяжеляют чтение кода. 122
Лекция 7. Инкапсуляция данных и методов На предыдущих занятиях мы разобрались, как использовать в .NET- приложениях существующие типы и как создавать свои. Однако, создавая свой тип, вы не всегда захотите, чтобы все члены этого типа были доступны извне. Некоторые из них представляют вспомогательные функции и данные, не имеющие отношения к другим классам. Делать же доступными следует только те члены, которые были задуманы для их использования вне класса (или иного типа). Вопросы управления видимостью рассматриваются в первом разделе данной лекции. До сего момента во всех примерах кода использовались только экзем- плярные члены. Однако в ряде случаев полезно бывает иметь данные и поведение, общие для всех экземпляров данного типа (разделяемые). Для их описания используется модификатор static. Вопросы объявления и использования статических членов рассматриваются во втором разделе лекции. Раздел 1. Контроль видимости членов типа Инкапсуляция - один из основных принципов объектно-ориентиро- ванного программирования. Так называется возможность скрывать данные и функциональность, предназначенные только для внутреннего использо- вания, так что они становятся недоступными для кода, определенного в других типах. Это существенно уменьшает риски, связанные со случайной или злонамеренной порчей данных типа извне. Вместе с тем часть данных и функциональности необходимо сделать доступными извне, возможно ограничив при этом область их доступности заданными пределами. Данный раздел посвящен средствам языка С#, предназначенным для управления степенью доступности типов и членов типов. Понятие инкапсуляции Все приложения имеют дело с данными. Приложения, разработанные на языках, не являющихся объектно-ориентированными, таких как С, как правило, отдельно хранят код и отдельно данные. Управление данными зачастую предоставляется в виде кода библиотечных функций, и обработка данных встроена в бизнес-логику приложения. Если логика системы разделена на части, реализуемые отдельными приложениями, работа с общими данными организуется через библиотечные функции, но операции все равно каждое приложение выполняет свои. Однако нередко требуется изменить формат хранения данных и/или код библиотечных функций для работы с ними. В этом случае все приложения, использующие данный код, должны быть обновлены, должна 123
изменяться и их логика, чтобы соответствовать новой структуре данных. Такие изменения весьма трудоемки и чреваты возникновением ошибок, которых раньше не было. Что такое инкапсуляция? Инкапсуляция - это способность типа скрывать свои внутренние данные и детали реализации, делая доступными извне только опреде- ленные части типа. Это важнейший принцип объектно-ориентированного программирования. Вы скрываете часть данных и логики от доступа извне. Для внешнего кода единственным вариантом взаимодействия с объектом или классом остается использование строго определенного множества открытых методов и свойств. Замечание. Свойства используются для контролируемого доступа к данным, как правило, закрытым. Они будут рассмотрены позже. Преимущества инкапсуляции Инкапсуляция позволяет скрыть информацию, например внутреннее состояние и детали реализации типа. Извне доступны только опреде- ленные вами члены. В результате клиентские приложения не могут произвести непредусмотренное изменение или порчу состояния вашего объекта, что привело бы к непредсказуемым результатам. Впоследствии вы можете изменить детали реализации вашего типа, не изменяя его внешний интерфейс, так что от ваших клиентов не потребуется обновлять свой код. Закрытые и открытые члены C# предоставляет ключевые слова, называемые также модификато- рами доступа, которые используются для задания уровня доступности типов и их членов. Различные модификаторы доступа определяют различную степень защиты. Некоторые из них относятся к типам, другие - к членам типа. Мы начнем их изучение с модификаторов private и public. Использование модификаторов доступа private и public с членами типа Если вы не указываете модификатор доступа для члена типа, то в C# по умолчанию принимается значение private. Это означает доступность в пределах типа и невидимость для всех остальных типов. Следующий при- мер демонстрирует синтаксис явного объявления полей и методов класса как закрытых. 124
class Sales { private double monthlyProfit; private void SetMonthlyProfit(double monthlyProfit) { this. monthlyProf it = monthlyProfit; } private double GetAnnualProfitForecast() { return (this.monthlyProfit * 12) ; } } Здесь оба метода имеют полный доступ к полю monthlyProfit, поскольку являются членами класса Sales. С помощью модификатора private вы защищаете состояние и функциональность класса, однако такой класс, где все члены закрытые, практически бесполезен, поскольку его нельзя использовать извне. Чтобы сделать некоторые члены доступными извне, можно, например, объявить их открытыми с помощью модификатора доступа public. Модификатор public является полной противоположностью private, задавая самый высокий уровень доступности. Он означает, что на доступ не накладывается никаких ограничений. В следующем примере оба метода класса Sales объявлены как открытые, и поэтому их можно вызвать из любого другого класса. class Sales { private double monthlyProfit; public void SetMonthlyProfit(double monthlyProfit) { this. monthlyProf it = monthlyProfit; } public double GetAnnualProfitForecast() { return (this.monthlyProfit * 12) ; } } class Program { static void Main() { Sales coirpanySales = new Sales(); coirpanySales. SetMonthlyProfit (3400) ; Console.WriteLine (coirpanySales.GetAnnualProfitForecast()) ; } } 125
Microsoft в целях облегчения чтения кода и унификации его оформле- ния рекомендует использовать для наименования полей стиль camelCasing, а для наименования методов использовать стиль PascalCasing, независимо от степени их доступности. Внутренний и открытый доступ Мы рассмотрели использование некоторых модификаторов доступа для сокрытия или открытия членов типа, но модификаторы доступа могут применяться и ко всему типу в целом. Здесь мы поговорим об использовании модификаторов доступа для типов. Примеры в основном приводятся для классов, однако все сказанное остается верным для любого типа, в том числе для структур и перечислений. Внутренние типы (internal) Если вы не указываете модификатор доступа для типа, то по умолча- нию применяется модификатор internal. Этот модификатор ограничивает область видимости типами, которые определены в этой же сборке. Приме- нение модификатора доступа public сделало бы тип видимым и из других сборок. В следующем объявлении типа Sales модификатор доступа не указан, а значит, он установлен как internal. class Sales { private double monthlyprofit; public void SetMonthlyProf it (double monthlyProfit) { this .monthlyProfit = monthlyProfit; } public double GetAnnualProfitForecast() { return (this.monthlyProfit * 12) ; } } При таком объявлении другие типы в пределах сборки могут обращаться к классу Sales и использовать другие его доступные элементы. Методы типа также можно объявлять как internal. Они будут доступны всем типам, которые входят в данную сборку и недоступны всем остальным. Такое же правило распространяется на поля и свойства, объявленные с модификатором доступа internal. Открытые типы (public) .NET Framework организует типы в сборки. Сборка может содержать код целого приложения или библиотеку типов и данные, которые приложение может использовать. Так, библиотека классов .NET Framework 126
содержит несколько сборок с большим количеством типов многоразового использования. Например, сборка System.IO обеспечивает функциональ- ность, необходимую для взаимодействия с файловой системой. От сборки будет не слишком много пользы, если она содержит только внутренние типы, т. к. другая сборка не сможет получить доступ к их функциональности. Поэтому многие типы объявляются как открытые (public). Пример демонстрирует объявление открытого класса Sales. Теперь типы из других сборок имеют к нему доступ. public class Sales { private double monthlyProfit; public void SetMonthlyProfit(double monthlyProfit) { this. monthlyProf it = monthlyProfit; } public double GetAnnualProfitForecast() { return (this.monthlyProfit * 12) ; } } Закрытые типы (private) Тип можно также объявить как private. Это можно сделать, только если тип объявлен внутри другого типа (в противном случае пользоваться таким типом было бы невозможно). Чаще всего такой прием используется для объявления перечислений, хотя в принципе он применим и к вло- женным классам и структурам. Типами, объявленными как private, можно пользоваться только в пределах типа, в который они вложены. В следующем примере демонстрируется синтаксис такого объявления. Здесь структура Revenue (доходы) объявлена как закрытая внутри класса Sales. Доступ к ней может быть осуществлен везде в пределах этого класса. public class Sales { private Revenue salesRevenue; public void SetRevenue (string currency, double amount) { this.salesRevenue = new Revenue(currency, amount); } private struct Revenue { string currency; double amount; 127
public Revenue (string currency, double amount) { this.currency = currency; this, amount = amount; } } } Раздел 2. Разделяемые методы и данные В разделе изучаются следующие вопросы: • создание и использование статических полей; • создание и использование статических методов; • создание статических типов и использование статических конструкторов; • создание и использование методов расширения. Создание и использование статических полей До сих пор мы рассматривали в основном вопросы создания и использования экземплярных членов типов. Эти члены содержат данные и выполняют действия, относящиеся к конкретному объекту - экземпляру этого типа. При создании объекта память выделяется для каждого поля, что позволяет хранить там данные соответствующего типа. Например, в следующем примере объекты sales2010 и sales2011 - два разных экземпляра класса Sales и каждый из них имеет собственные данные (внутреннее состояние). Изменения в одном объекте никак не сказывается на другом. Sales sales2010 = new Sales(); sales2010.SetMonthlyProfit(34672); Console.WriteLine(sales2010.GetAnnualProfitForecast()); Sales sales2011 = new Sales(); sales2011.SetMonthlyProfit(98675); Console.WriteLine(sales2011.GetAnnualProfitForecast()); Использование статических полей Статические поля, напротив, не относятся к экземплярному типу, они относятся к типу как таковому. Память под них выделяется независимо от наличия экземпляров типа, и все ссылки на статическое поле относятся к одному и тому же участку памяти. Для доступа к статическому члену типа не требуется создавать экземпляр этого типа. Память под статические члены выделяется при первом обращении к ним. 128
Для создания статического поля вы должны использовать модификатор static. Пример объявления статического поля и его инициализации: class Sales { public static double salesTaxPercentage = 20; } В области видимости доступ к статическому полю осуществляется по его имени, перед которым указывается имя класса и ставится точка: Sales.salesTaxPercentage = 32; Замечание. Статические поля можно также инициализировать в конструкторе, примеры приводятся дальше. Доступ к статическим полям возможен через экземпляр этого класса, поскольку они принадлежат не экземпляру, а классу в целом. Поэтому такой код компилироваться не будет: Sales sales = new Sales(); sales.salesTaxPercentage = 23; // Ошибка компиляции class Sales { public static double salesTaxPercentage; } Однако вы вполне можете получить доступ к статическим полям класса из экземплярных методов и из конструкторов класса просто по имени, поскольку статические поля доступны везде в пределах класса и могут использоваться для обмена информацией между экземплярами и хранения общих данных. Пример использования статического поля для подсчета созданных экземпляров класса User: class User { internal static int usersOnline; internal User() { usersOnline++; } } User a = new User(); User b = new User(); User c = new User(); User d = new User(); int totalUsersOnline = User.usersOnline; // Возвращает 4 129
Если бы поле usersOnline не было статическим, для каждого экземпляра была бы своя копия и все они получали бы после обработки конструктора значение 1, как показывает следующий пример: class User { internal int usersOnline; internal User() { usersOnline++; } } User a = new User(); User b = new User() ; User c = new User(); User d = new User() ; int totalUsersOnline = User. usersOnline; // Возвращает 1 Создание и использование статических методов Модификатор static можно также использовать для создания статических методов. Они весьма полезны и обычно используются в служебных классах для выполнения атомарных операций, не привязанных к экземплярным данным. Например, класс File из пространства имен System. 10 содержит несколько статических методов для совершения атомарных операций, к примеру метод Exist. Использование статических методов При объявлении статического метода используется ключевое слово static, как показано в следующем примере. class Sales { public static double GetMonthlySalesTax (double monthlyProfit) { } } Замечание. Обычно слово static помещают после модификатора доступа, хотя правила допускают и обратный порядок. Если в вашей организации есть установленный корпоративный стиль, то следует придерживаться его. Статический метод не может напрямую по имени обращаться к экземплярным членам класса (только через ссылку на экземпляр, и ее надо как-то получить). Просто по имени они могут иметь доступ только к 130
статическим членам. В следующем примере кода класс Sales предо- ставляет метод GetMonthlySalesTax, который использует статическое поле и данные, полученные через аргументы метода. class Sales { private static double salesTaxPercentage = 20; public static double GetMonthlySalesTax (double monthlyProfit) { return (salesTaxPercentage * monthlyProfit) / 100; } } Синтаксис, используемый для вызова статического метода, отличается от синтаксиса вызова экземплярного метода. Здесь перед точкой нужно указывать имя типа, а не переменной-экземпляра. Пример такого вызова: double monthlySalesTax = Sales.GetMonthlySalesTax(34267) ; Создание статических типов и использование статических конструкторов Тип, разумеется, может содержать и статические и экземплярные члены, причем и те и другие, очевидно, нужны. Если вы разрабатываете класс, который содержит только статические члены, его можно объявить как статический, используя в объявлении типа слово static. В этом случае класс не может содержать никаких экземп- лярных членов: ни полей, ни методов, ни конструкторов. Экземплярных конструкторов он не может содержать потому, что создавать объекты такого типа нельзя. Тем не менее статические типы могут содержать статические кон- структоры, которые ведут себя иначе, чем конструкторы экземплярные. При использовании статического конструктора он не вызывается через использование оператора new. Как правило, среда CLR неявно вызывает статический конструктор перед первой попыткой получить доступ к статическому члену типа. Пример объявления статического класса и его статического конст- руктора: static class Sales { static Sales() { } } 131
При объявлении статических конструкторов следует соблюдать сле- дующие правила: • вы можете определить для класса не более одного статического конструктора; • вы не можете вызывать его явным образом; • статический конструктор не может иметь параметров; • не может иметь модификатора доступа (неявно используется доступ private); • статический конструктор может иметь доступ только к стати- ческим членам класса. Реализация шаблона проектирования Singleton Статические конструкторы имеют много разных применений. Так, с их помощью вы можете реализовать шаблон проектирования Singleton. Суть этого шаблона в том, что разрешается создать только один экземпляр класса, с которым и будут работать все клиенты. Далее приводится пример кода, реализующего шаблон Singleton через класс Sales, сконструированный как обертка над классом SaleData. static class Sales { static SaleData data = null; static Sales () { if (SaleData.WebServerConnectionExists()) { data = SaleData.GetWebServerData(); } else if (SaleData.LocalDatabaseConnectionExists()) { data = SaleData.GetDatabaseData(); } else { throw new NotSupportedException ("No data source could be found.") ; } } public static string[] GetAllSalesRegions() { throw new NotlroplementedException () ; } } 132
Класс Sales содержит статический конструктор, который гарантирует, что до вызова метода GetAIISalesRegions объект data типа SaleData будет инициализирован, причем только один раз. В случае невозможности инициализации будет выброшено соответствующее исключение. Создание и использование методов расширения Целью методов расширения является обеспечение возможности расширения существующих типов за счет добавления собственной функциональности, причем так, чтобы это никак не сказалось на работе кода, который эти типы уже использует. Сделать это можно через определение нового класса, играющего роль обертки для ранее созданной функциональности и обеспечивающего необходимый новый метод. Однако такой подход может повлиять на существующий код и создать проблемы. Определение методов расширения При объявлении метода расширения первый параметр имеет тип, совпадающий с расширяемым типом, и объявляется с ключевым словом this, чтобы указать, что это метод расширения. Во время выполнения этот параметр указывает на экземпляр расширяемого типа, с которым вы в данный момент работаете. В результате вы легко можете управлять им, независимо от того, какую логику должен реализовать метод. Обычно методы расширения объявляются в статических классах, а эти классы - в специальных пространствах имен. Сами же методы расширения обязательно должны быть статическими. Следующий пример показывает, как можно объявить расширяющий метод NextRand для целого типа. Метод возвращает случайное целое число, используя существующий объект типа int для генерации этого значения. Существующий объект он получает через первый параметр. Второй параметр используется для задания максимального значения нового случайного числа. namespace MyNamespace. Extensions { static class IntExtension { internal static int NextRand(this int seed, int maxValue) { Random randomNumberGenerator = new Random (seed) ; return randomNumberGenerator.Next(maxValue) ; } } } 133
Использование методов расширения Клиентский код использует методы расширения точно так же, как и собственные методы данного типа применительно к конкретному экземпляру. Следующий пример показывает, как вызвать только что определенный метод расширения NextRand в пространстве имен MyNamespace.Client. Обратите внимание, что нигде нет упоминания класса IntExtention, в котором объявлен метод NextRand. using MyNamespace. Extensions; namespace MyNamespace. Client { class Program { static void Main() { int i = 8; int j = i.NextRand(20) ; } } } 134
Лекция 8. Наследование от классов и реализация интерфейсов В данной лекции мы рассмотрим, как организовано наследование от классов и интерфейсов в .NET Framework и каким образом эти механизмы можно использовать для уменьшения дублирования кода, сокращения времени разработки и решения других проблем. Наследование - это одно из важнейших понятий в объектно-ориен- тированном программировании. Вы можете использовать наследование, интерфейсы и абстрактные классы для разработки иерархии объектов в вашем коде. Это позволит уменьшить количество ошибок путем определения четких контрактов, которые должен выполнить класс, и обеспечения реализации по умолчанию, прописанной в коде базового типа. Раздел 1. Использование наследования для создания новых ссылочных типов Раздел посвящен тому, как в .NET Framework реализовано насле- дование типов и как его можно использовать для разработки лучше оформленного и более быстрого кода с меньшим количеством ошибок. Разработка иерархии объектов - это очень важный и ответственный процесс. Иерархия объектов должна быть тщательно продумана, при этом вы должны избегать дублирования кода. Основой этого процесса является понимание механизма наследования. Понятие наследования Наследование является ключевым понятием в мире объектно- ориентированного программирования. Вы можете его использовать для того, чтобы избежать повторов при определении разных классов, которые имеют ряд общих характеристик и, возможно, связаны друг с другом. Возможно, они являются разновидностями одного и того же общего типа, каждая с собственными отличительными особенностями. Так, например, менеджеры и рабочие являются работниками одного предприятия. Они имеют и общие характеристики, например табельный номер, и разные, например функциональные обязанности. При разработке разумно сначала создать класс Employee для представления сотрудника вообще. Он может включать в себя инфор- мацию, общую для всех сотрудников, например табельный номер и имя. Далее вы можете разработать класс Manager и класс Manualworker. Им обоим также необходимо хранить табельный номер и имя сотрудника. Вместо того чтобы дублировать эти объявления, вы можете просто указать, что эти классы наследуют от класса Employee, который уже имеет поля для хранения этих значений. Также в классе Employee могут быть методы, которые реализуют поведение, общее для всех сотрудников (в 135
примере - метод DoWork). В этом случае и класс Manager, и класс ManualWorker унаследуют это поведение. // Базовый класс class Employee { protected string empNum; protected string empName; protected void DoWork () { } } // Классы - наследники class Manager : Employee { public void DoManagementWork() { } } class Manualworker : Employee { public void DoManualWorkO { } } Использование наследования, таким образом, уменьшает дублиро- вание кода и снижает риск возникновения ошибок. Разумеется, вы можете добавить поля и методы, специфичные для менеджера и чернорабочего (в примере DoManagementWork и DoManualWork соответственно). Объявление классов-наследников Вы можете указать, что объявляемый класс является наследником другого класса (его принято называть базовым), указав после двоеточия имя базового класса, как в только что приведенном примере. Следует помнить, что при отсутствии модификаторов доступа члены класса являются закрытыми и не будут доступны в классе-наследнике (производном классе). Если вы хотите сделать их доступными в производном классе и далее по всей цепочке наследования, их следует объявить с модификатором protected. В классах, не входящих в число наследников, такие члены будут недоступны. Важное замечание. В отличие от C++ в C# поддерживается только единичное наследование. Предком класса может быть, таким образом, только один базовый класс. 136
Иерархия наследования .NET Framework В .NET Framework все типы прямо или косвенно наследуют от класса Object из пространства имен System. Этот класс обеспечивает функцио- нальность, которая полезна для всех типов, например методы ToString и Equals. Если при создании нового ссылочного типа вы не указываете базовый тип, неявно он считается наследником Object. При указании базового класса он все равно будет наследником Object по цепочке наследования. Поэтому во всех случаях прописанная в типе Object функциональность доступна в вашем типе. Простые типы, например структуры и перечисления, наследуют от типа System.ValueType, который, в свою очередь, является наследником класса Object. Однако, в отличие от классов, на этом цепочка наследования обрывается, и вы не можете создать тип-наследник простого типа. Например, одна структура не может быть наследником другой. Переопределение и сокрытие методов Производный класс наследует доступные методы базового класса. Однако вам может потребоваться реализовать в классе-наследнике собст- венную реализацию этих методов, сохранив при этом их сигнатуру. Для этого можно использовать два механизма: переопределение этих методов и их сокрытие. Переопределение методов При переопределении вы предоставляете реализацию, которая имеет тот же смысл, что и исходный метод, однако учитывает специфику вашего класса. Например, базовая реализация метода ToString, предоставляемая классом Object, возвращает строку с именем типа. Если вы хотите получить с ее помощью более полезную информацию, например о состо- янии объекта, метод ToString следует в своем типе переопределить. Для переопределения используется ключевое слово override. Пример демонстрирует этот прием применительно к классу Employee. class Object { public virtual string ToString() { // Представление объекта в виде строки // (код не приводится) } } class Employee { protected string empName; 137
public override string ToString() { return string.Format("Employee: {0}", empName); } } Переопределить вы можете только методы, объявленные в базовом классе с ключевыми словами virtual или abstract. Обычно виртуальные методы обеспечивают некоторую реализацию по умолчанию, которая, как предполагается, может быть в производных классах заменена собственной реализацией разработчика. Такие методы следует определить при проектировании базового класса и объявить их виртуальными. Обратите внимание, что при переопределении виртуального метода вы не можете изменить его уровень доступности. Так, если в базовом классе он был объявлен как защищенный (protected), при переопределении он таковым и останется. Сокрытие методов Вы можете определить методы производного класса, имеющие такое же имя, как и метод базового класса, даже если тот не был объявлен как виртуальный, абстрактный или переопределенный. Правда, это означает, что никакой связи между вашим методом и методом базового класса нет. В этом случае компилятор C# выдаст предупреждение о возможном кон- фликте, однако код компилироваться будет. Если вы делаете это осознанно, то лучше сообщить об этом компи- лятору с использованием ключевого слова new, как показано в следующем примере. В этом случае он не будет выдавать предупреждение. class Employee { protected void DoWork() { } } class Manager : Employee { public new void DoWork() { // Прячем метод DoWork базового класса } } При сокрытии метода вы можете изменить уровень его доступности. Например, он был закрытым или защищенным, а вы можете свой вариант 138
сделать полностью открытым. Впрочем, такой прием, как правило, не рекомендуется. Принято считать, что переопределение предпочтительнее сокрытия - за последним обычно скрывается непродуманность разработки иерархии типов. Вызов методов и конструкторов базового класса Производный класс может вызывать методы базового класса, исполь- зуя ключевое слово base перед именем метода. Это особенно полезно при использовании переопределения методов, поскольку позволяет вам реали- зовать собственную функциональность, сохранив возможность исполь- зования функциональности унаследованной. В следующем примере класс Manager переопределяет метод DoWork, унаследованный от класса Employee, и в его коде вызывает метод DoWork базового класса. По су- ществу это есть расширение функциональности базового класса путем добавления некоторых дополнительных операций, специфичных для класса производного. class Employee { protected virtual void DoWork() { } } class Manager : Employee { protected override void DoWork() { // Код, специфичный для класса Managers // Вызов метода DoWork базового класса base.DoWork(); } } Если бы в приведенном коде не было слова base, то это привело бы к рекурсивному вызову метода. Вызов конструкторов базового класса Кроме методов, производный класс автоматически наследует все поля базового класса. Эти поля обычно требуют инициализации при создании объекта. Как правило, такая инициализация выполняется в конструкторе, Вспомним, что все классы имеют как минимум один конструктор (если нет, то он генерируется компилятором). 139
Хорошим стилем для конструктора производного класса является вызов конструктора базового класса как часть процесса инициализации. С этой целью при объявлении конструктора производного класса можно употребить ключевое слово base с указанием параметров вызова, как показано в следующем примере. class Employee { protected string empName; // конструктор базового класса public Employee(string name) { this. empName = name; } } class Manager : Employee { protected string empGrade; public Manager(string name, string grade) : base (name) // вызов Employee (name) { this.empGrade = grade; } } Если вы не вызываете конструктор базового класса явным образом, за вас это сделает компилятор. Такой код class Manager : Employee { public Manager(string name, string grade) { } } он перепишет следующим образом: class Manager : Employee { public Manager(string name, string grade) : base() { } } 140
Такой код будет работать только в том случае, если класс Employee имеет открытый конструктор без параметров, что верно не для всех классов. Напомним, что такой конструктор генерируется автоматически только при отсутствии других конструкторов для класса. В случае отсутствия конструктора по умолчанию для базового класса этот код вызовет ошибку компиляции. Присваивание значений и ссылки на классы в иерархии наследования Реализация проверки типов в C# не позволяет присваивать переменным одного типа значения другого типа. Например, следующее присваивание вызовет ошибку, поскольку Manager и Manualworker - это разные типы. class Employee { } class Manager : Employee { } class Manualworker : Employee { } // Вызов конструктора Manager с указанием имени и уровня Manager myManager = new Manager ("Fred", "VP") ; Manualworker myWorker = myManager; // ошибка: разные типы Однако такое присвоение будет правильным, если тип выражения в правой части операции присваивания является предком переменной в левой части. Manager myManager = new Manager ("Fred", "VP") ; Employee myEmployee = myManager; // правильно Этот метод работает потому, что менеджера можно воспринимать как особую разновидность сотрудника, возможно обладающую некоторыми дополнительными характеристиками. При этом он обладает всеми характеристиками, необходимыми сотруднику. С таким же успехом переменной типа Employee вы можете присвоить ссылку на объект типа ManualWorker. Существует, правда, одно огра- ничение: при обращении к этим объектам через переменную myEmployee 141
для вас доступными будут только методы и поля, доступные в классе Employee, все дополнительные члены, появившиеся на следующих уровнях иерархии, не будут видны. Это объясняет, почему практически все можно присвоить переменной типа System.Object: все типы прямо или косвенно являются наследниками этого класса. Преобразование ссылок на объекты Обратное присваивание может привести к ошибке, поскольку не любой сотрудник является менеджером, поэтому перед присвоением следует убедиться в возможности такого приведения типов. C# предос- тавляет для этой цели операции is и as. Операция as пытается привести ссылку на объект к требуемому типу. Если это возможно, она возвращает правильную ссылку, в противном случае возвращает null. Следующие примеры демонстрируют применение операции as. Manager myManager = new Manager("Fred", "VP") ; Employee myEnployee = myManager; // myEnployee содержит // ссыпку на Manager Manager myManagerAgain = myEnployee as Manager; // Успешно - myEnployee является мнеджером Manualworker myWorker = new Manualworker ("Bert") ; myEnployee = myWorker; // myEnployee содержит // ссыпку на Manualworker myManagerAgain = myEnployee as Manager; // Возвратит null - myEnployee не является чернорабочим С этой же целью можно использовать уже знакомую нам операцию присвоения типа. Отличие от использования as заключается в том, что при невозможности преобразования будет выброшено исключение InvalidCastException. Для того чтобы проверить предварительно возмож- ность приведения типа, можно воспользоваться операцией is: if (myEnployee is Manager) { myManagerAgain = (Manager) myEnployee; } Полиморфизм Виртуальные методы, определенные в классах, связанных отноше- нием наследования, позволяют вызывать разные версии одного и того же метода, в зависимости от типа объекта, который определяется динами- 142
чески во время выполнения. Это называется полиморфизмом и являет собой очень мощную возможность объектно-ориентированных систем. Рассмотрим следующий пример кода с базовым классом Employee. class Employee { public virtual string GetTypeName() { return "This is an Employee"; } } class Manager : Employee { public override string GetTypeName() { return "This is a Manager"; } } class Manualworker : Employee { //He переписывает метод GetTypeName } Обратите внимание, что в классе Manager метод GetTypeName переопределен с использованием ключевого слова override, а в классе Manualworker он не переопределяется. Что будет выведено, если выполнить следующий код? Employee myEmployee; Manager myManager = new Manager(...); Manualworker myWorker = new Manualworker(...); myEmployee = myManager; Console.WriteLine(myEmployee.GetTypeName()); // Manager myEmployee = myWorker; Console. WriteLine (myEmployee. GetTypeName ()) ; // Manualworker Первый оператор вывода сообщит, что мы имеем дело с объектом класса Manager, т. к. во время выполнения будет определено, что объект, обращение к которому производится через ссылку типа Employee, фак- тически является объектом типа Manager, и, соответственно, сработает последняя реализация метода GetTypeName в цепочке наследования. 143
Собственно, то же самое произойдет и во втором случае, однако в цепочке наследования последней является реализация метода, объявленная в классе Employee, и выведена будет строка "This is an Employee". Использование ключевого слова sealed для классов и методов По умолчанию, когда вы определяете класс, все, кто имеет доступ к сборке, могут наследовать от него и добавлять собственные функции. Однако, если вы создали класс, не предназначенный для того, чтобы вы- ступать в качестве базового, можно явным образом запретить наследо- вание от него. Такие классы еще называют герметизированными. Для этой цели в языке C# есть ключевое слово sealed, используемое при объявлении класса, как демонстрирует пример. sealed class Manager : Employee { } При попытке использования объявленного таким образом класса в качестве базового компиляция завершается ошибкой. Обратите внимание, что по понятным причинам герметизированный класс не может объявлять никаких виртуальных методов. В .NET Framework все простые типы, включая структуры и перечис- ления, неявным образом являются герметизированными. Методы, объявленные как sealed Применительно к методам ключевое слово sealed используется для запрета дальнейшего переопределения в цепочке иерархии. Пример его использования применительно к методу DoWork: class Manager : Employee { protected sealed override void DoWork() { } } Без использования здесь ключевого слова sealed метод неявным образом считался бы виртуальным и мог бы быть переопределен в классах- наследниках. Раздел 2. Определение и реализация интерфейсов В разделе вводится понятие интерфейса и описывается, как их можно использовать для стандартизации кода. 144
Интерфейсы можно рассматривать, как своего рода контракты, определяющие набор методов, которые класс должен реализовать. Создавая класс, вы можете реализовать сразу несколько интерфейсов, например IComparable, чтобы гарантировать возможность сравнения объ- ектов на равенство, и IDisposable, чтобы гарантировать окончательную очистку объекта. Понятие интерфейса Наследование - очень мощный механизм, однако еще более мощным он представляется при реализации интерфейсов. Интерфейс не содержит ни кода, ни данных, он просто определяет методы и свойства, которые должен обеспечить класс, наследующий от этого интерфейса (еще говорят "реализующий этот интерфейс"). Использование интерфейсов позволяет отделить объявление имен и сигнатур методов класса от их реализации. Интерфейсы действуют как контракт, они гарантируют, что любой класс, реализующий интерфейс, будет предоставлять члены, прописанные в интерфейсе. Это позволяет упростить разработку и стандартизацию кода. Интерфейс определяет функциональность, которую класс должен предоставить. Забота об ее реализации возлагается на класс, а не на интерфейс. Разные классы могут реализовывать один и тот же интерфейс по-разному, лишь обеспечить работу методов, объявленных в интерфейсе. Например, от классов .NET Framework зачастую требует обеспечить возможность сравнивать их экземпляры, чтобы иметь возможность упорядочивания множества объектов. Однако, как сравнивать объекты, класс должен решить сам. Так, в классе Employee можно сортировать сотрудников по рангу, а класс string сравнивает строки в лексико- графическом порядке. Чтобы стандартизировать способ, посредством которого можно сравнивать два объекта произвольного типа, в простран- стве имен System определен интерфейс IComparable. Он содержит единственный метод CompareTo с такой сигнатурой int CompareTo(Object obj) ; Класс, реализующий интерфейс IComparable, должен предоставить свой код для этого метода. Метод должен возвращать нулевое значение, если объект, переданный через параметр, считается равным объекту, для которого метод вызван. Если объект, на котором был вызван метод, считается меньше, чем объект, переданный через параметр, следует возвратить значение меньше нуля. В единственном оставшемся случае возвращается положительное значение. При этом реализация метода целиком возлагается на класс, наследующий от этого интерфейса. Создание и реализация интерфейса Синтаксически интерфейс очень похож на класс, только вы должны просто объявлять методы, но не писать их код. 145
Объявление интерфейса Для объявления используется ключевое слово interface. Внутри вы объявляете методы так же, как сделали бы это в классе или структуре, только вместо тела метода вы ставите точку с запятой, как показано в примере. interface ICalculator { double Add () ; double Subtract () ; double Multiply () ; double Divide(); } Обратите внимание, что в этом примере имя интерфейса начинается с заглавной буквы I. Это не жесткое требование, а общее соглашение об именах, и документация .NET Framework рекомендует его придержи- ваться. Имена всех интерфейсов в пространстве имен System начинаются именно таким образом. Реализация интерфейса Для реализации интерфейса вы объявляете класс или структуру, которые от него наследуют, и обеспечиваете код для каждого опреде- ленного в интерфейсе метода. Замечание. Хотя структура не может наследовать от класса или структуры, она может реализовывать интерфейс. При реализации интерфейса необходимо обеспечить совпадение реализации методов с их объявлением в интерфейсе по следующим правилам: • имена методов и возвращаемые типы совпадают; • все параметры (включая ключевые слова ref и out) точно соот- ветствуют друг другу; • все методы, реализующие интерфейс, объявлены как public. Если вами используется явная реализация интерфейса (объясняется дальше), метод не имеет модификатора доступа. При несоблюдении этих правил код компилироваться не будет. Следующий пример демонстрирует возможную реализацию интер- фейса ICalculator. class Calculator : ICalculator { // Здесь только заглушки методов public double Add() { return 0; } 146
public double Subtract () { return 0; } public double Multiply () { return 0; } public double Divide () { return 0; } } Класс может наследовать только от одного другого класса, но может реализовать сколько угодно интерфейсов. В этом случае в объявлении класса вы указываете эти интерфейсы через запятую. При реализации нескольких интерфейсов следовать сформулированным выше правилам требуется для каждого интерфейса. Например, если вы хотите в классе Calculator реализовать интерфей- сы ICalculator и IComparable, вы можете его оформить примерно так: class Calculator : ICalculator, IComparable { } Доступ к объектам через интерфейс Как можно обращаться к объектам с помощью переменной соответ- ствующего типа, точно так же к ним можно обращаться и через пере- менную интерфейсного типа при условии, что класс объекта этот интер- фейс реализует. Пример демонстрирует инициализацию переменной типа ICalculator для ссылки на объект типа Calculator: Calculator myCalculator = new Calculator(); ICalculator iMyCalculator = myCalculator; Этот код работает, так как в объектах типа Calculator реализованы все методы интерфейса ICalculator. Обратное присвоение возможно не всегда, так как не все объекты, в которых реализован интерфейс ICalculator, относятся к типу Calculator. Так же как и в случае с классами, для того чтобы убедиться, что объект действительно является объектом типа Calculator, можно воспользоваться операциями as или is: Calculator calc = iMyCalculator as Calculator; bool isCalc = iMyCalculator is Calculator; 147
А так можно убедиться, что объект относится к классу, реализующему требуемый интерфейс: ICalculator iCalc = myCalculator as ICalculator; Передача параметров через ссылку на интерфейс Такой прием полезен тем, что позволяет определять методы, которые могут принимать параметры различных типов при условии, что они реа- лизуют заданный интерфейс. Пример объявления метода, который примет в качестве аргумента объект любого типа, реализующего интерфейс ICalculator: int PerformAnalysis(ICalculator calculator) { } Обратите внимание, что при доступе к объекту через ссылку на интерфейс доступны только те его члены, которые в этом интерфейсе объявлены. Явная и неявная реализация интерфейса Все примеры, рассмотренные до сих пор, использовали неявную реализацию интерфейса. В приведенном выше коде класса Calculator нет никаких указаний или даже намеков, что его методы реализуют методы, объявленные в интерфейсе ICalculator. Здесь никаких проблем, казалось бы, быть не может. Однако допус- тим, что класс Calculator реализует несколько интерфейсов и в них объяв- лены методы с одинаковой сигнатурой, причем предполагается, что семан- тика этих методов различна. Пусть, например, класс Calculator дополнительно реализует интерфейс ICalculator, в котором объявлены методы для сложения и вычитания сумм налога: interface ITaxCalculator { double Add () ; double Subtract () ; } Теперь при реализации интерфейса в классе Calculator так, как это было сделано раньше, возникает проблема. Рассмотрим следующий код. class Calculator : ICalculator, ITaxCalculator { public double Add() { return 0; } 148
public double Subtract () { return 0; } public double Multiply () { return 0; } public double Divide () { return 0; } } Вообще-то, с точки зрения синтаксиса здесь все корректно, оба интерфейса считаются реализованными, но что делать, если предпо- лагается разная реализация методов разных интерфейсов? Такой вариант, очевидно, не пройдет, так как не может в классе быть двух методов с одинаковой сигнатурой. class Calculator : ICalculator, ITaxCalculator { ...// Какие-нибудь поля класса // Метод Add для интерсейса ICalculator public double Add() { return 0; } // Метод Subtract для интерсейса ICalculator public double Subtract () { return 0; } public double Multiply () { return 0; } public double Divide () { return 0; } // Метод Add для интерсейса ITaxCalculator public double Add() { return calculatedValue + taxAmount; } // Метод Subtract для интерсейса ITaxCalculator public double Subtract () { return calculatedValue - taxAmount; } } 149
Разрешение конфликта имен путем использования явной реализации интерфейса Для явной реализации метода интерфейса необходимо в заголовке указать, какому интерфейсу принадлежит метод, который здесь реализуется. Пример кода: class Calculator : ICalculator, ITaxCalculator { ...// Какие-нибудь поля класса double ICalculator.Add() { return 0; } double ICalculator.Subtract() { return 0; } double ICalculator.Multiply() { return 0; } double ICalculator.Divide() { return 0; } double ITaxCalculator.Add() { return calculatedValue + taxAmount; } double ITaxCalculator.Subtract() { return calculatedValue - taxAmount; } } Из кода видно, что логика работы одноименных методов, объяв- ленных в разных интерфейсах, различна. Вызов явно реализованных методов интерфейса Обратите внимание, что, кроме префикса, перед именем метода в этом примере есть еще одно изменение: в них не указан модификатор доступа public. По правилам C# при явной реализации запрещено использовать любые модификаторы доступа. Это приводит еще к одному интересному феномену. Если вы создадите переменную типа Calculator в коде, то не сможете через нее вызвать какой-либо из методов Add, так как они по умолчанию являются закрытыми. Впрочем, если бы они были доступны, 150
нельзя было бы указать, какой из вариантов вызывается в следующем примере (неправильном, конечно): Calculator calc = new Calculator(); double result = calc.Add(); Но как же тогда вызывать эти методы? Ответ заключается в том, чтобы сделать это через ссылку на интерфейс, как показано в примере: Calculator calc = new Calculator(); ICalculator calculator = calc; double result = calculator.Add(); ITaxCalculator taxCalc = calc; double tax = taxCalc.Add() ; Несмотря на то, что методы в классе являются неявно закрытыми, через интерфейс они открыты, так что приведенный пример кода вполне корректен. С точки зрения Microsoft, именно явная реализация интерфейсов более предпочтительна. Частичные определения классов и реализация интерфейсов Отметим еще одну важную деталь реализации интерфейсов. Если вы используете частичные определения классов, то интерфейсы, применяемые к каждой части такого определения, относятся ко всему классу. Это означает, что определение public partial class MyClass : IMylntefacel { ••• } public partial class MyClass : IMyInteface2 { ... } будет эквивалентно следующему: public partial class MyClass : IMylntefacel, IMyInteface2 { ... } Такое же замечание можно сделать и об использовании атрибутов, которые рассматриваются несколько позже. Раздел 3. Определение абстрактных классов Раздел знакомит нас с понятием абстрактных классов, способами их использования для уменьшения дублирования кода, ускорения разработки и снижения вероятности возникновения ошибок, вызванных дублиро- ванием кода. Абстрактные классы сочетают в себе некоторые свойства наследования объектов и некоторые свойства интерфейсов. Сокращение 151
дублирования кода достигается за счет того, что абстрактные классы позволяют предположить реализацию методов по умолчанию. Понятие абстрактного класса Абстрактный класс обеспечивает механизм выделения общего кода из нескольких связанных классов и помещения его в один класс. Например, в иерархии классов с базовым классом Employee вы можете создать несколько классов для предоставления различных типов сотрудников, таких как Manager и Manual Worker, причем в дальнейшем, вероятно, появятся и новые типы. В таких ситуациях зачастую некоторые элементы имеют одинаковую реализацию для многих или для всех производных классов. Например, в нашем случае все сотрудники получают зарплату и потому реализующие этот процесс куски кода одинаковы. Пусть они описываются посредством интерфейса ISalaried и метода PaySalary: interface ISalaried { void PaySalary(); } class Manualworker : Employee, ISalaried { void ISalaried.PaySalary() { Console.WriteLine("Pay salary: {0}", currentsalary); // Код выплаты зарплаты } } class Manager : Employee, ISalaried { void ISalaried.PaySalary() { Console.WriteLine("Pay salary: {0}", currentsalary); // Код выплаты такой же как и для Manualworker } } Дублирование кода бросается в глаза. По возможности его следует избегать, тем самым уменьшая время на дальнейшее обслуживание этого кода и уменьшая вероятность ошибок. Такой рефакторинг разумнее всего выполнить, выделив повторяющийся код в новый, специально для этого созданный класс. Следующий пример показывает, как мог бы выглядеть такой код. 152
class SalariedEmployee : Employee, ISalaried { void ISalaried.PaySalary() { Console.WriteLine("Pay salary: {0}", currentsalary); // Общий код выплаты зарплаты } int currentsalary; } class Manualworker : SalariedEmployee, ISalaried { } class Manager : SalariedEmployee, ISalaried { } Это неплохое решение, однако оно имеет тот недостаток, что позво- ляет создавать экземпляры класса SalariedEmployee, что, по-видимому, не имеет смысла. Этот класс задуман только для того, чтобы обеспечить общую реализацию части функциональности. Так что SalariedEmployee - это абстракция, ему не соответствует какой-либо реальный субъект. Для указания того факта, что создание объектов данного класса не допускается, можно объявить его абстрактным путем использования модификатора abstract. abstract class SalariedEmployee : Employee, ISalaried { void ISalaried.PaySalary() { Console.WriteLine("Pay salary: {0}", currentsalary); // Общий код выплаты зарплаты } int currentsalary; } Если теперь в коде сделать попытку создать объект такого класса, компиляция завершится с ошибкой: SalariedEmployee myEmployee = new SalariedEmployee(); 153
Понятие абстрактного метода В абстрактном классе можно объявлять абстрактные методы. Абстрактный метод, в принципе, похож на виртуальный, с той лишь разницей, что он не содержит тела метода. По этой причине производный класс обязан переопределить этот метод. В следующем примере кода определен метод PayBonus в классе SalariedEmployee. Он определен как абстрактный, и все классы сотрудников должны иметь этот метод, но со своей реализацией, потому что логика выплаты премий может быть разной для разных типов сотрудников. abstract class SalariedEmployee : Employee, ISalariedEnployee { abstract void PayBonus(); } Абстрактные методы - очень удобный механизм, если требуется, чтобы все классы-наследники имели этот метод, однако нет возможности предоставить реализацию по умолчанию. Итак, при объявлении абстрактного метода следует использовать ключевое слово abstract, прописать сигнатуру метода, а вместо тела поставить точку с запятой, как это делается при объявлении методов в интерфейсе. Правда, в отличие от интерфейса здесь вы можете указать модификаторы доступа. Замечание. Попытка объявить абстрактный метод в неабстрактном классе вызовет ошибку при компиляции. 154
Лекция 9. Управление временем жизни объектов и работа с ресурсами Все приложения используют ресурсы. Для С#-приложений ресурсы делятся на две обширные категории: управляемые ресурсы, которые поддерживаются общеязыковой средой исполнения CLR, и неуправляемые ресурсы, поддержка которых осуществляется операционной системой за пределами CLR. Управляемый ресурс - это, как правило, объект одного из классов, определенного на управляемом языке, например С#. Примерами неуправляемых ресурсов являются компоненты COM (Common Object Model), дескрипторы файлов, соединения с базами данных, сетевые соединения. Управление ресурсами очень важно для любого приложения. .NET Framework упрощает этот процесс, автоматически освобождая ресурсы управляемого объекта, на который не осталось ни одной ссылки в приложении. Этим занимается специальная служба - сборщик мусора. Однако неуправляемые ресурсы сборщиком мусора не контролируются, поэтому программист должен позаботиться об их очистке надлежащим образом и не занимать их больше, чем это необходимо. Раздел 1. Процесс сборки мусора Каждый создаваемый объект имеет некоторый жизненный цикл от момента создания до уничтожения. Когда объект уничтожается, его состояние должно быть очищено, а все управляемые ресурсы, которыми он пользуется, должны быть освобождены. В .NET Framework этим занимается сборщик мусора. Жизненный цикл объекта Объект проходит несколько стадий жизненного цикла, начиная с создания и заканчивая уничтожением. Для разработчика процесс создания объекта очень прост - он просто использует ключевое слово new для этой цели. В результате выполняются следующие действия: 1. Выделяется блок памяти, имеющий объем, достаточный для хранения объекта. 2. Выделенная область памяти конвертируется в объект, а сам объект инициализируется. Вы можете управлять только вторым этапом, предоставив конст- руктор, который производит инициализацию объекта. Среда исполнения управляет распределением памяти только для управляемых объектов, если же вы используете библиотеки с неуправляемым кодом, для создаваемых неуправляемых объектов память вам придется выделять вручную. 155
После создания объекта вы можете использовать его свойства, методы и другие члены. Уничтожение объекта После завершения использования объекта он может быть уничтожен. Процесс уничтожения позволяет освободить все используемые им ресурсы. Как и создание, уничтожение предполагает наличие двух фаз: 1. Объект очищается, при этом, например, освобождаются все неуп- равляемые ресурсы, такие как дескрипторы файлов и подклю- чения к базам данных. 2. Освобождается память, занимаемая объектом. Вы можете контролировать только первый из этих двух шагов. Для этой цели вы можете написать деструктор. CLR позаботится об освобождении памяти, занимаемой управля- емыми объектами, для неуправляемых объектов вам придется делать это вручную. Управляемые ресурсы в .NET Framework Все элементы, которые может использовать управляемое приложение, с точки зрения .NET Framework делятся на две обширных категории: простые типы и ссылочные типы. Управление простыми типами Простые типы - это управляемые типы, которые создаются обычно на стеке. Стек управляется средой исполнения CLR. Как только объект на стеке выходит из области видимости, память, отведенная для него, освобождается немедленно. Так, например, при завершении метода объявленные там переменные простых типов (созданные на стеке) сразу же уничтожаются. Стек реализует дисциплину очереди LIFO (last in - first out). CLR поддерживает указатель на вершину стека. При создании переменной на стеке указатель перемещается вверх, а при ее выходе из области видимости - обратно вниз. Таким образом, размещение новых элементов и уничтожение старых происходит автоматически, и управление памятью становится весьма ненакладной операцией. Управление ссылочными типами Ссылочные типы размещаются в памяти, выделенной из кучи. Куча - это область памяти, также контролируемая средой CLR. Со стеком она не пересекается. Когда вы создаете объект, CLR выделяет на куче память для него и создает ссылку на эту область памяти, эта ссылка помещается на стек. 156
В отличие от простых типов на один и тот же объект ссылочного типа может существовать несколько ссылок на стеке. Некоторые ссылки могут выйти из области видимости и быть уничтожены. И только когда на объект не останется ни одной ссылки (нигде, а не только на стеке), он может быть уничтожен. Как мы увидим, это обычно происходит не сразу. Таким образом, время жизни объекта напрямую не связано со временем жизни какой-либо ссылки на него. Важной чертой сборщика мусора является мониторинг объекта в куче и определение момента, когда исчезает последняя ссылка на него. Тогда объект может быть безопасно уничтожен. Такой мониторинг - довольно трудоемкая и дорогостоящая операция, поэтому сборщик мусора выполняет ее только тогда, когда это действительно нужно (объем доступной памяти в куче становится меньше некоторого порогового значения). Вторая функция сборщика мусора - дефрагментация кучи. Если приложение пытается создать объект, для которого недостаточно свободного места в куче (нет непрерывного блока памяти), сборщик мусора пытается переместить существующие объекты, чтобы получить свободный блок необходимого объема. Это, как несложно догадаться, довольно дорогостоящая операция. Простые типы на куче Как правило, объекты простых типов создаются в стеке. Однако есть один сценарий, когда это не так. При разработке класса вы вполне можете (и очень даже часто) иметь в классе поля простых типов. При создании объекта память под весь объект выделяется из кучи. А поля - это его часть, следовательно, память для них берется оттуда же. Эти поля имеют такой же жизненный цикл, что и объект в целом, а память, которую они зани- мают, освобождается в процессе сборки мусора. Если поле простого типа определено в структуре, то они - часть структуры. Структура - простой тип, и располагается она на стеке (если только она не является полем класса). В этом случае жизненный цикл таких полей подчиняется обычным правилам для простых типов. Как работает сборщик мусора? Сборщик мусора освобождает ресурсы и память объектов, храня- щихся в куче. Он работает в собственном потоке и обычно запускается автоматически при определенных обстоятельствах. Когда работает сбор- щик мусора, другие потоки в приложениях приостанавливаются, так что он может переместить объекты в памяти, а затем обновить все ссылки на них так, чтобы они указывали на правильные места в памяти. Для освобождения ресурсов сборщик мусора выполняет следующие шаги: 157
1. Помечает каждый объект как мертвый, и он будет таким счи- таться, пока не доказано обратное. 2. Начинает процесс пометки объектов как живых. Сначала отме- чаются объекты, на которые есть ссылки в стеке. Далее рекур- сивно как живые отмечаются объекты, на которые ссылаются живые. Разумеется, логика этого процесса выстроена таким обра- зом, чтобы предотвратить рекурсивное зацикливание. 3. По завершении предыдущей фазы среди всех объектов, которые остались отмеченными как мертвые, выделяются те, которые имеют деструктор. Запуск деструктора принято называть фина- лизацией. Все объекты, требующие финализации, помещаются в специальную структуру, называемую freachable queue. Она хранит указатели на объекты, которые требуют выполнения кода финализации, прежде чем их ресурсы будут освобождены. 4. Объекты, помещенные в freachable queue, отмечаются как живые (теперь ведь на них есть ссылки). Объекты, как правило, добав- ляются в freachable queue только один раз. 5. Объекты, отмеченные как живые, перемещаются в куче, образуя непрерывный блок, таким образом происходит дефрагментация кучи. Ссылки на перемещенные объекты обновляются. 6. Другим потокам дозволяется продолжать работу. 7. В отдельном потоке для объектов, помещенных в freachable queue, запускается код финализации (деструкторы). Однако из памяти они не будут удалены до следующего запуска сборщика мусора. Использование деструкторов Если объекты вашего класса подлежат утилизации посредством сборщика мусора, вы перед их окончательным уничтожением можете выполнить любые действия для освобождения ресурсов и окончательной очистки. Для этого можно использовать деструктор. Важное замечание. Использование деструктора увеличивает накладные расходы на процесс уничтожения объекта и удлиняет жизнь самого объекта и тех объектов, на которые он ссылается. Для управляемых ресурсов все делает сборщик мусора, так что в деструкторе следует освобождать только неуправляемые ресурсы. Еще одно важное замечание. Момент, когда будет запущен деструк- тор, не определен, следовательно, вы не можете наверняка знать, когда ресурсы будут освобождены. Также не определен порядок, в котором деструкторы разных объектов будут выполняться, так что никаких предположений здесь делать нельзя, так же, как и вводить зависимости между объектами в деструкторе. Синтаксис объявления деструктора на примере класса Employee представлен ниже: 158
class Employee { ~Enployee() // Деструктор { // Код деструктора } } На деструкторы распространяются следующие ограничения: • Структуры и другие простые типы не могут иметь деструктора. Они хранятся на стеке, и к ним не применяется сборка мусора. • Деструкторы не имеют модификаторов доступа, они вызываются только сборщиком мусора, напрямую из кода их вызывать нельзя. • Деструктор не может иметь параметров, поскольку не вызывается явным образом. Если вы объявили деструктор, компилятор автоматически превратит его в метод Finalize. Это переопределенный метод, но вы не можете его пе- реопределять самостоятельно, вместо этого следует написать деструктор. Далее приведен код, которым компилятор заменит написанный вами деструктор. protected override void Finalize() { { // Код деструктора } finally { base.Finalize(); } } Обратите внимание, что код деструктора компилятор помещает в блок try, а код в блоке finally гарантирует вызов деструктора базового класса. Как было сказано ранее, ссылки на классы, имеющие деструктор, перед уничтожением помещаются в freachable queue. Код деструктора запускается затем в отдельном потоке, и только после этого ссылка удаляется из freachable queue, а сам объект окончательно может быть уничтожен только при следующей сборке мусора. Поэтому следует использовать деструкторы только в тех случаях и для тех классов, которым нужна именно такая функциональность. Неоправданное использование деструкторов может заметно ухудшить производительность приложения. 159
Класс GC (Garbage Collector - сборщик мусора) Большую часть времени не следует вмешиваться в работу сборщика мусора (он управляется средой CLR). Однако иногда, возможно, требуется его запустить принудительно или изменить режим его работы. Для этого вы можете использовать класс GC. Этот класс имеет несколько статических методов, которые можно вызывать из вашего кода. В следующей таблице перечислены некоторые из наиболее часто используемых методов класса GC. Метод Описание Замечания и пример вызова Collect Запускает сборку мусора Не следует злоупотреблять этим вызовом. Если запускать сборщик мусора чаще, чем необходимо, это негативно скажется на производи- тельности. Метод асинхронный, когда он возвращает управление, нет гарантии, что сборка мусора закончилась (или даже началась), он отработает, когда будет подходящий момент. GC.Collect(); WaitForPending Finalizers Приостанавливает текущий поток, пока не отработа- ют финализаторы для всех объектов из freachable queue Метод следует использовать, если необходимо дождаться завершения работы деструкторов. GC.WaitForPendingFinalizers(); SupressFinalize Предотвращает финализацию объекта, переданного как параметр Метод обязательно следует вызывать при реализации шаблона Dispose (рассматривается позже). Может повысить производи- тельность, так как исключает повторное срабатывание кода финализации. GC.SuppressFinalize(this); ReRegisterFor Finalize Объект помечается как требующий финализации Используется, если объект уже был финализирован или для него вызывался метод SupressFinalize. GC.ReRegisterForFinalize(this); 160
Метод Описание Замечания и пример вызова AddMemory Pressure Информирует среду исполнения, что вы должны выделить большой блок неуправляемой памяти Вызов метода позволяет освобо- дить ресурсы там, где это только возможно. При вызове метода вы должны указать, сколько памяти вам нужно. Если требуется несколько блоков памяти, метод можно вызвать несколько раз. Метод следует вызывать перед выделением большого блока неуправляемой памяти. Метод не следует вызывать перед созданием управляемых объектов. GC.AddMemoryPressure (1000) ; RemoveMemory Pressure Информирует среду исполнения о том, что вы освободили большой блок неуправляемой памяти Вызов позволяет понизить степень срочности запуска сборщика мусора. При вызове через параметр вы указываете, сколько памяти освободили. Если освобождаете несколько объектов памяти, можно вызвать метод несколько раз. Метод следует вызывать только в случае освобождения неуправля- емой памяти большого объема. Методы AddMemoryRessure и RemoveMemoryRessure следует всегда использовать парой и с указанием одинакового количества памяти. GC.RemoveMemoryPressure(1000); Раздел 2. Управление ресурсами Сборщик мусора автоматически освобождает память и ресурсы для управляемых объектов. Однако, если ваш класс использует неуправляемые ресурсы, об их освобождении вы должны позаботиться самостоятельно. Специально для этой цели был разработан шаблон проектирования Dispose. Он позволяет это сделать с соблюдением всех правил и своевременно. Реализация этого шаблона в ваших классах способствует тому, что они функционируют хорошо и не задерживают неуправляемые ресурсы дольше, чем это требуется. 161
Зачем нужно управление ресурсами Сборщик мусора предназначен для работы с управляемыми объек- тами. Он не понимает, как освободить ресурсы, связанные с неуправляе- мыми объектами. Если вы в классе ссылаетесь на неуправляемый объект, то при удалении последней ссылки на класс не будет и ссылок на этот объект. Однако операционная система не может освободить эти ресурсы, пока приложение не завершится. Так, библиотека классов предоставляет нам класс TextWriter, который можно использовать для открытия файла в локальной файловой системе и записи туда текста. Этот класс выступает как управляемая обертка над текстовыми файлами, которые являются неуправляемыми ресурсами, контролируемыми операционной системой. Когда TextWriter открывает файл, операционная система блокирует доступ к нему со стороны других процессов. По завершении работы с объектом TextWriter вы можете удалить все ссылки на него. Объект будет уничтожен, однако это не снимает блокировку, и для этого нужно предпринять дополнительные усилия. Если этого не сделать, файл не будет никому доступен до завершения работы приложения. В дополнение к подобным блокировкам есть и ряд других проблем, вызванных некорректным управлением ресурсами. Например, некоторые управляемые типы для повышения производительности используют буферизацию. Буфер освобождается или при заполнении, или при явном на то указании. Если при уничтожении объекта не удастся вытолкнуть данные из буфера в целевой приемник, эти данные могут быть потеряны. Такой сценарий, например, возможен при использовании объекта TextWriter. Поэтому он предоставляет метод Flush, который может использоваться для принудительной очистки буфера перед уничтожением объекта. Еще один пример дорогостоящих и зачастую ограниченных ресурсов - соединения с базами данных. Серверы баз данных часто поддерживают только ограниченное число одновременных подключений. Если вы их не закроете по завершении работы, то ресурсы исчерпаются очень быстро и ваше приложение потеряет возможность подключения к базе. Все такие проблемы вы можете предотвратить путем правильного управления ресурсами, которое гарантирует своевременное их освобождение. Шаблон проектирования Dispose Этот шаблон специально разработан для корректного освобождения ресурсов. .NET Framework предоставляет разработчикам интерфейс IDisposable и структуру кода, в соответствии с которой следует выполнять освобождение ресурсов. 162
Интерфейс IDisposable В интерфейсе определен единственный метод Dispose. Метод не имеет параметров, в его обязанности входит освобождение всех неуправляемых ресурсов, принадлежащих объекту. Он также должен освободить ресурсы, принадлежащие базовому типу, путем вызова метода Dispose для него. Язык C# имеет конструкции, которые обеспечивают своевременное освобождение ресурсов всеми классами, реализующими интерфейс IDisposable. Многие из классов .NET Framework, работающие с неуправляемыми ресурсами, как и TextWriter, реализуют интерфейс IDisposable. Вам тоже следует это делать при создании классов, работающих с неуправляемыми ресурсами. Отслеживание удаления объектов Вызов метода Dispose не уничтожает объект, он будет уничтожен только после удаления всех ссылок на него и последующего срабатывания сборщика мусора. Таким образом, при реализации шаблона Dispose вы должны отслеживать состояние объекта и проверять, что метод Dispose для него еще не был вызван, а ресурсы освобождены. Распространенным приемом является добавление в класс булевского поля IsDisposed (см. пример кода), проверка его значения и изменение при необходимости в методе Dispose. В методах, использующих объект, следует проверять это значение и, если оно истинно, выбрасывать исключение ObjectDisposedException. Исключением из этого правила является сам метод Dispose. У вас должна быть возможность вызывать его несколько раз без выбрасывания исключений и перехода в неопределенное состояние. Метод Dispose должен проверять состояние ресурсов перед тем, как освобождать их. Следующий пример демонстрирует реализацию интерфейса IDisposable классом, использующим объект TextWriter. Код гарантирует, что объект TextWriter закрыт правильно и все ресурсы освобождены. class LogFileWriter : IDisposable { private bool isDisposed = false; private TextWriter writer =...; //••• public void WriteDataToFile (...) { // Проверяем, не был ли объект уже уничтожен if (isDisposed) throw new ObjectDisposedException(...); //... } 163
public void Dispose() { if (!isDisposed) { // Закрываем объект TextWriter только если //он != null (в этом случае он уже уничтожен) if (writer != null) { writer.Flush(); writer.Close(); writer = null; } // Указываем, что объект уже уничтожен, // а ресурсы освобождены isDisposed = true; } } } Вызов метода Dispose из деструктора Если вы должны быть уверены, что метод Dispose для объекта обязательно будет вызван, вы можете поместить его вызов в деструктор класса. Правда, стоит иметь в виду, что финализация - довольно дорогостоящий процесс, так что пользоваться этим приемом следует только в случае, если это действительно необходимо. Предварительное освобождение управляемых ресурсов В некоторых случаях вы можете захотеть освободить пораньше не только неуправляемые, но и управляемые ресурсы. Например, это может быть большой массив, который больше не нужен, а память занимает. Ссылка на него будет уничтожена только после уничтожения вашего объекта, но при наличии деструктора он доживет до следующей сборки мусора, продлив жизнь и этому массиву. Вы можете ускорить освобождение памяти, установив в методе Dispose ссылку на этот массив в значение null. Хотя, если честно, здесь тоже нет гарантии, что память будет освобождена раньше - все зависит от сборщика мусора. Если метод Dispose вызывается из деструктора, не исключено, что он уже был вызван ранее вручную. В этом случае нет смысла в попытке освободить управляемые ресурсы более чем один раз. На этот случай рекомендуется создать перегруженный метод Dispose, который принимает булевский параметр, указывающий, был ли метод вызван как часть процесса финализации либо непосредственно в коде приложения. Принято соглашение, что этот параметр принимает значение true, если метод вызывается приложением, и false, если он вызван деструктором. В первом случае (значение параметра равно true) он должен 164
освободить все ресурсы (и управляемые, и неуправляемые). Если Dispose вызывается из деструктора, управляемые ресурсы или уже удалены, или обязательно будут удалены сборщиком мусора в свое время, а значит метод должен освободить только неуправляемые ресурсы. По этой причине метод Dispose без параметров в своем коде для освобождения ресурсов использует вызов Dispose(true), а деструктор - Dispose(false). Хорошим стилем считается объявить метод Dispose с параметром как защищенный и виртуальный. Таким образом он будет доступен только наследникам, а те смогут его переопределить, если у них есть свои ре- сурсы, подлежащие освобождению. Перегруженный метод Dispose должен вызывать метод Dispose базового класса, если он существует. Ниже приводится пример кода, написанный в соответствии с этими правилами. class LogFileWriter : ..., IDisposable { private bool isDisposed = false; private Textwriter writer = . . .; private int[] largeArray = ...; public void WriteDataToFile(...) { // Проверяем, не был ли объект уже уничтожен if (isDisposed) throw new ObjectDisposedException(...); } public void Dispose() { Dispose(true); } ~LogFileWriter() { Dispose(false); } protected virtual void Dispose(bool isDisposing) { if (!isDisposed) { if (isDisposing) { // Освобождаем ресурсы только если Dispose // был вызван приложением явным образом largeArray = null; } 165
// Всегда освобождаем неуправляемые ресурсы if (writer != null) writer.Flush(); writer.Close(); writer = null; } // Указываем, что объект уже уничтожен, // а ресурсы освобождены isDisposed = true; // Вызов метода Dispose родительского класса // (предполагаем, что такой метод там есть) base.Dispose(isDisposing); } } Подавление финализации Если у класса есть деструктор, то в процессе сборки мусора объекты его будут добавляться в freachable queue. Поэтому в своем открытом ме- тоде Dispose (без параметров) после того, как вы освободили все ресурсы, следует вызвать статический метод SuppressFinalize класса GC, передав ему через параметр текущий объект. Этот вызов отмечает объект как не подлежащий финализации, и сборщик мусора не будет запускать для него код завершения. Пример кода метода Dispose: public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } Управление ресурсами приложения Одной только реализации интерфейса IDisposable еще не достаточно для управления ресурсами, надо помнить, что требуется вызвать метод Dispose. Есть несколько подходов к тому, как распоряжаться объектом, когда он уже не нужен: • можно вручную вызвать метод Dispose в нужном месте своего кода, как показано в следующем примере: LogFileWriter Ifw = new LogFileWriter (...); // Используем объект LogFileWriter Ifw.Dispose(); 166
• использовать блоки try/finally, поместив вызов метода Dispose в блок finally, как показывает следующий пример: LogFileWriter lfw; try Ifw = new LogFileWriter (...); // Используем объект LogFileWriter } finally { if (Ifw != null) { Lfw.Dispose(); } } • использовать блок using для инкапсуляции своего объекта, как показано в следующем примере: using (LogFileWriter lfw = new LogFileWriter (...)) { // Используем объект LogFileWriter } Microsoft считает последний вариант (using) наиболее предпочти- тельным способом гарантировать очистку объекта по завершении его использования. При использовании блока using переменные, в нем объявленные, только в нем и доступны. И даже если код в блоке приведет к выбросу исключения, среда исполнения освободит все объявленные там объекты. Синтаксис языка C# предполагает объявление объектов в скобках после ключевого слова using (см. последний пример). Такой код функци- онально эквивалентен следующему: { LogFileWriter lfw; try lfw = new LogFileWriter (...); // Используем объект LogFileWriter } 167
finally { if (Ifw != null) { Ifw.Dispose(); } } } Обратите здесь внимание на фигурные скобки, в которые заключен весь код. Они нужны для того, чтобы подчеркнуть локальность объявления переменной Ifw. Как только мы выходим из блока, переменная выходит из области видимости, и соответствующий объект подлежит утилизации (если на него не было сохранено других ссылок). Если использование блока using вам не подходит (например, объект не реализует интерфейс IDisposable), можно использовать вариант с try/finally. В этом случае даже при выбрасывании исключения блок finally отработает и метод Dispose будет выполнен. Следует использовать try/finally везде, где невозможно использование блока using. 168
Лекция 10. Инкапсуляция данных и определение перегруженных операций Практически все разрабатываемые вами приложения предполагают разработку хотя бы одного типа, отображающего некоторую сущность. Типы обычно предоставляют своим клиентам некоторые методы и данные. Самый простой подход к предоставлению данных - это хранение их в виде открытых полей, однако в большинстве случаев такой прием нехорош ввиду недостаточной безопасности, эффективности или естественности. Например, при хранении в классе массива самый естественный подход - дать возможность обращения к его членам, используя обычный для массивов синтаксис, а именно квадратные скобки. Однако при хране- нии в классе данных, предназначенных только для чтения, использование открытого поля является неподходящим вариантом, поскольку клиент имеет возможность изменения данных. Для решения такого рода проблем язык C# предлагает такие инст- рументы, как свойства и индикаторы, которые позволяют инкапсулировать данные и предоставлять их клиенту наиболее подходящим образом. Другой вопрос, который будет рассмотрен в рамках данной темы, - это переопределение операций. Этот прием позволяет определить инту- итивно понятное выполнение обычных операций применительно к вашему типу. Например, естественно ожидать, что результатом операции сложения "Hello"+"World" будет "HelloWorld". Мы научимся реализовывать опера- ции именно таким, интуитивно понятным образом. Раздел 1. Создание и использование свойств Использование свойств - это очень хороший способ обеспечить контролируемый доступ к данным типа. Объявлению и использованию свойств и посвящен данный раздел. Понятие свойства В каком-то смысле свойство является чем-то средним между полем и методом. Для доступа к данным используется такой же синтаксис, как и для доступа к полю. Зато поведение свойства больше похоже на поведение метода. Свойство может содержать два элемента: • аксессор get для чтения значения, • аксессор set для изменения значения. Свойства - весьма распространенный способ инкапсуляции данных, к которым ваш класс предоставляет доступ. Чаще всего свойство отображает закрытое поле в вашем типе. Поле хранит данные, а для доступа к ним использует механизм аксессоров get и set. Вы не обязаны предоставлять оба аксессора. Если вы обеспечили только get, то свойство будет доступно 169
только для чтения, а если set - то только для записи. Впрочем, второй вариант имеет очень ограниченную область применения. Еще одним преимуществом использования полей является возможность контроля корректности данных. Например, открытое поле позволяет записать любое значение подходящего типа. А что делать, если нужно ограничить возможные его значения, скажем, каким-то диапазоном? Это легко сделать с использованием свойств, поскольку мы сами создаем логику чтения и записи через код аксессоров. Хотя свойства часто для хранения данных используют закрытые поля, вы не обязаны поступать именно так. Аксессор get может возвращать любое вычисленное значение или константу, может выполнять любые операции, допустимые в данном приложении. Свойства часто включают дополнительную логику. Например, если вы обновляете имя файла, представленное как свойство, логика аксессора set может проверить, доступен ли файл в настоящий момент, и при необходимости пере- именовать его или открыть другой файл в соответствии с требованиями приложения. Объявление свойства Свойство, как и поле, имеет тип и имя, однако в отличие от поля оно имеет программируемую логику чтения и сохранения данных. Аксессор get, как и метод, может содержать любой код, при этом он обязан либо возвратить объект типа, указанного при объявлении свойства, либо выбросить исключение. Аксессор get не обязан вообще что-либо делать, хотя, как правило, производится обновление закрытого поля, ассоциированного с данным свойством. Вам не нужно указывать параметр для аксессора set, он всегда принимает один параметр типа, указанного при объявлении свойства. Доступ к этому параметру в блоке кода аксессора осуществляется с использованием ключевого слова value. Следующий пример показывает, как можно определить простое свойство, обеспечивающее доступ к закрытому полю. За словом get следует блок кода, выполняющего считывание свойства, за словом set - блок, определяющий логику его записи. private string myString; public string MyString { get { return this.myString; } set { this.myString = value; } } 170
При объявлении свойства только для чтения аксессор set опускается. Аналогичным образом для объявления свойства только для записи следует опустить аксессор get. Объявление доступности свойства При объявлении свойства вы указываете модификатор доступа, Его действие распространяется на оба аксессора - get и set. Однако при жела- нии вы можете указать у аксессоров альтернативный модификатор. Например, при таком объявлении свойства доступ по чтению является открытым, а запись свойства возможна только в пределах данного класса. public string MyString { get { return this.myString; } private set { this.myString = value; } } Использование свойств Доступ к свойству со стороны клиентов осуществляется в точно такой же нотации (через точку), как и доступ к полям. Следующий пример демонстрирует синтаксис доступа к свойству MyString по записи и по чтению. При этом компилятор все попытки прочитать или записать свойство преобразует в вызовы соответствующих аксессоров. MyObject theClass = new MyObject; // Сохранение строки - вызывается аксессор set theClass.MyString = "Property set."; // Получение строки - вызывается аксессор get Console.WriteLine(theClass.MyString); Замечание. Вы можете также объявлять статические свойства - они будут иметь доступ только к статическим данным. Автоматические свойства При разработке нового типа вы включаете в него поля данных, кото- рые надо предоставить потребителям. Если для этих данных не требуется никакая дополнительная обработка или проверка, можно использовать просто открытые поля, не задействуя свойства. 171
В этом случае использование открытых полей не приводит к пробле- мам. Однако по мере развития приложения требования могут измениться, может появиться необходимость выполнения какой-то дополнительной логики при доступе к этим данным, следовательно, нужно будет заменить эти поля свойствами. С точки зрения разработчика, использование поля и свойства в простейшем случае - это одно и то же, но не с точки зрения компилятора, так как для свойств ему требуется генерировать код доступа к аксессорам. Этот момент важен для уже существующих приложений. Для них необ- ходимость на позднем этапе превратить поле в свойство приводит к тому, что требуется перекомпилировать все сборки, этот класс использующие, и, возможно, произвести их переустановку. Вы можете избежать этой лишней работы, используя свойства для представления данных еще на начальном этапе. В этом случае внесение дополнительной логики в доступ к ним не требует перекомпиляции приложений, использующих ваш тип. Там, где есть соблазн использовать открытое поле, можно его заменить сразу автоматическим свойством. Автоматические свойства обеспечивают упрощенный синтаксис с пустыми аксессорами get и set: public string Name { get; set; } Впоследствии, если возникнет потребность в дополнительной логике, можно просто после аксессора в фигурных скобках написать необходимый код. Приведенная в качестве примера конструкция превращается компилятором в код с объявлением закрытого поля, а также кода для его чтения и записи: private string _name; public string Name { get { return this._name; } set { this._name = value; } } Обратите внимание, что автоматические свойства всегда определя- ются с обоими аксессорами, поскольку предназначены для замены откры- тых полей. Если вас это не устраивает, используйте свойства, объявленные вручную. Для ваших клиентов нет никакой разницы в использовании автоматических или вручную запрограммированных свойств. 172
Инициализация объектов с использованием свойств Мы уже знаем, как используется конструктор для создания экземпляра объекта и инициализации его полей. Вы можете объявить несколько конструкторов с разной сигнатурой, чтобы дать возможность при создании объекта указывать разные комбинации полей разного типа. Однако мы обращали ранее внимание на проблему, возникающую при наличии нескольких полей одинакового типа. Пусть у нас есть класс, объявленный таким образом: class Employee { private string name; private string department; // Инициализируем оба поля public Employee(string empName, string empDepartment) { this, name = empName; this. department = empDepartment; } // Инициализируем только поле name public Employee(string empName) { this, name = empName; } // Инициализируем только поле department public Employee (string empDepartment) { this. department = empDepartment; } } Целью разработчика было обеспечение возможности при создании объекта указать либо имя сотрудника, либо название отдела. Такой код, очевидно, не скомпилируется, поскольку для компилятора два пред- ложенных конструктора неразличимы (имеют одинаковую сигнатуру). Если попробовать выполнить такое создание объекта, компилятор не будет знать, какой вариант конструктора вызвать. Решить эту проблему позволяет использование свойств для иници- ализации объекта. Такой синтаксис получил название "инициализатор объектов", он предполагает присвоение значений посредством исполь- зования пар "имя-значение". Следующий пример показывает, как можно объявить класс, поддерживающий инициализаторы объектов, и создать объект, используя этот синтаксис. 173
class Employee { // Конструктор по умолчанию public Employee() { } // Конструктор, устанавливающей ранг сотрудника public Employee(int grade) { } // Имя и отдел задаются автоматическими свойствами public string Name { get; set; } public string Department { get; set; } } // Создание объекта с установкой одного свойства Employee louisa = new Employee() {Department = "Technical" }; // Еще пример создания объекта с установкой одного свойства, // здесь нет необходимости использовать скобки для вызова // конструктора по умолчанию Employee john = new Employee { Name = "John" }; // Создание объекта с установкой нескольких свойств, // они разделяются запятыми Employee mike = new Employee { Name = "Mike", Department = "Technical" }; В первом примере (louisa) происходит вызов конструктора по умол- чанию, а затем названию отдела присваивается значение "Technical". В случае использования конструктора по умолчанию круглые скобки можно опустить, что демонстрируется вторым (john) и третьим (mike) примером. Если класс имеет конструктор с параметрами, то вызов его с инициализаторами объектов может выглядеть примерно так. Employee antony = new Employee(2) { Name = "Antony", Department = "Management" }; Здесь вызывается конструктор, устанавливающий ранг сотрудника. 174
При использовании инициализатора объектов сначала выполняется код конструктора, а затем устанавливается значение указанных свойств. При этом значения, установленные в конструкторе, могут быть изменены. Обычно советуют использовать только конструкторы, которые устанавливают все необходимые свойства по умолчанию. При создании объекта эти свойства можно перезаписать в инициализаторе объекта. Объявление свойств в интерфейсе Интерфейс представляет собой контракт, определяющий методы, которые класс должен реализовать. Интерфейс также может определять и свойства. Однако за детали реализации этих свойств (например, поля, на которые они ссылаются) отвечает уже класс. Для объявления свойства в интерфейсе используется точно такой же синтаксис, что и для автоматических свойств, за исключением того, что модификатор доступа в этом случае не указывается. Следующий пример демонстрирует синтаксис объявления свойств в интерфейсе. interface IPerson { string Name { get; set; } int Age { get; } DateTime DateOfBirth { set; } } Классы, реализующие интерфейс, могут это сделать явным или неявным образом. Следующий код содержит пример неявной реализации интерфейса: class Person : IPerson { public string Name { get { throw new NotlmplementedException () ; } set { throw new NotlmplementedException () ; } } public int Age { get { throw new NotlmplementedException () ; } } 175
public DateTime DateOfBirth { set { throw new NotlmplementedException () ; } } } Приведем также пример явной реализации интерфейса IPerson: class Person : IPerson { string IPerson.Name { get { throw new NotlmplementedException () ; } set { throw new NotlmplementedException () ; } } int IPerson.Age { get { throw new NotlmplementedException () ; } } DateTime IPerson.DateOfBirth { set { throw new NotlmplementedException () ; } } } Рекомендации по объявлению и использованию свойств Свойства обеспечивают отличный инструмент для предоставления данных клиентам ваших классов, однако их ненадлежащее использование создает риск возникновения ошибок и неправильного поведения ваших объектов. Эти риски можно уменьшить, если следовать некоторым рекомендациям. Использование свойств Легко говорить, что надо использовать свойства вместо открытых полей при разработке типа, однако это не всегда является лучшим вариантом. Так что окончательное решение всегда принимает разработчик с учетом того, какие операции с данными приложение будет выполнять. Например, если вы разрабатываете тип для представления банковс- кого счета, одно из полей может представлять текущий баланс. Казалось 176
бы, надо просто создать свойство, позволяющее эту величину читать и записывать, но такое поведение не будет отражать реальные банковские операции со счетом. Банк позволяет либо внести деньги, чтобы увеличить ваш баланс, либо их получить (с учетом необходимых ограничений), а не установить баланс вашего счета непосредственно. Следовательно, более целесообразным будет обеспечить методы для осуществления таких операций: Deposit и Withdraw соответственно. Таким образом, способ представления данных следует выбирать, исходя из способа работы с ними, а представление данных в виде свойства должно быть оправдано. Не реализуйте аксессор get с побочными эффектами. Аксессор get должен просто получить значение и выдать его клиенту. Реализация аксессора не должна изменять никакие другие данные типа, то есть влиять на внутреннее состояние объекта. Исключения из этого правила возможны, если есть, например, требования безопасности. В этом случае вы можете добавлять код для получения права доступа, провести авторизацию пользователя. Соглашения об именах Обычно используются имена либо в стиле PascalCasing, либо в стиле camelCasing - разница только в начальной букве. Например, поле с именем myData ассоциировано со свойством MyData, как демонстрирует следу- ющий пример (кстати, в примере намеренно допущена ошибка): int myData; public int MyData { get { return MyData; } } Ошибка в том, что этот код приведет к рекурсивному зацикливанию. Если подождать достаточно долго, то все закончится выбросом исклю- чения OutOfMemoryException. Правильно было написать return myData; Раздел 2. Создание и использование индексаторов Свойство, как правило, предоставляет доступ к одному элементу в типе. Однако некоторые типы изначально рассчитаны на хранение множества значений. Это, например, массивы и коллекции. Кроме того, элемент может содержать вложенные элементы, к которым вы хотели бы 177
организовать удобный доступ. Если, например, вы воспринимаете строку как множество символов, то вам может потребоваться обеспечить доступ к отдельным символам как к свойствам. Наиболее естественным стилем организации доступа к элементам в этом случае является нотация, при- меняемая в массивах. Именно такой способ доступа и обеспечивают индексаторы, являющиеся предметом изучения в данном разделе. Понятие индексатора Индексаторы обеспечивают механизм инкапсуляции множества значений таким же образом, как свойство инкапсулирует одно значение. Вы используете в коде приложения индексатор для доступа к одному конкретному значению из этого множества, для чего пишете, как и для свойства, аксессоры get и/или set. При этом сам элемент определяется по индексу, который аксессор получает как параметр. Использование аксессора предполагает тот же синтаксис, что и доступ к элементам массива. Однако в случае индексаторов гибкость этого процесса выше. Например, при индексации в своем классе вы можете использовать индексы нецелого типа, в отличие от доступа к массивам. Следующий пример демонстрирует использование простого индекса- тора для типа, который называется CustomerAddressBook: CustomerAddressBook addressBook = . . .; // Используем индексатор для получения адреса заказчика Address customerAddress = addressBook["а2332"]; Тип CustomerAddressBook предоставляет индексатор, позволяющий получить адрес клиента по его идентификатору. При этом идентификатор клиента представляет собой строку. Здесь пока приведен только пример использования индексатора. Тип может содержать несколько перегруженных индексаторов при условии, что они имеют параметры разных типов. Например, тип CustomerAddressBook мог бы дополнительно иметь индексатор, при- нимающий индекс целого типа, как показано в следующем примере: // Получение адреса заказчика по целочисленному индексу Address customerAddress = addressBook[99]; Разные индексаторы могут даже возвращать данные разных типов. Как объявить индексатор Синтаксис объявления индексатора напоминает синтаксис свойства: вы объявляете тип, аксессоры get и set, но имя индексатора всегда this. Типы и имена параметров указываются в квадратных скобках. Как и свойство, индексатор может обеспечивать доступ только для чтения (есть только get) или только для записи (есть только set). В коде аксессоров доступ к параметрам осуществляется по имени. Эти параметры нужны для того, чтобы определить конкретный элемент из 178
всего множества однотипных элементов. Предполагается, что они исполь- зуются только в этом качестве. Как и в случае свойств, get должен вернуть указанный элемент, a set - установить его новое значение. Разумеется, аксессоры могут реализовывать и некоторую дополнительную логику. Следующий пример содержит объявление простого индексатора в классе AddressBook для получения или обновления адреса заказчика в базе данных. Заказчик идентифицируется на строке CustomerlD. class AddressBook { public Address this[string CustomerlD] { get { return database.FindCustomer(CustomerlD); } set { database. UpdateCustomer (CustomerlD, value) ; } } } При разработке индексатора важно обеспечить проверку на допусти- мость переданного значения индекса. Замечание. Вы не можете объявлять статические индексаторы. Сравнение индексаторов и массивов При использовании индексаторов применяется такой же синтаксис, что и для массивов, однако между массивами и индексаторами есть несколько важных различий. Индексы При доступе к элементам массивов вы можете использовать только числовые значения индексов, тогда как при использовании индексаторов доступны и нечисловые индексы, что делает их использование более гибким инструментом. Перегрузка и переопределение Все массивы являются наследниками базового класса массива и не могут переопределить его поведение. Индексаторы же, во-первых, можно иметь в одном классе, а во- вторых, классы-наследники могут переопределить какой-либо индексатор и предоставить собственную его реализацию. 179
Использование индексатора в качестве параметра В этом аспекте индексаторы имеют меньшую гибкость по сравнению с массивами. Причина в том, что, используя индексатор, вы фактически вызываете метод своего класса (хотя эта подмена и делается компиля- тором). При вызове метода, принимающего параметры с модификатором ref или out, вы должны передать в метод указатель на ячейку в памяти. Элементы массива отображаются в ячейки памяти непосредственно, поэтому могут быть употреблены в таком качестве. Индексаторы же такого непосредственного отображения не имеют, поэтому их можно передавать только как значения. Объявление индексаторов в интерфейсах Вы можете объявить индексатор в интерфейсе. В этом случае любой класс, реализующий этот интерфейс, должен будет предоставить реа- лизацию индексатора. Для объявления индексатора в интерфейсе он указывается без модификаторов доступа с указанием аксессоров get и/или set без кода. Вместо кода аксессоров употребляется точка с запятой. Пример такого объявления: interface lEmployeeDatabase { Employee this[string Name] { get; set; } } Далее реализовать индексатор в классе можно как явным, так и неявным образом. Следующий пример показывает, как можно реализовать интерфейс неявно. class EmployeeDatabase : ZEmployeeDatabase { public Employee this [string Name] { get { return employee; } set { } } } 180
Раздел 3. Перегрузка операций Многие встроенные типы в языке C# предоставляют операции для выполнения некоторых обычных действий над ними. Например, операции определяют вполне очевидные действия над числовыми типа- ми. Вместе с тем мы уже видели, что операция + определена и для строк, при этом ее действия совсем не таковы, как для числовых типов, в случае строковых данных она используется для конкатенации. Это пример пере- груженной операции. Вы можете реализовать перегрузку операций и для своего типа. Этот раздел посвящен тому, как это сделать, в нем также даются рекомендации, которым разумно следовать при перегрузке операций. Что такое перегрузка операций В Visual C# есть ряд операций для совершения стандартных действий над объектами. Вы можете использовать эти операции для построения выражений, причем точное поведение каждой из операций зависит от типа объекта, к которому она применяется. Операция по своей сути является методом, который принимает набор параметров и возвращает результирующее значение. При вызове операций операнды передаются в качестве параметров этого метода, а значение, возвращенное методом, является результатом операции. При перегрузке операции вы предоставляете собственную реализацию этого метода. Visual C# определяет три категории операций, которые вы можете перегрузить: • Унарные операции. Они включают в себя операции !, ++, —, +, -. Замечание. Операция "унарный плюс" фактически ничего не де- лает (значения умножаются на 1), она введена, по-видимому, из соображений симметрии с унарным минусом. При перегрузке унарных операций вы должны определить один параметр того же типа, что и тип, для которого определяется операция. • Бинарные операции. Эти операции включают в себя и %. При перегрузке этих операций вы должны указать два параметра, причем хотя бы один из них должен быть того же типа, что и класс, определяющий данную операцию. • Операции приведения типа. Эти операции можно использовать для преобразования одного типа данных в другой. При пере- грузке этих операций указывается один параметр, он задает исходные данные, которые следует преобразовать. Эти данные могут иметь любой допустимый тип. 181
Переопределить можно не все операции, определенные в Visual С#. В следующей таблице перечислены правила, которым подчиняется пере- грузка операций. Операция Возможности для переопределения + - I ~ ++ — true, false Эти унарные операции могут быть перегружены +, *, /, %, &, 1, Л << >> J J Эти бинарные операции могут быть перегружены |Г V л V'R _п и" II Эти операции сравнения могут быть перегружены &&, 11 Эти логические операции не могут быть перегружены явно, однако они выполняются с использованием операций & и 1, которые могут быть перегружены [] Операция индексирования массива не может быть перегружена, однако вы имеете возможность определить собственный индексатор 0 Операция приведения типа не может быть перегружена, однако вы можете определить собственные операции приведения, вопрос рассматривается дальше + Л-" Л Оо । _Н V * V II JI II “ JI Операции присваивания не могут быть перегружены явно, однако они выполняются с использованием соответствующих бинарных операций, которые могут быть перегружены, например += опирается на определение операции + =, ., ?:, ->, new, is, sizeof, typeof Эти операции не могут быть перегружены Синтаксис перегрузки операции Поскольку операция функционально подобна методу, синтаксис ее переопределения подобен объявлению метода. При этом в качестве имени метода указывается ключевое слово operator, за которым следует символ операции, к примеру + или %. Следующий пример демонстрирует переопределение операции + для сложения двух структур типа Hour. 182
struct Hour { public Hour(int initialvalue) { this, value = initialvalue; } public static Hour operator +(Hour Ihs, Hour rhs) { return new Hour(Ihs.value + rhs.value); } private int value; } Обратите внимание на следующие моменты: • все операции должны быть переопределены как открытые (public); • все операции должны быть объявлены как статические. Они не могут быть полиморфными и использовать в объявлении моди- фикатор virtual, abstract, override или sealed. Совет. Есть некоторый сложившийся стиль объявления некоторых конструкций, переопределенных операций в частности. Например, разработчики часто используют для левой и правой стороны операции сокращения Ihs (left-hand side) и rhs (right-hand side). При использовании определенной таким образом операции для типа Hour компилятор автоматически преобразует ваш код с использованием операции + в вызов метода. Например, такой код Hour Example(Hour a, Hour b) { return a + b; } будет понят следующим образом (хотя формально он и не удовлетворяет синтаксису С#): Hour Example(Hour a, Hour b) { return Hour.operator + (a,b); // псевдокод } Есть еще одно правило, которому вы обязаны следовать при объяв- лении операции: хотя бы один из параметров должен иметь тот же тип, что и тип, определяющий данную операцию. 183
Операции следуют правилам перегрузки, общим для всех методов. А именно, вы можете иметь сколько угодно перегруженных вариантов при условии, что они имеют разную сигнатуру. Например, в структуре Hour можно определить дополнительную реализацию операции +, где в качестве второго слагаемого выступает целое значение (например, количество часов). Ограничения при перегрузке операций При перегрузке операции именно ваш код определяет, как она выполняется, однако есть некоторые правила, изменить которые вы не правомочны. • Вы не можете изменить приоритет или ассоциативность (слева напра- во или наоборот) операции, так как они привязаны к символу опе- рации, а не к типу операндов. Например, выражение а+Ь*с всегда будет пониматься как а+(Ь*с) независимо от типов а, b и с. • Вы не можете изменить кратность (количество операндов) операции. Например, * это всегда бинарный оператор, а ++ унарный. • Вы не можете создавать новые символы операций (что-нибудь типа **). Если необходимо выполнить действие, для которого нет подходящей операции, необходимо для этой цели создать метод. • Вы не можете изменить смысл операций применительно к встроен- ным типам. Поэтому выражение 1+2 всегда будет пониматься оди- наково и равно 3. Поскольку хотя бы один из операндов должен иметь тип, совпадающий с содержащим операцию типом, все операнды не могут иметь встроенный тип. Операции сравнения вы обязаны реализовывать только парами. Например, если вы переопределяете операцию <, то обязательно нужно переопределить и >, если ==, то обязательно и != и т. д. Замечание. Для пары == и != есть еще и дополнительные требования. При их переопределении вы обязаны также переопределить виртуальные методы Equals и GetHashCode, унаследованные вашим типом (каким бы он ни был) от System.Object. При этом метод Equals должен выдавать такой же результат, что и == (обычно один определяется через вызов другого). Метод GetHashCode активно используется многими классами, например при использовании объекта в качестве ключа в хэш-таблице. Рекомендации по перегрузке операций При переопределении операций для ваших типов хорошим стилем считается соблюдение следующих правил: • не следует изменять значения операндов; • операции, интуитивно понимаемые как симметричные, следует симметричными и делать; 184
• определять таким образом следует только интуитивно понима- емые операции. Разберем эти правила несколько подробнее. Не изменять значения операндов Реализация операции не должна изменять значения никакого из операндов. Если какой-то из операндов имеет ссылочный тип, то в процессе вычисления основного результата дополнительно изменится и значение этого операнда, то есть операция будет иметь нежелательный побочный эффект. Так, в следующем примере класс Salary объявляет операцию + таким образом, что сумма зарплаты изменяется на заданную величину (number), и возвращает модифицированный объект типа Salary. class Salary { private decimal amount; public decimal Amount { get { return this.amount; } } public Salary(decimal amount) { this, amount = amount; } public static Salary operator +(Salary salary, decimal number) { salary.amount += number; return salary; } } Это плохая реализация, поскольку изменяется значение первого операнда. По завершении операции он будет иметь то же значение, что и результат. Замечание. Если бы тип Salary был структурой, а не классом, такого побочного эффекта не было бы. Структуры являются простым типом, и при передаче их методу происходит копирование всего типа, а не передача ссылки. В классе же Salary исправить ситуацию можно путем создания в коде операции + нового объекта специально для того, чтобы его возвратить. 185
class Salary { private decimal amount; public decimal Amount { get { return this.amount; } } public Salary (decimal amount) { this, amount = amount; } public static Salary operator +(Salary salary, decimal number) { return new Salary (salary. Amount + number) ; } } Объявление симметричных операций В случае определения бинарных операций, которые интуитивно воспринимаются как симметричные, не следует навязывать пользователю заданный порядок операндов. В случае класса Salary это означает, что пользователь должен иметь возможность использовать выражение зарплата + 100 или 100 + зарплата и получить в обоих случаях один и тот же результат. Чтобы обеспечить эту возможность, следует предоставить две реализации операции с разным порядком операндов. На практике во избежание дублирования кода одна из них обычно просто вызывает другую, как показано в следующем примере: public static Salary operator +(Salary salary, decimal number) { return new Salary (salary. Amount + number) ; } public static Salary operator +(Salary salary, decimal number) { // Вызов первой реализации операции + return salary + number; } 186
Определение только интуитивно очевидных операций Определять действие как операцию разумно лишь тогда, когда оно интуитивно ассоциируется с символом операции. Эти операции должны быть естественны и соответствовать реальному поведению объекта. Так, вероятно, не следует определять операции + и - для изменения состояния банковского счета, поскольку в жизни эта операция сопряжена с дополнительными проверками и выполняется компонентами, за них отвечающими. Вместо операций + и - следует реализовать методы Deposit и Withdraw, которые выполняют эти действия более реалистичным и осмысленным образом. Реализация и использование операций преобразования типа Для преобразования типа из одного в другой также используются операции. Преобразование может быть неявным или явным. Неявное пре- образование следует использовать, если оно всегда возможно и не приводит к потере точности. Такие преобразования типа иногда называют расширяющими. Операция, реализующая расширяющее преобразование, может быть вызвана компилятором автоматически без явного указания со стороны программиста. Примером может служить преобразование из int в double: int i = 99; double d = i; // Безопасное, расширяющее преобразование Явное преобразование требуется во всех остальных случаях: может произойти потеря информации или преобразование возможно не для всех данных. Такие преобразования называют сужающими. В этом случае программист должен ясно выразить свое намерение, употребив операцию преобразования типа явно. Простой пример демонстрирует использование явного преобразования типа: double d = 99.9; int i = (int)d; // Сужающее преобразование, потеря точности Здесь преобразование приведет к отбрасыванию дробной части, значение i будет равно 99. Объявление операций преобразования типа Синтаксис объявления аналогичен синтаксису объявления других перегруженных операций. Операции должны быть объявлены открытыми и статическими. Перед ключевым словом operator обязательно должно быть указано, какая операция определяется: явная (explicit) или неявная (implicit), - после слова operator указывается результирующий тип. 187
Следующий пример демонстрирует объявление неявной операции приведения типа Hour к типу int: struct Hour { public static implicit operator int(Hour from) { return from.value; } private int value; } Единственный параметр в этом примере имеет тип, совпадающий с охватывающим типом, он задает приводимое значение. Такое преобразование может вызываться неявно, например: class Example { public static void MyMethod(int parameter) { ... } public static void Main() { Hour lunch = new Hour (12) ; Example.MyMethod(lunch) ; //неявное преобразование } } Следующий пример приведен для демонстрации синтаксиса объявления явного преобразования типа, в данном примере - из int (тип аргумента) в Hour (результирующий тип). struct Hour { public Hour(int hr) { this.value = hr % 24; } public static explicit operator Hour(int from) { return new Hour (from) ; } private int value; } В этом случае преобразование является сужающим, поскольку не любое целое значение соответствует допустимому количеству часов. В примере логика упрощена за счет того, что просто берется остаток от деления на 24. 188
Симметричные операции и преобразование типов Использование преобразования типов предоставляет нам альтерна- тивный способ обеспечения симметричности операций. Например, реализация операции + для структуры Hour и целого числа могла бы свестись к базовой реализации операции + для двух структур Hour, а для вариантов Hour+int и int+Hour достаточно бы было осу- ществить преобразование из int в Hour (явное) и вызов базовой версии этой операции. struct Hour { public Hour(int hr) { this.value = hr % 24; } public static Hour operator +(Hour Ihs, Hour rhs) { return new Hour(Ihs.value + rhs.value); } public static implicit operator Hour(int from) { return new Hour(from) ; } private int value; } Здесь ввиду наличия неявного преобразования из типа int в тип Hour выражение Hour+int, например, будет компилятором неявно преобра- зовано в Hour+Hour, после чего уже выполняется сложение. 189
Список литературы 1. C# 4.0 и платформа .NET 4 для профессионалов : пер. с англ. / К. Нейгел, Б. Ивьен, Д. Глинн, К. Уотсон. — М. : И. Д. Вильямс, 2011. — 1440 с. 2. Шилдт, Г. C# 4.0 Полное руководство : пер. с англ. / Г. Шилдт. — М. : И. Д. Вильямс, 2011. — 1056 с. 3. Троелсен, Э. Язык программирования C# 2010 и платформа .NET : пер. с англ. / Э. Троелсен. — М. : И. Д. Вильямс, 2011. — 1392 с. 4. Трей, Н. C# 2010 — Ускоренный курс для профессионалов : пер. с англ. / Н. Трей. — М. : И. Д. Вильямс, 2010. — 592 с. 5. Макки, А. Введение в .NET 4.0 и Visual Studio 2010 для професси- оналов : пер. с англ. / А. Макки. — М. : И. Д. Вильямс, 2010. — 416 с. 190
Оглавление Введение.....................................................3 Лекция 1. Введение в C# и .NET Framework.....................4 Раздел 1. Введение в .NET Framework 4......................4 Что такое .NET Framework 4?...............................4 Назначение Visual C#......................................5 Понятие сборки............................................5 Как Common Language Runtime работает со сборками..........6 Инструменты .NET Framework................................7 Раздел 2. Создание проектов в среде разработки Visual Studio 2010.........................................7 Основные черты Visual Studio 2010.........................8 Шаблоны проектов Visual Studio 2010.......................8 Шаблоны приложений в Visual Studio 2010...................9 Структура проектов и решений Visual Studio................9 Создание приложения .NET Framework.......................11 Как скомпилировать и запустить приложение C#.............11 Раздел 3. Создание приложения на C#.......................12 Классы и пространства имен - первое знакомство...........12 Структура консольного приложения.........................13 Ввод и вывод в консольном приложении.....................14 Рекомендации по использованию комментариев в C#..........15 Раздел 4. Создание приложений с графическим интерфейсом.... 15 Что такое WPF?...........................................15 Структура WPF-приложения.................................16 Библиотека элементов управления WPF......................17 События WPF..............................................19 Создание простого WPF-приложения в Visual Studio 2010....20 Раздел 5. Документирование приложения.....................21 XML-комментарии..........................................21 Теги, обычно используемые в XML-комментариях.............22 Создание документации из XML-тегов.......................22 Использование Sandcastle для создания справочного файла в формате .chm...........................................23 Раздел 6. Отладка приложений в среде Visual Studio 2010...24 Основные моменты отладки в Visual Studio 2010............24 Использование точек останова.............................25 Пошаговая отладка в Visual Studio........................26 Использование окон отладки...............................27 191
Лекция 2. Программные конструкции C#........................28 Раздел 1. Объявление и инициализация переменных...........28 Что такое переменная?....................................28 Что такое тип данных?....................................28 Объявление и инициализация переменных....................29 Область видимости переменной.............................31 Приведение типов.........................................32 Константы и переменные только для чтения.................34 Раздел 2. Выражения и операции............................35 Что такое выражения?.....................................35 Операции C#..............................................36 Приоритет операций.......................................37 Несколько замечаний об использовании операции + для конкатенации строк...................................38 Раздел 3. Создание и использование массивов...............38 Что такое массив?........................................38 Объявление и инициализация массивов......................39 Общие свойства и методы, предоставляемые массивами.......40 Доступ к данным в массиве................................42 Раздел 4. Операторы выбора................................43 Оператор if в сокращенной форме..........................43 Оператор if в полной форме...............................43 Условная операция........................................44 Вложенные операторы if...................................44 Оператор множественного выбора switch....................44 Раздел 5. Использование операторов цикла..................45 Цикл while...............................................45 Цикл do..................................................46 Цикл for.................................................46 Лекция 3. Объявление и вызов методов........................47 Раздел 1. Объявление и вызов методов......................47 Что такое метод?.........................................47 Создание метода..........................................48 Вызов методов............................................49 Создание и вызов перегруженных методов...................50 Использование списка параметров..........................51 Рефакторинг участка кода в метод.........................53 Тестирование метода......................................55 192
Раздел 2. Параметры по умолчанию и выходные параметры.......57 Что такое опциональные параметры?.........................57 Использование именованных аргументов при вызове методов....59 Выходные параметры........................................59 Лекция 4. Обработка исключений...............................61 Раздел 1. Перехват исключений..............................61 Что такое исключение?.....................................61 Использование блоков try/catch............................62 Использование свойств исключений..........................65 Использование блока finally...............................66 Использование ключевых слов checked и unchecked...........68 Раздел 2. Выбрасывание исключений..........................70 Создание объекта исключения...............................70 Выбрасывание исключения...................................72 Рекомендуемые методики работы с исключениями..............73 Лекция 5. Работа с файлами...................................75 Раздел 1. Доступ к файловой системе........................75 Управление файлами........................................75 Чтение и запись в файлы...................................78 Управление директориями...................................80 Работа с классом Path.....................................83 Использование стандартных диалоговых окон для работы с файловой системой.......................................84 Использование классов OpenFileDialog и SaveFileDialog.....85 Раздел 2. Чтение и запись файлов с использованием потоков... 86 Что такое потоки?.........................................86 Чтение и запись бинарных данных...........................87 Чтение и запись текстовых файлов..........................91 Чтение и запись данных встроенных типов...................94 Лекция 6. Создание новых типов...............................98 Раздел 1. Создание и использование перечислений............98 Что такое перечисление?...................................98 Создание перечисляемых типов данных в C#..................99 Присваивание значений переменным перечисляемых типов......100 Раздел 2. Создание и использование классов.................101 Понятие класса............................................101 Добавление членов в класс.................................102 Объявление конструкторов и инициализация объектов.........104 Создание объектов.........................................107 193
Доступ к членам класса.....................................109 Использование частичных классов и частичных методов........110 Раздел 3. Создание и использование структур.................112 Понятие структуры..........................................112 Объявление и использование структур........................113 Инициализация структур.....................................114 Раздел 4. Сравнение ссылочных типов и типов значений........115 Сравнение ссылочных и простых типов........................115 Передача методом простых типов по ссылке...................116 Упаковка и распаковка......................................118 Нулевые типы...............................................119 Лекция 7. Инкапсуляция данных и методов.......................123 Раздел 1. Контроль видимости членов типа....................123 Понятие инкапсуляции.......................................123 Закрытые и открытые члены..................................124 Внутренний и открытый доступ...............................126 Раздел 2. Разделяемые методы и данные.......................128 Создание и использование статических полей.................128 Создание и использование статических методов...............130 Создание статических типов и использование статических конструкторов..................................131 Создание и использование методов расширения................133 Лекция 8. Наследование от классов и реализация интерфейсов.....................................135 Раздел 1. Использование наследования для создания новых ссылочных типов......................................135 Понятие наследования.......................................135 Иерархия наследования .NET Framework.......................137 Переопределение и сокрытие методов.........................137 Вызов методов и конструкторов базового класса..............139 Присваивание значений и ссылки на классы в иерархии наследования.... 141 Полиморфизм................................................142 Использование ключевого слова sealed для классов и методов.144 Раздел 2. Определение и реализация интерфейсов..............144 Понятие интерфейса.........................................145 Создание и реализация интерфейса...........................145 Доступ к объектам через интерфейс..........................147 Явная и неявная реализация интерфейса......................148 194
Раздел 3. Определение абстрактных классов..................151 Понятие абстрактного класса...............................152 Понятие абстрактного метода...............................154 Лекция 9. Управление временем жизни объектов и работа с ресурсами.........................................155 Раздел 1. Процесс сборки мусора............................155 Жизненный цикл объекта....................................155 Управляемые ресурсы в .NET Framework......................156 Как работает сборщик мусора?..............................157 Использование деструкторов................................158 Класс GC (Garbage Collector - сборщик мусора).............160 Раздел 2. Управление ресурсами.............................161 Зачем нужно управление ресурсами..........................162 Шаблон проектирования Dispose.............................162 Вызов метода Dispose из деструктора.......................164 Управление ресурсами приложения...........................166 Лекция 10. Инкапсуляция данных и определение перегруженных операций.......................................169 Раздел 1. Создание и использование свойств.................169 Понятие свойства..........................................169 Объявление свойства.......................................170 Автоматические свойства...................................171 Инициализация объектов с использованием свойств...........173 Объявление свойств в интерфейсе...........................175 Рекомендации по объявлению и использованию свойств........176 Раздел 2. Создание и использование индексаторов............177 Понятие индексатора.......................................178 Как объявить индексатор...................................178 Сравнение индексаторов и массивов.........................179 Объявление индексаторов в интерфейсах.....................180 Раздел 3. Перегрузка операций..............................181 Что такое перегрузка операций.............................181 Синтаксис перегрузки операции.............................182 Ограничения при перегрузке операций.......................184 Рекомендации по перегрузке операций.......................184 Реализация и использование операций преобразования типа...187 Список литературы............................................190 195
Учебное издание Васильчиков Владимир Васильевич Программирование на языке C# для .NET Framework Курс лекций Часть 1 Учебное пособие Редактор, корректор М. Э. Левакова Компьютерный набор и верстка В. В. Васильчикова, М. Э. Васильчиковой Подписано в печать 28.02.13. Формат 60x84 1/16. Бум. офсетная. Гарнитура "Times New Roman". Уел. печ. л. 11,39. Уч.-изд. л. 8,0 . Тираж 100 экз. Заказ Оригинал-макет подготовлен в редакционно-издательском отделе Ярославского государственного университета им. П. Г. Демидова. Отпечатано на ризографе. Ярославский государственный университет им. П. Г. Демидова. 150000, Ярославль, ул. Советская, 14.